f16bc2387f
build / mod (push) Successful in 3m52s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
578 lines
23 KiB
Diff
578 lines
23 KiB
Diff
From 3de7a6875140a107e28563cc44489b55f5794517 Mon Sep 17 00:00:00 2001
|
||
From: chexit <chexit@duck.com>
|
||
Date: Wed, 28 Oct 2026 12:37:45 +0300
|
||
Subject: [PATCH 01/36] Mod API (AirDef fork): world models, client particles,
|
||
sounds, ground heights
|
||
|
||
render.set-models draws block models with an interpolated pose per id;
|
||
render.spawn-particles and render.play-sound act on this client only;
|
||
render.query-ground / render.ground read the motion-blocking heightmap.
|
||
|
||
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
||
---
|
||
crates/horizon-client/src/game.rs | 2 +
|
||
crates/horizon-client/src/game/mod_models.rs | 197 +++++++++++++++++++
|
||
crates/horizon-client/src/game/mods.rs | 24 +++
|
||
crates/horizon-mod-sdk/wit/horizon.wit | 58 ++++++
|
||
crates/horizon-mods/src/api_level.rs | 10 +-
|
||
crates/horizon-mods/src/runtime.rs | 116 ++++++++++-
|
||
6 files changed, 403 insertions(+), 4 deletions(-)
|
||
create mode 100644 crates/horizon-client/src/game/mod_models.rs
|
||
|
||
diff --git a/crates/horizon-client/src/game.rs b/crates/horizon-client/src/game.rs
|
||
index 1d7bbbbd6..7c342cd86 100644
|
||
--- a/crates/horizon-client/src/game.rs
|
||
+++ b/crates/horizon-client/src/game.rs
|
||
@@ -115,6 +115,7 @@ mod mod_input;
|
||
#[cfg(feature = "mods")]
|
||
mod mod_recipes;
|
||
mod mod_render;
|
||
+mod mod_models;
|
||
mod mods;
|
||
mod mount_menu;
|
||
mod mounts;
|
||
@@ -8232,6 +8233,7 @@ impl Game<'_> {
|
||
}
|
||
}
|
||
self.mark_entity(out.len(), "");
|
||
+ self.mod_model_geometry(camera, alpha, &mut out);
|
||
self.moving_piston_geometry(camera, alpha, &mut out);
|
||
self.mark_entity(out.len(), "minecraft:tnt");
|
||
self.tnt_geometry(camera, alpha, &mut out);
|
||
diff --git a/crates/horizon-client/src/game/mod_models.rs b/crates/horizon-client/src/game/mod_models.rs
|
||
new file mode 100644
|
||
index 000000000..3c7f9345c
|
||
--- /dev/null
|
||
+++ b/crates/horizon-client/src/game/mod_models.rs
|
||
@@ -0,0 +1,197 @@
|
||
+//! AirDef fork: capability `render`'s world models, client particles and
|
||
+//! sounds. A mod sets its models once a tick (`render.set-models`); each id
|
||
+//! keeps its last two poses and is drawn between them every frame, so models
|
||
+//! move smoothly at any frame rate.
|
||
+use super::Game;
|
||
+#[cfg(feature = "mods")]
|
||
+use {
|
||
+ glam::{DVec3, Mat4, Quat, Vec3},
|
||
+ horizon_client::{
|
||
+ mesher::Vertex,
|
||
+ particles::{ParticleOptions, ParticleType},
|
||
+ },
|
||
+ horizon_core::random::RandomSource,
|
||
+ horizon_mods::ParticleSpawn,
|
||
+ horizon_world::registry::BlockStateId,
|
||
+ std::collections::HashMap,
|
||
+};
|
||
+
|
||
+/// One model of a mod: where it was a tick ago and where it is now.
|
||
+#[cfg(feature = "mods")]
|
||
+struct Pose {
|
||
+ state: BlockStateId,
|
||
+ previous: DVec3,
|
||
+ position: DVec3,
|
||
+ old_rotation: Quat,
|
||
+ rotation: Quat,
|
||
+ old_scale: f32,
|
||
+ scale: f32,
|
||
+ glow: bool,
|
||
+}
|
||
+
|
||
+/// The models the running mods keep, by (mod, id).
|
||
+#[cfg(feature = "mods")]
|
||
+#[derive(Default)]
|
||
+pub(super) struct ModModels {
|
||
+ poses: HashMap<(usize, u32), Pose>,
|
||
+ /// Block states by the text mods name them, parsed once.
|
||
+ states: HashMap<String, Option<BlockStateId>>,
|
||
+}
|
||
+
|
||
+/// Full brightness: sky and block light 15, as the entity shader packs them.
|
||
+#[cfg(feature = "mods")]
|
||
+const GLOW: u32 = 240 | (240 << 8);
|
||
+
|
||
+/// A build without the `mods` feature draws no mod models.
|
||
+#[cfg(not(feature = "mods"))]
|
||
+impl Game<'_> {
|
||
+ pub(super) fn mod_model_geometry(
|
||
+ &self,
|
||
+ _camera: glam::DVec3,
|
||
+ _alpha: f64,
|
||
+ _out: &mut Vec<horizon_client::mesher::Vertex>,
|
||
+ ) {
|
||
+ }
|
||
+}
|
||
+
|
||
+#[cfg(feature = "mods")]
|
||
+impl Game<'_> {
|
||
+ /// After the mods' tick: each model's new pose, its last one kept to
|
||
+ /// draw from. Ids no longer set are dropped; new ones start where they
|
||
+ /// are.
|
||
+ pub(super) fn update_mod_models(&mut self) {
|
||
+ let mut seen: Vec<(usize, u32)> = Vec::new();
|
||
+ let registry = &self.pack.assets.registry;
|
||
+ let models = &mut self.mods.models;
|
||
+ for (owner, m) in self.mods.mods.models() {
|
||
+ let state = *models
|
||
+ .states
|
||
+ .entry(m.model.clone())
|
||
+ .or_insert_with(|| super::render_control::parse_state(registry, &m.model).ok());
|
||
+ let Some(state) = state else {
|
||
+ continue;
|
||
+ };
|
||
+ let key = (owner, m.id);
|
||
+ seen.push(key);
|
||
+ let position = DVec3::new(m.x, m.y, m.z);
|
||
+ let (x, y, z, w) = m.rotation;
|
||
+ let rotation = Quat::from_xyzw(x, y, z, w);
|
||
+ match models.poses.get_mut(&key) {
|
||
+ Some(p) => {
|
||
+ p.previous = p.position;
|
||
+ p.old_rotation = p.rotation;
|
||
+ p.old_scale = p.scale;
|
||
+ p.position = position;
|
||
+ p.rotation = rotation;
|
||
+ p.scale = m.scale;
|
||
+ p.state = state;
|
||
+ p.glow = m.glow;
|
||
+ }
|
||
+ None => {
|
||
+ models.poses.insert(
|
||
+ key,
|
||
+ Pose {
|
||
+ state,
|
||
+ previous: position,
|
||
+ position,
|
||
+ old_rotation: rotation,
|
||
+ rotation,
|
||
+ old_scale: m.scale,
|
||
+ scale: m.scale,
|
||
+ glow: m.glow,
|
||
+ },
|
||
+ );
|
||
+ }
|
||
+ }
|
||
+ }
|
||
+ if seen.len() != models.poses.len() {
|
||
+ let seen: std::collections::HashSet<_> = seen.into_iter().collect();
|
||
+ models.poses.retain(|k, _| seen.contains(k));
|
||
+ }
|
||
+ }
|
||
+
|
||
+ /// Draws every mod model between its last two poses, lit by the world
|
||
+ /// (or at full brightness), its faces shaded by its own rotation.
|
||
+ pub(super) fn mod_model_geometry(&self, camera: DVec3, alpha: f64, out: &mut Vec<Vertex>) {
|
||
+ if self.mods.models.poses.is_empty() {
|
||
+ return;
|
||
+ }
|
||
+ let t = alpha.clamp(0.0, 1.0);
|
||
+ for p in self.mods.models.poses.values() {
|
||
+ let at = p.previous.lerp(p.position, t);
|
||
+ // Far models are culled by distance; the view frustum does the rest.
|
||
+ if at.distance_squared(camera) > 512.0 * 512.0 {
|
||
+ continue;
|
||
+ }
|
||
+ let rotation = p.old_rotation.slerp(p.rotation, t as f32);
|
||
+ let scale = p.old_scale + (p.scale - p.old_scale) * t as f32;
|
||
+ if scale <= 0.0 {
|
||
+ continue;
|
||
+ }
|
||
+ let light = if p.glow {
|
||
+ GLOW
|
||
+ } else {
|
||
+ let l = self.world.light(
|
||
+ at.x.floor() as i32,
|
||
+ at.y.floor() as i32,
|
||
+ at.z.floor() as i32,
|
||
+ );
|
||
+ (u32::from(l >> 4) * 16) | ((u32::from(l & 15) * 16) << 8)
|
||
+ };
|
||
+ // The model's unit cube turned and scaled about its centre.
|
||
+ let m = Mat4::from_scale_rotation_translation(
|
||
+ Vec3::splat(scale),
|
||
+ rotation,
|
||
+ (at - camera).as_vec3(),
|
||
+ ) * Mat4::from_translation(Vec3::splat(-0.5));
|
||
+ self.block_model_vertices(p.state, 0, m, light, true, out);
|
||
+ }
|
||
+ }
|
||
+
|
||
+ /// `render.spawn-particles`: made on this client at once.
|
||
+ pub(super) fn mod_particles(&mut self, particles: Vec<ParticleSpawn>) {
|
||
+ for s in particles {
|
||
+ let Some(ty) = ParticleType::from_name(&s.particle) else {
|
||
+ continue;
|
||
+ };
|
||
+ let rgb = [
|
||
+ ((s.color >> 16) & 255) as f32 / 255.0,
|
||
+ ((s.color >> 8) & 255) as f32 / 255.0,
|
||
+ (s.color & 255) as f32 / 255.0,
|
||
+ ];
|
||
+ let rgb = if s.color == 0 { [1.0; 3] } else { rgb };
|
||
+ let options = match ty {
|
||
+ ParticleType::Dust => ParticleOptions::dust(rgb, s.scale),
|
||
+ ParticleType::EntityEffect | ParticleType::TintedLeaves | ParticleType::Flash => {
|
||
+ ParticleOptions::colored(ty, [rgb[0], rgb[1], rgb[2], 1.0])
|
||
+ }
|
||
+ ParticleType::Block
|
||
+ | ParticleType::FallingDust
|
||
+ | ParticleType::DustPillar
|
||
+ | ParticleType::BlockMarker
|
||
+ | ParticleType::Item
|
||
+ | ParticleType::ItemSlime
|
||
+ | ParticleType::ItemSnowball
|
||
+ | ParticleType::ItemCobweb => continue,
|
||
+ _ => ParticleOptions::simple(ty),
|
||
+ };
|
||
+ let (sx, sy, sz) = s.spread;
|
||
+ let (vx, vy, vz) = s.velocity;
|
||
+ let speed = f64::from(s.speed);
|
||
+ for _ in 0..s.count {
|
||
+ let r = self.particles.random();
|
||
+ let p = [
|
||
+ s.x + r.next_gaussian() * f64::from(sx),
|
||
+ s.y + r.next_gaussian() * f64::from(sy),
|
||
+ s.z + r.next_gaussian() * f64::from(sz),
|
||
+ ];
|
||
+ let v = [
|
||
+ f64::from(vx) + r.next_gaussian() * speed,
|
||
+ f64::from(vy) + r.next_gaussian() * speed,
|
||
+ f64::from(vz) + r.next_gaussian() * speed,
|
||
+ ];
|
||
+ self.particles.create(&options, p, v);
|
||
+ }
|
||
+ }
|
||
+ }
|
||
+}
|
||
diff --git a/crates/horizon-client/src/game/mods.rs b/crates/horizon-client/src/game/mods.rs
|
||
index 52dc46fe0..3eef96aa3 100644
|
||
--- a/crates/horizon-client/src/game/mods.rs
|
||
+++ b/crates/horizon-client/src/game/mods.rs
|
||
@@ -46,6 +46,9 @@ pub(super) struct ModsState {
|
||
/// Capability `render`'s camera shakes.
|
||
#[cfg(feature = "mods")]
|
||
pub(super) shake: super::mod_render::Shake,
|
||
+ /// Capability `render`'s world models (AirDef fork), with their last poses.
|
||
+ #[cfg(feature = "mods")]
|
||
+ pub(super) models: super::mod_models::ModModels,
|
||
/// `--dev-mod PATH`: always on, reloaded when rebuilt.
|
||
pub(super) dev: Vec<PathBuf>,
|
||
/// When dev mods were last checked for a rebuild.
|
||
@@ -596,9 +599,17 @@ impl Game<'_> {
|
||
return;
|
||
}
|
||
self.tick_mod_use();
|
||
+ for (i, columns) in self.mods.mods.take_ground_queries() {
|
||
+ let ground = columns
|
||
+ .into_iter()
|
||
+ .map(|(x, z)| (x, z, self.rain_height(x, z)))
|
||
+ .collect();
|
||
+ self.mods.mods.answer_ground(i, ground);
|
||
+ }
|
||
let snapshot = self.mod_snapshot();
|
||
self.mods.mods.set_snapshot(snapshot);
|
||
self.mods.mods.tick(self.ticks);
|
||
+ self.update_mod_models();
|
||
self.show_mod_output();
|
||
}
|
||
/// Restarts the dev mods rebuilt since the last check, once a second.
|
||
@@ -716,6 +727,19 @@ impl Game<'_> {
|
||
self.mods.shake.add(strength, seconds);
|
||
continue;
|
||
}
|
||
+ Output::Particles { particles } => {
|
||
+ self.mod_particles(particles);
|
||
+ continue;
|
||
+ }
|
||
+ Output::Sound {
|
||
+ event,
|
||
+ at,
|
||
+ volume,
|
||
+ pitch,
|
||
+ } => {
|
||
+ self.audio.play(&event, Some(at), volume, pitch);
|
||
+ continue;
|
||
+ }
|
||
Output::PlaceRecipe { book_id, all } => {
|
||
self.mod_place_recipe(book_id, all);
|
||
continue;
|
||
diff --git a/crates/horizon-mod-sdk/wit/horizon.wit b/crates/horizon-mod-sdk/wit/horizon.wit
|
||
index 0680cce92..89d1906a9 100644
|
||
--- a/crates/horizon-mod-sdk/wit/horizon.wit
|
||
+++ b/crates/horizon-mod-sdk/wit/horizon.wit
|
||
@@ -429,6 +429,64 @@ interface render {
|
||
/// Distance setting, when on, wins. One far terrain shows at a time: the
|
||
/// first loaded mod's.
|
||
set-far-terrain: func(far-terrain: option<far-terrain>);
|
||
+ /// A block model drawn in the world (AirDef fork).
|
||
+ record model-instance {
|
||
+ /// Stays the same while the object lives: the game moves an id
|
||
+ /// smoothly from where the last `set-models` put it.
|
||
+ id: u32,
|
||
+ /// A block state as commands write it (`airdef:shahed`,
|
||
+ /// `minecraft:stone`, `minecraft:furnace[facing=east]`): its block
|
||
+ /// model is drawn. An unknown one draws nothing.
|
||
+ model: string,
|
||
+ /// Where the model's centre is, in blocks.
|
||
+ x: f64,
|
||
+ y: f64,
|
||
+ z: f64,
|
||
+ /// Rotation about the model's centre: a quaternion (x, y, z, w).
|
||
+ rotation: tuple<f32, f32, f32, f32>,
|
||
+ /// Uniform scale, 1.0 a block (0 to 64).
|
||
+ scale: f32,
|
||
+ /// Drawn at full brightness, whatever the light where it is.
|
||
+ glow: bool,
|
||
+ }
|
||
+ /// Replaces the mod's models: up to 4096, drawn with the world's
|
||
+ /// entities until replaced. Call it once a tick: the game interpolates
|
||
+ /// each id between its last two positions and rotations every frame.
|
||
+ /// An empty list removes them.
|
||
+ set-models: func(models: list<model-instance>);
|
||
+ /// Particles to spawn on this client (AirDef fork).
|
||
+ record particle-spawn {
|
||
+ /// As `/particle` names the type: `minecraft:flame`, `minecraft:dust`.
|
||
+ particle: string,
|
||
+ x: f64,
|
||
+ y: f64,
|
||
+ z: f64,
|
||
+ /// Gaussian spread round the point, per axis, in blocks.
|
||
+ spread: tuple<f32, f32, f32>,
|
||
+ /// Every particle's starting velocity, blocks a tick.
|
||
+ velocity: tuple<f32, f32, f32>,
|
||
+ /// Gaussian speed added to it per axis.
|
||
+ speed: f32,
|
||
+ /// 1 to 256.
|
||
+ count: u32,
|
||
+ /// 0xRRGGBB for dust and coloured types (0: white).
|
||
+ color: u32,
|
||
+ /// Dust size (0.01 to 4).
|
||
+ scale: f32,
|
||
+ }
|
||
+ /// Spawns particles on this client only: no command, no server.
|
||
+ /// At most 512 spawns a call.
|
||
+ spawn-particles: func(particles: list<particle-spawn>);
|
||
+ /// Plays a sound event (`minecraft:entity.generic.explode`,
|
||
+ /// `<mod>:<name>`) at a point on this client.
|
||
+ play-sound: func(event: string, x: f64, y: f64, z: f64, volume: f32, pitch: f32);
|
||
+ /// Asks for the ground height of these columns (x, z): at most 512 a
|
||
+ /// tick, answered by `ground` from the next tick on.
|
||
+ query-ground: func(columns: list<tuple<s32, s32>>);
|
||
+ /// The answers to the last tick's `query-ground`: (x, z, y), y one above
|
||
+ /// the highest block that blocks motion or holds a fluid, as the motion
|
||
+ /// blocking heightmap; -64 where the chunk isn't loaded.
|
||
+ ground: func() -> list<tuple<s32, s32, s32>>;
|
||
}
|
||
|
||
/// What the game calls. Only the hooks `init` asks for are ever called.
|
||
diff --git a/crates/horizon-mods/src/api_level.rs b/crates/horizon-mods/src/api_level.rs
|
||
index 045a9346b..b64792eaa 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 = 7;
|
||
+pub const LEVEL: u32 = 8;
|
||
|
||
/// 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); 46] = [
|
||
+pub const FUNCTIONS: [(&str, u32); 51] = [
|
||
("log.write", 0),
|
||
("game.get-player", 0),
|
||
("game.get-clock", 0),
|
||
@@ -60,6 +60,12 @@ pub const FUNCTIONS: [(&str, u32); 46] = [
|
||
("recipes.place", 6),
|
||
("recipes.give", 6),
|
||
("render.set-far-terrain", 7),
|
||
+ // AirDef fork: world models, client particles and sounds.
|
||
+ ("render.set-models", 8),
|
||
+ ("render.spawn-particles", 8),
|
||
+ ("render.play-sound", 8),
|
||
+ ("render.query-ground", 8),
|
||
+ ("render.ground", 8),
|
||
];
|
||
|
||
/// What the game's program carries so the launcher can read its level
|
||
diff --git a/crates/horizon-mods/src/runtime.rs b/crates/horizon-mods/src/runtime.rs
|
||
index cff44722b..317cd4f06 100644
|
||
--- a/crates/horizon-mods/src/runtime.rs
|
||
+++ b/crates/horizon-mods/src/runtime.rs
|
||
@@ -71,6 +71,7 @@ mod render_bindings {
|
||
wasmtime::component::bindgen!({
|
||
path: "../horizon-mod-sdk/wit",
|
||
world: "render-host",
|
||
+ additional_derives: [PartialEq],
|
||
});
|
||
}
|
||
use render_bindings::horizon::mods::input;
|
||
@@ -79,8 +80,8 @@ pub use render_bindings::horizon::mods::input::{Event as InputEvent, HeldItem, I
|
||
/// What capability `render` asks the game to draw (WIT `interface render`).
|
||
pub use render_bindings::horizon::mods::render::{
|
||
Corner as RenderCorner, FarTerrain as ModFarTerrain, FillShape, FrameRate, ItemShape,
|
||
- Minimap as ModMinimap, Point as RenderPoint, QuadShape, Rect as RenderRect, Shape, SpriteShape,
|
||
- TextShape, Zoom as ModZoom,
|
||
+ Minimap as ModMinimap, ModelInstance, ParticleSpawn, Point as RenderPoint, QuadShape,
|
||
+ Rect as RenderRect, Shape, SpriteShape, TextShape, Zoom as ModZoom,
|
||
};
|
||
use render_bindings::{RenderHost, horizon::mods::render};
|
||
/// Capability `recipes`'s imports (docs/mods.md): the game's recipes and items.
|
||
@@ -238,6 +239,15 @@ pub enum Output {
|
||
CloseScreen { owner: usize },
|
||
/// `render.shake`: strength 0 to 1, fading out over `seconds` (≤ 10).
|
||
Shake { strength: f32, seconds: f32 },
|
||
+ /// `render.spawn-particles` (AirDef fork): particles to make on this client.
|
||
+ Particles { particles: Vec<ParticleSpawn> },
|
||
+ /// `render.play-sound` (AirDef fork): a sound event at a point on this client.
|
||
+ Sound {
|
||
+ event: String,
|
||
+ at: [f64; 3],
|
||
+ volume: f32,
|
||
+ pitch: f32,
|
||
+ },
|
||
/// `recipes.place`: the recipe book's click on this display id, all of
|
||
/// it when `all`.
|
||
PlaceRecipe { book_id: i32, all: bool },
|
||
@@ -416,6 +426,12 @@ struct State {
|
||
minimap: Option<ModMinimap>,
|
||
/// Capability `render`'s far terrain, when the mod draws one.
|
||
far_terrain: Option<ModFarTerrain>,
|
||
+ /// Capability `render`'s world models (AirDef fork), drawn until replaced.
|
||
+ models: Vec<ModelInstance>,
|
||
+ /// `render.query-ground`'s columns, answered before the next tick.
|
||
+ ground_queries: Vec<(i32, i32)>,
|
||
+ /// The game's answers to them.
|
||
+ ground: Vec<(i32, i32, i32)>,
|
||
input: InputState,
|
||
browser: BrowserState,
|
||
/// Capability `storage`'s folder; made on first `set`.
|
||
@@ -619,6 +635,9 @@ impl Mods {
|
||
zoom: None,
|
||
minimap: None,
|
||
far_terrain: None,
|
||
+ models: Vec::new(),
|
||
+ ground_queries: Vec::new(),
|
||
+ ground: Vec::new(),
|
||
input: InputState::default(),
|
||
browser: BrowserState::default(),
|
||
data_dir,
|
||
@@ -1173,6 +1192,32 @@ impl Mods {
|
||
.iter()
|
||
.find_map(|e| e.run.as_ref()?.store.data().minimap)
|
||
}
|
||
+ /// Every running mod's world models (AirDef fork), with the mod's index
|
||
+ /// so ids of different mods don't meet.
|
||
+ pub fn models(&self) -> impl Iterator<Item = (usize, &ModelInstance)> {
|
||
+ self.entries.iter().enumerate().flat_map(|(i, e)| {
|
||
+ e.run
|
||
+ .iter()
|
||
+ .flat_map(move |r| r.store.data().models.iter().map(move |m| (i, m)))
|
||
+ })
|
||
+ }
|
||
+ /// The columns each running mod asked the ground height of, taken.
|
||
+ pub fn take_ground_queries(&mut self) -> Vec<(usize, Vec<(i32, i32)>)> {
|
||
+ self.entries
|
||
+ .iter_mut()
|
||
+ .enumerate()
|
||
+ .filter_map(|(i, e)| {
|
||
+ let q = std::mem::take(&mut e.run.as_mut()?.store.data_mut().ground_queries);
|
||
+ (!q.is_empty()).then_some((i, q))
|
||
+ })
|
||
+ .collect()
|
||
+ }
|
||
+ /// Mod `i`'s answers to its ground queries, for its next calls.
|
||
+ pub fn answer_ground(&mut self, i: usize, ground: Vec<(i32, i32, i32)>) {
|
||
+ if let Some(run) = self.entries.get_mut(i).and_then(|e| e.run.as_mut()) {
|
||
+ run.store.data_mut().ground = ground;
|
||
+ }
|
||
+ }
|
||
/// The far terrain the first running mod draws (capability `render`).
|
||
pub fn far_terrain(&self) -> Option<ModFarTerrain> {
|
||
self.entries
|
||
@@ -1734,6 +1779,12 @@ const MINIMAP_SIZE: (f32, f32) = (16.0, 256.0);
|
||
const MINIMAP_SCALE: (f32, f32) = (0.5, 4.0);
|
||
/// `set-far-terrain`'s distance in chunks, as the LOD Distance setting's.
|
||
const FAR_TERRAIN_DISTANCE: (u32, u32) = (64, 1024);
|
||
+/// `set-models`' most models a mod keeps, and their largest scale.
|
||
+const MODELS: usize = 4096;
|
||
+const MODEL_SCALE: f32 = 64.0;
|
||
+/// `spawn-particles`' most spawns a call and particles a spawn.
|
||
+const PARTICLE_SPAWNS: usize = 512;
|
||
+const PARTICLES_PER_SPAWN: u32 = 256;
|
||
|
||
/// A mod's corner panel: shapes in a `width` × `height` box the game
|
||
/// places in `corner`, stacked with the other overlays.
|
||
@@ -1851,6 +1902,67 @@ impl render::Host for State {
|
||
self.out.push(Output::Shake { strength, seconds });
|
||
}
|
||
}
|
||
+ fn set_models(&mut self, models: Vec<ModelInstance>) {
|
||
+ let finite = |v: f64| v.is_finite() && v.abs() < 30_000_000.0;
|
||
+ self.models = models
|
||
+ .into_iter()
|
||
+ .filter(|m| finite(m.x) && finite(m.y) && finite(m.z))
|
||
+ .take(MODELS)
|
||
+ .map(|mut m| {
|
||
+ let (x, y, z, w) = m.rotation;
|
||
+ let len = (x * x + y * y + z * z + w * w).sqrt();
|
||
+ m.rotation = if len.is_finite() && len > 1e-6 {
|
||
+ (x / len, y / len, z / len, w / len)
|
||
+ } else {
|
||
+ (0.0, 0.0, 0.0, 1.0)
|
||
+ };
|
||
+ m.scale = if m.scale.is_finite() {
|
||
+ m.scale.clamp(0.0, MODEL_SCALE)
|
||
+ } else {
|
||
+ 1.0
|
||
+ };
|
||
+ m.model = clip(m.model);
|
||
+ m
|
||
+ })
|
||
+ .collect();
|
||
+ }
|
||
+ fn spawn_particles(&mut self, particles: Vec<ParticleSpawn>) {
|
||
+ let particles: Vec<ParticleSpawn> = particles
|
||
+ .into_iter()
|
||
+ .filter(|p| p.x.is_finite() && p.y.is_finite() && p.z.is_finite())
|
||
+ .take(PARTICLE_SPAWNS)
|
||
+ .map(|mut p| {
|
||
+ p.count = p.count.clamp(1, PARTICLES_PER_SPAWN);
|
||
+ p.particle = clip(p.particle);
|
||
+ p
|
||
+ })
|
||
+ .collect();
|
||
+ if !particles.is_empty() {
|
||
+ self.out.push(Output::Particles { particles });
|
||
+ }
|
||
+ }
|
||
+ fn query_ground(&mut self, columns: Vec<(i32, i32)>) {
|
||
+ let room = 512usize.saturating_sub(self.ground_queries.len());
|
||
+ self.ground_queries.extend(columns.into_iter().take(room));
|
||
+ }
|
||
+ fn ground(&mut self) -> Vec<(i32, i32, i32)> {
|
||
+ self.ground.clone()
|
||
+ }
|
||
+ fn play_sound(&mut self, event: String, x: f64, y: f64, z: f64, volume: f32, pitch: f32) {
|
||
+ if !(x.is_finite() && y.is_finite() && z.is_finite()) {
|
||
+ return;
|
||
+ }
|
||
+ let ok = |v: f32, max: f32| if v.is_finite() { v.clamp(0.0, max) } else { 1.0 };
|
||
+ let sounds = self.out.iter().filter(|o| matches!(o, Output::Sound { .. })).count();
|
||
+ if sounds < 64 {
|
||
+ self.out.push(Output::Sound {
|
||
+ event: clip(event),
|
||
+ at: [x, y, z],
|
||
+ volume: ok(volume, 16.0),
|
||
+ pitch: ok(pitch, 2.0),
|
||
+ });
|
||
+ }
|
||
+ }
|
||
}
|
||
|
||
impl command::Host for State {
|
||
--
|
||
2.54.0 (Apple Git-157)
|
||
|