mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-07 11:06:11 -10:00
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:
1 parent
b12c89650e
commit
c23b4e8e41
10 files changed
+202
-13
No files matched your search
@@ -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
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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`].
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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`
|
||||
|
||||
|
||||
@@ -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
@@ -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 |
|
||||
|
||||
Reference in new issue
Block a user