Send cushion breaking particles to nearby multiplayer clients

Co-Authored-By: Codex <noreply@openai.com>
This commit is contained in:
Eshayat Al-Wasiu
2026-10-26 22:40:12 +06:00
parent a42857ab48
commit 2a98f584df
5 changed files with 135 additions and 13 deletions
+2
View File
@@ -1584,6 +1584,7 @@ struct Game<'a> {
guest_use: Option<GuestUse>,
/// Level events of this tick for the guests, and the guest acting now.
lan_events: Vec<(i32, [i32; 3], i32, Option<u32>)>,
lan_cushion_breaks: Vec<(horizon_world::registry::BlockStateId, [f64; 3])>,
/// Block events (position, b0, b1, state) and entity events (id, event,
/// position) for the guests.
lan_block_events: Vec<([i32; 3], i32, i32, u32)>,
@@ -12477,6 +12478,7 @@ impl<'a> Game<'a> {
forced_hit: None,
guest_use: None,
lan_events: Vec::new(),
lan_cushion_breaks: Vec::new(),
lan_block_events: Vec::new(),
lan_entity_events: Vec::new(),
lan_critical_hits: Vec::new(),
+11 -13
View File
@@ -348,11 +348,15 @@ impl Game<'_> {
let Some(state) = self.pack.assets.registry.default_state(&name) else {
return;
};
let at = [
c.position.x.floor() as i32,
c.position.y.floor() as i32,
c.position.z.floor() as i32,
];
let p = c.position;
// Entity.getY(2/3) is relative to the cushion's quarter-block height.
let particle_at = [p.x, p.y + 0.25 * (2.0 / 3.0), p.z];
// Guest actions temporarily take self.lan; flush once the hub is back.
// Queue before resolving a sprite: a dedicated server has no renderer.
if self.audio.capture.is_some() {
self.lan_cushion_breaks.push((state, particle_at));
}
let at = [p.x.floor() as i32, p.y.floor() as i32, p.z.floor() as i32];
let Some((sprite, tint)) = self.particle_sprite(state, at) else {
return;
};
@@ -361,14 +365,8 @@ impl Game<'_> {
sprite,
tint,
);
let p = c.position;
self.particles.send_particles(
&options,
[p.x, p.y + 0.666_666_7, p.z],
[0.25, 0.0625, 0.25],
0.05,
10,
);
self.particles
.send_particles(&options, particle_at, [0.25, 0.0625, 0.25], 0.05, 10);
}
/// `BlockAttachedEntity.tick`: survive and burn checks after the post-increment reaches 100.
+29
View File
@@ -21,6 +21,7 @@ use horizon_core::geometry::Vec3;
use horizon_protocol::{
crypto::offline_uuid,
entity_data::{Value, serializer},
packets::play::{self, Clientbound as C},
};
use std::{
collections::HashMap,
@@ -665,6 +666,7 @@ impl Game<'_> {
self.audio.capture = None;
self.lan_events.clear();
self.lan_critical_hits.clear();
self.lan_cushion_breaks.clear();
self.streaming.extra.clear();
if announce {
self.system_message(Text::translate(
@@ -1229,6 +1231,33 @@ impl Game<'_> {
/// Send the tick's level events, sounds and mob animations.
fn lan_flush_effects(&mut self, host: &mut LanHost) {
let critical_hits = std::mem::take(&mut self.lan_critical_hits);
for (state, at) in std::mem::take(&mut self.lan_cushion_breaks) {
if let Some(id) = host
.hub
.registry_id("minecraft:particle_type", "minecraft:block")
{
host.hub.broadcast_near(
at,
32.0,
&C::LevelParticles(play::LevelParticles {
particle: horizon_protocol::entity_data::Particle {
id,
data: Some(horizon_protocol::entity_data::ParticleData::State(
state.0 as i32,
)),
},
override_limiter: false,
always_show: false,
pos: at,
dist: [0.25, 0.0625, 0.25],
max_speed: [0.05; 3],
count: 10,
randomization: 0,
}),
None,
);
}
}
for (kind, pos, data, acting) in std::mem::take(&mut self.lan_events) {
if kind == 2001 {
host.broken
+87
View File
@@ -0,0 +1,87 @@
"""Cushion particles must reach clients even when the server has no sprite atlas."""
from pathlib import Path
import shutil
import subprocess
import tempfile
import unittest
ROOT = Path(__file__).resolve().parents[1]
@unittest.skipUnless(shutil.which("rustc"), "requires Rust")
class CushionParticlesTests(unittest.TestCase):
def test_headless_server_records_break_effect_before_sprite_lookup(self):
source = (ROOT / "crates/horizon-client/src/game/cushions.rs").read_text()
start = source.index(" fn cushion_break_particles(")
end = source.index(" /// `BlockAttachedEntity.tick`", start)
fixture = r'''
#![allow(dead_code)]
#[derive(Clone, Copy)]
struct Vec3 { x: f64, y: f64, z: f64 }
struct Cushion { position: Vec3 }
impl Cushion { fn color_name(&self) -> &str { "blue" } }
struct Registry;
impl Registry {
fn default_state(&self, name: &str) -> Option<u32> {
assert_eq!(name, "minecraft:blue_wool"); Some(123)
}
}
struct Assets { registry: Registry }
struct Pack { assets: Assets }
struct Audio { capture: Option<()> }
mod horizon_client { pub mod particles {
pub enum ParticleType { Block }
pub struct ParticleOptions;
impl ParticleOptions {
pub fn block(_: ParticleType, _: String, _: u32) -> Self { Self }
}
} }
#[derive(Default)]
struct Particles { count: u32 }
impl Particles {
fn send_particles(&mut self, _: &horizon_client::particles::ParticleOptions,
at: [f64; 3], spread: [f64; 3], speed: f64, count: u32) {
assert!((at[1] - (10.0 + 1.0/6.0)).abs() < 1e-12);
assert_eq!(spread, [0.25, 0.0625, 0.25]);
assert_eq!(speed, 0.05); assert_eq!(count, 10); self.count += count;
}
}
struct Game {
pack: Pack, audio: Audio, particles: Particles, headless: bool,
lan_cushion_breaks: Vec<(u32, [f64; 3])>,
}
impl Game {
fn particle_sprite(&self, _: u32, _: [i32; 3]) -> Option<(String, u32)> {
(!self.headless).then(|| ("block/blue_wool".into(), 0xffffff))
}
METHOD
}
fn main() {
let cushion = Cushion { position: Vec3 { x: 1.5, y: 10.0, z: 2.5 } };
for headless in [true, false] {
for serving in [true, false] {
let mut game = Game {
pack: Pack { assets: Assets { registry: Registry } },
audio: Audio { capture: serving.then_some(()) },
particles: Particles::default(), headless, lan_cushion_breaks: vec![],
};
game.cushion_break_particles(&cushion);
assert_eq!(game.lan_cushion_breaks.len(), usize::from(serving));
if serving {
let (state, at) = game.lan_cushion_breaks[0];
assert_eq!(state, 123);
assert_eq!([at[0], at[2]], [1.5, 2.5]);
assert!((at[1] - (10.0 + 1.0/6.0)).abs() < 1e-12);
}
assert_eq!(game.particles.count, if headless { 0 } else { 10 });
}
}
}
'''.replace("METHOD", source[start:end])
with tempfile.TemporaryDirectory(prefix="cushion-particles-") as directory:
path = Path(directory)
(path / "main.rs").write_text(fixture)
subprocess.run(["rustc", "--edition=2024", str(path / "main.rs"), "-o", str(path / "probe")],
check=True, capture_output=True, text=True)
result = subprocess.run([str(path / "probe")], capture_output=True, text=True)
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
+6
View File
@@ -7235,9 +7235,15 @@ def check_cushions(host, guest, out):
assert player('status').get('riding') is None, seen
player('command', command='gamemode creative')
player.raw('look-at', at[0], at[1] + .125, at[2])
before = {g.name: g('status').get('particles', {}).get('block', 0) for g in (host, guest)}
player.raw('mouse', 'left', 'click')
player.wait(10)
assert not cushions(host) and not cushions(player), seen
seen[label]['break_particles'] = {
g.name: g('status').get('particles', {}).get('block', 0) - before[g.name]
for g in (host, guest)
}
assert all(n == 10 for n in seen[label]['break_particles'].values()), seen
return seen