Mod API: game.raycast, the block the player looks at (level 2)

`game.raycast(distance)` returns the first block along the player's view
(fluids pass), up to 1024 blocks: through the loaded chunks as the
crosshair's ray goes, then across the distant terrain's ground heights
(`Lod::ground`, from Stink-O's fork). The game casts it only while a running
mod imports the function. It is API level 2, so older Horizons refuse a mod
that calls it with "update Horizon".

Co-authored-by: Stink-O <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
committed 2026-10-26 12:27:06 +01:00
1 parent b12c89650e
commit c23b4e8e41
10 files changed
+202 -13

No files matched your search

+57
View File
@@ -415,8 +415,65 @@ impl Game<'_> {
cps,
font_widths: self.mods.font_widths.clone(),
players: Vec::new(),
view_hit: self.mod_view_hit(),
}
}
/// The first block along the player's view within
/// [`horizon_mods::RAYCAST_MAX`] and its distance from the eye
/// (`game.raycast`): through the loaded chunks as the crosshair's ray
/// goes (fluids pass), then across the distant terrain's ground heights,
/// a block at a time.
fn mod_view_hit(&self) -> Option<(f32, horizon_mods::Hit)> {
if !self.mods.mods.wants_raycast() {
return None;
}
let eye = self.eye_position()?;
let dir = super::view_vector(self.yaw, self.pitch);
let reach = f64::from(horizon_mods::RAYCAST_MAX);
let (pos, point, state) = match self.clip_ray(eye, dir, reach) {
Some(hit) => (
hit.pos,
hit.point,
self.world.block(hit.pos[0], hit.pos[1], hit.pos[2]),
),
None => self.lod_ray(eye, dir, reach)?,
};
let distance = (0..3).map(|i| (point[i] - eye[i]).powi(2)).sum::<f64>();
let hit = horizon_mods::Hit {
block_x: pos[0],
block_y: pos[1],
block_z: pos[2],
x: point[0],
y: point[1],
z: point[2],
block: self.res.facts().get(state).name.to_string(),
};
Some((distance.sqrt() as f32, hit))
}
/// Past the resident chunks: the view walked across the distant
/// terrain's ground heights (`Lod::ground`), a block at a time. Columns in
/// loaded chunks are left to `clip_ray`, which has already missed them.
fn lod_ray(
&self,
eye: [f64; 3],
dir: [f64; 3],
reach: f64,
) -> Option<([i32; 3], [f64; 3], horizon_world::registry::BlockStateId)> {
let lod = self.lod.as_ref()?;
let mut t = 0.0;
while t <= reach {
let p = [0, 1, 2].map(|i| eye[i] + dir[i] * t);
let (x, z) = (p[0].floor() as i32, p[2].floor() as i32);
if !self.world.loaded(x, z)
&& let Some((height, state)) = lod.ground(x, z)
&& p[1] <= f64::from(height)
{
return Some(([x, height - 1, z], [p[0], f64::from(height), p[2]], state));
}
t += 1.0;
}
None
}
/// A game tick.
pub(super) fn tick_mods(&mut self) {
// Here, at the player's level: shown inside a console call (a control
+19
View File
@@ -752,6 +752,25 @@ impl Lod {
saved_order: VecDeque::new(),
}
}
/// The solid ground at block column (x, z): its top face's height and
/// its block, from the finest tile sampled there. For aiming past the
/// resident chunks (`game.raycast`); water is passed, as the view's ray
/// passes it.
pub fn ground(&self, x: i32, z: i32) -> Option<(i32, BlockStateId)> {
(0..=MAX_LEVEL).find_map(|level| {
let size = 16 << level;
let key = TileKey {
x: x.div_euclid(size),
z: z.div_euclid(size),
level,
};
let columns = self.ready.get(&key).filter(|c| c.len() == CELLS * CELLS)?;
let cell = size / CELLS as i32;
let (cx, cz) = ((x - key.x * size) / cell, (z - key.z * size) / cell);
let surface = columns[cz as usize * CELLS + cx as usize].surface?;
Some((surface.height, surface.state))
})
}
pub fn stats(&self) -> (usize, usize) {
(self.active.len(), self.ready.len())
}
+20 -1
View File
@@ -18,7 +18,8 @@ interface log {
write: func(level: level, message: string);
}
/// Capability `game-read`: the local player and the world clock.
/// Capability `game-read`: the local player, the world clock and the block
/// the player looks at.
interface game {
record player {
/// Feet position, in blocks.
@@ -46,6 +47,24 @@ interface game {
get-player: func() -> option<player>;
/// None on the title screen.
get-clock: func() -> option<clock>;
/// A block along the player's view.
record hit {
/// The block hit.
block-x: s32,
block-y: s32,
block-z: s32,
/// The point on its face where the view meets it.
x: f64,
y: f64,
z: f64,
/// `minecraft:stone`, ...
block: string,
}
/// The first block along the player's view (fluids pass), up to
/// `distance` blocks (at most 1024; past the loaded chunks, across the
/// distant terrain when LOD Distance is on), as of this tick. None when
/// nothing is hit, and on the title screen.
raycast: func(distance: f32) -> option<hit>;
}
/// Capability `chat`: messages only the local player sees.
+3 -2
View File
@@ -7,13 +7,13 @@
//! version's level so the launcher can tell before it installs.
/// The newest level this Horizon serves.
pub const LEVEL: u32 = 1;
pub const LEVEL: u32 = 2;
/// Every function the game offers mods, as `interface.function`, with its
/// level. Level 0 is the contract of Horizon 0.2.67 to 0.2.69. A new
/// function takes the next level and raises [`LEVEL`] (the `api_reference`
/// test fails until it is listed here).
pub const FUNCTIONS: [(&str, u32); 32] = [
pub const FUNCTIONS: [(&str, u32); 33] = [
("log.write", 0),
("game.get-player", 0),
("game.get-clock", 0),
@@ -46,6 +46,7 @@ pub const FUNCTIONS: [(&str, u32); 32] = [
("players.online", 1),
("players.tell", 1),
("players.broadcast", 1),
("game.raycast", 2),
];
/// A function's level; None for one this Horizon doesn't have.
+27 -2
View File
@@ -41,7 +41,7 @@ mod ui_bindings {
});
}
pub use bindings::horizon::mods::{
game::{Clock, Player},
game::{Clock, Hit, Player},
hud::{Corner, Line},
};
use bindings::{
@@ -89,6 +89,8 @@ const FUEL_FRAME: u64 = 10_000_000;
const FUEL_EVENT: u64 = 100_000_000;
/// Chat lines a mod may show per call, HUD lines and their length.
const CHAT_PER_CALL: usize = 8;
/// How far `game.raycast` sees, in blocks.
pub const RAYCAST_MAX: f32 = 1024.0;
const HUD_LINES: usize = 16;
const LINE_CHARS: usize = 256;
/// Total bytes a mod's `storage` may hold on disk, across all its keys.
@@ -124,6 +126,10 @@ pub struct Snapshot {
pub font_widths: Arc<[f32]>,
/// The players on the server, for server mods.
pub players: Vec<ServerPlayer>,
/// The first block along the player's view within [`RAYCAST_MAX`] and
/// its distance from the eye, for `game.raycast`; the game works it out
/// only while a mod calls it ([`Mods::wants_raycast`]).
pub view_hit: Option<(f32, Hit)>,
}
/// Which mods a game starts: the window's game the client mods, the game
@@ -227,6 +233,8 @@ struct Running {
/// server mod.
ui: Option<UiHost>,
hooks: Hooks,
/// It imports `game.raycast`.
raycasts: bool,
}
/// A running mod's exports and the hooks its `init` asked for.
@@ -490,7 +498,12 @@ impl Mods {
free
});
self.out.append(&mut store.data_mut().out);
Ok(Some(Running { store, ui, hooks }))
Ok(Some(Running {
store,
ui,
hooks,
raycasts: functions.iter().any(|f| f == "game.raycast"),
}))
}
/// True when at least one mod runs: the game skips its mod work otherwise.
@@ -743,6 +756,13 @@ impl Mods {
.iter()
.filter_map(|e| e.run.as_ref()?.store.data().corner.as_ref())
}
/// Some running mod calls `game.raycast`: the snapshot needs a
/// [`Snapshot::view_hit`].
pub fn wants_raycast(&self) -> bool {
self.entries
.iter()
.any(|e| e.run.as_ref().is_some_and(|r| r.raycasts))
}
/// Some running mod has a `frame` hook: the game refreshes the snapshot
/// every frame for it.
pub fn wants_frames(&self) -> bool {
@@ -1093,6 +1113,11 @@ impl game::Host for State {
fn get_clock(&mut self) -> Option<Clock> {
self.snapshot.clock
}
fn raycast(&mut self, distance: f32) -> Option<Hit> {
let (at, hit) = self.snapshot.view_hit.as_ref()?;
// NaN compares false: no hit.
(*at <= distance.min(RAYCAST_MAX) && !distance.is_nan()).then(|| hit.clone())
}
}
impl State {
/// Queues a chat line unless the call already sent [`CHAT_PER_CALL`].
+44 -3
View File
@@ -2,8 +2,8 @@
//! examples and a guest
//! that misbehaves on command. Needs `rustup target add wasm32-unknown-unknown`.
use horizon_mods::{
BlockPos, Clock, Corner, GameMode, HeldItem, Mods, Output, Package, Player, Runs, ServerPlayer,
Shape, Snapshot, Status,
BlockPos, Clock, Corner, GameMode, HeldItem, Hit, Mods, Output, Package, Player, Runs,
ServerPlayer, Shape, Snapshot, Status,
manifest::componentize,
testing::{Script, TestGame},
};
@@ -26,7 +26,7 @@ id = "test_ui_guest"
name = "Test UI Guest"
version = "0.1.0"
api = "0.1"
capabilities = ["chat", "ui", "render", "input"]
capabilities = ["game-read", "chat", "ui", "render", "input"]
experimental = ["ui", "render", "input"]
"#;
@@ -722,6 +722,47 @@ fn render_minimap_is_clamped_and_hidden() {
assert_eq!(map(&mut mods, "off"), None);
}
/// `game.raycast`: the view's hit the game worked out, up to the distance
/// asked for; the game works it out only while a mod imports the call.
#[test]
fn game_raycast_sees_the_view_hit_within_its_distance() {
let mut mods = load("horizon_mods_test_ui_guest.hmod");
assert!(mods.wants_raycast());
assert!(!load("coords_hud.hmod").wants_raycast());
let said = |mods: &mut Mods, args: &str| -> Vec<String> {
assert!(mods.command(&format!("raycast {args}")));
mods.drain()
.into_iter()
.filter_map(|o| match o {
Output::Chat { text, .. } => Some(text),
_ => None,
})
.collect()
};
assert_eq!(said(&mut mods, "100"), ["no hit"]);
mods.set_snapshot(Snapshot {
view_hit: Some((
40.5,
Hit {
block_x: 12,
block_y: 63,
block_z: -44,
x: 12.5,
y: 64.0,
z: -43.75,
block: "minecraft:grass_block".into(),
},
)),
..player()
});
let hit = "hit minecraft:grass_block at 12 63 -44 (12.5 64.0 -43.8)";
assert_eq!(said(&mut mods, "100"), [hit]);
assert_eq!(said(&mut mods, "40"), ["no hit"]);
// NaN asks for nothing; past 1024 is 1024.
assert_eq!(said(&mut mods, "x"), ["no hit"]);
assert_eq!(said(&mut mods, "1e9"), [hit]);
}
#[test]
fn corner_panels_text_width_and_readings() {
let mut mods = load("horizon_mods_test_ui_guest.hmod");
+14 -2
View File
@@ -4,9 +4,10 @@
//! `/render_hud`, `/render_clear` and `/render_shake` test `render`;
//! `/input_claim`, `/input_poll`, `/input_held` and `/input_wheel` test
//! `input`; `/zoom FACTOR SMOOTH TURN` tests `render.set-zoom`;
//! `/corner` and `/readings` the calls the official extras mods use.
//! `/corner` and `/readings` the calls the official extras mods use;
//! `/raycast DISTANCE` says what `game.raycast` sees.
use horizon_mod_sdk::{
Mod, Registration, chat,
Mod, Registration, chat, game,
input::{self, Event},
render::{self, FillShape, ItemShape, Point, QuadShape, Rect, Shape, SpriteShape, TextShape},
ui::{self, ItemWidget, ListWidget, Screen, TextWidget, Widget},
@@ -97,6 +98,7 @@ impl Mod for Guest {
"input_wheel",
"zoom",
"minimap",
"raycast",
"corner",
"readings",
]
@@ -217,6 +219,16 @@ impl Mod for Guest {
});
render::set_minimap(map);
}
"raycast" => {
let distance = args.parse().unwrap_or(f32::NAN);
chat::show(&match game::raycast(distance) {
Some(h) => format!(
"hit {} at {} {} {} ({:.1} {:.1} {:.1})",
h.block, h.block_x, h.block_y, h.block_z, h.x, h.y, h.z
),
None => "no hit".into(),
});
}
// Clamped to (1, 10); the two after it shake nothing.
"render_shake" => {
render::shake(2.0, 99.0);
+16 -1
View File
@@ -76,7 +76,7 @@ The mod's log, in the game's log under the mod's id. Always granted.
Access: capability `game-read`.
Capability `game-read`: the local player and the world clock.
Capability `game-read`: the local player, the world clock and the block the player looks at.
### record `player`
@@ -98,10 +98,25 @@ Capability `game-read`: the local player and the world clock.
| `game-time` | `u64` | Ticks since the world was created. |
| `day-time` | `u64` | 24000 ticks per day; 0 is 6:00 on the first day. |
### record `hit`
A block along the player's view.
| Field | Type | |
| --- | --- | --- |
| `block-x` | `s32` | The block hit. |
| `block-y` | `s32` | |
| `block-z` | `s32` | |
| `x` | `f64` | The point on its face where the view meets it. |
| `y` | `f64` | |
| `z` | `f64` | |
| `block` | `string` | `minecraft:stone`, ... |
| Function | Signature | What it does |
| --- | --- | --- |
| `get-player` | `func() -> option<player>` | None on the title screen. |
| `get-clock` | `func() -> option<clock>` | None on the title screen. |
| `raycast` | `func(distance: f32) -> option<hit>` | The first block along the player's view (fluids pass), up to `distance` blocks (at most 1024; past the loaded chunks, across the distant terrain when LOD Distance is on), as of this tick. None when nothing is hit, and on the title screen. |
## `chat`
+1 -1
View File
@@ -38,7 +38,7 @@ don't import it); faster, or fixing behaviour to what the reference already says
A new WIT function doesn't break older mods, but a mod that calls it can't run on a Horizon from before it. So every
function the game offers mods has an **API level**, the one it arrived at (`crates/horizon-mods/src/api_level.rs`):
level 0 is the contract of Horizon 0.2.67 to 0.2.69, level 1 adds `render.set-minimap` and server mods' `players`. A mod needs the level of
level 0 is the contract of Horizon 0.2.67 to 0.2.69, level 1 adds `render.set-minimap` and server mods' `players`, level 2 `game.raycast`. A mod needs the level of
the newest function it imports (a component imports only the functions it calls). The level is read from the file,
never declared:
+1 -1
View File
@@ -83,7 +83,7 @@ folder, loads no runtime and plays exactly like vanilla 26.3.
| Interface | Capability | Functions |
|---|---|---|
| `log` | always | `write(level, message)`, in the game log as `[mod <id>]` |
| `game` | `game-read` | `get-player()`: x y z, yaw, pitch, health, food, dimension; `get-clock()`: game time, day time |
| `game` | `game-read` | `get-player()`: x y z, yaw, pitch, health, food, dimension; `get-clock()`: game time, day time; `raycast(distance)`: the first block along the view (fluids pass), up to 1024 blocks and across the distant terrain, with the point hit and the block's id |
| `chat` | `chat` | `show(text)`: a line only the local player sees, after the mod's name (8 per call) |
| `hud` | `hud` | `set-panel(corner, lines)`, `clear()`: up to 16 lines of 256 characters |
| `storage` | `storage` | `get(key)`, `set(key, value)`, `delete(key)`: a per-mod folder of up to 64 files, 4 MiB total, at most 1 MiB written per call |