Particle emitters: waxing, scraping, sparks, eating, snowballs, eggs, slimes
The new particle types now come from the events that spawn them in vanilla. - Level events 3003 (wax on), 3004 (wax off), 3005 (scrape): ParticleUtils.spawnParticlesOnBlockFaces, three to five particles on each face, from the client level's random. They are sent by honeycomb on a block (HoneycombItem.useOn), axe scraping and wax removal (BlockTransformer's TransformParticle.send), dispensed honeycomb, and copper golems being waxed or scraped. - Level event 3002: a struck lightning rod sends sparks along its axis (ParticleUtils.spawnParticlesAlongAxis); without an axis they cover the block's faces. - Eating: LivingEntity.spawnItemParticles, five crumbs every four ticks and sixteen when consumed (Consumable.onConsume), placed in front of the eyes and thrown along the look, as item particles in full colour. They were greyed terrain crumbs above the eyes. They now follow Consumable.shouldEmitParticlesAndSounds with the remaining ticks before the countdown, as LivingEntity does: six times over 32 ticks, not seven, each one a tick earlier. - Snowballs (Snowball.handleEntityEvent(3)): eight item crumbs at rest. Eggs (ThrownEgg): eight scattered by up to 0.04 a tick. - Slime, magma cube and sulfur cube landings (AbstractCubeMob.tick): size × 16 item_slime, flame or sulfur_cube_goo particles around the body, from draws the mob's random also makes on the server. New harness tools/test_emitter_particles_parity.py: the real ParticleUtils emitters and LivingEntity.spawnItemParticles. Part of #63 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,74 @@
|
||||
#![forbid(unsafe_code)]
|
||||
//! Runs tools/test_emitter_particles_parity.py's emitters through
|
||||
//! `particles::on_block_faces`, `particles::along_axis` and
|
||||
//! `particles::item_use_particles`.
|
||||
use horizon_client::{Result, particles};
|
||||
use horizon_core::random::{LegacyRandom, RandomSource};
|
||||
use serde_json::Value;
|
||||
use std::{
|
||||
fs::File,
|
||||
io::{BufRead, BufReader, BufWriter, Write},
|
||||
};
|
||||
|
||||
fn d(v: f64) -> String {
|
||||
format!("{:016x}", v.to_bits())
|
||||
}
|
||||
|
||||
fn main() -> Result<()> {
|
||||
let args: Vec<String> = std::env::args().collect();
|
||||
let mut out = BufWriter::new(File::create(&args[2])?);
|
||||
for (n, line) in BufReader::new(File::open(&args[1])?).lines().enumerate() {
|
||||
let c: Value = serde_json::from_str(&line?)?;
|
||||
writeln!(out, "== {n}")?;
|
||||
let mut random = LegacyRandom::new(c["seed"].as_i64().ok_or("seed")?);
|
||||
let mut lines = Vec::new();
|
||||
let record = |kind: &str| {
|
||||
let kind = kind.to_owned();
|
||||
move |p: [f64; 3], v: [f64; 3]| {
|
||||
format!(
|
||||
"+ {kind} {} {} {} {} {} {}",
|
||||
d(p[0]),
|
||||
d(p[1]),
|
||||
d(p[2]),
|
||||
d(v[0]),
|
||||
d(v[1]),
|
||||
d(v[2])
|
||||
)
|
||||
}
|
||||
};
|
||||
if c["kind"] == "item_use" {
|
||||
let p: [f64; 3] = std::array::from_fn(|i| c["pos"][i].as_f64().unwrap_or(0.0));
|
||||
let f = |k: &str| c[k].as_f64().unwrap_or(0.0) as f32;
|
||||
let line = record("item");
|
||||
particles::item_use_particles(
|
||||
&mut random,
|
||||
f("pitch"),
|
||||
f("yaw"),
|
||||
p,
|
||||
p[1] + f64::from(f("eye")),
|
||||
c["count"].as_u64().unwrap_or(0) as u32,
|
||||
|p, v| lines.push(line(p, v)),
|
||||
);
|
||||
} else {
|
||||
let p: [i32; 3] = std::array::from_fn(|i| c["pos"][i].as_i64().unwrap_or(0) as i32);
|
||||
match c["axis"].as_i64() {
|
||||
Some(axis @ 0..=2) => {
|
||||
let line = record("electric_spark");
|
||||
particles::along_axis(&mut random, axis as usize, p, |p, v| {
|
||||
lines.push(line(p, v))
|
||||
});
|
||||
}
|
||||
_ => {
|
||||
let line = record("wax_on");
|
||||
particles::on_block_faces(&mut random, p, |p, v| lines.push(line(p, v)));
|
||||
}
|
||||
}
|
||||
}
|
||||
for l in lines {
|
||||
writeln!(out, "{l}")?;
|
||||
}
|
||||
writeln!(out, "next {}", random.next_long())?;
|
||||
}
|
||||
out.flush()?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -52,6 +52,7 @@ mod lan_actions;
|
||||
mod lan_screen;
|
||||
mod lan_trading;
|
||||
mod leads;
|
||||
mod level_particles;
|
||||
mod lightning;
|
||||
mod llamas;
|
||||
mod loom;
|
||||
@@ -1578,6 +1579,11 @@ impl Game<'_> {
|
||||
data,
|
||||
} => self.sculk_charge_event(pos, data),
|
||||
LevelEvent::LevelEvent { id: 3007, pos, .. } => self.shriek_event(pos),
|
||||
LevelEvent::LevelEvent {
|
||||
id: id @ 3002..=3005,
|
||||
pos,
|
||||
data,
|
||||
} => self.block_particle_event(id, pos, data),
|
||||
LevelEvent::LevelEvent {
|
||||
id: 1500,
|
||||
pos,
|
||||
@@ -2326,11 +2332,13 @@ impl Game<'_> {
|
||||
return;
|
||||
}
|
||||
let duration = 32;
|
||||
let ticks = ticks + 1;
|
||||
// Consumable.shouldEmitParticlesAndSounds(useItemRemainingTicks), with
|
||||
// the remaining ticks before this tick counts down: every 4 ticks once
|
||||
// more than 21.875% of the time has passed (none on the last tick).
|
||||
let remaining = duration - ticks.min(duration);
|
||||
let ticks = ticks + 1;
|
||||
let eye = self.eye_position().unwrap_or_default();
|
||||
// Consumable.shouldEmitParticlesAndSounds: every 4 ticks in the last 25.
|
||||
if remaining <= 25 && remaining % 4 == 0 {
|
||||
if duration - remaining > (duration as f32 * 0.21875) as u32 && remaining % 4 == 0 {
|
||||
let pitch =
|
||||
(self.world.random.next_float() - self.world.random.next_float()) * 0.2 + 1.0;
|
||||
self.audio.play(
|
||||
@@ -2339,38 +2347,12 @@ impl Game<'_> {
|
||||
0.5 + 0.5 * self.world.random.next_int_bound(2).unwrap_or(0) as f32,
|
||||
pitch,
|
||||
);
|
||||
// `ItemParticleOption`: the particle material of a random layer of
|
||||
// the stack resolved for the ground (`BreakingItemParticle`).
|
||||
let state = self.resolve_item(
|
||||
&stack,
|
||||
horizon_assets::item_model::DisplayContext::Ground,
|
||||
None,
|
||||
0,
|
||||
);
|
||||
if let Some(sprite) = state.pick_particle(&mut self.world.random) {
|
||||
let (yaw, pitch) = (
|
||||
f64::from(self.yaw).to_radians(),
|
||||
f64::from(self.pitch).to_radians(),
|
||||
);
|
||||
let at = [
|
||||
eye[0] - yaw.sin() * pitch.cos() * 0.3,
|
||||
eye[1] - 0.1,
|
||||
eye[2] + yaw.cos() * pitch.cos() * 0.3,
|
||||
];
|
||||
for _ in 0..5 {
|
||||
let v = [
|
||||
(f64::from(self.world.random.next_float()) - 0.5) * 0.1,
|
||||
0.1,
|
||||
(f64::from(self.world.random.next_float()) - 0.5) * 0.1,
|
||||
];
|
||||
let p =
|
||||
Particle::terrain(&mut self.world.random, at, v, sprite.clone(), 0xFFFFFF);
|
||||
self.particles.push(p);
|
||||
}
|
||||
}
|
||||
self.eat_particles(&stack, 5);
|
||||
}
|
||||
if ticks >= duration {
|
||||
self.using = None;
|
||||
// Consumable.onConsume: emitParticlesAndSounds(..., 16).
|
||||
self.eat_particles(&stack, 16);
|
||||
// Consumable.onConsume.
|
||||
self.post_entity_event("minecraft:eat", horizon_client::mobs::Who::Player);
|
||||
if let Some((nutrition, saturation)) = info.food
|
||||
@@ -6293,7 +6275,7 @@ impl Game<'_> {
|
||||
_ => "east",
|
||||
};
|
||||
let registry = self.res.assets.registry;
|
||||
let (state, sound, loot, damage) = {
|
||||
let (state, sound, loot, damage, particle) = {
|
||||
let around = Column {
|
||||
world: &self.world,
|
||||
registry,
|
||||
@@ -6306,12 +6288,25 @@ impl Game<'_> {
|
||||
else {
|
||||
return Ok(false);
|
||||
};
|
||||
(state, t.sound.clone(), t.loot.clone(), t.damage)
|
||||
(
|
||||
state,
|
||||
t.sound.clone(),
|
||||
t.loot.clone(),
|
||||
t.damage,
|
||||
t.particle.clone(),
|
||||
)
|
||||
};
|
||||
let Ok(new) = registry.resolve(&state) else {
|
||||
return Ok(false);
|
||||
};
|
||||
self.level_set(pos, new)?;
|
||||
// BlockTransformer.TransformParticle.send.
|
||||
match particle.as_str() {
|
||||
"scrape" => self.level_event_at(3005, pos, 0),
|
||||
"wax_on" => self.level_event_at(3003, pos, 0),
|
||||
"wax_off" => self.level_event_at(3004, pos, 0),
|
||||
_ => {}
|
||||
}
|
||||
if loot.as_deref() == Some("minecraft:till/rooted_dirt") {
|
||||
self.pop_resource(
|
||||
[pos[0] + face[0], pos[1] + face[1], pos[2] + face[2]],
|
||||
@@ -6494,6 +6489,7 @@ impl Game<'_> {
|
||||
&& let Ok(state) = registry.resolve(&waxed)
|
||||
{
|
||||
self.level_set(hit.pos, state)?;
|
||||
self.level_event_at(3003, hit.pos, 0);
|
||||
self.audio.play(
|
||||
"minecraft:item.honeycomb.wax_on",
|
||||
Some(at(hit.pos)),
|
||||
|
||||
@@ -256,8 +256,13 @@ impl Game<'_> {
|
||||
self.damage_held_tool();
|
||||
return true;
|
||||
}
|
||||
let block = {
|
||||
let p = self.mobs[index].position;
|
||||
[p.x.floor() as i32, p.y.floor() as i32, p.z.floor() as i32]
|
||||
};
|
||||
if name == "minecraft:honeycomb" && !waxed {
|
||||
self.mobs[index].extra.vector[0] = -2.0;
|
||||
self.level_event_at(3003, block, 0);
|
||||
self.audio
|
||||
.play("minecraft:item.honeycomb.wax_on", Some(at), 1.0, 1.0);
|
||||
self.consume_held();
|
||||
@@ -267,6 +272,7 @@ impl Game<'_> {
|
||||
self.mobs[index].extra.vector[0] = -1.0;
|
||||
self.audio
|
||||
.play("minecraft:item.axe.scrape", Some(at), 1.0, 1.0);
|
||||
self.level_event_at(3004, block, 0);
|
||||
self.damage_held_tool();
|
||||
return true;
|
||||
}
|
||||
@@ -276,6 +282,7 @@ impl Game<'_> {
|
||||
m.extra.variant = horizon_client::mobs::copper_golem::weather_previous(m.extra.variant);
|
||||
self.audio
|
||||
.play("minecraft:item.axe.scrape", Some(at), 1.0, 1.0);
|
||||
self.level_event_at(3005, block, 0);
|
||||
self.damage_held_tool();
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -888,6 +888,9 @@ impl Game<'_> {
|
||||
MobEvent::Particles { kind, at, count } => {
|
||||
// Mob particle bursts (hearts, smoke, splashes) around a point.
|
||||
if let Some(ty) = horizon_client::particles::ParticleType::from_name(kind) {
|
||||
if kind.starts_with("item_") {
|
||||
self.ensure_item_particle_sprites();
|
||||
}
|
||||
let options = horizon_client::particles::ParticleOptions::simple(ty);
|
||||
// Count 0: one particle exactly there, at rest.
|
||||
if count == 0 {
|
||||
@@ -1750,23 +1753,24 @@ impl Game<'_> {
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
/// Item break particles (snowballs, eggs).
|
||||
/// `Snowball`/`ThrownEgg.handleEntityEvent(3)`: eight crumbs of the
|
||||
/// thrown item where it broke, at rest for a snowball, scattered by the
|
||||
/// client entity's random (the particle engine's here) for an egg.
|
||||
fn item_burst(&mut self, at: [f64; 3], item: &str) {
|
||||
let sprite = format!("item/{}", item.trim_start_matches("minecraft:"));
|
||||
let options = self.item_particle(&horizon_client::items::ItemStack::new(item, 1));
|
||||
let egg = item != "minecraft:snowball";
|
||||
for _ in 0..8 {
|
||||
let v = [
|
||||
(self.world.random.next_double() - 0.5) * 0.08,
|
||||
self.world.random.next_double() * 0.1,
|
||||
(self.world.random.next_double() - 0.5) * 0.08,
|
||||
];
|
||||
let p = horizon_client::particles::Particle::terrain(
|
||||
&mut self.world.random,
|
||||
at,
|
||||
v,
|
||||
sprite.clone(),
|
||||
0xFFFFFF,
|
||||
);
|
||||
self.particles.push(p);
|
||||
let v = if egg {
|
||||
let r = self.particles.random();
|
||||
[
|
||||
(f64::from(r.next_float()) - 0.5) * 0.08,
|
||||
(f64::from(r.next_float()) - 0.5) * 0.08,
|
||||
(f64::from(r.next_float()) - 0.5) * 0.08,
|
||||
]
|
||||
} else {
|
||||
[0.0; 3]
|
||||
};
|
||||
self.particles.add(&options, at, v);
|
||||
}
|
||||
}
|
||||
/// Right click on a mob (Mob.interact / mobInteract). Returns true when consumed.
|
||||
|
||||
@@ -4,7 +4,7 @@ use super::Game;
|
||||
use horizon_assets::item_model::DisplayContext;
|
||||
use horizon_client::{
|
||||
items::ItemStack,
|
||||
particles::{ParticleOptions, ParticleType},
|
||||
particles::{self, ParticleOptions, ParticleType},
|
||||
};
|
||||
|
||||
impl Game<'_> {
|
||||
@@ -14,6 +14,28 @@ impl Game<'_> {
|
||||
let state = self.resolve_item(stack, DisplayContext::Ground, None, 0);
|
||||
ParticleOptions::item(state.layers.iter().map(|l| l.particle.clone()).collect())
|
||||
}
|
||||
/// `LivingEntity.spawnItemParticles(stack, count)` for the local player,
|
||||
/// drawing from the world random as its eating sounds do.
|
||||
pub(super) fn eat_particles(&mut self, stack: &ItemStack, count: u32) {
|
||||
let Some(p) = self.player.as_ref().map(|p| p.position) else {
|
||||
return;
|
||||
};
|
||||
let eye_y = self.eye_position().map_or(p.y, |e| e[1]);
|
||||
let options = self.item_particle(stack);
|
||||
let mut spawned = Vec::new();
|
||||
particles::item_use_particles(
|
||||
&mut self.world.random,
|
||||
self.pitch,
|
||||
self.yaw,
|
||||
[p.x, p.y, p.z],
|
||||
eye_y,
|
||||
count,
|
||||
|p, v| spawned.push((p, v)),
|
||||
);
|
||||
for (p, v) in spawned {
|
||||
self.particles.add(&options, p, v);
|
||||
}
|
||||
}
|
||||
/// The slime ball, snowball and cobweb models behind `item_slime`,
|
||||
/// `item_snowball` and `item_cobweb`, resolved on first use.
|
||||
pub(super) fn ensure_item_particle_sprites(&mut self) {
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
//! Level events whose client handler (`LevelEventHandler`) spawns particles
|
||||
//! on a block: electric sparks (3002), waxing (3003), wax off (3004) and
|
||||
//! scraping (3005).
|
||||
use super::Game;
|
||||
use horizon_client::particles::{self, ParticleOptions, ParticleType};
|
||||
use horizon_core::position::BlockPos;
|
||||
use horizon_world::level::LevelEvent;
|
||||
|
||||
impl Game<'_> {
|
||||
/// `LevelEventHandler.levelEvent` 3002 to 3005, drawing from the client
|
||||
/// level's random.
|
||||
pub(super) fn block_particle_event(&mut self, id: i32, pos: BlockPos, data: i32) {
|
||||
let ty = match id {
|
||||
3002 => ParticleType::ElectricSpark,
|
||||
3003 => ParticleType::WaxOn,
|
||||
3004 => ParticleType::WaxOff,
|
||||
3005 => ParticleType::Scrape,
|
||||
_ => return,
|
||||
};
|
||||
let p = [pos.x, pos.y, pos.z];
|
||||
let mut spawned = Vec::new();
|
||||
let random = self.particles.level_random();
|
||||
// 3002 with an axis: a lightning rod; otherwise copper cleaned by lightning.
|
||||
match usize::try_from(data) {
|
||||
Ok(axis) if id == 3002 && axis < 3 => {
|
||||
particles::along_axis(random, axis, p, |p, v| spawned.push((p, v)));
|
||||
}
|
||||
_ => particles::on_block_faces(random, p, |p, v| spawned.push((p, v))),
|
||||
}
|
||||
let options = ParticleOptions::simple(ty);
|
||||
for (p, v) in spawned {
|
||||
self.particles.add(&options, p, v);
|
||||
}
|
||||
}
|
||||
/// Queue and play level event `id` at `pos` (`Level.levelEvent`).
|
||||
pub(super) fn level_event_at(&mut self, id: i32, pos: [i32; 3], data: i32) {
|
||||
self.level_events.push(LevelEvent::LevelEvent {
|
||||
id,
|
||||
pos: BlockPos::new(pos[0], pos[1], pos[2]),
|
||||
data,
|
||||
});
|
||||
let _ = self.process_level_events();
|
||||
}
|
||||
}
|
||||
@@ -246,6 +246,13 @@ impl Game<'_> {
|
||||
// The rod's onPlace schedules the 8-tick unpowering.
|
||||
let _ = self.level_set(p, powered);
|
||||
}
|
||||
// Level event 3002 with the rod's axis: sparks along it.
|
||||
let axis = match s.properties.get("facing").map(String::as_str) {
|
||||
Some("east" | "west") => 0,
|
||||
Some("north" | "south") => 2,
|
||||
_ => 1,
|
||||
};
|
||||
self.level_event_at(3002, p, axis);
|
||||
}
|
||||
}
|
||||
/// `thunderHit` for entities within 3 blocks (up to 9 above).
|
||||
|
||||
@@ -7,7 +7,10 @@ use super::{
|
||||
control::{MoveKind, Operation, rotlerp},
|
||||
goals::{Cx, Goal, JUMP, LOOK, MOVE, NearestTarget, Prey, goal_clone, reduced},
|
||||
};
|
||||
use horizon_core::{geometry::Vec3, random::RandomSource};
|
||||
use horizon_core::{
|
||||
geometry::{Vec3, trig},
|
||||
random::RandomSource,
|
||||
};
|
||||
|
||||
// AnimState slots.
|
||||
const SQUISH: usize = 0;
|
||||
@@ -144,6 +147,31 @@ pub fn ai_step(m: &mut Mob, w: &MobWorld, events: &mut Vec<MobEvent>) {
|
||||
v[SQUISH] += (v[TARGET_SQUISH] - v[SQUISH]) * 0.5;
|
||||
let was = m.extra.flag2;
|
||||
if m.on_ground && !was {
|
||||
// The landing splash: drawn on the server too, where it spawns nothing.
|
||||
let size = m.width() * 2.0;
|
||||
let radius = size / 2.0;
|
||||
let kind = match m.species() {
|
||||
Species::MagmaCube => "flame",
|
||||
Species::SulfurCube => "sulfur_cube_goo",
|
||||
_ => "item_slime",
|
||||
};
|
||||
let mut i = 0;
|
||||
while (i as f32) < size * 16.0 {
|
||||
let dir = m.random.next_float() * std::f64::consts::TAU as f32;
|
||||
let d = m.random.next_float() * 0.5 + 0.5;
|
||||
let xd = trig::sin(f64::from(dir)) * radius * d;
|
||||
let zd = trig::cos(f64::from(dir)) * radius * d;
|
||||
events.push(MobEvent::Particles {
|
||||
kind,
|
||||
at: [
|
||||
m.position.x + f64::from(xd),
|
||||
m.position.y,
|
||||
m.position.z + f64::from(zd),
|
||||
],
|
||||
count: 0,
|
||||
});
|
||||
i += 1;
|
||||
}
|
||||
let squish = match (m.species(), tiny(m)) {
|
||||
(Species::SulfurCube, _) => super::sulfur_cube::squish_sound(m),
|
||||
(Species::MagmaCube, true) => "entity.magma_cube.squish_small",
|
||||
|
||||
@@ -554,6 +554,110 @@ pub fn destroy_positions(
|
||||
}
|
||||
}
|
||||
|
||||
/// `LivingEntity.spawnItemParticles(stack, count)` (eating, drinking, an item
|
||||
/// breaking): `count` crumbs in front of the eyes, thrown up and forwards,
|
||||
/// both turned by the pitch and yaw in degrees.
|
||||
pub fn item_use_particles(
|
||||
random: &mut impl RandomSource,
|
||||
pitch: f32,
|
||||
yaw: f32,
|
||||
position: [f64; 3],
|
||||
eye_y: f64,
|
||||
count: u32,
|
||||
mut emit: impl FnMut([f64; 3], [f64; 3]),
|
||||
) {
|
||||
let (x_rot, y_rot) = (-pitch * trig::DEG_TO_RAD, -yaw * trig::DEG_TO_RAD);
|
||||
for _ in 0..count {
|
||||
let d = Vec3::new(
|
||||
(f64::from(random.next_float()) - 0.5) * 0.1,
|
||||
f64::from(random.next_float()) * 0.1 + 0.1,
|
||||
0.0,
|
||||
)
|
||||
.rotate_x(x_rot)
|
||||
.rotate_y(y_rot);
|
||||
let y = f64::from(-random.next_float()) * 0.6 - 0.3;
|
||||
let p = Vec3::new((f64::from(random.next_float()) - 0.5) * 0.3, y, 0.6)
|
||||
.rotate_x(x_rot)
|
||||
.rotate_y(y_rot);
|
||||
emit(
|
||||
[p.x + position[0], p.y + eye_y, p.z + position[2]],
|
||||
[d.x, d.y + 0.05, d.z],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// `Mth.nextDouble(random, min, max)`.
|
||||
fn next_double_between(random: &mut impl RandomSource, min: f64, max: f64) -> f64 {
|
||||
if min >= max {
|
||||
min
|
||||
} else {
|
||||
random.next_double() * (max - min) + min
|
||||
}
|
||||
}
|
||||
/// `ParticleUtils.spawnParticlesOnBlockFaces(level, pos, particle, UniformInt.of(3, 5))`
|
||||
/// (level events 3002 without an axis, 3003 wax on, 3004 wax off, 3005 scrape):
|
||||
/// three to five particles on each face, in `Direction.values()` order, moving
|
||||
/// along the face at up to half a block a tick.
|
||||
pub fn on_block_faces(
|
||||
random: &mut impl RandomSource,
|
||||
pos: [i32; 3],
|
||||
mut emit: impl FnMut([f64; 3], [f64; 3]),
|
||||
) {
|
||||
const FACES: [[i32; 3]; 6] = [
|
||||
[0, -1, 0],
|
||||
[0, 1, 0],
|
||||
[0, 0, -1],
|
||||
[0, 0, 1],
|
||||
[-1, 0, 0],
|
||||
[1, 0, 0],
|
||||
];
|
||||
for face in FACES {
|
||||
let count = random.next_int_bound(5 - 3 + 1).unwrap_or(0) + 3;
|
||||
for _ in 0..count {
|
||||
// getRandomSpeedRanges, then spawnParticleOnFace with step factor 0.55.
|
||||
let speed: [f64; 3] = std::array::from_fn(|_| next_double_between(random, -0.5, 0.5));
|
||||
let mut p = [0.0; 3];
|
||||
let mut v = [0.0; 3];
|
||||
for i in 0..3 {
|
||||
let center = f64::from(pos[i]) + 0.5;
|
||||
if face[i] == 0 {
|
||||
p[i] = center + next_double_between(random, -0.5, 0.5);
|
||||
v[i] = speed[i];
|
||||
} else {
|
||||
p[i] = center + f64::from(face[i]) * 0.55;
|
||||
}
|
||||
}
|
||||
emit(p, v);
|
||||
}
|
||||
}
|
||||
}
|
||||
/// `ParticleUtils.spawnParticlesAlongAxis(axis, level, pos, 0.125, particle,
|
||||
/// UniformInt.of(10, 19))` (level event 3002 on a lightning rod): sparks along
|
||||
/// the rod, moving along it. `axis` is 0, 1, 2 for X, Y, Z.
|
||||
pub fn along_axis(
|
||||
random: &mut impl RandomSource,
|
||||
axis: usize,
|
||||
pos: [i32; 3],
|
||||
mut emit: impl FnMut([f64; 3], [f64; 3]),
|
||||
) {
|
||||
let count = random.next_int_bound(19 - 10 + 1).unwrap_or(0) + 10;
|
||||
for _ in 0..count {
|
||||
let p: [f64; 3] = std::array::from_fn(|i| {
|
||||
f64::from(pos[i])
|
||||
+ 0.5
|
||||
+ next_double_between(random, -1.0, 1.0) * if i == axis { 0.5 } else { 0.125 }
|
||||
});
|
||||
let v: [f64; 3] = std::array::from_fn(|i| {
|
||||
if i == axis {
|
||||
next_double_between(random, -1.0, 1.0)
|
||||
} else {
|
||||
0.0
|
||||
}
|
||||
});
|
||||
emit(p, v);
|
||||
}
|
||||
}
|
||||
|
||||
impl Particle {
|
||||
/// `Particle(level, x, y, z)` + `SingleQuadParticle` (no initial velocity).
|
||||
fn at(random: &mut LegacyRandom, ty: ParticleType, p: [f64; 3]) -> Self {
|
||||
@@ -1033,6 +1137,10 @@ impl ParticleEngine {
|
||||
pub fn random(&mut self) -> &mut LegacyRandom {
|
||||
&mut self.random
|
||||
}
|
||||
/// `ClientLevel.random` as client effects use it (`LevelEventHandler`).
|
||||
pub fn level_random(&mut self) -> &mut LegacyRandom {
|
||||
&mut self.level_random
|
||||
}
|
||||
/// `ClientLevel.addParticle`: dropped beyond 32 blocks of the camera unless
|
||||
/// the type overrides the limiter.
|
||||
pub fn add(&mut self, options: &ParticleOptions, p: [f64; 3], v: [f64; 3]) {
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
package dev.horizonlab.parity;
|
||||
|
||||
import com.google.gson.*;
|
||||
import java.lang.reflect.*;
|
||||
import java.nio.file.*;
|
||||
import java.util.*;
|
||||
import net.minecraft.SharedConstants;
|
||||
import net.minecraft.client.multiplayer.ClientLevel;
|
||||
import net.minecraft.core.BlockPos;
|
||||
import net.minecraft.core.Direction;
|
||||
import net.minecraft.core.particles.ParticleOptions;
|
||||
import net.minecraft.core.particles.ParticleTypes;
|
||||
import net.minecraft.core.registries.BuiltInRegistries;
|
||||
import net.minecraft.resources.Identifier;
|
||||
import net.minecraft.server.Bootstrap;
|
||||
import net.minecraft.util.ParticleUtils;
|
||||
import net.minecraft.util.RandomSource;
|
||||
import net.minecraft.util.valueproviders.UniformInt;
|
||||
import net.minecraft.world.entity.Entity;
|
||||
import net.minecraft.world.entity.animal.cow.Cow;
|
||||
import net.minecraft.world.item.ItemStack;
|
||||
import net.minecraft.world.level.Level;
|
||||
import net.minecraft.world.phys.Vec3;
|
||||
|
||||
/** Runs the real particle emitters of level events 3002 to 3005 (ParticleUtils
|
||||
* spawnParticlesOnBlockFaces and spawnParticlesAlongAxis, as LevelEventHandler
|
||||
* calls them) and LivingEntity.spawnItemParticles (eating, drinking, breaking)
|
||||
* against a stand-in level that records what they spawn. */
|
||||
public final class VanillaEmitterParticlesOracle {
|
||||
static String d(double v) { return String.format("%016x", Double.doubleToRawLongBits(v)); }
|
||||
static Field field(Class<?> type, String name) throws Exception {
|
||||
var f = type.getDeclaredField(name); f.setAccessible(true); return f;
|
||||
}
|
||||
static final class Level0 extends ClientLevel {
|
||||
List<String> events;
|
||||
Level0() { super(null, null, null, null, 0, 0, null, false, 0L, 0); }
|
||||
@Override public void addParticle(ParticleOptions o, double x, double y, double z, double xd, double yd, double zd) {
|
||||
events.add("+ " + BuiltInRegistries.PARTICLE_TYPE.getKey(o.getType()).getPath() + " " + d(x) + " " + d(y) + " " + d(z)
|
||||
+ " " + d(xd) + " " + d(yd) + " " + d(zd));
|
||||
}
|
||||
}
|
||||
|
||||
public static void main(String[] args) throws Exception {
|
||||
SharedConstants.tryDetectVersion(); Bootstrap.bootStrap(); ParityData.init();
|
||||
var u = Class.forName("sun.misc.Unsafe"); var unsafe = field(u, "theUnsafe").get(null);
|
||||
var allocate = u.getMethod("allocateInstance", Class.class);
|
||||
var level = (Level0) allocate.invoke(unsafe, Level0.class); level.events = new ArrayList<>();
|
||||
Field levelRandom = field(Level.class, "random");
|
||||
Field levelField = field(Entity.class, "level"), position = field(Entity.class, "position"), random = field(Entity.class, "random"),
|
||||
xRot = field(Entity.class, "xRot"), yRot = field(Entity.class, "yRot"), eyeHeight = field(Entity.class, "eyeHeight");
|
||||
try (var output = Files.newBufferedWriter(Path.of(args[1]))) {
|
||||
int n = 0;
|
||||
for (String line : Files.readAllLines(Path.of(args[0]))) {
|
||||
var c = JsonParser.parseString(line).getAsJsonObject();
|
||||
output.write("== " + n++); output.newLine();
|
||||
level.events.clear();
|
||||
var r = RandomSource.create(c.get("seed").getAsLong());
|
||||
var p = c.getAsJsonArray("pos");
|
||||
var kind = c.get("kind").getAsString();
|
||||
if (kind.equals("item_use")) {
|
||||
var entity = (Cow) allocate.invoke(unsafe, Cow.class);
|
||||
levelField.set(entity, level);
|
||||
position.set(entity, new Vec3(p.get(0).getAsDouble(), p.get(1).getAsDouble(), p.get(2).getAsDouble()));
|
||||
random.set(entity, r);
|
||||
xRot.setFloat(entity, c.get("pitch").getAsFloat());
|
||||
yRot.setFloat(entity, c.get("yaw").getAsFloat());
|
||||
eyeHeight.setFloat(entity, c.get("eye").getAsFloat());
|
||||
var item = BuiltInRegistries.ITEM.getValue(Identifier.parse(c.get("item").getAsString()));
|
||||
entity.spawnItemParticles(new ItemStack(item), c.get("count").getAsInt());
|
||||
} else {
|
||||
levelRandom.set(level, r);
|
||||
var pos = new BlockPos(p.get(0).getAsInt(), p.get(1).getAsInt(), p.get(2).getAsInt());
|
||||
int axis = c.get("axis").getAsInt();
|
||||
if (axis >= 0) ParticleUtils.spawnParticlesAlongAxis(Direction.Axis.VALUES[axis], level, pos, 0.125, ParticleTypes.ELECTRIC_SPARK, UniformInt.of(10, 19));
|
||||
else ParticleUtils.spawnParticlesOnBlockFaces(level, pos, ParticleTypes.WAX_ON, UniformInt.of(3, 5));
|
||||
}
|
||||
for (var e : level.events) { output.write(e); output.newLine(); }
|
||||
output.write("next " + r.nextLong()); output.newLine();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Compare particle emitters with the real vanilla ones: ParticleUtils
|
||||
spawnParticlesOnBlockFaces and spawnParticlesAlongAxis as LevelEventHandler
|
||||
calls them for level events 3002 (electric sparks), 3003 (wax on), 3004 (wax
|
||||
off) and 3005 (scrape), and LivingEntity.spawnItemParticles (eating, drinking):
|
||||
particle, count, positions, motion and every random draw."""
|
||||
import json
|
||||
import random
|
||||
|
||||
try:
|
||||
from . import lab, oracle_harness as harness
|
||||
except ImportError:
|
||||
import lab, oracle_harness as harness
|
||||
|
||||
|
||||
def cases(rng):
|
||||
for _ in range(600):
|
||||
pos = [rng.randrange(-30000, 30000), rng.randrange(-64, 320), rng.randrange(-30000, 30000)]
|
||||
yield dict(kind='block', pos=pos, axis=rng.choice([-1, -1, 0, 1, 2]), seed=rng.randrange(-2**63, 2**63))
|
||||
angles = [0.0, 90.0, -90.0, 45.0, -45.0, 180.0, -180.0, 12.5, 359.75, -721.5]
|
||||
for _ in range(900):
|
||||
# Dyadic values: serde_json's decimal parsing may round others a unit off.
|
||||
pos = [rng.randrange(-2**20, 2**20) / 64 for _ in range(3)]
|
||||
yield dict(kind='item_use', pos=pos, pitch=rng.choice(angles + [rng.randrange(-5760, 5760) / 64]),
|
||||
yaw=rng.choice(angles + [rng.randrange(-23040, 23040) / 64]),
|
||||
eye=rng.choice([1.62, 1.27, 0.4, 1.74]), count=rng.choice([5, 16, 1, 0]),
|
||||
item=rng.choice(['minecraft:apple', 'minecraft:cooked_beef', 'minecraft:potion', 'minecraft:honey_bottle']),
|
||||
seed=rng.randrange(-2**63, 2**63))
|
||||
|
||||
|
||||
def make_cases(build):
|
||||
path = build / 'cases.jsonl'
|
||||
count = 0
|
||||
with path.open('w') as out:
|
||||
for case in cases(random.Random(0x454d4954)):
|
||||
out.write(json.dumps(case, separators=(',', ':')) + '\n')
|
||||
count += 1
|
||||
return path, count
|
||||
|
||||
|
||||
def main():
|
||||
scope = ('The real ParticleUtils.spawnParticlesOnBlockFaces (UniformInt 3..5 per face) and spawnParticlesAlongAxis '
|
||||
'(radius 0.125, UniformInt 10..19) on a stand-in level with a seeded random, as LevelEventHandler runs '
|
||||
'them for level events 3002-3005, and the real LivingEntity.spawnItemParticles on an entity given a '
|
||||
'position, rotation, eye height and seeded random. Which code sends those events, and the particles '
|
||||
'once spawned, are covered elsewhere.')
|
||||
harness.differential(
|
||||
'rust-emitter-particles-parity', 'VanillaEmitterParticlesOracle', 'emitter_particles_probe', make_cases, scope,
|
||||
sources=[lab.ROOT / 'crates/horizon-client/src/particles.rs'])
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
Reference in New Issue
Block a user