From c23b4e8e416a59bd7001f77f3e8738b04db9deb3 Mon Sep 17 00:00:00 2001 From: Ludovic Marie <136255825+ludovic111@users.noreply.github.com> Date: Mon, 26 Oct 2026 12:27:06 +0100 Subject: [PATCH] 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 <180130752+Stink-O@users.noreply.github.com> Co-Authored-By: Claude Opus 5.5 --- crates/horizon-client/src/game/mods.rs | 57 +++++++++++++++++++ crates/horizon-client/src/lod.rs | 19 +++++++ crates/horizon-mod-sdk/wit/horizon.wit | 21 ++++++- crates/horizon-mods/src/api_level.rs | 5 +- crates/horizon-mods/src/runtime.rs | 29 +++++++++- crates/horizon-mods/tests/sandbox.rs | 47 ++++++++++++++- crates/horizon-mods/tests/ui_guest/src/lib.rs | 16 +++++- docs/api/mods-reference.md | 17 +++++- docs/api/stability.md | 2 +- docs/mods.md | 2 +- 10 files changed, 202 insertions(+), 13 deletions(-) diff --git a/crates/horizon-client/src/game/mods.rs b/crates/horizon-client/src/game/mods.rs index a83277376..1752dec91 100644 --- a/crates/horizon-client/src/game/mods.rs +++ b/crates/horizon-client/src/game/mods.rs @@ -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::(); + 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 diff --git a/crates/horizon-client/src/lod.rs b/crates/horizon-client/src/lod.rs index a429863a7..1ee61cdf4 100644 --- a/crates/horizon-client/src/lod.rs +++ b/crates/horizon-client/src/lod.rs @@ -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()) } diff --git a/crates/horizon-mod-sdk/wit/horizon.wit b/crates/horizon-mod-sdk/wit/horizon.wit index 84be4d4f0..7f8245c48 100644 --- a/crates/horizon-mod-sdk/wit/horizon.wit +++ b/crates/horizon-mod-sdk/wit/horizon.wit @@ -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; /// None on the title screen. get-clock: func() -> option; + /// 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; } /// Capability `chat`: messages only the local player sees. diff --git a/crates/horizon-mods/src/api_level.rs b/crates/horizon-mods/src/api_level.rs index e7b72c20b..0627b71ae 100644 --- a/crates/horizon-mods/src/api_level.rs +++ b/crates/horizon-mods/src/api_level.rs @@ -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. diff --git a/crates/horizon-mods/src/runtime.rs b/crates/horizon-mods/src/runtime.rs index f1859337d..ea603ee31 100644 --- a/crates/horizon-mods/src/runtime.rs +++ b/crates/horizon-mods/src/runtime.rs @@ -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, + /// 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, 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 { self.snapshot.clock } + fn raycast(&mut self, distance: f32) -> Option { + 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`]. diff --git a/crates/horizon-mods/tests/sandbox.rs b/crates/horizon-mods/tests/sandbox.rs index aaec89ec7..8eceec815 100644 --- a/crates/horizon-mods/tests/sandbox.rs +++ b/crates/horizon-mods/tests/sandbox.rs @@ -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 { + 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"); diff --git a/crates/horizon-mods/tests/ui_guest/src/lib.rs b/crates/horizon-mods/tests/ui_guest/src/lib.rs index 3d4c66076..32e811fc7 100644 --- a/crates/horizon-mods/tests/ui_guest/src/lib.rs +++ b/crates/horizon-mods/tests/ui_guest/src/lib.rs @@ -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); diff --git a/docs/api/mods-reference.md b/docs/api/mods-reference.md index d7bf879b8..a197b2c04 100644 --- a/docs/api/mods-reference.md +++ b/docs/api/mods-reference.md @@ -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` | None on the title screen. | | `get-clock` | `func() -> option` | None on the title screen. | +| `raycast` | `func(distance: f32) -> option` | 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` diff --git a/docs/api/stability.md b/docs/api/stability.md index 65cfc0c13..f6b40fabe 100644 --- a/docs/api/stability.md +++ b/docs/api/stability.md @@ -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: diff --git a/docs/mods.md b/docs/mods.md index 03ca120b2..7093a22be 100644 --- a/docs/mods.md +++ b/docs/mods.md @@ -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 ]` | -| `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 |