Send cushion breaking particles to nearby multiplayer clients
Co-Authored-By: Codex <noreply@openai.com>
This commit is contained in:
@@ -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(),
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user