mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-07 11:06:11 -10:00
Compare commits
121
Commits
No files matched your search
@@ -15,6 +15,11 @@ type_complexity = "allow"
|
||||
needless_range_loop = "allow"
|
||||
large_enum_variant = "allow"
|
||||
|
||||
# Line tables keep file:line in backtraces; full debug info made every
|
||||
# example and test binary hundreds of megabytes and slow to link.
|
||||
[profile.dev]
|
||||
debug = "line-tables-only"
|
||||
|
||||
[profile.release]
|
||||
lto = "thin"
|
||||
codegen-units = 1
|
||||
|
||||
@@ -16,41 +16,42 @@ Horizon is a Rust rewrite of Minecraft Java **26.3** targeting one-to-one vanill
|
||||
|
||||
Parity is **not** complete. Still missing: the warden and the sculk family, the breeze, sniffer, creaking, happy ghast, llamas and a few other mobs, raids and pillager patrols, villager breeding and gossip, the loom, crafter and recipe book, scoreboards, subtitles, and spawned mob gear enchantments. Multiplayer joins vanilla servers but Horizon cannot host one. See [Rust rewrite status](docs/rust-parity.md).
|
||||
|
||||
### Java parity: 80%
|
||||
### Java parity: 84%
|
||||
|
||||
Each subsystem scores 0 (absent), 0.25 (started, not playable), 0.5 (playable,
|
||||
major vanilla features missing), 0.75 (playable, mostly complete) or 1 (complete
|
||||
and verified against vanilla by differential tests). The percentage is the
|
||||
average; `tests/test_parity.py` keeps it in sync with this table. Last audited
|
||||
2026-10-10 against the code, not the docs: of the 232 `tools/test_*_parity.py`
|
||||
differential harnesses, 223 pass, 8 fail and the item model harness runs out of
|
||||
memory; the failures are new harnesses that still find divergences.
|
||||
2026-10-10 against the code, not the docs. The harnesses that failed in the
|
||||
previous audit (block updates, random ticks, redstone, command parsing, menu
|
||||
clicks, whole chunks, entity models and item models) now pass; the save
|
||||
round-trip harness has not been rerun.
|
||||
|
||||
| Subsystem | Score | Main gaps |
|
||||
| --- | ---: | --- |
|
||||
| Window, input and game loop | 0.75 | Game loop and options match vanilla; camera, auto-jump, toggle sprint/sneak and the frame limiter not compared |
|
||||
| Assets and resources | 0.75 | Item model pipeline not yet used for all GUI icons; item model harness does not finish; no pack screen |
|
||||
| Assets and resources | 0.75 | Item models match vanilla over 196,290 stacks, but the client does not draw items through that pipeline yet; no pack screen |
|
||||
| Network protocol and multiplayer | 0.75 | Opens worlds to LAN, but hosting is not tested against a vanilla client; no dedicated server mode |
|
||||
| Chunk storage and palettes | 1 | None: client and saves share paletted containers, identical to vanilla over 266 scenarios |
|
||||
| Section meshing | 1 | None: 112 scenes and over a million quads identical to vanilla |
|
||||
| Rendering | 0.75 | Entity model harness fails; block entity renderers and HUD overlays not compared |
|
||||
| Rendering | 0.75 | Entity models match over 150 layers; block entity renderers and HUD overlays not compared |
|
||||
| Lighting | 1 | None: saved and relit light matches vanilla, slabs and stairs included |
|
||||
| World generation | 0.75 | Whole-chunk harness still finds single-block differences near camps, shipwrecks and villages |
|
||||
| Region files and saves | 0.75 | Save round-trip harness fails; older DataVersions are not upgraded |
|
||||
| Entities and physics | 0.75 | Attributes and dimensions match; AI, loot and spawn rules not compared; drowned, villagers, traders and the wither unverified |
|
||||
| Interaction and inventory | 0.75 | Menu click harness fails |
|
||||
| Menus, HUD and chat | 0.75 | Command parse harness fails; no /item modify, /spectate, /waypoint or /dialog; no tab list or world border rendering |
|
||||
| World generation | 1 | None: 2,352 whole chunks over 225 structure sites, in all three dimensions, identical to vanilla |
|
||||
| Region files and saves | 0.75 | Saves round-trip with vanilla, and worlds from 1.18.2 to 26.2 upgrade as vanilla's DataFixers do over 22 sample worlds; worlds older than 1.18.2 are not upgraded |
|
||||
| Entities and physics | 0.75 | Attributes, dimensions, death loot, spawn rules and natural spawning (15,500 NaturalSpawner cases) match; AI not compared; drowned, villagers, traders and the wither unverified |
|
||||
| Interaction and inventory | 1 | None: menu clicks match over 6,000 click sequences |
|
||||
| Menus, HUD and chat | 0.75 | Commands parse as vanilla over 39,685 inputs, but /item modify, /spectate, /waypoint and /dialog do not run; no tab list or world border rendering |
|
||||
| Audio | 0.75 | Sound engine, music and ambience not compared with vanilla |
|
||||
| Block behaviours and ticking | 0.75 | Block update, random tick and redstone harnesses fail; a few dispenser behaviours missing |
|
||||
| Block behaviours and ticking | 0.75 | Block updates, random ticks, redstone and dispensers match vanilla; a container replaced by another block keeps its contents instead of spilling them, and there are no copper golems |
|
||||
|
||||
### Next steps
|
||||
|
||||
1. Fix the divergences the failing harnesses report: block updates, random ticks, redstone, command parsing, menu clicks, save round-trip, whole chunks and entity models.
|
||||
2. Let the item model harness finish, and draw every GUI icon through the item model pipeline.
|
||||
1. Upgrade saves older than 1.18.2 (the DataFixers before DataVersion 2970).
|
||||
2. Draw every item through the item model pipeline, and add the resource pack screen.
|
||||
3. An audio harness: sound engine attenuation, weighted sound events, music and ambience.
|
||||
4. Mob AI, loot and spawn-rule harnesses, and an oracle for drowned, villagers, traders and the wither.
|
||||
5. The missing commands, the tab list and world border rendering.
|
||||
6. Test LAN hosting with a vanilla client, and upgrade saves from older DataVersions.
|
||||
4. A mob AI harness, and an oracle for drowned, villagers, traders and the wither.
|
||||
5. The missing commands, containers spilling when replaced, copper golems, the tab list and world border rendering.
|
||||
6. Test LAN hosting with a vanilla client, and add a dedicated server mode.
|
||||
|
||||
```sh
|
||||
python3 tools/prepare_native_client.py --prepare # needs JDK 25; extracts private vanilla resources
|
||||
|
||||
@@ -6,6 +6,7 @@ use horizon_assets::{
|
||||
item_model::*,
|
||||
math::Mat4,
|
||||
model::{CuboidModel, Direction},
|
||||
palette::{PalettedPermutations, apply_palette},
|
||||
resolve::{MISSING_MODEL, ModelLibrary},
|
||||
};
|
||||
use serde_json::{Value, json};
|
||||
@@ -23,6 +24,8 @@ fn bits(v: f32) -> u32 {
|
||||
|
||||
struct Sprites {
|
||||
root: PathBuf,
|
||||
/// The items atlas's paletted permutations (armour trim sprites).
|
||||
permutations: Vec<PalettedPermutations>,
|
||||
cache: RefCell<BTreeMap<String, Option<SpriteMask>>>,
|
||||
}
|
||||
impl SpriteLookup for Sprites {
|
||||
@@ -38,27 +41,48 @@ impl SpriteLookup for Sprites {
|
||||
}
|
||||
}
|
||||
impl Sprites {
|
||||
fn load(&self, sprite: &str) -> Option<SpriteMask> {
|
||||
let (namespace, path) = sprite.split_once(':')?;
|
||||
let png = self
|
||||
.root
|
||||
.join(format!("assets/{namespace}/textures/{path}.png"));
|
||||
let mut decoder = png::Decoder::new(BufReader::new(File::open(&png).ok()?));
|
||||
fn png(&self, sprite: &str, directory: &str) -> PathBuf {
|
||||
let (namespace, path) = sprite.split_once(':').unwrap_or(("minecraft", sprite));
|
||||
self.root
|
||||
.join(format!("assets/{namespace}/{directory}/{path}.png"))
|
||||
}
|
||||
/// The image as ARGB pixels in reading order, with its size.
|
||||
fn argb(png: &Path) -> Option<(usize, usize, Vec<u32>)> {
|
||||
let mut decoder = png::Decoder::new(BufReader::new(File::open(png).ok()?));
|
||||
decoder.set_transformations(png::Transformations::EXPAND | png::Transformations::STRIP_16);
|
||||
let mut reader = decoder.read_info().ok()?;
|
||||
let mut bytes = vec![0; reader.output_buffer_size()?];
|
||||
let info = reader.next_frame(&mut bytes).ok()?;
|
||||
let channels = info.color_type.samples();
|
||||
let alpha = |pixel: &[u8]| match info.color_type {
|
||||
png::ColorType::Rgba => pixel[3],
|
||||
png::ColorType::GrayscaleAlpha => pixel[1],
|
||||
_ => 255,
|
||||
};
|
||||
let (iw, ih) = (info.width as usize, info.height as usize);
|
||||
let pixels: Vec<u8> = bytes[..info.buffer_size()]
|
||||
let pixels = bytes[..info.buffer_size()]
|
||||
.chunks_exact(channels)
|
||||
.map(alpha)
|
||||
.map(|p| {
|
||||
let (r, g, b, a) = match info.color_type {
|
||||
png::ColorType::Rgba => (p[0], p[1], p[2], p[3]),
|
||||
png::ColorType::Rgb => (p[0], p[1], p[2], 255),
|
||||
png::ColorType::GrayscaleAlpha => (p[0], p[0], p[0], p[1]),
|
||||
_ => (p[0], p[0], p[0], 255),
|
||||
};
|
||||
u32::from_be_bytes([a, r, g, b])
|
||||
})
|
||||
.collect();
|
||||
Some((info.width as usize, info.height as usize, pixels))
|
||||
}
|
||||
fn load(&self, sprite: &str) -> Option<SpriteMask> {
|
||||
let png = self.png(sprite, "textures");
|
||||
let (iw, ih, argb) = match Self::argb(&png) {
|
||||
Some(image) => image,
|
||||
None => {
|
||||
// PalettedPermutations: the base texture through PaletteMapping.
|
||||
let p = self.permutations.iter().find_map(|s| s.find(sprite))?;
|
||||
let (w, h, mut pixels) = Self::argb(&self.png(p.base, "textures"))?;
|
||||
let (_, _, key) = Self::argb(&self.png(p.palette_key, "textures/palettes"))?;
|
||||
let (_, _, target) = Self::argb(&self.png(p.palette, "textures/palettes"))?;
|
||||
apply_palette(&mut pixels, &key, &target)?;
|
||||
(w, h, pixels)
|
||||
}
|
||||
};
|
||||
let pixels: Vec<u8> = argb.iter().map(|p| (p >> 24) as u8).collect();
|
||||
let meta = fs::read_to_string(png.with_extension("png.mcmeta")).ok();
|
||||
let animation = meta
|
||||
.and_then(|m| serde_json::from_str::<Value>(&m).ok())
|
||||
@@ -215,6 +239,10 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let library = ModelLibrary::with_item_generator(cuboids)?;
|
||||
let sprites = Sprites {
|
||||
root: root.clone(),
|
||||
permutations: PalettedPermutations::from_atlas(
|
||||
&fs::read_to_string(root.join("assets/minecraft/atlases/items.json"))
|
||||
.unwrap_or_default(),
|
||||
),
|
||||
cache: RefCell::default(),
|
||||
};
|
||||
let grass = {
|
||||
|
||||
@@ -342,9 +342,11 @@ fn optional_transformation(m: &Map<String, Value>) -> Result<Option<Mat4>> {
|
||||
get(m, "transformation").map(transformation).transpose()
|
||||
}
|
||||
/// `Mth.floor(float)` as `ARGB.as8BitChannel` uses it.
|
||||
/// `ARGB.as8BitChannel`: `Mth.floor(value * 255.0F)`.
|
||||
fn channel(v: f32) -> i32 {
|
||||
let i = v as i32;
|
||||
if (i as f32) > v { i - 1 } else { i }
|
||||
let scaled = v * 255.0;
|
||||
let i = scaled as i32;
|
||||
if (i as f32) > scaled { i - 1 } else { i }
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -207,9 +207,7 @@ impl SpecialModel {
|
||||
}
|
||||
SpecialKind::PlayerHead => "player_head",
|
||||
SpecialKind::ShulkerBox { texture, openness } => {
|
||||
if texture != "minecraft:shulker" {
|
||||
put("texture", json!(texture));
|
||||
}
|
||||
put("texture", json!(texture));
|
||||
if *openness != 0.0 {
|
||||
put("openness", json!(openness));
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ pub mod item_model;
|
||||
mod json;
|
||||
pub mod math;
|
||||
pub mod model;
|
||||
pub mod palette;
|
||||
pub mod resolve;
|
||||
|
||||
pub use json::DecodeError;
|
||||
@@ -1,31 +1,91 @@
|
||||
//! Column-major `Matrix4f` arithmetic with the operand order of JOML's
|
||||
//! default (non-FMA) code paths, for composing item-model transformations.
|
||||
//! Column-major `Matrix4f` arithmetic as JOML 1.10.9 computes it, for composing
|
||||
//! item-model transformations: each operation takes the path JOML picks from
|
||||
//! the matrix properties (identity, translation, affine...), and `Math.fma` is
|
||||
//! `a * b + c` since Minecraft leaves `joml.useMathFma` off.
|
||||
|
||||
/// A JOML `Matrix4f`: `m[column][row]`.
|
||||
/// `Matrix4fc.PROPERTY_*` bits.
|
||||
const PERSPECTIVE: u8 = 1;
|
||||
const AFFINE: u8 = 2;
|
||||
const IDENTITY: u8 = 4;
|
||||
const TRANSLATION: u8 = 8;
|
||||
const ORTHONORMAL: u8 = 16;
|
||||
|
||||
/// JOML `Math.fma` without `joml.useMathFma`.
|
||||
#[inline]
|
||||
fn fma(a: f32, b: f32, c: f32) -> f32 {
|
||||
a * b + c
|
||||
}
|
||||
/// JOML `Math.absEqualsOne`.
|
||||
fn abs_equals_one(r: f32) -> bool {
|
||||
r.to_bits() & 0x7FFF_FFFF == 0x3F80_0000
|
||||
}
|
||||
|
||||
/// A JOML `Matrix4f`: `m[column][row]`, with its property bits.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct Mat4(pub [[f32; 4]; 4]);
|
||||
pub struct Mat4(pub [[f32; 4]; 4], pub u8);
|
||||
|
||||
impl Mat4 {
|
||||
pub const IDENTITY: Self = Self([
|
||||
[1.0, 0.0, 0.0, 0.0],
|
||||
[0.0, 1.0, 0.0, 0.0],
|
||||
[0.0, 0.0, 1.0, 0.0],
|
||||
[0.0, 0.0, 0.0, 1.0],
|
||||
]);
|
||||
/// `new Matrix4f()`.
|
||||
pub const IDENTITY: Self = Self(
|
||||
[
|
||||
[1.0, 0.0, 0.0, 0.0],
|
||||
[0.0, 1.0, 0.0, 0.0],
|
||||
[0.0, 0.0, 1.0, 0.0],
|
||||
[0.0, 0.0, 0.0, 1.0],
|
||||
],
|
||||
IDENTITY | AFFINE | TRANSLATION | ORTHONORMAL,
|
||||
);
|
||||
/// `Matrix4f.translation(x, y, z)`.
|
||||
pub fn translation(t: [f32; 3]) -> Self {
|
||||
let mut m = Self::IDENTITY;
|
||||
m.0[3][..3].copy_from_slice(&t);
|
||||
m.1 = AFFINE | TRANSLATION | ORTHONORMAL;
|
||||
m
|
||||
}
|
||||
/// `ExtraCodecs.MATRIX4F`: sixteen values in row-major order.
|
||||
/// `ExtraCodecs.MATRIX4F`: sixteen values in row-major order, then
|
||||
/// `determineProperties`.
|
||||
pub fn from_row_major(values: [f32; 16]) -> Self {
|
||||
let mut m = Self([[0.0; 4]; 4]);
|
||||
let mut m = Self([[0.0; 4]; 4], 0);
|
||||
for (i, v) in values.into_iter().enumerate() {
|
||||
m.0[i & 3][i >> 2] = v;
|
||||
}
|
||||
m.1 = m.determine_properties();
|
||||
m
|
||||
}
|
||||
/// `Matrix4f.determineProperties`.
|
||||
fn determine_properties(&self) -> u8 {
|
||||
let m = &self.0;
|
||||
let mut properties = 0;
|
||||
if m[0][3] == 0.0 && m[1][3] == 0.0 {
|
||||
if m[2][3] == 0.0 && m[3][3] == 1.0 {
|
||||
properties |= AFFINE;
|
||||
if m[0] == [1.0, 0.0, 0.0, 0.0]
|
||||
&& m[1] == [0.0, 1.0, 0.0, 0.0]
|
||||
&& m[2] == [0.0, 0.0, 1.0, 0.0]
|
||||
{
|
||||
properties |= TRANSLATION | ORTHONORMAL;
|
||||
if m[3][0] == 0.0 && m[3][1] == 0.0 && m[3][2] == 0.0 {
|
||||
properties |= IDENTITY;
|
||||
}
|
||||
}
|
||||
} else if m[0][1] == 0.0
|
||||
&& m[0][2] == 0.0
|
||||
&& m[1][0] == 0.0
|
||||
&& m[1][2] == 0.0
|
||||
&& m[2][0] == 0.0
|
||||
&& m[2][1] == 0.0
|
||||
&& m[3][0] == 0.0
|
||||
&& m[3][1] == 0.0
|
||||
&& m[3][3] == 0.0
|
||||
{
|
||||
properties |= PERSPECTIVE;
|
||||
}
|
||||
}
|
||||
properties
|
||||
}
|
||||
fn has(&self, property: u8) -> bool {
|
||||
self.1 & property != 0
|
||||
}
|
||||
/// The matrix as `[row][column]` entries, the order `getRowColumn` reads.
|
||||
pub fn rows(&self) -> [[f32; 4]; 4] {
|
||||
std::array::from_fn(|r| std::array::from_fn(|c| self.0[c][r]))
|
||||
@@ -33,37 +93,143 @@ impl Mat4 {
|
||||
/// `this * right` (`Matrix4f.mul`).
|
||||
pub fn mul(&self, right: &Self) -> Self {
|
||||
let (m, r) = (&self.0, &right.0);
|
||||
if self.has(IDENTITY) {
|
||||
return *right;
|
||||
}
|
||||
if right.has(IDENTITY) {
|
||||
return *self;
|
||||
}
|
||||
let mut out = [[0.0; 4]; 4];
|
||||
if self.has(TRANSLATION) && right.has(AFFINE) {
|
||||
// mulTranslationAffine
|
||||
for c in 0..4 {
|
||||
out[c] = [r[c][0], r[c][1], r[c][2], m[c][3]];
|
||||
}
|
||||
for row in 0..3 {
|
||||
out[3][row] = r[3][row] + m[3][row];
|
||||
}
|
||||
return Self(out, AFFINE | right.1 & ORTHONORMAL);
|
||||
}
|
||||
if self.has(AFFINE) && right.has(AFFINE) {
|
||||
// mulAffine
|
||||
for c in 0..3 {
|
||||
for row in 0..3 {
|
||||
out[c][row] = fma(
|
||||
m[0][row],
|
||||
r[c][0],
|
||||
fma(m[1][row], r[c][1], m[2][row] * r[c][2]),
|
||||
);
|
||||
}
|
||||
out[c][3] = m[c][3];
|
||||
}
|
||||
for row in 0..3 {
|
||||
out[3][row] = fma(
|
||||
m[0][row],
|
||||
r[3][0],
|
||||
fma(m[1][row], r[3][1], fma(m[2][row], r[3][2], m[3][row])),
|
||||
);
|
||||
}
|
||||
out[3][3] = m[3][3];
|
||||
return Self(out, AFFINE | self.1 & right.1 & ORTHONORMAL);
|
||||
}
|
||||
if self.has(PERSPECTIVE) && right.has(AFFINE) {
|
||||
// mulPerspectiveAffine
|
||||
for c in 0..4 {
|
||||
out[c] = [
|
||||
m[0][0] * r[c][0],
|
||||
m[1][1] * r[c][1],
|
||||
m[2][2] * r[c][2],
|
||||
m[2][3] * r[c][2],
|
||||
];
|
||||
}
|
||||
out[3][2] = m[2][2] * r[3][2] + m[3][2];
|
||||
out[3][3] = m[2][3] * r[3][2];
|
||||
return Self(out, 0);
|
||||
}
|
||||
if right.has(AFFINE) {
|
||||
// mulAffineR
|
||||
for c in 0..3 {
|
||||
for row in 0..4 {
|
||||
out[c][row] = fma(
|
||||
m[0][row],
|
||||
r[c][0],
|
||||
fma(m[1][row], r[c][1], m[2][row] * r[c][2]),
|
||||
);
|
||||
}
|
||||
}
|
||||
for row in 0..4 {
|
||||
out[3][row] = fma(
|
||||
m[0][row],
|
||||
r[3][0],
|
||||
fma(m[1][row], r[3][1], fma(m[2][row], r[3][2], m[3][row])),
|
||||
);
|
||||
}
|
||||
return Self(
|
||||
out,
|
||||
self.1 & !(PERSPECTIVE | IDENTITY | TRANSLATION | ORTHONORMAL),
|
||||
);
|
||||
}
|
||||
// mul0
|
||||
for c in 0..4 {
|
||||
for row in 0..4 {
|
||||
out[c][row] = m[0][row] * r[c][0]
|
||||
+ m[1][row] * r[c][1]
|
||||
+ m[2][row] * r[c][2]
|
||||
+ m[3][row] * r[c][3];
|
||||
out[c][row] = fma(
|
||||
m[0][row],
|
||||
r[c][0],
|
||||
fma(
|
||||
m[1][row],
|
||||
r[c][1],
|
||||
fma(m[2][row], r[c][2], m[3][row] * r[c][3]),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Self(out)
|
||||
Self(out, 0)
|
||||
}
|
||||
/// `Matrix4f.translate(x, y, z)` (`translateGeneric`).
|
||||
/// `Matrix4f.translate(x, y, z)`.
|
||||
pub fn translate(&self, t: [f32; 3]) -> Self {
|
||||
if self.has(IDENTITY) {
|
||||
return Self::translation(t);
|
||||
}
|
||||
// translateGeneric
|
||||
let mut out = *self;
|
||||
for row in 0..4 {
|
||||
out.0[3][row] = self.0[0][row] * t[0]
|
||||
+ (self.0[1][row] * t[1] + (self.0[2][row] * t[2] + self.0[3][row]));
|
||||
out.0[3][row] = fma(
|
||||
self.0[0][row],
|
||||
t[0],
|
||||
fma(
|
||||
self.0[1][row],
|
||||
t[1],
|
||||
fma(self.0[2][row], t[2], self.0[3][row]),
|
||||
),
|
||||
);
|
||||
}
|
||||
out.1 = self.1 & !(PERSPECTIVE | IDENTITY);
|
||||
out
|
||||
}
|
||||
/// `Matrix4f.scale(x, y, z)`: the first three columns are scaled.
|
||||
/// `Matrix4f.scale(x, y, z)`.
|
||||
pub fn scale(&self, s: [f32; 3]) -> Self {
|
||||
let one = s.iter().all(|&v| abs_equals_one(v));
|
||||
if self.has(IDENTITY) {
|
||||
// scaling
|
||||
let mut out = Self::IDENTITY;
|
||||
for (c, v) in s.into_iter().enumerate() {
|
||||
out.0[c][c] = v;
|
||||
}
|
||||
out.1 = AFFINE | if one { ORTHONORMAL } else { 0 };
|
||||
return out;
|
||||
}
|
||||
// scaleGeneric: the first three columns are scaled.
|
||||
let mut out = *self;
|
||||
for c in 0..3 {
|
||||
for row in 0..4 {
|
||||
out.0[c][row] = self.0[c][row] * s[c];
|
||||
}
|
||||
}
|
||||
out.1 =
|
||||
self.1 & !(PERSPECTIVE | IDENTITY | TRANSLATION | if one { 0 } else { ORTHONORMAL });
|
||||
out
|
||||
}
|
||||
/// `Matrix4f.rotate(Quaternionfc)` (`rotateGeneric`).
|
||||
/// `Matrix4f.rotate(Quaternionfc)`.
|
||||
pub fn rotate(&self, q: [f32; 4]) -> Self {
|
||||
let [x, y, z, w] = q;
|
||||
let (w2, x2, y2, z2) = (w * w, x * x, y * y, z * z);
|
||||
@@ -71,14 +237,43 @@ impl Mat4 {
|
||||
let (dzw, dxy, dxz, dyw, dyz, dxw) = (zw + zw, xy + xy, xz + xz, yw + yw, yz + yz, xw + xw);
|
||||
let r = [
|
||||
[w2 + x2 - z2 - y2, dxy + dzw, dxz - dyw],
|
||||
[dxy - dzw, y2 - z2 + w2 - x2, dyz + dxw],
|
||||
[-dzw + dxy, y2 - z2 + w2 - x2, dyz + dxw],
|
||||
[dyw + dxz, dyz - dxw, z2 - y2 - x2 + w2],
|
||||
];
|
||||
let m = &self.0;
|
||||
let mut out = *self;
|
||||
for c in 0..3 {
|
||||
for row in 0..4 {
|
||||
out.0[c][row] = m[0][row] * r[c][0] + m[1][row] * r[c][1] + m[2][row] * r[c][2];
|
||||
if self.has(IDENTITY) {
|
||||
// rotation
|
||||
for c in 0..3 {
|
||||
out.0[c][..3].copy_from_slice(&r[c]);
|
||||
}
|
||||
out.1 = AFFINE | ORTHONORMAL;
|
||||
return out;
|
||||
}
|
||||
out.1 = self.1 & !(PERSPECTIVE | IDENTITY | TRANSLATION);
|
||||
if self.has(TRANSLATION) {
|
||||
// rotateTranslation
|
||||
for c in 0..3 {
|
||||
out.0[c] = [r[c][0], r[c][1], r[c][2], 0.0];
|
||||
}
|
||||
} else if self.has(AFFINE) {
|
||||
// rotateAffine
|
||||
for c in 0..3 {
|
||||
for row in 0..3 {
|
||||
out.0[c][row] = m[0][row] * r[c][0] + m[1][row] * r[c][1] + m[2][row] * r[c][2];
|
||||
}
|
||||
out.0[c][3] = 0.0;
|
||||
}
|
||||
} else {
|
||||
// rotateGeneric
|
||||
for c in 0..3 {
|
||||
for row in 0..4 {
|
||||
out.0[c][row] = fma(
|
||||
m[0][row],
|
||||
r[c][0],
|
||||
fma(m[1][row], r[c][1], m[2][row] * r[c][2]),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
@@ -94,28 +289,50 @@ pub fn normalize_quaternion(q: [f32; 4]) -> [f32; 4] {
|
||||
let inv = 1.0 / (x * x + (y * y + (z * z + w * w))).sqrt();
|
||||
[x * inv, y * inv, z * inv, w * inv]
|
||||
}
|
||||
/// JOML `Math.sin(float)` without fast math.
|
||||
fn joml_sin(rad: f32) -> f32 {
|
||||
f64::from(rad).sin() as f32
|
||||
}
|
||||
/// JOML `Math.cosFromSin(float, float)` without fast math: the magnitude from
|
||||
/// the sine, the sign from the angle's quadrant (exact zeros at right angles).
|
||||
fn cos_from_sin(sin: f32, angle: f32) -> f32 {
|
||||
let cos = (1.0 - sin * sin).sqrt();
|
||||
let tau = (std::f64::consts::PI * 2.0) as f32;
|
||||
let a = angle + std::f32::consts::FRAC_PI_2;
|
||||
let mut b = a - (a / tau) as i32 as f32 * tau;
|
||||
if b < 0.0 {
|
||||
b += tau;
|
||||
}
|
||||
if b >= std::f32::consts::PI { -cos } else { cos }
|
||||
}
|
||||
/// `Quaternionf.rotationXYZ` from angles in radians.
|
||||
pub fn quaternion_xyz(x: f32, y: f32, z: f32) -> [f32; 4] {
|
||||
let half = |a: f32| -> (f32, f32) {
|
||||
let h = a * 0.5;
|
||||
((f64::from(h).sin()) as f32, (f64::from(h).cos()) as f32)
|
||||
let s = joml_sin(h);
|
||||
(s, cos_from_sin(s, h))
|
||||
};
|
||||
let ((sx, cx), (sy, cy), (sz, cz)) = (half(x), half(y), half(z));
|
||||
let (cx_cy, sx_cy, cx_sy, sx_sy) = (cx * cy, sx * cy, cx * sy, sx * sy);
|
||||
let (cycz, sysz, sycz, cysz) = (cy * cz, sy * sz, sy * cz, cy * sz);
|
||||
[
|
||||
sx_cy * cz + cx_sy * sz,
|
||||
cx_sy * cz - sx_cy * sz,
|
||||
cx_cy * sz + sx_sy * cz,
|
||||
cx_cy * cz - sx_sy * sz,
|
||||
sx * cycz + cx * sysz,
|
||||
cx * sycz - sx * cysz,
|
||||
cx * cysz + sx * sycz,
|
||||
cx * cycz - sx * sysz,
|
||||
]
|
||||
}
|
||||
/// `new Quaternionf(new AxisAngle4f(angle, axis))`.
|
||||
/// `new Quaternionf(new AxisAngle4f(angle, axis))`: the angle is wrapped into
|
||||
/// [0, 2π) and the axis is used as given.
|
||||
pub fn quaternion_from_axis_angle(angle: f32, axis: [f32; 3]) -> [f32; 4] {
|
||||
let half = angle * 0.5;
|
||||
let (sin, cos) = (half.sin(), half.cos());
|
||||
let norm = 1.0 / (axis[0] * axis[0] + axis[1] * axis[1] + axis[2] * axis[2]).sqrt();
|
||||
let s = sin * norm;
|
||||
[axis[0] * s, axis[1] * s, axis[2] * s, cos]
|
||||
let tau = (std::f64::consts::PI * 2.0) as f32;
|
||||
let angle = if angle < 0.0 {
|
||||
tau + angle % tau
|
||||
} else {
|
||||
angle
|
||||
} % tau;
|
||||
let sin = joml_sin(angle * 0.5);
|
||||
let cos = cos_from_sin(sin, angle * 0.5);
|
||||
[axis[0] * sin, axis[1] * sin, axis[2] * sin, cos]
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -137,4 +354,9 @@ mod tests {
|
||||
let m = Mat4::IDENTITY.rotate(q);
|
||||
assert!((m.0[0][1] - 1.0).abs() < 1e-6 && m.0[0][0].abs() < 1e-6);
|
||||
}
|
||||
#[test]
|
||||
fn half_turn_has_exact_zeros_like_joml() {
|
||||
let half_turn = 180.0 * (std::f64::consts::PI / 180.0) as f32;
|
||||
assert_eq!(quaternion_xyz(0.0, half_turn, 0.0), [0.0, 1.0, 0.0, 0.0]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
//! `PalettedPermutations` atlas sources: sprites such as
|
||||
//! `trims/items/boots_trim_quartz` are the base texture with its colours
|
||||
//! swapped through a palette pair (`PaletteMapping`), not files of their own.
|
||||
use serde_json::Value;
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
/// One `minecraft:paletted_permutations` source of an atlas definition.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct PalettedPermutations {
|
||||
/// Base sprite names (`minecraft:trims/items/boots_trim`).
|
||||
pub textures: Vec<String>,
|
||||
/// The key palette (`minecraft:trim_base`).
|
||||
pub palette_key: String,
|
||||
/// Suffix to target palette (`quartz` → `minecraft:trim/quartz`).
|
||||
pub permutations: BTreeMap<String, String>,
|
||||
pub separator: String,
|
||||
}
|
||||
|
||||
/// A permuted sprite: its base texture and the palette pair to apply.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct Permutation<'a> {
|
||||
pub base: &'a str,
|
||||
pub palette_key: &'a str,
|
||||
pub palette: &'a str,
|
||||
}
|
||||
|
||||
fn qualified(id: &str) -> String {
|
||||
if id.contains(':') {
|
||||
id.to_owned()
|
||||
} else {
|
||||
format!("minecraft:{id}")
|
||||
}
|
||||
}
|
||||
|
||||
impl PalettedPermutations {
|
||||
/// The `paletted_permutations` sources of an atlas JSON (`atlases/items.json`).
|
||||
pub fn from_atlas(atlas: &str) -> Vec<Self> {
|
||||
let Ok(json) = serde_json::from_str::<Value>(atlas) else {
|
||||
return Vec::new();
|
||||
};
|
||||
let sources = json
|
||||
.get("sources")
|
||||
.and_then(Value::as_array)
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
sources
|
||||
.iter()
|
||||
.filter(|s| {
|
||||
s.get("type")
|
||||
.and_then(Value::as_str)
|
||||
.map(qualified)
|
||||
.as_deref()
|
||||
== Some("minecraft:paletted_permutations")
|
||||
})
|
||||
.filter_map(|s| {
|
||||
Some(Self {
|
||||
textures: s
|
||||
.get("textures")?
|
||||
.as_array()?
|
||||
.iter()
|
||||
.filter_map(Value::as_str)
|
||||
.map(qualified)
|
||||
.collect(),
|
||||
palette_key: qualified(s.get("palette_key")?.as_str()?),
|
||||
permutations: s
|
||||
.get("permutations")?
|
||||
.as_object()?
|
||||
.iter()
|
||||
.filter_map(|(k, v)| Some((k.clone(), qualified(v.as_str()?))))
|
||||
.collect(),
|
||||
separator: s
|
||||
.get("separator")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("_")
|
||||
.to_owned(),
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
/// The permutation that produces `sprite` (`withSuffix(separator + key)`).
|
||||
pub fn find(&self, sprite: &str) -> Option<Permutation<'_>> {
|
||||
let sprite = qualified(sprite);
|
||||
self.textures.iter().find_map(|base| {
|
||||
let suffix = sprite
|
||||
.strip_prefix(base.as_str())?
|
||||
.strip_prefix(self.separator.as_str())?;
|
||||
Some(Permutation {
|
||||
base,
|
||||
palette_key: &self.palette_key,
|
||||
palette: self.permutations.get(suffix)?,
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// `PaletteMapping.create(key, target).apply` over ARGB pixels; the palettes
|
||||
/// are the pixels of their images in reading order (`Palette.from`).
|
||||
pub fn apply_palette(pixels: &mut [u32], key: &[u32], target: &[u32]) -> Option<()> {
|
||||
if key.len() != target.len() {
|
||||
return None;
|
||||
}
|
||||
let mut map = BTreeMap::new();
|
||||
for (&k, &t) in key.iter().zip(target) {
|
||||
if k >> 24 != 0 {
|
||||
map.insert(k | 0xFF00_0000, t);
|
||||
}
|
||||
}
|
||||
for pixel in pixels {
|
||||
let alpha = *pixel >> 24;
|
||||
if alpha == 0 {
|
||||
continue;
|
||||
}
|
||||
let rgb = *pixel | 0xFF00_0000;
|
||||
let target = map.get(&rgb).copied().unwrap_or(rgb);
|
||||
*pixel = (alpha * (target >> 24) / 255) << 24 | target & 0x00FF_FFFF;
|
||||
}
|
||||
Some(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn finds_trim_permutations_and_maps_colours() {
|
||||
let atlas = r#"{"sources":[{"type":"minecraft:paletted_permutations","palette_key":"minecraft:trim_base",
|
||||
"permutations":{"quartz":"minecraft:trim/quartz"},"textures":["minecraft:trims/items/boots_trim"]}]}"#;
|
||||
let sources = PalettedPermutations::from_atlas(atlas);
|
||||
let p = sources[0]
|
||||
.find("minecraft:trims/items/boots_trim_quartz")
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
(p.base, p.palette),
|
||||
("minecraft:trims/items/boots_trim", "minecraft:trim/quartz")
|
||||
);
|
||||
assert!(
|
||||
sources[0]
|
||||
.find("minecraft:trims/items/boots_trim_gold")
|
||||
.is_none()
|
||||
);
|
||||
let mut pixels = [0xFF11_2233, 0x8011_2233, 0x0011_2233, 0xFF44_5566];
|
||||
apply_palette(&mut pixels, &[0xFF11_2233], &[0xFFAA_BBCC]).unwrap();
|
||||
assert_eq!(pixels, [0xFFAA_BBCC, 0x80AA_BBCC, 0x0011_2233, 0xFF44_5566]);
|
||||
}
|
||||
}
|
||||
@@ -23,5 +23,9 @@ cpal = "0.16"
|
||||
symphonia = { version = "0.5", default-features = false, features = ["ogg", "vorbis"] }
|
||||
miniz_oxide = "0.8"
|
||||
|
||||
[features]
|
||||
# Records the call sites of mob random draws (`mob_ai_probe` traces).
|
||||
rng-trace = ["horizon-core/rng-trace"]
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
@@ -0,0 +1,185 @@
|
||||
//! Replays `tools/test_ambient_sound_parity.py` scripts through Horizon's
|
||||
//! ambient sound handlers over its `SoundEngine`:
|
||||
//! `ambient_fixture ROOT INPUT OUTPUT`.
|
||||
#[path = "audio_common/mod.rs"]
|
||||
mod common;
|
||||
use common::{Fake, d, f, flush};
|
||||
use horizon_client::ambience::{Ambience, AmbientContext, AmbientSounds, Mood, bubble_column_in};
|
||||
use horizon_client::sound_engine::{Bound, SoundEngine, TickContext, Ticking};
|
||||
use horizon_client::sound_events::{SoundRegistry, normalize};
|
||||
use horizon_core::random::RandomSource;
|
||||
use std::io::Write;
|
||||
|
||||
/// The oracle's scripted brightness.
|
||||
fn light(mode: i32, x: i32, y: i32, z: i32) -> (u8, u8) {
|
||||
let h = x.wrapping_mul(73856093) ^ y.wrapping_mul(19349663) ^ z.wrapping_mul(83492791);
|
||||
let u = h as u32;
|
||||
match mode {
|
||||
0 => (15, 0),
|
||||
1 => (0, 0),
|
||||
2 => (0, ((u >> 4) & 15) as u8),
|
||||
_ => (
|
||||
if h & 3 == 0 { ((u >> 8) & 15) as u8 } else { 0 },
|
||||
((u >> 12) & 15) as u8,
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
fn sounds(a: &[&str]) -> AmbientSounds {
|
||||
AmbientSounds {
|
||||
r#loop: (a[1] != "-").then(|| normalize(a[1])),
|
||||
mood: (a[2] != "-").then(|| {
|
||||
let m: Vec<&str> = a[2].split(',').collect();
|
||||
Mood {
|
||||
sound: normalize(m[0]),
|
||||
tick_delay: m[1].parse().expect("delay"),
|
||||
block_search_extent: m[2].parse().expect("extent"),
|
||||
offset: d(m[3]),
|
||||
}
|
||||
}),
|
||||
additions: a[3..]
|
||||
.iter()
|
||||
.map(|s| {
|
||||
let p: Vec<&str> = s.split(',').collect();
|
||||
(normalize(p[0]), d(p[1]))
|
||||
})
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let args: Vec<String> = std::env::args().collect();
|
||||
let mut engine = SoundEngine::new(SoundRegistry::load(std::path::Path::new(&args[1])));
|
||||
engine.listening = true;
|
||||
let mut backend = Fake::default();
|
||||
let mut ambience = Ambience::new(0);
|
||||
let mut current = AmbientSounds::default();
|
||||
let (mut position, mut eye, mut eye_in_water, mut spectator, mut removed, mut mode) =
|
||||
([0.0f64; 3], 0.0f32, false, false, false, 0);
|
||||
let mut height = 1.8f32;
|
||||
let mut columns: Vec<([i32; 3], bool)> = Vec::new();
|
||||
let input = std::fs::read_to_string(&args[2]).expect("input");
|
||||
let mut out = std::io::BufWriter::new(std::fs::File::create(&args[3]).expect("output"));
|
||||
for line in input.lines() {
|
||||
writeln!(out, "{line}").expect("write");
|
||||
let a: Vec<&str> = line.split(' ').collect();
|
||||
match a[0] {
|
||||
"start" => {
|
||||
engine.stop_all(&mut backend);
|
||||
ambience.clear(&mut Bound {
|
||||
engine: &mut engine,
|
||||
backend: &mut backend,
|
||||
});
|
||||
ambience = Ambience::new(a[1].parse().expect("seed"));
|
||||
current = AmbientSounds::default();
|
||||
(position, eye, eye_in_water, spectator, removed, mode) =
|
||||
([0.0; 3], 0.0, false, false, false, 0);
|
||||
height = 1.8;
|
||||
columns.clear();
|
||||
}
|
||||
"sounds" => current = sounds(&a),
|
||||
"player" => {
|
||||
position = [d(a[1]), d(a[2]), d(a[3])];
|
||||
eye = f(a[4]);
|
||||
eye_in_water = a[5] == "1";
|
||||
spectator = a[6] == "1";
|
||||
removed = a[7] == "1";
|
||||
mode = a[8].parse().expect("mode");
|
||||
height = f(a[9]);
|
||||
columns = a[10..]
|
||||
.iter()
|
||||
.map(|c| {
|
||||
let p: Vec<&str> = c.split(',').collect();
|
||||
let n = |i: usize| p[i].parse::<i32>().expect("column");
|
||||
([n(0), n(1), n(2)], p[3] == "1")
|
||||
})
|
||||
.collect();
|
||||
}
|
||||
"volume" => {
|
||||
let source: usize = a[1].parse().expect("source");
|
||||
engine.source_volumes[source] = f(a[2]);
|
||||
engine.refresh_category_volume(source, &mut backend);
|
||||
}
|
||||
"tick" => {
|
||||
let light = |b: [i32; 3]| light(mode, b[0], b[1], b[2]);
|
||||
let ctx = AmbientContext {
|
||||
sounds: ¤t,
|
||||
position,
|
||||
eye_y: position[1] + f64::from(eye),
|
||||
underwater: eye_in_water,
|
||||
spectator,
|
||||
light: &light,
|
||||
bubble_column: bubble_column_in(
|
||||
[
|
||||
position[0] - f64::from(0.3f32),
|
||||
position[1],
|
||||
position[2] - f64::from(0.3f32),
|
||||
position[0] + f64::from(0.3f32),
|
||||
position[1] + f64::from(height),
|
||||
position[2] + f64::from(0.3f32),
|
||||
],
|
||||
|b| columns.iter().find(|c| c.0 == b).map(|c| c.1),
|
||||
),
|
||||
};
|
||||
ambience.tick(
|
||||
&ctx,
|
||||
&mut Bound {
|
||||
engine: &mut engine,
|
||||
backend: &mut backend,
|
||||
},
|
||||
);
|
||||
let tick = TickContext {
|
||||
player_underwater: ambience.was_underwater(),
|
||||
player_removed: removed,
|
||||
};
|
||||
engine.tick(false, &tick, &mut backend);
|
||||
}
|
||||
_ => panic!("{line}"),
|
||||
}
|
||||
writeln!(
|
||||
out,
|
||||
" rand={} mood={:08x} underwater={} delay={}",
|
||||
ambience.random.clone().next_long(),
|
||||
ambience.moodiness.to_bits(),
|
||||
ambience.was_underwater(),
|
||||
ambience.underwater_delay()
|
||||
)
|
||||
.expect("write");
|
||||
for (event, id) in ambience.loops() {
|
||||
let i = engine.instance(*id).expect("loop");
|
||||
let Ticking::BiomeLoop { fade, direction } = i.ticking else {
|
||||
panic!("loop kind");
|
||||
};
|
||||
writeln!(
|
||||
out,
|
||||
" loop {event} fade={fade} direction={direction} stopped={} active={}",
|
||||
i.stopped,
|
||||
engine.is_active(*id)
|
||||
)
|
||||
.expect("write");
|
||||
}
|
||||
let mut playing: Vec<String> = engine
|
||||
.active_instances()
|
||||
.into_iter()
|
||||
.map(|id| {
|
||||
let i = engine.instance(id).expect("instance");
|
||||
format!(
|
||||
" playing {} volume={:08x} looping={} relative={} at={:016x},{:016x},{:016x} stopped={}",
|
||||
i.identifier,
|
||||
i.volume.to_bits(),
|
||||
i.looping,
|
||||
i.relative,
|
||||
i.position[0].to_bits(),
|
||||
i.position[1].to_bits(),
|
||||
i.position[2].to_bits(),
|
||||
i.tickable() && i.stopped
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
playing.sort();
|
||||
for p in playing {
|
||||
writeln!(out, "{p}").expect("write");
|
||||
}
|
||||
flush(&mut engine, &mut out);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
//! Audio environment attributes from Horizon's `MusicTables` and
|
||||
//! `AmbientTables` for `tools/test_audio_attributes_parity.py`:
|
||||
//! `audio_attributes_fixture ROOT INPUT OUTPUT`.
|
||||
use horizon_client::ambience::AmbientTables;
|
||||
use horizon_client::music::{Music, MusicTables};
|
||||
use horizon_client::sound_events::normalize;
|
||||
use std::io::Write;
|
||||
|
||||
fn music(m: Option<&Music>) -> String {
|
||||
m.map_or("-".to_owned(), |m| {
|
||||
format!(
|
||||
"{},{},{},{}",
|
||||
normalize(&m.sound),
|
||||
m.min_delay,
|
||||
m.max_delay,
|
||||
m.replace_current_music
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let args: Vec<String> = std::env::args().collect();
|
||||
let root = std::path::Path::new(&args[1]);
|
||||
let tables = MusicTables::load(root);
|
||||
let ambient = AmbientTables::load(root);
|
||||
let input = std::fs::read_to_string(&args[2]).expect("input");
|
||||
let mut out = std::io::BufWriter::new(std::fs::File::create(&args[3]).expect("output"));
|
||||
for (name, m) in [
|
||||
("MENU", Music::menu()),
|
||||
("CREDITS", Music::credits()),
|
||||
("END_BOSS", Music::end_boss()),
|
||||
("CREATIVE", Music::creative()),
|
||||
("END", Music::end()),
|
||||
("UNDER_WATER", Music::under_water()),
|
||||
("GAME", Music::game()),
|
||||
] {
|
||||
writeln!(out, "{name} {}", music(Some(&m))).expect("write");
|
||||
}
|
||||
for line in input.lines() {
|
||||
let f: Vec<&str> = line.split(' ').collect();
|
||||
let (dimension, biome) = (f[0], f[1]);
|
||||
let bg = tables.background(dimension, biome);
|
||||
let mut s = format!(
|
||||
"{line} default={} creative={} underwater={}",
|
||||
music(bg.default.as_ref()),
|
||||
music(bg.creative.as_ref()),
|
||||
music(bg.underwater.as_ref())
|
||||
);
|
||||
for creative in [false, true] {
|
||||
for underwater in [false, true] {
|
||||
s.push_str(&format!(
|
||||
" select={}",
|
||||
music(bg.select(creative, underwater))
|
||||
));
|
||||
}
|
||||
}
|
||||
s.push_str(&format!(
|
||||
" volume={:08x}",
|
||||
tables.volume(dimension, biome).to_bits()
|
||||
));
|
||||
let a = ambient.get(dimension, biome);
|
||||
s.push_str(&format!(
|
||||
" loop={}",
|
||||
a.r#loop.as_deref().map_or("-".to_owned(), normalize)
|
||||
));
|
||||
s.push_str(&format!(
|
||||
" mood={}",
|
||||
a.mood.as_ref().map_or("-".to_owned(), |m| format!(
|
||||
"{},{},{},{:016x}",
|
||||
normalize(&m.sound),
|
||||
m.tick_delay,
|
||||
m.block_search_extent,
|
||||
m.offset.to_bits()
|
||||
))
|
||||
));
|
||||
for (sound, chance) in &a.additions {
|
||||
s.push_str(&format!(
|
||||
" addition={},{:016x}",
|
||||
normalize(sound),
|
||||
chance.to_bits()
|
||||
));
|
||||
}
|
||||
writeln!(out, "{s}").expect("write");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
//! Shared by the audio fixtures: a backend recording what an OpenAL source
|
||||
//! would hold, and the per-step dump the Java oracles print.
|
||||
#![allow(dead_code)]
|
||||
use horizon_client::sound_engine::{Backend, ChannelId, InstanceId, PlayResult, SoundEngine};
|
||||
use horizon_client::sound_events::Sound;
|
||||
use std::collections::HashMap;
|
||||
use std::io::Write;
|
||||
|
||||
const INITIAL: u32 = 0x1011;
|
||||
const PLAYING: u32 = 0x1012;
|
||||
const PAUSED: u32 = 0x1013;
|
||||
const STOPPED: u32 = 0x1014;
|
||||
|
||||
/// An OpenAL source's state as `alGetSource*` reports it.
|
||||
#[derive(Clone)]
|
||||
pub struct Source {
|
||||
pub state: u32,
|
||||
pub gain: f32,
|
||||
pub pitch: f32,
|
||||
pub position: [f32; 3],
|
||||
pub relative: bool,
|
||||
pub looping: bool,
|
||||
pub model: u32,
|
||||
pub max: f32,
|
||||
pub rolloff: f32,
|
||||
pub reference: f32,
|
||||
}
|
||||
impl Default for Source {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
state: INITIAL,
|
||||
gain: 1.0,
|
||||
pitch: 1.0,
|
||||
position: [0.0; 3],
|
||||
relative: false,
|
||||
looping: false,
|
||||
model: 0xD002,
|
||||
max: f32::MAX,
|
||||
rolloff: 1.0,
|
||||
reference: 1.0,
|
||||
}
|
||||
}
|
||||
}
|
||||
/// Buffers and streams are ready at once (silence in the oracle); stopping an
|
||||
/// initial source leaves it initial, as in OpenAL Soft.
|
||||
#[derive(Default)]
|
||||
pub struct Fake {
|
||||
pub sources: HashMap<ChannelId, Source>,
|
||||
next: ChannelId,
|
||||
}
|
||||
impl Backend for Fake {
|
||||
fn create(&mut self, _streaming: bool) -> Option<ChannelId> {
|
||||
self.next += 1;
|
||||
self.sources.insert(self.next, Source::default());
|
||||
Some(self.next)
|
||||
}
|
||||
fn destroy(&mut self, channel: ChannelId) {
|
||||
self.sources.remove(&channel);
|
||||
}
|
||||
fn set_pitch(&mut self, c: ChannelId, pitch: f32) {
|
||||
self.sources.get_mut(&c).expect("source").pitch = pitch;
|
||||
}
|
||||
fn set_volume(&mut self, c: ChannelId, volume: f32) {
|
||||
self.sources.get_mut(&c).expect("source").gain = volume;
|
||||
}
|
||||
fn set_attenuation(&mut self, c: ChannelId, linear: Option<f32>) {
|
||||
let s = self.sources.get_mut(&c).expect("source");
|
||||
match linear {
|
||||
Some(max) => {
|
||||
s.model = 0xD003;
|
||||
s.max = max;
|
||||
s.rolloff = 1.0;
|
||||
s.reference = 0.0;
|
||||
}
|
||||
None => s.model = 0,
|
||||
}
|
||||
}
|
||||
fn set_looping(&mut self, c: ChannelId, looping: bool) {
|
||||
self.sources.get_mut(&c).expect("source").looping = looping;
|
||||
}
|
||||
fn set_position(&mut self, c: ChannelId, p: [f64; 3]) {
|
||||
self.sources.get_mut(&c).expect("source").position =
|
||||
[p[0] as f32, p[1] as f32, p[2] as f32];
|
||||
}
|
||||
fn set_relative(&mut self, c: ChannelId, relative: bool) {
|
||||
self.sources.get_mut(&c).expect("source").relative = relative;
|
||||
}
|
||||
fn start(&mut self, c: ChannelId, _sound: &Sound, _looping: bool) {
|
||||
self.sources.get_mut(&c).expect("source").state = PLAYING;
|
||||
}
|
||||
fn stop(&mut self, c: ChannelId) {
|
||||
if let Some(s) = self.sources.get_mut(&c)
|
||||
&& s.state != INITIAL
|
||||
{
|
||||
s.state = STOPPED;
|
||||
}
|
||||
}
|
||||
fn pause(&mut self, c: ChannelId) {
|
||||
let s = self.sources.get_mut(&c).expect("source");
|
||||
if s.state == PLAYING {
|
||||
s.state = PAUSED;
|
||||
}
|
||||
}
|
||||
fn unpause(&mut self, c: ChannelId) {
|
||||
let s = self.sources.get_mut(&c).expect("source");
|
||||
if s.state == PAUSED {
|
||||
s.state = PLAYING;
|
||||
}
|
||||
}
|
||||
fn stopped(&mut self, c: ChannelId) -> bool {
|
||||
self.sources.get(&c).is_none_or(|s| s.state == STOPPED)
|
||||
}
|
||||
}
|
||||
pub fn bits(v: f32) -> String {
|
||||
format!("{:08x}", v.to_bits())
|
||||
}
|
||||
pub fn f(hex: &str) -> f32 {
|
||||
f32::from_bits(u32::from_str_radix(hex, 16).expect("float"))
|
||||
}
|
||||
pub fn d(hex: &str) -> f64 {
|
||||
f64::from_bits(u64::from_str_radix(hex, 16).expect("double"))
|
||||
}
|
||||
/// The shared per-step dump (instances, notifications, pool counts).
|
||||
pub fn describe(engine: &SoundEngine, backend: &Fake, name: &str, id: InstanceId) -> String {
|
||||
let mut s = format!(" {name} active={}", engine.is_active(id));
|
||||
match engine.channel(id).and_then(|c| backend.sources.get(&c)) {
|
||||
None => s.push_str(" channel=-"),
|
||||
Some(src) => s.push_str(&format!(
|
||||
" state={:x} gain={} pitch={} pos={},{},{} rel={} loop={} model={:x} max={} rolloff={} ref={}",
|
||||
src.state,
|
||||
bits(src.gain),
|
||||
bits(src.pitch),
|
||||
bits(src.position[0]),
|
||||
bits(src.position[1]),
|
||||
bits(src.position[2]),
|
||||
u8::from(src.relative),
|
||||
u8::from(src.looping),
|
||||
src.model,
|
||||
bits(src.max),
|
||||
bits(src.rolloff),
|
||||
bits(src.reference)
|
||||
)),
|
||||
}
|
||||
s
|
||||
}
|
||||
pub fn flush(engine: &mut SoundEngine, out: &mut impl Write) {
|
||||
for n in std::mem::take(&mut engine.notifications) {
|
||||
writeln!(
|
||||
out,
|
||||
"notify {} {} {} {} {:016x} {:016x} {:016x}",
|
||||
n.identifier,
|
||||
n.subtitle.as_deref().unwrap_or("-"),
|
||||
bits(n.range),
|
||||
n.looping,
|
||||
n.position[0].to_bits(),
|
||||
n.position[1].to_bits(),
|
||||
n.position[2].to_bits()
|
||||
)
|
||||
.expect("write");
|
||||
}
|
||||
let [used, streaming] = engine.used_channels();
|
||||
writeln!(
|
||||
out,
|
||||
" Sounds: {used}/{} + {streaming}/{}",
|
||||
engine.limits[0], engine.limits[1]
|
||||
)
|
||||
.expect("write");
|
||||
}
|
||||
|
||||
pub fn result_name(r: PlayResult) -> &'static str {
|
||||
match r {
|
||||
PlayResult::Started => "STARTED",
|
||||
PlayResult::StartedSilently => "STARTED_SILENTLY",
|
||||
PlayResult::NotStarted => "NOT_STARTED",
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
#![forbid(unsafe_code)]
|
||||
//! Runs tools/test_auto_jump_parity.py's cases through the local player's
|
||||
//! `MotionWorld::update_auto_jump` over the client's resident test scene
|
||||
//! (actual resources, collision registry and block-collision scan).
|
||||
use horizon_client::{
|
||||
assets::Assets,
|
||||
collisions,
|
||||
motion::{MotionResources, MotionWorld, PlayerMotion},
|
||||
world::World,
|
||||
};
|
||||
use horizon_core::geometry::Vec3;
|
||||
use serde_json::Value;
|
||||
use std::{
|
||||
fs,
|
||||
io::{BufRead, BufReader, BufWriter, Write},
|
||||
path::PathBuf,
|
||||
};
|
||||
type Result<T> = std::result::Result<T, Box<dyn std::error::Error>>;
|
||||
|
||||
fn fl(v: &Value) -> f32 {
|
||||
f32::from_bits(u32::from_str_radix(v.as_str().unwrap(), 16).unwrap())
|
||||
}
|
||||
fn db(v: &Value) -> f64 {
|
||||
f64::from_bits(u64::from_str_radix(v.as_str().unwrap(), 16).unwrap())
|
||||
}
|
||||
|
||||
fn main() -> Result<()> {
|
||||
let args: Vec<_> = std::env::args().skip(1).collect();
|
||||
let [root, input, output, scene_json] = &args[..] else {
|
||||
return Err("usage: auto_jump_probe RESOURCES INPUT OUTPUT SCENE_JSON".into());
|
||||
};
|
||||
let root = PathBuf::from(root);
|
||||
let assets = Assets::load(&root)?;
|
||||
let mut scene = World::test_scene(&assets, 1)?;
|
||||
let binding = collisions::load(&root, assets.registry)?;
|
||||
let resources = MotionResources::load(&root, &assets)?;
|
||||
fs::write(
|
||||
scene_json,
|
||||
serde_json::to_vec(&serde_json::json!({
|
||||
"radius": 1, "resident_chunks": scene.chunks.len(), "model_gallery": assets.model_gallery,
|
||||
}))?,
|
||||
)?;
|
||||
let mut out = BufWriter::new(fs::File::create(output)?);
|
||||
for (n, line) in BufReader::new(fs::File::open(input)?).lines().enumerate() {
|
||||
let c: Value = serde_json::from_str(&line?)?;
|
||||
writeln!(out, "== {n}")?;
|
||||
let mut restore = Vec::new();
|
||||
for b in c["blocks"].as_array().unwrap() {
|
||||
let (x, y, z) = (
|
||||
b[0].as_i64().unwrap() as i32,
|
||||
b[1].as_i64().unwrap() as i32,
|
||||
b[2].as_i64().unwrap() as i32,
|
||||
);
|
||||
let mut state = assets
|
||||
.registry
|
||||
.default_state(b[3].as_str().unwrap())
|
||||
.ok_or("missing fixture block")?;
|
||||
if let Some(properties) = b[4].as_object()
|
||||
&& !properties.is_empty()
|
||||
{
|
||||
let mut definition = assets.registry.state(state).unwrap().clone();
|
||||
for (name, value) in properties {
|
||||
definition
|
||||
.properties
|
||||
.insert(name.clone(), value.as_str().unwrap().into());
|
||||
}
|
||||
state = assets
|
||||
.registry
|
||||
.id(&definition)
|
||||
.ok_or("missing fixture state")?;
|
||||
}
|
||||
restore.push((x, y, z, scene.block(x, y, z)));
|
||||
scene.set(x, y, z, state)?;
|
||||
}
|
||||
let p = &c["player"];
|
||||
let position = Vec3::new(db(&p["pos"][0]), db(&p["pos"][1]), db(&p["pos"][2]));
|
||||
let mut player = PlayerMotion::new(&resources, position)?;
|
||||
player.ground.on_ground = p["ground"].as_bool().unwrap();
|
||||
player.shift_key_down = p["shift"].as_bool().unwrap();
|
||||
player.yaw = fl(&p["yr"]);
|
||||
player.pitch = fl(&p["xr"]);
|
||||
let key = player
|
||||
.attributes
|
||||
.key("minecraft:movement_speed")
|
||||
.ok_or("speed")?;
|
||||
let handle = player
|
||||
.attributes
|
||||
.get_instance(Some(key))
|
||||
.map_err(|e| format!("{e:?}"))?
|
||||
.ok_or("speed instance")?;
|
||||
let speed = f64::from(fl(&p["speed"]));
|
||||
player
|
||||
.attributes
|
||||
.edit(&handle, |a, o| a.set_base_value(speed, o))
|
||||
.map_err(|e| format!("{e:?}"))?;
|
||||
player.move_vector = [fl(&p["move"][0]), fl(&p["move"][1])];
|
||||
player.dimensions.width = fl(&p["width"]);
|
||||
player.dimensions.height = fl(&p["height"]);
|
||||
player.status.jump_boost = p["boost"].as_i64().map(|a| a as i32);
|
||||
player.auto_jump_enabled = p["enabled"].as_bool().unwrap();
|
||||
player.auto_jump_time = p["time"].as_i64().unwrap() as i32;
|
||||
MotionWorld {
|
||||
player: &mut player,
|
||||
scene: &mut scene,
|
||||
assets: &assets,
|
||||
collision_binding: &binding,
|
||||
resources: &resources,
|
||||
}
|
||||
.update_auto_jump(fl(&c["xa"]), fl(&c["za"]))?;
|
||||
writeln!(out, "jump {}", player.auto_jump_time)?;
|
||||
for (x, y, z, state) in restore.into_iter().rev() {
|
||||
scene.set(x, y, z, state)?;
|
||||
}
|
||||
}
|
||||
out.flush()?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,455 @@
|
||||
//! Headless transport for the block-entity animation parity gate: replays each
|
||||
//! scripted case (block, neighbourhood, entities, block events, game times)
|
||||
//! through `horizon_client::block_entity_anim` and prints the same lines as
|
||||
//! `VanillaBlockEntityAnimOracle`: the block entity's fields, the level calls
|
||||
//! its tick made and the render state the renderer derives from `BlockAnim`.
|
||||
#![forbid(unsafe_code)]
|
||||
use horizon_client::{
|
||||
assets::Assets,
|
||||
block_entity_anim::{
|
||||
Bell, BellEntity, BlockEntityAnims, ChestLid, Conduit, ConduitTarget, DIRECTIONS,
|
||||
PotWobble, Shulker, ShulkerStatus, Skull, WobbleStyle, chest_lid_x_rot, direction_index,
|
||||
},
|
||||
block_entity_render::BlockEntityRenderer,
|
||||
blocks::{BlockFacts, Fluid},
|
||||
items::Items,
|
||||
};
|
||||
use horizon_core::random::LegacyRandom;
|
||||
use horizon_world::registry::{BlockRegistry, BlockStateId};
|
||||
use serde_json::Value;
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
fmt::Write as _,
|
||||
fs,
|
||||
io::{BufWriter, Write},
|
||||
path::PathBuf,
|
||||
};
|
||||
type Result<T> = std::result::Result<T, Box<dyn std::error::Error>>;
|
||||
|
||||
fn f(v: f32) -> String {
|
||||
format!("{:08x}", v.to_bits())
|
||||
}
|
||||
fn d(v: f64) -> String {
|
||||
format!("{:016x}", v.to_bits())
|
||||
}
|
||||
fn bx(b: [f64; 6]) -> String {
|
||||
b.map(d).join(" ")
|
||||
}
|
||||
|
||||
fn find_state(registry: &BlockRegistry, spec: &str) -> Option<u32> {
|
||||
let (name, props) = match spec.split_once('[') {
|
||||
Some((n, rest)) => (n, rest.trim_end_matches(']')),
|
||||
None => (spec, ""),
|
||||
};
|
||||
let wanted: Vec<(&str, &str)> = props
|
||||
.split(',')
|
||||
.filter(|p| !p.is_empty())
|
||||
.filter_map(|p| p.split_once('='))
|
||||
.collect();
|
||||
let default = registry.state(registry.default_state(name)?)?.clone();
|
||||
(0..registry.len() as u32).find(|&id| {
|
||||
registry.state(BlockStateId(id)).is_some_and(|s| {
|
||||
s.name == name
|
||||
&& s.properties.iter().all(|(k, v)| {
|
||||
wanted
|
||||
.iter()
|
||||
.find(|(wk, _)| wk == k)
|
||||
.map_or(default.properties.get(k) == Some(v), |(_, wv)| wv == v)
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
fn prop<'a>(spec: &'a str, key: &str) -> Option<&'a str> {
|
||||
let props = spec.split_once('[')?.1.trim_end_matches(']');
|
||||
props
|
||||
.split(',')
|
||||
.filter_map(|p| p.split_once('='))
|
||||
.find(|(k, _)| *k == key)
|
||||
.map(|(_, v)| v)
|
||||
}
|
||||
|
||||
fn offset(key: &str) -> [i32; 3] {
|
||||
let mut it = key.split(',').map(|v| v.parse::<i32>().unwrap_or(0));
|
||||
[
|
||||
it.next().unwrap_or(0),
|
||||
it.next().unwrap_or(0),
|
||||
it.next().unwrap_or(0),
|
||||
]
|
||||
}
|
||||
|
||||
fn mob(e: &Value) -> BellEntity {
|
||||
let p = |i: usize| e["pos"][i].as_f64().unwrap_or(0.0);
|
||||
BellEntity {
|
||||
position: [p(0), p(1), p(2)],
|
||||
alive: e["alive"].as_bool().unwrap_or(true) && !e["removed"].as_bool().unwrap_or(false),
|
||||
raider: e["raider"].as_bool().unwrap_or(false),
|
||||
}
|
||||
}
|
||||
|
||||
fn lid_text(l: Option<&ChestLid>) -> String {
|
||||
l.map_or("-".to_owned(), |l| {
|
||||
format!("{} {} {}", l.should_be_open, f(l.openness), f(l.o_openness))
|
||||
})
|
||||
}
|
||||
|
||||
/// The case's players: feet positions and whether each is a spectator.
|
||||
fn players(v: &Value) -> Vec<([f64; 3], bool)> {
|
||||
v.as_array()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.map(|p| {
|
||||
let c = |i: usize| p["pos"][i].as_f64().unwrap_or(0.0);
|
||||
(
|
||||
[c(0), c(1), c(2)],
|
||||
p["spectator"].as_bool().unwrap_or(false),
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn status(s: ShulkerStatus) -> &'static str {
|
||||
match s {
|
||||
ShulkerStatus::Closed => "CLOSED",
|
||||
ShulkerStatus::Opening => "OPENING",
|
||||
ShulkerStatus::Opened => "OPENED",
|
||||
ShulkerStatus::Closing => "CLOSING",
|
||||
}
|
||||
}
|
||||
|
||||
fn main() -> Result<()> {
|
||||
let args: Vec<PathBuf> = std::env::args_os().skip(1).map(PathBuf::from).collect();
|
||||
let [root, inputs, output] = args.as_slice() else {
|
||||
return Err("usage: block_entity_anim_fixture RESOURCES INPUTS OUTPUT".into());
|
||||
};
|
||||
let assets = Assets::load(root)?;
|
||||
let registry = assets.registry;
|
||||
let facts = BlockFacts::load(root, registry)?;
|
||||
let renderer = BlockEntityRenderer::load(root, registry)?;
|
||||
let tags = Items::tags_only(root)?;
|
||||
let frames = tags
|
||||
.block_tags
|
||||
.get("minecraft:conduit_effect_block")
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
let mut out = BufWriter::new(fs::File::create(output)?);
|
||||
for line in fs::read_to_string(inputs)?
|
||||
.lines()
|
||||
.filter(|l| !l.is_empty())
|
||||
{
|
||||
let case: Value = serde_json::from_str(line)?;
|
||||
writeln!(out, "case {}", case["name"].as_str().unwrap_or_default())?;
|
||||
let block = case["block"].as_str().unwrap_or_default().to_owned();
|
||||
let int = |v: &Value| v.as_i64().unwrap_or(0) as i32;
|
||||
let pos = [
|
||||
int(&case["pos"][0]),
|
||||
int(&case["pos"][1]),
|
||||
int(&case["pos"][2]),
|
||||
];
|
||||
// The neighbourhood as block states (conduits).
|
||||
let mut blocks: HashMap<[i32; 3], String> = HashMap::new();
|
||||
blocks.insert(pos, block.clone());
|
||||
let place = |blocks: &mut HashMap<[i32; 3], String>, set: &Value| {
|
||||
for (k, v) in set.as_object().into_iter().flatten() {
|
||||
let o = offset(k);
|
||||
blocks.insert(
|
||||
[pos[0] + o[0], pos[1] + o[1], pos[2] + o[2]],
|
||||
v.as_str().unwrap_or_default().to_owned(),
|
||||
);
|
||||
}
|
||||
};
|
||||
place(&mut blocks, &case["world"]);
|
||||
let entities: Vec<BellEntity> = case["entities"]
|
||||
.as_array()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.map(mob)
|
||||
.collect();
|
||||
let target = case.get("target").map(|t| {
|
||||
let e = mob(t);
|
||||
ConduitTarget {
|
||||
position: e.position,
|
||||
eye_y: e.position[1] + f64::from(t["eye"].as_f64().unwrap_or(0.0) as f32),
|
||||
width: t["width"].as_f64().unwrap_or(0.0) as f32,
|
||||
height: t["height"].as_f64().unwrap_or(0.0) as f32,
|
||||
}
|
||||
});
|
||||
let mut random = LegacyRandom::new(case["seed"].as_i64().unwrap_or(0));
|
||||
let name = block.split('[').next().unwrap_or_default().to_owned();
|
||||
let mut anims = BlockEntityAnims::default();
|
||||
let partner = case["partner"].as_str().map(|k| {
|
||||
let o = offset(k);
|
||||
[pos[0] + o[0], pos[1] + o[1], pos[2] + o[2]]
|
||||
});
|
||||
let mut gamers = players(&case["players"]);
|
||||
if name == "minecraft:enchanting_table" {
|
||||
anims.book_random = LegacyRandom::new(case["seed"].as_i64().unwrap_or(0));
|
||||
}
|
||||
let facing = prop(&block, "facing")
|
||||
.and_then(direction_index)
|
||||
.unwrap_or(1);
|
||||
let mut text = String::new();
|
||||
for step in case["steps"].as_array().into_iter().flatten() {
|
||||
let time = step["time"].as_i64().unwrap_or(0);
|
||||
let partial = step["partial"].as_f64().unwrap_or(0.0) as f32;
|
||||
let mut log = String::new();
|
||||
place(&mut blocks, &step["set"]);
|
||||
let event = step["event"].as_array().map(|e| (int(&e[0]), int(&e[1])));
|
||||
text.clear();
|
||||
if step.get("players").is_some() {
|
||||
gamers = players(&step["players"]);
|
||||
}
|
||||
if name.ends_with("chest") {
|
||||
let lid = anims.chests.entry(pos).or_default();
|
||||
if let Some((a, b)) = event {
|
||||
let handled = lid.trigger_event(a, b);
|
||||
let _ = write!(log, " event {handled}");
|
||||
}
|
||||
if let Some(other) = partner {
|
||||
let lid = anims.chests.entry(other).or_default();
|
||||
if let Some(e) = step["partnerEvent"].as_array() {
|
||||
let handled = lid.trigger_event(int(&e[0]), int(&e[1]));
|
||||
let _ = write!(log, " partner event {handled}");
|
||||
}
|
||||
}
|
||||
// lidAnimateTick: this chest, then the other half.
|
||||
if let Some(l) = anims.chests.get_mut(&pos) {
|
||||
l.tick();
|
||||
}
|
||||
if let Some(l) = partner.and_then(|p| anims.chests.get_mut(&p)) {
|
||||
l.tick();
|
||||
}
|
||||
let state_at = |p: [i32; 3]| {
|
||||
find_state(
|
||||
registry,
|
||||
blocks.get(&p).map_or("minecraft:air", String::as_str),
|
||||
)
|
||||
.unwrap_or(0)
|
||||
};
|
||||
let (open, _) = renderer
|
||||
.chest_openness_of(pos, state_at(pos), &state_at, &|p| anims.lid(p, partial));
|
||||
let _ = write!(
|
||||
text,
|
||||
"{} partner {} render {} {}",
|
||||
lid_text(anims.chests.get(&pos)),
|
||||
partner.map_or("none".to_owned(), |p| lid_text(anims.chests.get(&p))),
|
||||
f(open),
|
||||
f(chest_lid_x_rot(open))
|
||||
);
|
||||
} else if name == "minecraft:enchanting_table" {
|
||||
let feet: Vec<[f64; 3]> = gamers
|
||||
.iter()
|
||||
.filter(|(_, spectator)| !spectator)
|
||||
.map(|(p, _)| *p)
|
||||
.collect();
|
||||
let BlockEntityAnims {
|
||||
books, book_random, ..
|
||||
} = &mut anims;
|
||||
books.entry(pos).or_default().tick(pos, &feet, book_random);
|
||||
let b = anims.books[&pos];
|
||||
let r = anims
|
||||
.block_anim(pos, time, partial, Default::default())
|
||||
.book
|
||||
.render(partial);
|
||||
let _ = write!(
|
||||
text,
|
||||
"{} {} {} {} {} {} {} {} {} {} render {} {} {} {} book {} {} {}",
|
||||
b.time,
|
||||
f(b.flip),
|
||||
f(b.o_flip),
|
||||
f(b.flip_t),
|
||||
f(b.flip_a),
|
||||
f(b.open),
|
||||
f(b.o_open),
|
||||
f(b.rot),
|
||||
f(b.o_rot),
|
||||
f(b.t_rot),
|
||||
f(r.flip),
|
||||
f(r.open),
|
||||
f(r.time),
|
||||
f(r.y_rot),
|
||||
f(r.openness),
|
||||
f(r.page_flip1),
|
||||
f(r.page_flip2)
|
||||
);
|
||||
} else if name.ends_with("shulker_box") {
|
||||
let s = anims.shulkers.entry(pos).or_insert_with(Shulker::default);
|
||||
if let Some((a, b)) = event {
|
||||
let handled = s.trigger_event(a, b);
|
||||
let _ = write!(log, " event {handled}");
|
||||
}
|
||||
let tick = s.tick(pos, facing);
|
||||
for _ in 0..tick.neighbor_updates {
|
||||
log.push_str(" neighbors");
|
||||
}
|
||||
if let Some(b) = tick.push {
|
||||
let _ = write!(log, " push {}", bx(b));
|
||||
}
|
||||
let (count, st) = (s.open_count, s.status);
|
||||
let a = anims.block_anim(pos, time, partial, Default::default());
|
||||
// ShulkerBoxRenderer: getProgress(partial).
|
||||
let (progress, old) = a.shulker;
|
||||
let render = old + partial * (progress - old);
|
||||
let s = &anims.shulkers[&pos];
|
||||
let _ = write!(
|
||||
text,
|
||||
"{} {} {} {} render {}",
|
||||
status(st),
|
||||
count,
|
||||
f(s.progress),
|
||||
f(s.progress_old),
|
||||
f(render)
|
||||
);
|
||||
} else if name == "minecraft:bell" {
|
||||
let b = anims.bells.entry(pos).or_insert_with(Bell::default);
|
||||
if let Some((e0, e1)) = event {
|
||||
let handled = b.trigger_event(e0, e1, time, || {
|
||||
let inflated = [
|
||||
f64::from(pos[0]) - 48.0,
|
||||
f64::from(pos[1]) - 48.0,
|
||||
f64::from(pos[2]) - 48.0,
|
||||
f64::from(pos[0]) + 1.0 + 48.0,
|
||||
f64::from(pos[1]) + 1.0 + 48.0,
|
||||
f64::from(pos[2]) + 1.0 + 48.0,
|
||||
];
|
||||
let _ = write!(log, " gather {}", bx(inflated));
|
||||
(0..entities.len() as u64)
|
||||
.filter(|&i| {
|
||||
let p = entities[i as usize].position;
|
||||
(0..3).all(|a| p[a] >= inflated[a] && p[a] < inflated[a + 3])
|
||||
})
|
||||
.collect()
|
||||
});
|
||||
let _ = write!(log, " event {handled}");
|
||||
}
|
||||
if !step["noTick"].as_bool().unwrap_or(false) {
|
||||
let tick = b.tick(pos, |h| entities.get(h as usize).copied());
|
||||
if tick.resonate {
|
||||
log.push_str(" sound minecraft:block.bell.resonate");
|
||||
}
|
||||
if let Some((particles, _)) = tick.ended {
|
||||
for (p, color) in particles {
|
||||
let _ = write!(
|
||||
log,
|
||||
" particle minecraft:entity_effect {color} {} {} {} {} {} {}",
|
||||
d(p[0]),
|
||||
d(p[1]),
|
||||
d(p[2]),
|
||||
d(0.0),
|
||||
d(0.0),
|
||||
d(0.0)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
let b = &anims.bells[&pos];
|
||||
let a = anims.block_anim(pos, time, partial, Default::default());
|
||||
let dir = |d: Option<&str>| d.map_or("null".to_owned(), str::to_owned);
|
||||
let _ = write!(
|
||||
text,
|
||||
"{} {} {} {} {} {} render {} {}",
|
||||
b.ticks,
|
||||
b.shaking,
|
||||
dir(b.click_direction.map(|d| DIRECTIONS[usize::from(d)])),
|
||||
b.resonating,
|
||||
b.resonation_ticks,
|
||||
b.last_ring_timestamp,
|
||||
f(a.bell_ticks as f32 + partial),
|
||||
dir(a.bell_shake)
|
||||
);
|
||||
} else if name == "minecraft:conduit" {
|
||||
let c = anims.conduits.entry(pos).or_insert_with(Conduit::default);
|
||||
let state_of = |p: [i32; 3]| -> Option<u32> {
|
||||
find_state(
|
||||
registry,
|
||||
blocks.get(&p).map_or("minecraft:air", String::as_str),
|
||||
)
|
||||
};
|
||||
let is_water = |p: [i32; 3]| {
|
||||
state_of(p).is_some_and(|s| facts.get(BlockStateId(s)).fluid == Fluid::Water)
|
||||
};
|
||||
let is_frame = |p: [i32; 3]| {
|
||||
let spec = blocks.get(&p).map_or("minecraft:air", String::as_str);
|
||||
frames.contains(spec.split('[').next().unwrap_or_default())
|
||||
};
|
||||
let particles = c.client_tick(pos, time, is_water, is_frame, target, &mut random);
|
||||
for (p, v) in particles {
|
||||
let _ = write!(
|
||||
log,
|
||||
" particle minecraft:nautilus {} {} {} {} {} {}",
|
||||
d(p[0]),
|
||||
d(p[1]),
|
||||
d(p[2]),
|
||||
d(v[0]),
|
||||
d(v[1]),
|
||||
d(v[2])
|
||||
);
|
||||
}
|
||||
let c = &anims.conduits[&pos];
|
||||
let a = anims.block_anim(pos, time, partial, Default::default());
|
||||
// ConduitRenderer.extractRenderState as block_entity_render derives it.
|
||||
let (ticks, active, hunting, raw) = a.conduit;
|
||||
let rotation = (raw + if active { partial } else { 0.0 }) * -0.0375;
|
||||
let _ = write!(
|
||||
text,
|
||||
"{} {} {} {} {} render {} {} {} {} {}",
|
||||
c.tick_count,
|
||||
c.is_active,
|
||||
c.is_hunting,
|
||||
f(c.active_rotation),
|
||||
c.effect_blocks.len(),
|
||||
active,
|
||||
f(rotation),
|
||||
f(ticks as f32 + partial),
|
||||
ticks / 66 % 3,
|
||||
hunting
|
||||
);
|
||||
} else if name.ends_with("_head") || name.ends_with("_skull") {
|
||||
let now = step["state"].as_str().unwrap_or(&block);
|
||||
let s = anims.skulls.entry(pos).or_insert_with(Skull::default);
|
||||
s.tick(prop(now, "powered") == Some("true"));
|
||||
let s = anims.skulls[&pos];
|
||||
let a = anims.block_anim(pos, time, partial, Default::default());
|
||||
// block_entity_render: count + partial while animating.
|
||||
let (animating, count) = a.skull;
|
||||
let render = count as f32 + if animating { partial } else { 0.0 };
|
||||
let _ = write!(
|
||||
text,
|
||||
"{} {} render {}",
|
||||
s.animating,
|
||||
s.tick_count,
|
||||
f(render)
|
||||
);
|
||||
} else if name == "minecraft:decorated_pot" {
|
||||
let w = anims.pots.entry(pos).or_insert_with(PotWobble::default);
|
||||
if let Some((e0, e1)) = event {
|
||||
let handled = w.trigger_event(e0, e1, time);
|
||||
let _ = write!(log, " event {handled}");
|
||||
}
|
||||
let w = anims.pots[&pos];
|
||||
let a = anims.block_anim(pos, time, partial, Default::default());
|
||||
let (style, progress) = match a.wobble {
|
||||
Some((true, p)) => ("POSITIVE", p),
|
||||
Some((false, p)) => ("null", p),
|
||||
None => ("null", 0.0),
|
||||
};
|
||||
let _ = write!(
|
||||
text,
|
||||
"{} {} render {} {}",
|
||||
w.started_at,
|
||||
match w.style {
|
||||
Some(WobbleStyle::Positive) => "POSITIVE",
|
||||
Some(WobbleStyle::Negative) => "NEGATIVE",
|
||||
None => "null",
|
||||
},
|
||||
f(progress),
|
||||
style
|
||||
);
|
||||
} else {
|
||||
text = format!("unsupported {block}");
|
||||
}
|
||||
writeln!(out, "{text} |{log}")?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,391 @@
|
||||
//! Headless transport for the block-entity render parity gate: replays each
|
||||
//! case (block state, light, partial tick and ticked block-entity fields)
|
||||
//! through `BlockEntityRenderer` at the block origin and writes the quads
|
||||
//! (texture, sprite-local UVs, baked colour, light) and sign text calls.
|
||||
#![forbid(unsafe_code)]
|
||||
use glam::{DVec3, Mat4, Quat, Vec3};
|
||||
use horizon_client::{
|
||||
assets::Assets,
|
||||
banner::{self, Banners},
|
||||
block_entity_anim::EnchantingBook,
|
||||
block_entity_render::{
|
||||
BlockAnim, BlockEntityRenderer, END_GATEWAY_LAYER, END_PORTAL_LAYER, Scene, TextCall,
|
||||
},
|
||||
chunk::{Chunk, Light},
|
||||
hud::{HudAtlas, Rect},
|
||||
signs::{DYE_TEXT, SignData, SignText},
|
||||
world::World,
|
||||
};
|
||||
use horizon_world::registry::{BlockRegistry, BlockStateId};
|
||||
use serde_json::{Value, json};
|
||||
use std::{
|
||||
cell::RefCell,
|
||||
collections::HashMap,
|
||||
fs,
|
||||
io::{BufWriter, Write},
|
||||
path::{Path, PathBuf},
|
||||
sync::Arc,
|
||||
};
|
||||
type Result<T> = std::result::Result<T, Box<dyn std::error::Error>>;
|
||||
|
||||
/// Every texture the vanilla oracle used gets its own unit square on a small
|
||||
/// grid (offsets stay small so f32 UVs keep their precision), so a vertex's
|
||||
/// texture and sprite-local UV can be recovered.
|
||||
fn fake_sprites(list: &Path) -> Result<(HashMap<String, Rect>, Vec<String>, usize)> {
|
||||
let names: Vec<String> = fs::read_to_string(list)?
|
||||
.lines()
|
||||
.filter(|l| !l.is_empty())
|
||||
.map(str::to_owned)
|
||||
.collect();
|
||||
let grid = (names.len() as f64).sqrt().ceil().max(1.0) as usize;
|
||||
let map = names
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, n)| {
|
||||
let (u0, v0) = ((i % grid) as f32 * 2.0, (i / grid) as f32 * 2.0);
|
||||
(
|
||||
n.clone(),
|
||||
Rect {
|
||||
u0,
|
||||
v0,
|
||||
u1: u0 + 1.0,
|
||||
v1: v0 + 1.0,
|
||||
w: 16,
|
||||
h: 16,
|
||||
},
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
Ok((map, names, grid))
|
||||
}
|
||||
|
||||
fn find_state(registry: &BlockRegistry, spec: &str) -> Option<u32> {
|
||||
let (name, props) = match spec.split_once('[') {
|
||||
Some((n, rest)) => (n, rest.trim_end_matches(']')),
|
||||
None => (spec, ""),
|
||||
};
|
||||
let wanted: Vec<(&str, &str)> = props
|
||||
.split(',')
|
||||
.filter(|p| !p.is_empty())
|
||||
.filter_map(|p| p.split_once('='))
|
||||
.collect();
|
||||
let default = registry.state(registry.default_state(name)?)?.clone();
|
||||
(0..registry.len() as u32).find(|&id| {
|
||||
registry.state(BlockStateId(id)).is_some_and(|s| {
|
||||
s.name == name
|
||||
&& s.properties.iter().all(|(k, v)| {
|
||||
wanted
|
||||
.iter()
|
||||
.find(|(wk, _)| wk == k)
|
||||
.map_or(default.properties.get(k) == Some(v), |(_, wv)| wv == v)
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
fn sign_side(v: &Value) -> SignText {
|
||||
let mut side = SignText::default();
|
||||
for (line, text) in side
|
||||
.lines
|
||||
.iter_mut()
|
||||
.zip(v["lines"].as_array().into_iter().flatten())
|
||||
{
|
||||
*line = text.as_str().unwrap_or_default().to_owned();
|
||||
}
|
||||
let color = v["color"].as_str().unwrap_or("black");
|
||||
side.color = DYE_TEXT.iter().position(|(n, _)| *n == color).unwrap_or(15) as u8;
|
||||
side.glowing = v["glowing"].as_bool().unwrap_or(false);
|
||||
side
|
||||
}
|
||||
|
||||
fn intern(s: &str) -> &'static str {
|
||||
match s {
|
||||
"north" => "north",
|
||||
"south" => "south",
|
||||
"west" => "west",
|
||||
"east" => "east",
|
||||
"up" => "up",
|
||||
_ => "down",
|
||||
}
|
||||
}
|
||||
|
||||
fn main() -> Result<()> {
|
||||
let args: Vec<PathBuf> = std::env::args_os().skip(1).map(PathBuf::from).collect();
|
||||
let [root, inputs, textures, output] = args.as_slice() else {
|
||||
return Err("usage: block_entity_render_fixture RESOURCES INPUTS TEXTURES OUTPUT".into());
|
||||
};
|
||||
let assets = Assets::load(root)?;
|
||||
let registry = assets.registry;
|
||||
let renderer = BlockEntityRenderer::load(root, registry)?;
|
||||
let atlas = HudAtlas::build(root)?;
|
||||
let banners = Banners::load(root);
|
||||
let (sprites, names, grid) = fake_sprites(textures)?;
|
||||
let air = registry.default_state("minecraft:air").ok_or("no air")?;
|
||||
let mut out = BufWriter::new(fs::File::create(output)?);
|
||||
for line in fs::read_to_string(inputs)?
|
||||
.lines()
|
||||
.filter(|l| !l.is_empty())
|
||||
{
|
||||
let case: Value = serde_json::from_str(line)?;
|
||||
let block = case["block"].as_str().unwrap_or_default();
|
||||
if block == "minecraft:beacon" {
|
||||
// BeaconRenderer: the beam sections from the case, as Horizon's beacon keeps them.
|
||||
let int = |v: &Value| v.as_i64().unwrap_or(0);
|
||||
let pos = [
|
||||
int(&case["pos"][0]),
|
||||
int(&case["pos"][1]),
|
||||
int(&case["pos"][2]),
|
||||
];
|
||||
let partial = case["partial"].as_f64().unwrap_or(0.0) as f32;
|
||||
let time = int(&case["gameTime"]).rem_euclid(40) as f32 + partial;
|
||||
let cam = &case["camera"];
|
||||
let dx = cam[0].as_f64().unwrap_or(0.0) - (pos[0] as f64 + 0.5);
|
||||
let dz = cam[2].as_f64().unwrap_or(0.0) - (pos[2] as f64 + 0.5);
|
||||
let scale = ((dx * dx + dz * dz).sqrt() as f32 / 96.0).max(1.0);
|
||||
let sections: Vec<(u32, i32)> = case["beam"]
|
||||
.as_array()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.map(|s| (int(&s[0]) as u32 & 0xFF_FFFF, int(&s[1]) as i32))
|
||||
.collect();
|
||||
let texture = "entity/beacon/beacon_beam";
|
||||
let rect = sprites.get(texture).copied().ok_or("no beam sprite")?;
|
||||
let mut verts = Vec::new();
|
||||
horizon_client::block_entity_render::beacon_beam(
|
||||
Vec3::ZERO,
|
||||
§ions,
|
||||
time,
|
||||
scale,
|
||||
rect,
|
||||
&mut verts,
|
||||
);
|
||||
let quads: Vec<Value> = verts
|
||||
.chunks(4)
|
||||
.map(|q| {
|
||||
let v: Vec<Value> = q
|
||||
.iter()
|
||||
.map(|v| json!([v.pos[0], v.pos[1], v.pos[2], v.uv[0] - rect.u0, v.uv[1]]))
|
||||
.collect();
|
||||
json!({"texture": texture, "v": v, "color": q[0].color, "light": q[0].light, "custom": true})
|
||||
})
|
||||
.collect();
|
||||
writeln!(out, "{}", json!({"quads": quads, "texts": []}))?;
|
||||
continue;
|
||||
}
|
||||
let Some(state) = find_state(registry, block) else {
|
||||
writeln!(out, "{}", json!({"skip": "unknown block state"}))?;
|
||||
continue;
|
||||
};
|
||||
if !renderer.renders(state) {
|
||||
writeln!(out, "{}", json!({"skip": "no renderer"}))?;
|
||||
continue;
|
||||
}
|
||||
let int = |v: &Value| v.as_i64().unwrap_or(0) as i32;
|
||||
let float = |v: &Value| v.as_f64().unwrap_or(0.0) as f32;
|
||||
let pos = [
|
||||
int(&case["pos"][0]),
|
||||
int(&case["pos"][1]),
|
||||
int(&case["pos"][2]),
|
||||
];
|
||||
let packed = (int(&case["light"][0]) << 4 | int(&case["light"][1])) as u8;
|
||||
let mut world = World::new(air, 0);
|
||||
for dx in -1..=1 {
|
||||
for dz in -1..=1 {
|
||||
let (cx, cz) = ((pos[0] >> 4) + dx, (pos[2] >> 4) + dz);
|
||||
let mut chunk = Chunk::empty(cx, cz);
|
||||
for section in &mut chunk.sections {
|
||||
Arc::make_mut(section).light = Light::Uniform(packed);
|
||||
}
|
||||
world.chunks.insert((cx, cz), Arc::new(chunk));
|
||||
}
|
||||
}
|
||||
// Neighbour blocks the case sets (End gateway faces).
|
||||
for (direction, spec) in case["neighbours"].as_object().into_iter().flatten() {
|
||||
let d = match direction.as_str() {
|
||||
"down" => [0, -1, 0],
|
||||
"up" => [0, 1, 0],
|
||||
"north" => [0, 0, -1],
|
||||
"south" => [0, 0, 1],
|
||||
"west" => [-1, 0, 0],
|
||||
_ => [1, 0, 0],
|
||||
};
|
||||
let neighbour = find_state(registry, spec.as_str().unwrap_or_default())
|
||||
.ok_or("unknown neighbour state")?;
|
||||
world.set(
|
||||
pos[0] + d[0],
|
||||
pos[1] + d[1],
|
||||
pos[2] + d[2],
|
||||
BlockStateId(neighbour),
|
||||
)?;
|
||||
}
|
||||
let book = case
|
||||
.get("book")
|
||||
.map_or_else(EnchantingBook::default, |book| {
|
||||
let [flip, o_flip, open, o_open, rot, o_rot] =
|
||||
["flip", "oFlip", "open", "oOpen", "rot", "oRot"].map(|k| float(&book[k]));
|
||||
EnchantingBook {
|
||||
time: int(&book["time"]),
|
||||
flip,
|
||||
o_flip,
|
||||
open,
|
||||
o_open,
|
||||
rot,
|
||||
o_rot,
|
||||
..EnchantingBook::default()
|
||||
}
|
||||
});
|
||||
let sign = case.get("sign").map(|s| {
|
||||
let mut data = SignData {
|
||||
hanging: block.contains("hanging"),
|
||||
..SignData::default()
|
||||
};
|
||||
data.front = sign_side(&s["front"]);
|
||||
data.back = sign_side(&s["back"]);
|
||||
data
|
||||
});
|
||||
let layers: Vec<(String, u32)> = case["patterns"]
|
||||
.as_array()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.map(|p| {
|
||||
(
|
||||
banners.banner_texture(p[0].as_str().unwrap_or_default()),
|
||||
banner::dye_rgb(p[1].as_str().unwrap_or_default()),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
let anim = BlockAnim {
|
||||
shulker: (float(&case["progress"]), float(&case["progressOld"])),
|
||||
bell_ticks: int(&case["bellTicks"]),
|
||||
bell_shake: case["shake"].as_str().map(intern),
|
||||
conduit: (
|
||||
int(&case["conduit"]["tickCount"]),
|
||||
case["conduit"]["active"].as_bool().unwrap_or(false),
|
||||
case["conduit"]["hunting"].as_bool().unwrap_or(false),
|
||||
float(&case["conduit"]["activeRotation"]),
|
||||
),
|
||||
skull: (
|
||||
case["skullAnimation"][0].as_bool().unwrap_or(false),
|
||||
int(&case["skullAnimation"][1]),
|
||||
),
|
||||
sherds: [0, 1, 2, 3].map(|i| {
|
||||
case["sherds"][i]
|
||||
.as_str()
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(str::to_owned)
|
||||
}),
|
||||
wobble: case["wobble"]
|
||||
.as_array()
|
||||
.map(|w| (w[0].as_str() == Some("POSITIVE"), float(&w[1]))),
|
||||
book,
|
||||
gateway: (
|
||||
case["gateway"]["age"].as_i64().unwrap_or(0),
|
||||
int(&case["gateway"]["cooldown"]),
|
||||
),
|
||||
};
|
||||
// The case's level height (minimum y, height): the End's by default.
|
||||
let max_y = match case["level"].as_array() {
|
||||
Some(l) => int(&l[0]) + int(&l[1]) - 1,
|
||||
None => 255,
|
||||
};
|
||||
let open = float(&case["open"]);
|
||||
let near = case["sign"]["near"].as_bool().unwrap_or(false);
|
||||
let camera = DVec3::new(
|
||||
case["camera"][0].as_f64().unwrap_or(0.0),
|
||||
case["camera"][1].as_f64().unwrap_or(0.0),
|
||||
case["camera"][2].as_f64().unwrap_or(0.0),
|
||||
);
|
||||
// Signs pick the outline from the camera distance: put the camera where the case says.
|
||||
let camera = if case.get("sign").is_some() {
|
||||
let centre = DVec3::new(f64::from(pos[0]), f64::from(pos[1]), f64::from(pos[2])) + 0.5;
|
||||
centre
|
||||
+ if near {
|
||||
DVec3::ZERO
|
||||
} else {
|
||||
DVec3::new(20.0, 0.0, 0.0)
|
||||
}
|
||||
} else {
|
||||
camera
|
||||
};
|
||||
let (yaw, pitch) = (float(&case["yaw"]), float(&case["pitch"]));
|
||||
let texts = RefCell::new(Vec::<TextCall>::new());
|
||||
let scene = Scene {
|
||||
camera,
|
||||
orientation: Quat::from_euler(
|
||||
glam::EulerRot::YXZ,
|
||||
std::f32::consts::PI - yaw * (std::f32::consts::PI / 180.0),
|
||||
-pitch * (std::f32::consts::PI / 180.0),
|
||||
0.0,
|
||||
),
|
||||
anim: &|_| anim.clone(),
|
||||
texts: Some(&texts),
|
||||
partial: float(&case["partial"]),
|
||||
game_time: case["gameTime"].as_i64().unwrap_or(0),
|
||||
max_y,
|
||||
world: &world,
|
||||
sprites: &sprites,
|
||||
nether: false,
|
||||
lid: &|_| open,
|
||||
sign: &|_| sign.as_ref(),
|
||||
banner: &|_| layers.clone(),
|
||||
glyph: &|c| atlas.glyph_cell(c),
|
||||
};
|
||||
let mut verts = Vec::new();
|
||||
renderer.render_block(&scene, pos, state, Mat4::IDENTITY, packed, &mut verts);
|
||||
let quads: Vec<Value> = verts
|
||||
.chunks(4)
|
||||
.map(|q| {
|
||||
// Custom geometry: the End portal shader's position-only cube (its
|
||||
// render type samples end_sky first) and End gateway beams.
|
||||
if q[0].layer == END_PORTAL_LAYER || q[0].layer == END_GATEWAY_LAYER {
|
||||
let v: Vec<Value> = q
|
||||
.iter()
|
||||
.map(|v| json!([v.pos[0], v.pos[1], v.pos[2], v.uv[0], v.uv[1]]))
|
||||
.collect();
|
||||
return json!({"texture": "environment/end_sky", "v": v, "color": q[0].color,
|
||||
"light": q[0].light, "custom": true});
|
||||
}
|
||||
if q[0].layer == 6 {
|
||||
let texture = "entity/end_portal/end_gateway_beam";
|
||||
let u0 = sprites.get(texture).map_or(0.0, |r| r.u0);
|
||||
let v: Vec<Value> = q
|
||||
.iter()
|
||||
.map(|v| json!([v.pos[0], v.pos[1], v.pos[2], v.uv[0] - u0, v.uv[1]]))
|
||||
.collect();
|
||||
return json!({"texture": texture, "v": v, "color": q[0].color,
|
||||
"light": q[0].light, "custom": true});
|
||||
}
|
||||
let (cu, cv) = ((q[0].uv[0] / 2.0).floor(), (q[0].uv[1] / 2.0).floor());
|
||||
let texture = names
|
||||
.get(cu as usize + cv as usize * grid)
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
let v: Vec<Value> = q
|
||||
.iter()
|
||||
.map(|v| {
|
||||
json!([
|
||||
v.pos[0],
|
||||
v.pos[1],
|
||||
v.pos[2],
|
||||
v.uv[0] - cu * 2.0,
|
||||
v.uv[1] - cv * 2.0
|
||||
])
|
||||
})
|
||||
.collect();
|
||||
json!({"texture": texture, "v": v, "color": q[0].color, "light": q[0].light})
|
||||
})
|
||||
.collect();
|
||||
let texts: Vec<Value> = texts
|
||||
.borrow()
|
||||
.iter()
|
||||
.filter(|t| !t.text.is_empty())
|
||||
.map(|t| {
|
||||
json!({"matrix": t.matrix.to_cols_array(), "x": t.x, "y": t.y, "text": t.text,
|
||||
"color": t.color, "outline": t.outline,
|
||||
"light": ((t.light & 0xFF) << 16 | (t.light >> 8 & 0xFF))})
|
||||
})
|
||||
.collect();
|
||||
writeln!(out, "{}", json!({"quads": quads, "texts": texts}))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,342 @@
|
||||
#![forbid(unsafe_code)]
|
||||
//! Runs the JSON-lines scenarios of tools/test_camera_parity.py through the
|
||||
//! Rust camera, field-of-view, avatar bob and view/hurt bobbing code.
|
||||
use horizon_client::{
|
||||
Result,
|
||||
camera::{
|
||||
AvatarBob, BobState, Camera, CameraEntity, CameraFluid, CameraType, FovPlayer, HurtBob,
|
||||
LivingCamera, UseItem, bob_matrix, field_of_view_modifier,
|
||||
},
|
||||
};
|
||||
use horizon_core::{
|
||||
geometry::{Aabb, ShapeOp, Vec3, VoxelShape},
|
||||
position::BlockPos,
|
||||
};
|
||||
use horizon_world::world_ray::{RayBlockMode, RayFluidMode, RayWorld, clip_world};
|
||||
use serde_json::Value;
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
convert::Infallible,
|
||||
fs::File,
|
||||
io::{BufRead, BufReader, BufWriter, Write},
|
||||
};
|
||||
|
||||
fn fl(v: &Value) -> f32 {
|
||||
f32::from_bits(u32::from_str_radix(v.as_str().unwrap(), 16).unwrap())
|
||||
}
|
||||
fn db(v: &Value) -> f64 {
|
||||
f64::from_bits(u64::from_str_radix(v.as_str().unwrap(), 16).unwrap())
|
||||
}
|
||||
fn fb(v: f32) -> String {
|
||||
format!("{:08x}", if v.is_nan() { 0x7fc0_0000 } else { v.to_bits() })
|
||||
}
|
||||
fn dbs(v: f64) -> String {
|
||||
format!(
|
||||
"{:016x}",
|
||||
if v.is_nan() {
|
||||
0x7ff8_0000_0000_0000
|
||||
} else {
|
||||
v.to_bits()
|
||||
}
|
||||
)
|
||||
}
|
||||
fn b(v: &Value) -> bool {
|
||||
v.as_bool().unwrap()
|
||||
}
|
||||
fn vec3(v: &Value) -> [f64; 3] {
|
||||
[db(&v[0]), db(&v[1]), db(&v[2])]
|
||||
}
|
||||
|
||||
fn fov_player(c: &Value) -> FovPlayer {
|
||||
FovPlayer {
|
||||
flying: b(&c["flying"]),
|
||||
walking_speed: fl(&c["ws"]),
|
||||
movement_speed: db(&c["speed"]),
|
||||
using: c["use"].as_array().map(|u| {
|
||||
let item = match u[0].as_str().unwrap() {
|
||||
"bow" => UseItem::Bow,
|
||||
"spyglass" => UseItem::Spyglass,
|
||||
_ => UseItem::Other,
|
||||
};
|
||||
(item, u[1].as_i64().unwrap() as i32)
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
/// A level of visual shapes keyed by cell, without fluids.
|
||||
struct Cells {
|
||||
shapes: Vec<VoxelShape>,
|
||||
index: HashMap<BlockPos, usize>,
|
||||
}
|
||||
impl RayWorld for Cells {
|
||||
type Error = Infallible;
|
||||
type Block = usize;
|
||||
type Fluid = ();
|
||||
fn block_state(&mut self, p: BlockPos) -> std::result::Result<usize, Infallible> {
|
||||
Ok(self.index.get(&p).copied().unwrap_or(0))
|
||||
}
|
||||
fn fluid_state(&mut self, _: BlockPos) -> std::result::Result<(), Infallible> {
|
||||
Ok(())
|
||||
}
|
||||
fn block_shape(
|
||||
&mut self,
|
||||
s: usize,
|
||||
_: BlockPos,
|
||||
_: RayBlockMode,
|
||||
) -> std::result::Result<VoxelShape, Infallible> {
|
||||
Ok(self.shapes[s].clone())
|
||||
}
|
||||
fn interaction_shape(
|
||||
&mut self,
|
||||
_: usize,
|
||||
_: BlockPos,
|
||||
) -> std::result::Result<VoxelShape, Infallible> {
|
||||
Ok(VoxelShape::empty())
|
||||
}
|
||||
fn fluid_is_source(&mut self, _: ()) -> std::result::Result<bool, Infallible> {
|
||||
Ok(false)
|
||||
}
|
||||
fn fluid_is_empty(&mut self, _: ()) -> std::result::Result<bool, Infallible> {
|
||||
Ok(true)
|
||||
}
|
||||
fn fluid_is_water(&mut self, _: ()) -> std::result::Result<bool, Infallible> {
|
||||
Ok(false)
|
||||
}
|
||||
fn fluid_shape(&mut self, _: (), _: BlockPos) -> std::result::Result<VoxelShape, Infallible> {
|
||||
Ok(VoxelShape::empty())
|
||||
}
|
||||
}
|
||||
|
||||
fn cells(c: &Value) -> Result<Cells> {
|
||||
let mut world = Cells {
|
||||
shapes: vec![VoxelShape::empty()],
|
||||
index: HashMap::new(),
|
||||
};
|
||||
for (key, boxes) in c["cells"].as_object().unwrap() {
|
||||
let at: Vec<i32> = key.split(' ').map(|v| v.parse().unwrap()).collect();
|
||||
let mut shape = VoxelShape::empty();
|
||||
for a in boxes.as_array().unwrap() {
|
||||
shape = shape.join(
|
||||
&VoxelShape::from_box(Aabb::new(
|
||||
Vec3::new(db(&a[0]), db(&a[1]), db(&a[2])),
|
||||
Vec3::new(db(&a[3]), db(&a[4]), db(&a[5])),
|
||||
))?,
|
||||
ShapeOp::OR,
|
||||
)?;
|
||||
}
|
||||
world
|
||||
.index
|
||||
.insert(BlockPos::new(at[0], at[1], at[2]), world.shapes.len());
|
||||
world.shapes.push(shape);
|
||||
}
|
||||
Ok(world)
|
||||
}
|
||||
|
||||
fn camera_type(v: &Value) -> CameraType {
|
||||
[
|
||||
CameraType::FirstPerson,
|
||||
CameraType::ThirdPersonBack,
|
||||
CameraType::ThirdPersonFront,
|
||||
][v.as_u64().unwrap() as usize]
|
||||
}
|
||||
|
||||
/// The camera entity and, for players, what the field of view reads.
|
||||
fn entity(s: &Value) -> (CameraEntity, Option<FovPlayer>) {
|
||||
let player = s["kind"] == "player";
|
||||
let living = player.then(|| LivingCamera {
|
||||
scale: db(&s["scale_attr"]) as f32,
|
||||
camera_distance: db(&s["dist"]),
|
||||
sleeping: s["sleeping"].as_array().map(|a| {
|
||||
a[0].as_u64()
|
||||
.map(|d| [0.0f32, 90.0, 180.0, 270.0][d as usize])
|
||||
}),
|
||||
dead: s["dead"].as_i64().map(|t| t as i32),
|
||||
});
|
||||
let mount = if player {
|
||||
s["mount"].as_array().map(|a| (db(&a[0]) as f32, db(&a[1])))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
(
|
||||
CameraEntity {
|
||||
position: vec3(&s["pos"]),
|
||||
previous: vec3(&s["prev"]),
|
||||
x_rot: fl(&s["xr"]),
|
||||
y_rot: fl(&s["yr"]),
|
||||
x_rot_o: fl(&s["xro"]),
|
||||
y_rot_o: fl(&s["yro"]),
|
||||
eye_height: fl(&s["eye"]),
|
||||
living,
|
||||
mount,
|
||||
},
|
||||
player.then(|| fov_player(s)),
|
||||
)
|
||||
}
|
||||
|
||||
fn camera(c: &Value, out: &mut impl Write) -> Result<()> {
|
||||
let mut world = cells(c)?;
|
||||
let fov_option = c["fov"].as_i64().unwrap() as i32;
|
||||
let effect = db(&c["effect"]) as f32;
|
||||
let mut kind = camera_type(&c["type"]);
|
||||
let mut camera = Camera::default();
|
||||
let mut current: Option<(CameraEntity, Option<FovPlayer>)> = None;
|
||||
for op in c["ops"].as_array().unwrap() {
|
||||
match op[0].as_str().unwrap() {
|
||||
"entity" => {
|
||||
current = Some(entity(&op[1]));
|
||||
writeln!(out, "entity")?;
|
||||
}
|
||||
"type" => {
|
||||
kind = camera_type(&op[1]);
|
||||
writeln!(out, "type")?;
|
||||
}
|
||||
"tick" => {
|
||||
let target = current
|
||||
.as_ref()
|
||||
.and_then(|(_, p)| p.as_ref())
|
||||
.map_or(1.0, |p| {
|
||||
field_of_view_modifier(p, kind.is_first_person(), effect)
|
||||
});
|
||||
camera.tick(current.as_ref().map(|(e, _)| e.eye_height), target);
|
||||
writeln!(
|
||||
out,
|
||||
"tick {} {} {} {}",
|
||||
fb(camera.eye_height),
|
||||
fb(camera.eye_height_old),
|
||||
fb(camera.fov_modifier),
|
||||
fb(camera.old_fov_modifier)
|
||||
)?;
|
||||
}
|
||||
"frame" => {
|
||||
let pt = fl(&op[1]);
|
||||
let fluid = [
|
||||
CameraFluid::None,
|
||||
CameraFluid::Water,
|
||||
CameraFluid::Lava,
|
||||
CameraFluid::PowderSnow,
|
||||
][op[2].as_u64().unwrap() as usize];
|
||||
let panoramic = b(&op[3]);
|
||||
let (e, _) = current.as_ref().expect("entity");
|
||||
camera.align_with_entity(e, pt, kind, |from, to| {
|
||||
let Ok(hit) = clip_world(
|
||||
&mut world,
|
||||
Vec3::new(from[0], from[1], from[2]),
|
||||
Vec3::new(to[0], to[1], to[2]),
|
||||
RayBlockMode::Visual,
|
||||
RayFluidMode::None,
|
||||
usize::MAX,
|
||||
) else {
|
||||
panic!("ray")
|
||||
};
|
||||
(!hit.miss).then(|| {
|
||||
let l = hit.hit.location;
|
||||
[l.x, l.y, l.z]
|
||||
})
|
||||
});
|
||||
let dead = e.living.and_then(|l| l.dead);
|
||||
let fov = camera.calculate_fov(pt, fov_option, effect, dead, fluid, panoramic);
|
||||
let hud = camera.calculate_hud_fov(pt, effect, dead, fluid);
|
||||
let p = camera.position;
|
||||
let q = camera.rotation;
|
||||
let v = |a: [f32; 3]| format!("{} {} {}", fb(a[0]), fb(a[1]), fb(a[2]));
|
||||
writeln!(
|
||||
out,
|
||||
"frame {} {} {} {} {} {} {} {} {} {} {} {} {} {} {}",
|
||||
dbs(p[0]),
|
||||
dbs(p[1]),
|
||||
dbs(p[2]),
|
||||
fb(camera.x_rot),
|
||||
fb(camera.y_rot),
|
||||
fb(q.x),
|
||||
fb(q.y),
|
||||
fb(q.z),
|
||||
fb(q.w),
|
||||
v(camera.forwards),
|
||||
v(camera.up),
|
||||
v(camera.left),
|
||||
camera.detached,
|
||||
fb(fov),
|
||||
fb(hud)
|
||||
)?;
|
||||
}
|
||||
other => panic!("{other}"),
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn bob(c: &Value, out: &mut impl Write) -> Result<()> {
|
||||
let state = BobState {
|
||||
living: (!c["living"].is_null()).then(|| {
|
||||
let l = &c["living"];
|
||||
HurtBob {
|
||||
hurt_time: fl(&l["hurt"]),
|
||||
hurt_duration: l["duration"].as_i64().unwrap() as i32,
|
||||
hurt_dir: fl(&l["dir"]),
|
||||
death_time: (!l["death"].is_null()).then(|| fl(&l["death"])),
|
||||
}
|
||||
}),
|
||||
player: c["player"].as_array().map(|p| (fl(&p[0]), fl(&p[1]))),
|
||||
};
|
||||
let m = bob_matrix(&state, db(&c["tilt"]), b(&c["view"]));
|
||||
let mut line = String::from("bob");
|
||||
for v in m.m {
|
||||
line.push(' ');
|
||||
line.push_str(&fb(v));
|
||||
}
|
||||
writeln!(out, "{line} {}", m.properties)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn avatar(c: &Value, out: &mut impl Write) -> Result<()> {
|
||||
let mut state = AvatarBob::default();
|
||||
for op in c["ops"].as_array().unwrap() {
|
||||
let name = op[0].as_str().unwrap();
|
||||
match name {
|
||||
"tick" => state.tick(),
|
||||
"move" => state.add_walked(fl(&op[1]), fl(&op[2])),
|
||||
"bob" => state.update_bob(b(&op[1]) && !b(&op[2]) && !b(&op[3]), vec3(&op[4])),
|
||||
"reset" => state.reset_bob(),
|
||||
"frame" => {
|
||||
let pt = fl(&op[1]);
|
||||
writeln!(
|
||||
out,
|
||||
"frame {} {}",
|
||||
fb(state.interpolated_bob(pt)),
|
||||
fb(state.backwards_interpolated_walk_distance(pt))
|
||||
)?;
|
||||
continue;
|
||||
}
|
||||
other => panic!("{other}"),
|
||||
}
|
||||
writeln!(out, "{name}")?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn main() -> Result<()> {
|
||||
let args: Vec<String> = std::env::args().collect();
|
||||
let input = BufReader::new(File::open(&args[1])?);
|
||||
let mut out = BufWriter::new(File::create(&args[2])?);
|
||||
for (n, line) in input.lines().enumerate() {
|
||||
let c: Value = serde_json::from_str(&line?)?;
|
||||
writeln!(out, "== {n}")?;
|
||||
match c["t"].as_str().unwrap() {
|
||||
"fov" => writeln!(
|
||||
out,
|
||||
"{}",
|
||||
fb(field_of_view_modifier(
|
||||
&fov_player(&c),
|
||||
b(&c["fp"]),
|
||||
fl(&c["scale"])
|
||||
))
|
||||
)?,
|
||||
"camera" => camera(&c, &mut out)?,
|
||||
"bob" => bob(&c, &mut out)?,
|
||||
"avatar" => avatar(&c, &mut out)?,
|
||||
other => panic!("{other}"),
|
||||
}
|
||||
}
|
||||
out.flush()?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -54,7 +54,9 @@ fn parser_id(ty: &ArgType) -> String {
|
||||
ArgType::ResOrTagKey(_) => "minecraft:resource_or_tag_key".into(),
|
||||
ArgType::StrEnum(values, _) => match values.first().copied() {
|
||||
Some("world_surface") => "minecraft:heightmap".into(),
|
||||
Some("none") if values.len() == 3 && values[1] == "left_right" => "minecraft:template_mirror".into(),
|
||||
Some("none") if values.len() == 3 && values[1] == "left_right" => {
|
||||
"minecraft:template_mirror".into()
|
||||
}
|
||||
Some("none") if values.len() == 3 => "minecraft:swing_animation".into(),
|
||||
_ => "minecraft:template_rotation".into(),
|
||||
},
|
||||
@@ -194,10 +196,12 @@ fn main() -> horizon_client::Result<()> {
|
||||
reg.persistent_components = strings(&r["persistent_components"]);
|
||||
reg.learnable_recipes = strings(&r["learnable_recipes"]);
|
||||
for (clock, markers) in r["clock_time_markers"].as_object().into_iter().flatten() {
|
||||
reg.clock_time_markers.insert(clock.clone(), strings(markers));
|
||||
reg.clock_time_markers
|
||||
.insert(clock.clone(), strings(markers));
|
||||
}
|
||||
for (id, criteria) in r["advancement_criteria"].as_object().into_iter().flatten() {
|
||||
reg.advancement_criteria.insert(id.clone(), strings(criteria));
|
||||
reg.advancement_criteria
|
||||
.insert(id.clone(), strings(criteria));
|
||||
}
|
||||
reg.datapacks_enabled = strings(&r["datapacks_selected"]);
|
||||
let enabled = reg.datapacks_enabled.clone();
|
||||
|
||||
@@ -0,0 +1,278 @@
|
||||
//! Entity death drops for differential tests against vanilla
|
||||
//! (tools/test_entity_death_loot_parity.py): reads the cases of
|
||||
//! `VanillaEntityDeathLootOracle` and prints what `mobs::drops::die` drops.
|
||||
//!
|
||||
//! usage: entity_death_loot_fixture RESOURCES INPUT OUTPUT
|
||||
use horizon_client::{
|
||||
mobs::{Mob, MobTypes, Species, cube, drops, gear, raider, render::DYE_NAMES, sulfur_cube},
|
||||
random_sequences::RandomSequences,
|
||||
};
|
||||
use horizon_core::{geometry::Vec3, random::LegacyRandom};
|
||||
use horizon_world::loot::{LootDamage, LootData, LootEntity, LootStack};
|
||||
use std::{collections::BTreeMap, fmt::Write as _, fs, path::Path, sync::Arc};
|
||||
|
||||
/// `id[;enchantment:level...]`.
|
||||
fn stack(spec: &str) -> LootStack {
|
||||
let mut parts = spec.split(';');
|
||||
let mut s = LootStack {
|
||||
item: parts.next().unwrap_or("minecraft:air").to_owned(),
|
||||
count: 1,
|
||||
damage: 0,
|
||||
enchantments: Vec::new(),
|
||||
potion: None,
|
||||
map: false,
|
||||
ominous_amplifier: None,
|
||||
};
|
||||
for e in parts {
|
||||
let (name, level) = e.rsplit_once(':').unwrap_or((e, "1"));
|
||||
let name = if name.contains(':') {
|
||||
name.to_owned()
|
||||
} else {
|
||||
format!("minecraft:{name}")
|
||||
};
|
||||
s.enchantments.push((name, level.parse().unwrap_or(1)));
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
fn line(s: &LootStack) -> String {
|
||||
let mut out = format!("drop {} {}", s.item, s.count);
|
||||
if s.damage != 0 {
|
||||
let _ = write!(out, " damage={}", s.damage);
|
||||
}
|
||||
for (e, l) in &s.enchantments {
|
||||
let _ = write!(out, " ench={e}:{l}");
|
||||
}
|
||||
if let Some(p) = &s.potion {
|
||||
let _ = write!(out, " potion={p}");
|
||||
}
|
||||
if let Some(a) = s.ominous_amplifier {
|
||||
let _ = write!(out, " ominous={a}");
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn main() -> horizon_client::Result<()> {
|
||||
let args: Vec<String> = std::env::args().collect();
|
||||
let root = Path::new(
|
||||
args.get(1)
|
||||
.ok_or("usage: entity_death_loot_fixture RESOURCES INPUT OUTPUT")?,
|
||||
);
|
||||
let input = fs::read_to_string(args.get(2).ok_or("missing INPUT")?)?;
|
||||
let types = MobTypes::load(root)?;
|
||||
let loot = LootData::load(root)?;
|
||||
let mut out = String::new();
|
||||
for raw in input.lines() {
|
||||
if raw.trim().is_empty() || raw.starts_with('#') {
|
||||
continue;
|
||||
}
|
||||
let mut c = BTreeMap::new();
|
||||
let mut name = "";
|
||||
for token in raw.split_whitespace() {
|
||||
match token.split_once('=') {
|
||||
Some((k, v)) => {
|
||||
c.insert(k, v);
|
||||
}
|
||||
None => name = token,
|
||||
}
|
||||
}
|
||||
let get =
|
||||
|k: &str, d: &'static str| -> String { c.get(k).copied().unwrap_or(d).to_owned() };
|
||||
let _ = writeln!(out, "== {name}");
|
||||
let Some(kind) = types.types.get(c["type"]) else {
|
||||
let _ = writeln!(out, "error unsupported {}", c["type"]);
|
||||
continue;
|
||||
};
|
||||
let kind = Arc::new(kind.clone());
|
||||
let world_seed: i64 = get("seed", "0").parse()?;
|
||||
let kills: i64 = get("kills", "1").parse()?;
|
||||
let rand: i64 = get("rand", "0").parse()?;
|
||||
let mut sequences = RandomSequences::default();
|
||||
for k in 0..kills {
|
||||
let _ = writeln!(out, "kill {k}");
|
||||
let mut m = Mob::new(1, kind.clone(), Vec3::new(0.5, 64.0, 0.5), 0.0);
|
||||
// EntityType.create(LOAD) gives no equipment.
|
||||
for slot in drops::SLOTS {
|
||||
drops::clear_slot(&mut m, slot);
|
||||
}
|
||||
if get("baby", "0") == "1" && drops::can_be_baby(m.species()) {
|
||||
m.extra.age = -24000;
|
||||
}
|
||||
if get("fire", "0") == "1" {
|
||||
m.fire_ticks = 100;
|
||||
}
|
||||
// A cube made with EntityType.create(LOAD) was never sized.
|
||||
if matches!(m.species(), Species::Slime | Species::MagmaCube) {
|
||||
m.extra.size = 0;
|
||||
}
|
||||
if let Some(size) = c.get("size") {
|
||||
let size: i32 = size.parse()?;
|
||||
if m.species() == Species::SulfurCube {
|
||||
sulfur_cube::set_size(&mut m, size, true);
|
||||
} else {
|
||||
cube::set_size(&mut m, size);
|
||||
}
|
||||
}
|
||||
if let Some(color) = c.get("color") {
|
||||
m.extra.color = DYE_NAMES.iter().position(|d| d == color).unwrap_or(0) as u8;
|
||||
}
|
||||
m.extra.sheared = get("sheared", "0") == "1";
|
||||
if get("leader", "0") == "1" {
|
||||
raider::state_mut(&mut m).patrol_leader = true;
|
||||
}
|
||||
if get("captain", "0") == "1" {
|
||||
m.extra.head = Some(Arc::from(raider::BANNER));
|
||||
}
|
||||
let mut chances = [drops::DEFAULT_DROP_CHANCE; 8];
|
||||
for (i, slot) in drops::SLOTS.iter().enumerate() {
|
||||
if let Some(spec) = c.get(slot) {
|
||||
let s = stack(spec);
|
||||
let item: Option<Arc<str>> = Some(Arc::from(s.item.as_str()));
|
||||
let index = match *slot {
|
||||
"mainhand" => {
|
||||
m.extra.main_hand = item;
|
||||
Some(gear::MAIN)
|
||||
}
|
||||
"offhand" => {
|
||||
m.extra.off_hand = item;
|
||||
Some(gear::OFF)
|
||||
}
|
||||
"head" => {
|
||||
m.extra.head = item;
|
||||
Some(gear::HEAD)
|
||||
}
|
||||
"body" if drops::head_is_body(m.species()) => {
|
||||
m.extra.head = item;
|
||||
None
|
||||
}
|
||||
"saddle" => {
|
||||
m.set_saddle(Some(horizon_client::items::ItemStack::new(&s.item, 1)));
|
||||
None
|
||||
}
|
||||
"chest" => {
|
||||
m.extra.chest = item;
|
||||
Some(gear::CHEST)
|
||||
}
|
||||
"legs" => {
|
||||
m.extra.legs = item;
|
||||
Some(gear::LEGS)
|
||||
}
|
||||
"feet" => {
|
||||
m.extra.feet = item;
|
||||
Some(gear::FEET)
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
if let Some(index) = index
|
||||
&& !s.enchantments.is_empty()
|
||||
{
|
||||
let levels = s
|
||||
.enchantments
|
||||
.iter()
|
||||
.map(|(e, l)| (Arc::from(e.as_str()), *l as u32))
|
||||
.collect();
|
||||
gear::set(&mut m, index, levels);
|
||||
}
|
||||
}
|
||||
if let Some(chance) = c.get(format!("chance_{slot}").as_str()) {
|
||||
chances[i] = chance.parse()?;
|
||||
}
|
||||
}
|
||||
m.extra.drop_chances = Some(Box::new(chances));
|
||||
m.random = LegacyRandom::new(rand + k);
|
||||
// The killer, the last player to hurt the victim and the damage source.
|
||||
let killer_id = get("killer", "none");
|
||||
let weapon = c.get("weapon").map(|w| stack(w));
|
||||
let armed = |kind: &str| {
|
||||
let mut e = LootEntity::new(kind);
|
||||
if let Some(w) = &weapon {
|
||||
e.equipment.push(("mainhand".into(), w.clone()));
|
||||
}
|
||||
e
|
||||
};
|
||||
let player = (killer_id == "player" || get("pkill", "0") == "1").then(|| {
|
||||
if killer_id == "player" {
|
||||
armed("minecraft:player")
|
||||
} else {
|
||||
LootEntity::new("minecraft:player")
|
||||
}
|
||||
});
|
||||
let killer = match killer_id.as_str() {
|
||||
"player" => player.clone(),
|
||||
"none" => None,
|
||||
id => {
|
||||
let mut e = armed(id);
|
||||
e.frog_variant = c.get("frog").map(|f| f.to_string());
|
||||
e.powered = get("charged", "0") == "1";
|
||||
Some(e)
|
||||
}
|
||||
};
|
||||
let pkill = get("pkill", if killer_id == "player" { "1" } else { "0" }) == "1";
|
||||
let projectile = |kind: &str| LootEntity {
|
||||
kind: kind.to_owned(),
|
||||
..LootEntity::default()
|
||||
};
|
||||
let damage =
|
||||
|kind: &str, source: Option<LootEntity>, direct: Option<LootEntity>| LootDamage {
|
||||
kind: kind.to_owned(),
|
||||
source,
|
||||
direct,
|
||||
};
|
||||
let source = match get("source", "attack").as_str() {
|
||||
"attack" => match &killer {
|
||||
None => damage("minecraft:generic", None, None),
|
||||
Some(k) if k.kind == "minecraft:player" => {
|
||||
damage("minecraft:player_attack", Some(k.clone()), Some(k.clone()))
|
||||
}
|
||||
Some(k) => damage("minecraft:mob_attack", Some(k.clone()), Some(k.clone())),
|
||||
},
|
||||
"arrow" => damage(
|
||||
"minecraft:arrow",
|
||||
killer.clone(),
|
||||
Some(projectile("minecraft:arrow")),
|
||||
),
|
||||
"fireball" => match &killer {
|
||||
Some(k) => damage(
|
||||
"minecraft:fireball",
|
||||
Some(k.clone()),
|
||||
Some(projectile("minecraft:fireball")),
|
||||
),
|
||||
None => damage(
|
||||
"minecraft:unattributed_fireball",
|
||||
Some(projectile("minecraft:fireball")),
|
||||
Some(projectile("minecraft:fireball")),
|
||||
),
|
||||
},
|
||||
"explosion" => match &killer {
|
||||
Some(k) => damage(
|
||||
"minecraft:player_explosion",
|
||||
Some(k.clone()),
|
||||
Some(k.clone()),
|
||||
),
|
||||
None => damage("minecraft:explosion", None, None),
|
||||
},
|
||||
"lightning" => damage("minecraft:lightning_bolt", None, None),
|
||||
"fire" => damage("minecraft:on_fire", None, None),
|
||||
_ => damage("minecraft:generic", None, None),
|
||||
};
|
||||
let death = drops::Death {
|
||||
loot: &loot,
|
||||
damage: Some(source),
|
||||
player: player.filter(|_| pkill),
|
||||
luck: get("luck", "0").parse()?,
|
||||
vehicle: c.get("vehicle").map(|v| LootEntity::new(v)),
|
||||
mob_drops: true,
|
||||
world_seed,
|
||||
};
|
||||
let (items, _) = drops::die(&mut m, &death, &mut sequences);
|
||||
for d in items {
|
||||
let _ = match d {
|
||||
drops::Drop::Item(s) => writeln!(out, "{}", line(&s)),
|
||||
drops::Drop::Orb(v) => writeln!(out, "orb {v}"),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
fs::write(args.get(3).ok_or("missing OUTPUT")?, out)?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
#![forbid(unsafe_code)]
|
||||
//! Runs the JSON-lines scenarios of tools/test_frame_limiter_parity.py through
|
||||
//! the Rust `FramerateLimiter` with the same scripted virtual clock.
|
||||
use horizon_client::{
|
||||
Result,
|
||||
window::{FramerateLimiter, LimiterClock, end_frame},
|
||||
};
|
||||
use serde_json::Value;
|
||||
use std::{
|
||||
fs::File,
|
||||
io::{BufRead, BufReader, BufWriter, Write},
|
||||
};
|
||||
|
||||
/// The scripted clock of the case: every query costs `call` ns, parking
|
||||
/// sleeps the request plus the next overshoot (at least 1 ns), spinning
|
||||
/// costs `spin` ns and interruptions follow their script.
|
||||
struct Script {
|
||||
now: i64,
|
||||
call: i64,
|
||||
spin: i64,
|
||||
overshoot: Vec<i64>,
|
||||
next_overshoot: usize,
|
||||
interrupts: Vec<bool>,
|
||||
next_interrupt: usize,
|
||||
parks: Vec<i64>,
|
||||
spins: u64,
|
||||
queries: u64,
|
||||
}
|
||||
impl LimiterClock for Script {
|
||||
fn nanos(&mut self) -> i64 {
|
||||
self.queries += 1;
|
||||
let t = self.now;
|
||||
self.now = self.now.wrapping_add(self.call);
|
||||
t
|
||||
}
|
||||
fn park(&mut self, nanos: i64) {
|
||||
self.parks.push(nanos);
|
||||
let o = self.overshoot[self.next_overshoot % self.overshoot.len()];
|
||||
self.next_overshoot += 1;
|
||||
self.now = self.now.wrapping_add(nanos.max(0).wrapping_add(o).max(1));
|
||||
}
|
||||
fn spin(&mut self) {
|
||||
self.spins += 1;
|
||||
self.now = self.now.wrapping_add(self.spin);
|
||||
}
|
||||
fn interrupted(&mut self) -> bool {
|
||||
let i = self.interrupts[self.next_interrupt % self.interrupts.len()];
|
||||
self.next_interrupt += 1;
|
||||
i
|
||||
}
|
||||
}
|
||||
|
||||
fn main() -> Result<()> {
|
||||
let args: Vec<String> = std::env::args().collect();
|
||||
let input = BufReader::new(File::open(&args[1])?);
|
||||
let mut out = BufWriter::new(File::create(&args[2])?);
|
||||
for (n, line) in input.lines().enumerate() {
|
||||
let c: Value = serde_json::from_str(&line?)?;
|
||||
writeln!(out, "== {n}")?;
|
||||
let i = |k: &str| c[k].as_i64().unwrap();
|
||||
let mut clock = Script {
|
||||
now: i("now"),
|
||||
call: i("call"),
|
||||
spin: i("spin"),
|
||||
overshoot: c["overshoot"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|v| v.as_i64().unwrap())
|
||||
.collect(),
|
||||
next_overshoot: 0,
|
||||
interrupts: c["interrupts"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|v| v.as_bool().unwrap())
|
||||
.collect(),
|
||||
next_interrupt: 0,
|
||||
parks: Vec::new(),
|
||||
spins: 0,
|
||||
queries: 0,
|
||||
};
|
||||
let mut limiter =
|
||||
FramerateLimiter::with_state(i("last"), i("average"), i("last_limit") as i32);
|
||||
for frame in c["frames"].as_array().unwrap() {
|
||||
clock.now = clock.now.wrapping_add(frame[0].as_i64().unwrap());
|
||||
clock.parks.clear();
|
||||
clock.spins = 0;
|
||||
clock.queries = 0;
|
||||
end_frame(&mut limiter, frame[1].as_i64().unwrap() as i32, &mut clock);
|
||||
let parks: Vec<String> = clock.parks.iter().map(i64::to_string).collect();
|
||||
writeln!(
|
||||
out,
|
||||
"frame {} {} {} {} queries {} spins {} parks [{}]",
|
||||
clock.now,
|
||||
limiter.last_frame_time(),
|
||||
limiter.average_overshoot_ns(),
|
||||
limiter.last_framerate_limit(),
|
||||
clock.queries,
|
||||
clock.spins,
|
||||
parks.join(",")
|
||||
)?;
|
||||
}
|
||||
}
|
||||
out.flush()?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,270 @@
|
||||
//! Headless transport for the HUD parity gate: replays each case through
|
||||
//! `horizon_client::hud_layout` and writes the draw calls in vanilla's
|
||||
//! GuiGraphicsExtractor terms. Case kinds: `decorations` (crosshair,
|
||||
//! hotbar, health, mount hearts, experience bar and level) and the
|
||||
//! overlays handled in `kinds`.
|
||||
#![forbid(unsafe_code)]
|
||||
use horizon_client::{
|
||||
hud::{HudAtlas, HudBatch},
|
||||
hud_layout::{self, GuiDraw, HealthHud, HealthInputs, HeartKind, HotbarInputs, IDENTITY, Pose},
|
||||
lang,
|
||||
text::Text,
|
||||
};
|
||||
use serde_json::{Value, json};
|
||||
use std::{
|
||||
fs,
|
||||
io::{BufWriter, Write},
|
||||
path::PathBuf,
|
||||
};
|
||||
type Result<T> = std::result::Result<T, Box<dyn std::error::Error>>;
|
||||
#[path = "hud_fixture/kinds.rs"]
|
||||
mod kinds;
|
||||
|
||||
fn main() -> Result<()> {
|
||||
let args: Vec<PathBuf> = std::env::args_os().skip(1).map(PathBuf::from).collect();
|
||||
let [root, inputs, output] = args.as_slice() else {
|
||||
return Err("usage: hud_fixture RESOURCES INPUTS OUTPUT".into());
|
||||
};
|
||||
let atlas = HudAtlas::build(root)?;
|
||||
lang::set(lang::Language::load(&[root], lang::DEFAULT));
|
||||
let mut out = BufWriter::new(fs::File::create(output)?);
|
||||
for line in fs::read_to_string(inputs)?
|
||||
.lines()
|
||||
.filter(|l| !l.is_empty())
|
||||
{
|
||||
let c: Value = serde_json::from_str(line)?;
|
||||
// State the case reads back besides the draws (e.g. `scopeScale`).
|
||||
let mut state = serde_json::Map::new();
|
||||
let draws = match c["kind"].as_str().unwrap_or("decorations") {
|
||||
"decorations" => decorations(&c, &atlas),
|
||||
kind => kinds::run(kind, &c, &atlas, &mut state),
|
||||
};
|
||||
state.insert("draws".into(), json!(encode(&draws)));
|
||||
writeln!(out, "{}", Value::Object(state))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Style runs of styled text: `[text, rgb|null, bold, italic]` per run of
|
||||
/// equally styled characters, or none for plain text.
|
||||
fn runs(t: &Text) -> Option<Value> {
|
||||
let mut out: Vec<(String, Option<u32>, bool, bool)> = Vec::new();
|
||||
let mut styled = false;
|
||||
for (ch, s) in t.chars() {
|
||||
let key = (s.color, s.is_bold(), s.is_italic());
|
||||
styled |= key != (None, false, false);
|
||||
match out.last_mut() {
|
||||
Some(last) if (last.1, last.2, last.3) == key => last.0.push(ch),
|
||||
_ => out.push((ch.to_string(), key.0, key.1, key.2)),
|
||||
}
|
||||
}
|
||||
styled.then(|| {
|
||||
json!(
|
||||
out.into_iter()
|
||||
.map(|r| json!([r.0, r.1, r.2, r.3]))
|
||||
.collect::<Vec<_>>()
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
fn encode(draws: &[GuiDraw]) -> Vec<Value> {
|
||||
let mut stack: Vec<Pose> = vec![IDENTITY];
|
||||
let mut out = Vec::new();
|
||||
for d in draws {
|
||||
let pose = *stack.last().unwrap();
|
||||
let mut v = match d {
|
||||
GuiDraw::Push(ops) => {
|
||||
let mut m = pose;
|
||||
for op in ops {
|
||||
hud_layout::pose_apply(&mut m, *op);
|
||||
}
|
||||
stack.push(m);
|
||||
continue;
|
||||
}
|
||||
GuiDraw::Pop => {
|
||||
stack.pop();
|
||||
continue;
|
||||
}
|
||||
GuiDraw::Sprite {
|
||||
sprite,
|
||||
x,
|
||||
y,
|
||||
w,
|
||||
h,
|
||||
region,
|
||||
color,
|
||||
} => {
|
||||
json!({"type": "sprite", "sprite": sprite, "x": x, "y": y, "w": w, "h": h, "region": region, "color": *color as i32})
|
||||
}
|
||||
GuiDraw::AtlasSprite {
|
||||
sprite,
|
||||
x,
|
||||
y,
|
||||
w,
|
||||
h,
|
||||
color,
|
||||
} => {
|
||||
json!({"type": "atlas", "sprite": sprite, "x": x, "y": y, "w": w, "h": h, "color": *color as i32})
|
||||
}
|
||||
GuiDraw::Blit {
|
||||
texture,
|
||||
blend,
|
||||
x,
|
||||
y,
|
||||
w,
|
||||
h,
|
||||
uv,
|
||||
src,
|
||||
tex,
|
||||
color,
|
||||
} => json!({
|
||||
"type": "blit", "texture": format!("textures/{texture}.png"), "pipeline": blend.pipeline(),
|
||||
"x": x, "y": y, "w": w, "h": h, "uv": uv, "src": src, "tex": tex, "color": *color as i32}),
|
||||
GuiDraw::Fill {
|
||||
x0,
|
||||
y0,
|
||||
x1,
|
||||
y1,
|
||||
color,
|
||||
} => {
|
||||
json!({"type": "fill", "x0": x0, "y0": y0, "x1": x1, "y1": y1, "color": *color as i32})
|
||||
}
|
||||
GuiDraw::Text {
|
||||
text,
|
||||
x,
|
||||
y,
|
||||
color,
|
||||
shadow,
|
||||
} => {
|
||||
let mut v = json!({"type": "text", "text": text.plain(), "x": x, "y": y, "color": *color as i32, "shadow": shadow});
|
||||
if let Some(r) = runs(text) {
|
||||
v["runs"] = r;
|
||||
}
|
||||
v
|
||||
}
|
||||
GuiDraw::Item {
|
||||
slot,
|
||||
x,
|
||||
y,
|
||||
seed,
|
||||
scale,
|
||||
pivot,
|
||||
} => {
|
||||
// translate(pivot) * scale * translate(-pivot) as a 3x2 matrix.
|
||||
let m = [
|
||||
scale[0],
|
||||
0.0,
|
||||
0.0,
|
||||
scale[1],
|
||||
pivot[0] - pivot[0] * scale[0],
|
||||
pivot[1] - pivot[1] * scale[1],
|
||||
];
|
||||
out.push(json!({"type": "item", "slot": slot, "x": x, "y": y, "seed": seed, "matrix": m}));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
if pose != IDENTITY {
|
||||
v["matrix"] = json!(pose);
|
||||
}
|
||||
out.push(v);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn decorations(c: &Value, atlas: &HudAtlas) -> Vec<GuiDraw> {
|
||||
let int = |k: &str, d: i64| c[k].as_i64().unwrap_or(d) as i32;
|
||||
let float = |k: &str, d: f64| c[k].as_f64().unwrap_or(d) as f32;
|
||||
let (w, h) = (int("width", 0), int("height", 0));
|
||||
let mode = c["gameMode"].as_str().unwrap_or("survival");
|
||||
let survival = mode == "survival" || mode == "adventure";
|
||||
let indicator = c["attackIndicator"].as_str().unwrap_or("OFF");
|
||||
let partial = float("partial", 0.0);
|
||||
let effects: Vec<&str> = c["effects"]
|
||||
.as_array()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter_map(Value::as_str)
|
||||
.collect();
|
||||
let has = |e: &str| effects.contains(&e);
|
||||
let mut draws = Vec::new();
|
||||
// Spectators only see it over a spectatable target (none here).
|
||||
if c["crosshair"].as_bool().unwrap_or(false) && mode != "spectator" {
|
||||
let attack = (indicator == "CROSSHAIR").then(|| float("attackScale", 1.0));
|
||||
draws.extend(hud_layout::crosshair(w, h, attack, false));
|
||||
}
|
||||
if mode != "spectator" {
|
||||
let pop = |v: &Value| v.as_f64().map(|p| p as f32 - partial);
|
||||
let slots: [Option<f32>; 9] = std::array::from_fn(|i| pop(&c["slots"][i]));
|
||||
let inputs = HotbarInputs {
|
||||
selected: int("selected", 0),
|
||||
right_handed: c["rightHanded"].as_bool().unwrap_or(true),
|
||||
offhand: !c["offhand"].is_null(),
|
||||
slots,
|
||||
offhand_pop: pop(&c["offhand"]).unwrap_or(0.0),
|
||||
attack: (indicator == "HOTBAR").then(|| float("attackScale", 1.0)),
|
||||
};
|
||||
draws.extend(hud_layout::hotbar(w, h, &inputs));
|
||||
}
|
||||
let vehicle_hearts = if c.get("vehicleMaxHealth").is_some() {
|
||||
hud_layout::vehicle_max_hearts(float("vehicleMaxHealth", 0.0))
|
||||
} else {
|
||||
0
|
||||
};
|
||||
if survival {
|
||||
let millis = 1_000_000_000i64;
|
||||
let mut hud = HealthHud {
|
||||
last_health: int("lastHealth", 0),
|
||||
display_health: int("displayHealth", 0),
|
||||
last_health_time: millis - i64::from(int("lastHealthAgo", 0)),
|
||||
health_blink_time: i64::from(int("healthBlinkTime", 0)),
|
||||
};
|
||||
let kind = if has("minecraft:poison") {
|
||||
HeartKind::Poisoned
|
||||
} else if has("minecraft:wither") {
|
||||
HeartKind::Withered
|
||||
} else if c["frozen"].as_bool().unwrap_or(false) {
|
||||
HeartKind::Frozen
|
||||
} else {
|
||||
HeartKind::Normal
|
||||
};
|
||||
let inputs = HealthInputs {
|
||||
tick: int("tick", 0),
|
||||
millis,
|
||||
health: float("health", 20.0),
|
||||
max_health: float("maxHealth", 20.0),
|
||||
absorption: float("absorption", 0.0),
|
||||
armor: int("armor", 0),
|
||||
food: int("food", 20),
|
||||
saturation: float("saturation", 5.0),
|
||||
kind,
|
||||
regeneration: has("minecraft:regeneration"),
|
||||
hunger: has("minecraft:hunger"),
|
||||
hardcore: c["hardcore"].as_bool().unwrap_or(false),
|
||||
damage_cooldown: int("damageCooldown", 0) > 0,
|
||||
air: int("air", 300),
|
||||
max_air: int("maxAir", 300),
|
||||
under_water: c["underWater"].as_bool().unwrap_or(false),
|
||||
vehicle_hearts,
|
||||
};
|
||||
draws.extend(hud.layout(&inputs, w, h));
|
||||
}
|
||||
if vehicle_hearts > 0 {
|
||||
draws.extend(hud_layout::vehicle_health(
|
||||
w,
|
||||
h,
|
||||
vehicle_hearts,
|
||||
float("vehicleHealth", 0.0),
|
||||
));
|
||||
}
|
||||
let width = |s: &str| HudBatch::text_width(atlas, s) as i32;
|
||||
let level = if survival { int("xpLevel", 0) } else { 0 };
|
||||
draws.extend(hud_layout::experience(
|
||||
w,
|
||||
h,
|
||||
float("xpProgress", 0.0),
|
||||
level,
|
||||
c["experienceBar"].as_bool().unwrap_or(false),
|
||||
&width,
|
||||
));
|
||||
draws
|
||||
}
|
||||
@@ -0,0 +1,326 @@
|
||||
//! The overlay case kinds of the HUD parity fixture.
|
||||
use horizon_client::{
|
||||
brewing,
|
||||
chat::{Chat, ChatOptions},
|
||||
component,
|
||||
hud::HudAtlas,
|
||||
hud_layout::{self, GuiDraw},
|
||||
hud_overlays::{
|
||||
self, BossBar, CameraOverlays, EffectIcon, HeldName, ItemDecorations, Titles, ToolHighlight,
|
||||
},
|
||||
scoreboard::{NumberFormat, RenderType, Scoreboard},
|
||||
text::{self, Text},
|
||||
};
|
||||
use serde_json::Value;
|
||||
|
||||
fn int(c: &Value, k: &str, d: i64) -> i32 {
|
||||
c[k].as_i64().unwrap_or(d) as i32
|
||||
}
|
||||
fn float(c: &Value, k: &str, d: f64) -> f32 {
|
||||
c[k].as_f64().unwrap_or(d) as f32
|
||||
}
|
||||
/// A text component in its JSON form.
|
||||
fn comp(v: &Value) -> Text {
|
||||
component::to_text(v, &mut |s| Text::literal(s))
|
||||
}
|
||||
/// `Options.getBackgroundColor(0.0F)` from the case's text background options.
|
||||
fn background(c: &Value) -> u32 {
|
||||
hud_layout::background_color(
|
||||
c["chatOnly"].as_bool().unwrap_or(true),
|
||||
c["textOpacity"].as_f64().unwrap_or(0.5),
|
||||
0.0,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn run(
|
||||
kind: &str,
|
||||
c: &Value,
|
||||
atlas: &HudAtlas,
|
||||
state: &mut serde_json::Map<String, Value>,
|
||||
) -> Vec<GuiDraw> {
|
||||
let (w, h) = (int(c, "width", 0), int(c, "height", 0));
|
||||
match kind {
|
||||
"effects" => effects(c, w),
|
||||
"title" => title(c, w, h, atlas),
|
||||
"selected" => selected(c, w, h, atlas),
|
||||
"boss" => boss(c, w, h, atlas),
|
||||
"jump" => hud_overlays::jump_bar(w, h, int(c, "cooldown", 0), float(c, "jumpScale", 0.0)),
|
||||
"sidebar" => sidebar(c, w, h, atlas),
|
||||
"decor" => decor(c, atlas),
|
||||
"overlays" => overlays(c, w, h, state),
|
||||
"chat" => chat(c, h, atlas),
|
||||
_ => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn effects(c: &Value, w: i32) -> Vec<GuiDraw> {
|
||||
let icons: Vec<EffectIcon> = c["active"]
|
||||
.as_array()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.map(|e| {
|
||||
let id = e["id"]
|
||||
.as_str()
|
||||
.unwrap_or("")
|
||||
.trim_start_matches("minecraft:");
|
||||
let def = brewing::effect(id).expect("effect");
|
||||
EffectIcon {
|
||||
id: id.to_owned(),
|
||||
beneficial: def.category == brewing::Category::Beneficial,
|
||||
color: def.color as i32,
|
||||
ambient: e["ambient"].as_bool().unwrap_or(false),
|
||||
duration: e["duration"].as_i64().unwrap_or(0) as i32,
|
||||
show_icon: e["icon"].as_bool().unwrap_or(true),
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
hud_overlays::effects(w, c["demo"].as_bool().unwrap_or(false), &icons)
|
||||
}
|
||||
|
||||
/// Replays `Hud.setTitle` / `setSubtitle` / `setTimes` / `clearTitles` /
|
||||
/// `resetTitleTimes` / `setOverlayMessage` and ticks, then extracts the
|
||||
/// action bar and the title.
|
||||
fn title(c: &Value, w: i32, h: i32, atlas: &HudAtlas) -> Vec<GuiDraw> {
|
||||
let mut t = Titles::default();
|
||||
for op in c["ops"].as_array().into_iter().flatten() {
|
||||
let n = |i: usize| op[i].as_i64().unwrap_or(0) as i32;
|
||||
match op[0].as_str().unwrap_or("") {
|
||||
"title" => t.set_title(comp(&op[1])),
|
||||
"subtitle" => t.set_subtitle(comp(&op[1])),
|
||||
"times" => t.set_times(n(1), n(2), n(3)),
|
||||
"clear" => t.clear(),
|
||||
"reset" => t.reset_times(),
|
||||
"overlay" => t.set_overlay(comp(&op[1]), op[2].as_bool().unwrap_or(false)),
|
||||
"tick" => {
|
||||
for _ in 0..n(1) {
|
||||
t.tick();
|
||||
}
|
||||
}
|
||||
other => panic!("unknown title op {other}"),
|
||||
}
|
||||
}
|
||||
let partial = float(c, "partial", 0.0);
|
||||
let bg = background(c);
|
||||
let mut out = t.overlay_message(w, h, partial, &atlas.font, bg);
|
||||
out.extend(t.title(w, h, partial, &atlas.font, bg));
|
||||
out
|
||||
}
|
||||
|
||||
/// Ticks `toolHighlightTimer` through the selected stacks, then extracts the name.
|
||||
fn selected(c: &Value, w: i32, h: i32, atlas: &HudAtlas) -> Vec<GuiDraw> {
|
||||
let mut highlight = ToolHighlight::default();
|
||||
let time = c["displayTime"].as_f64().unwrap_or(1.0);
|
||||
for step in c["steps"].as_array().into_iter().flatten() {
|
||||
let held = (!step.is_null()).then(|| {
|
||||
let custom = &step["custom"];
|
||||
HeldName {
|
||||
item: step["item"].as_str().unwrap_or("").to_owned(),
|
||||
hover_name: if custom.is_null() {
|
||||
Text::translate(step["key"].as_str().unwrap_or(""), &[])
|
||||
} else {
|
||||
comp(custom)
|
||||
},
|
||||
rarity_color: text::named_color(step["rarity"].as_str().unwrap_or("white"))
|
||||
.unwrap_or(0xFFFFFF),
|
||||
custom_name: !custom.is_null(),
|
||||
}
|
||||
});
|
||||
highlight.tick(held, time);
|
||||
}
|
||||
let mode = c["gameMode"].as_str().unwrap_or("survival");
|
||||
let can_hurt = mode == "survival" || mode == "adventure";
|
||||
highlight.draw(w, h, can_hurt, &atlas.font, background(c))
|
||||
}
|
||||
|
||||
fn boss(c: &Value, w: i32, h: i32, atlas: &HudAtlas) -> Vec<GuiDraw> {
|
||||
let bars: Vec<BossBar> = c["bars"]
|
||||
.as_array()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.map(|b| BossBar {
|
||||
name: comp(&b["name"]),
|
||||
progress: b["progress"].as_f64().unwrap_or(0.0) as f32,
|
||||
color: b["color"].as_u64().unwrap_or(0) as usize,
|
||||
overlay: b["overlay"].as_u64().unwrap_or(0) as usize,
|
||||
})
|
||||
.collect();
|
||||
hud_overlays::boss_overlay(w, h, &bars, &atlas.font)
|
||||
}
|
||||
|
||||
/// A `NumberFormat` from the case: null, `{type: blank}`, `{type: fixed,
|
||||
/// value}` or `{type: styled, style}`.
|
||||
fn number_format(v: &Value) -> Option<NumberFormat> {
|
||||
match v["type"].as_str()? {
|
||||
"blank" => Some(NumberFormat::Blank),
|
||||
"fixed" => Some(NumberFormat::Fixed(v["value"].clone())),
|
||||
"styled" => Some(NumberFormat::Styled(v["style"].clone())),
|
||||
other => panic!("unknown number format {other}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn sidebar(c: &Value, w: i32, h: i32, atlas: &HudAtlas) -> Vec<GuiDraw> {
|
||||
let mut board = Scoreboard::default();
|
||||
let o = &c["objective"];
|
||||
board.add_objective(
|
||||
"sidebar",
|
||||
"dummy",
|
||||
o["display"].clone(),
|
||||
RenderType::Integer,
|
||||
false,
|
||||
number_format(&o["format"]),
|
||||
);
|
||||
for s in c["scores"].as_array().into_iter().flatten() {
|
||||
let owner = s["owner"].as_str().unwrap_or("");
|
||||
let score = board.score_mut(owner, "sidebar");
|
||||
score.value = s["value"].as_i64().unwrap_or(0) as i32;
|
||||
score.display = (!s["display"].is_null()).then(|| s["display"].clone());
|
||||
score.number_format = number_format(&s["format"]);
|
||||
}
|
||||
for t in c["teams"].as_array().into_iter().flatten() {
|
||||
let name = t["name"].as_str().unwrap_or("");
|
||||
let team = board.add_team(name);
|
||||
team.prefix = t["prefix"].clone();
|
||||
team.suffix = t["suffix"].clone();
|
||||
team.color = t["color"].as_str().map(str::to_owned);
|
||||
for m in t["members"].as_array().into_iter().flatten() {
|
||||
board.add_to_team(m.as_str().unwrap_or(""), name);
|
||||
}
|
||||
}
|
||||
let opacity = c["textOpacity"].as_f64().unwrap_or(0.5);
|
||||
let chat_only = c["chatOnly"].as_bool().unwrap_or(true);
|
||||
let objective = board.objective("sidebar").expect("objective").clone();
|
||||
hud_overlays::sidebar(
|
||||
w,
|
||||
h,
|
||||
&board,
|
||||
&objective,
|
||||
&atlas.font,
|
||||
hud_layout::background_color(chat_only, opacity, 0.3),
|
||||
hud_layout::background_color(chat_only, opacity, 0.4),
|
||||
)
|
||||
}
|
||||
|
||||
/// `itemDecorations` for a stack with a count, damage and an item cooldown
|
||||
/// `elapsed` ticks into `duration` (ItemCooldowns.getCooldownPercent).
|
||||
fn decor(c: &Value, atlas: &HudAtlas) -> Vec<GuiDraw> {
|
||||
let count = int(c, "count", 1);
|
||||
if count <= 0 {
|
||||
return Vec::new();
|
||||
}
|
||||
let cd = &c["cooldown"];
|
||||
let cooldown = if cd.is_null() {
|
||||
0.0
|
||||
} else {
|
||||
let (duration, elapsed) = (int(cd, "duration", 0), int(cd, "elapsed", 0));
|
||||
if duration <= elapsed {
|
||||
0.0 // ItemCooldowns.tick dropped it
|
||||
} else {
|
||||
let remaining = duration as f32 - (elapsed as f32 + float(c, "partial", 0.0));
|
||||
(remaining / duration as f32).clamp(0.0, 1.0)
|
||||
}
|
||||
};
|
||||
let max_damage = if c["unbreakable"].as_bool().unwrap_or(false) {
|
||||
0
|
||||
} else {
|
||||
int(c, "maxDamage", 0)
|
||||
};
|
||||
let d = ItemDecorations {
|
||||
count: count as u32,
|
||||
count_text: c["countText"].as_str().map(str::to_owned),
|
||||
bar: hud_overlays::damage_bar(int(c, "damage", 0), max_damage),
|
||||
cooldown,
|
||||
};
|
||||
hud_overlays::item_decorations(int(c, "x", 0), int(c, "y", 0), &d, &atlas.font)
|
||||
}
|
||||
|
||||
/// `Hud.extractCameraOverlays` then `extractSleepOverlay`; the border is
|
||||
/// static, so only its warning blocks matter.
|
||||
fn overlays(c: &Value, w: i32, h: i32, state: &mut serde_json::Map<String, Value>) -> Vec<GuiDraw> {
|
||||
let partial = float(c, "partial", 0.0);
|
||||
let warning = match &c["border"] {
|
||||
Value::Null => 0.0,
|
||||
b => {
|
||||
// WorldBorder.getDistanceToBorder with the static extent's clamped box.
|
||||
let max = 29_999_984.0;
|
||||
let half = b["size"].as_f64().unwrap_or(0.0) / 2.0;
|
||||
let (cx, cz) = (
|
||||
b["centerX"].as_f64().unwrap_or(0.0),
|
||||
b["centerZ"].as_f64().unwrap_or(0.0),
|
||||
);
|
||||
let (min_x, max_x) = ((cx - half).clamp(-max, max), (cx + half).clamp(-max, max));
|
||||
let (min_z, max_z) = ((cz - half).clamp(-max, max), (cz + half).clamp(-max, max));
|
||||
let (x, z) = (
|
||||
f64::from(float(c, "camX", 0.0)),
|
||||
f64::from(float(c, "camZ", 0.0)),
|
||||
);
|
||||
let distance = (x - min_x).min(max_x - x).min(z - min_z).min(max_z - z);
|
||||
hud_overlays::border_warning(
|
||||
distance as f32,
|
||||
int(b, "warningBlocks", 5),
|
||||
int(b, "warningTime", 15),
|
||||
0.0,
|
||||
0.0,
|
||||
)
|
||||
}
|
||||
};
|
||||
let ticks_frozen = int(c, "ticksFrozen", 0);
|
||||
let inputs = CameraOverlays {
|
||||
vignette: c["vignette"]
|
||||
.as_bool()
|
||||
.unwrap_or(false)
|
||||
.then(|| (float(c, "vignetteBrightness", 1.0), warning)),
|
||||
first_person: c["firstPerson"].as_bool().unwrap_or(false),
|
||||
scoping: c["scoping"].as_bool().unwrap_or(false),
|
||||
game_time_delta: partial,
|
||||
equipment: match c["head"].as_str() {
|
||||
Some("carved_pumpkin") => vec!["misc/pumpkinblur".to_owned()],
|
||||
_ => Vec::new(),
|
||||
},
|
||||
ticks_frozen,
|
||||
percent_frozen: ticks_frozen.min(140) as f32 / 140.0,
|
||||
partial,
|
||||
portal: [
|
||||
c["portal"][0].as_f64().unwrap_or(0.0) as f32,
|
||||
c["portal"][1].as_f64().unwrap_or(0.0) as f32,
|
||||
],
|
||||
nausea: float(c, "nausea", 0.0),
|
||||
screen_effect_scale: float(c, "screenEffectScale", 1.0),
|
||||
};
|
||||
let mut scope = float(c, "scopeScale", 0.5);
|
||||
let mut out = hud_overlays::camera_overlays(w, h, &inputs, &mut scope);
|
||||
out.extend(hud_overlays::sleep_overlay(w, h, int(c, "sleepTimer", 0)));
|
||||
state.insert("scopeScale".into(), serde_json::json!(scope));
|
||||
out
|
||||
}
|
||||
|
||||
/// Messages added at their ticks (`Hud.tickCount`), then the chat in
|
||||
/// background mode at tick `now`.
|
||||
fn chat(c: &Value, h: i32, atlas: &HudAtlas) -> Vec<GuiDraw> {
|
||||
let option = |k: &str| c[k].as_f64().unwrap_or(0.0);
|
||||
let mut chat = Chat::default();
|
||||
chat.options = ChatOptions {
|
||||
opacity: option("chatOpacity"),
|
||||
background_opacity: option("textOpacity"),
|
||||
scale: option("chatScale"),
|
||||
width: option("chatWidth"),
|
||||
height_focused: option("chatHeightFocused"),
|
||||
height_unfocused: option("chatHeightUnfocused"),
|
||||
line_spacing: option("chatLineSpacing"),
|
||||
};
|
||||
for m in c["messages"].as_array().into_iter().flatten() {
|
||||
// GuiMessageTag indicator colours: systemSinglePlayer and chatError.
|
||||
let indicator = match m["tag"].as_str() {
|
||||
Some("system") => Some(13684944),
|
||||
Some("error") => Some(16733525),
|
||||
_ => None,
|
||||
};
|
||||
chat.add(
|
||||
atlas,
|
||||
comp(&m["text"]),
|
||||
indicator,
|
||||
m["at"].as_u64().unwrap_or(0),
|
||||
false,
|
||||
);
|
||||
}
|
||||
chat.layout(h, c["now"].as_u64().unwrap_or(0), false)
|
||||
}
|
||||
@@ -0,0 +1,601 @@
|
||||
//! Replays `tools/test_mob_ai_parity.py` scenarios on Horizon's mob AI and
|
||||
//! prints each mob's AI-visible state after every tick in the format of
|
||||
//! `VanillaMobAiOracle`.
|
||||
//!
|
||||
//! usage: mob_ai_probe RESOURCES SCENARIOS OUTPUT
|
||||
use horizon_client::{
|
||||
assets::Assets,
|
||||
biome::Biomes,
|
||||
blocks::BlockFacts,
|
||||
chunk::{Chunk, HEIGHT, MIN_Y},
|
||||
collisions,
|
||||
items::Items,
|
||||
light::{LightTables, light_chunk},
|
||||
mobs::{
|
||||
Mob, MobData, MobEvent, MobTypes, MobWorld, PlayerView, Seen, Species, Who,
|
||||
control::Operation, spawning::LocalDifficulty,
|
||||
},
|
||||
world::World,
|
||||
};
|
||||
use horizon_core::{
|
||||
geometry::{Aabb, Vec3},
|
||||
random::LegacyRandom,
|
||||
};
|
||||
use horizon_world::{chunk::BlockState, registry::BlockRegistry, registry::BlockStateId};
|
||||
use std::{collections::BTreeMap, fmt::Write as _, fs, path::Path, sync::Arc};
|
||||
|
||||
struct MobSpec {
|
||||
kind: String,
|
||||
pos: [f64; 3],
|
||||
yaw: f32,
|
||||
seed: i64,
|
||||
baby: bool,
|
||||
flags: Vec<String>,
|
||||
}
|
||||
#[derive(Default)]
|
||||
struct Scenario {
|
||||
name: String,
|
||||
ticks: i32,
|
||||
time: i64,
|
||||
rain: bool,
|
||||
thunder: bool,
|
||||
origin: [i32; 2],
|
||||
blocks: Vec<Vec<String>>,
|
||||
player: Option<[f64; 3]>,
|
||||
creative: bool,
|
||||
mobs: Vec<MobSpec>,
|
||||
}
|
||||
|
||||
fn parse(text: &str) -> Vec<Scenario> {
|
||||
let mut out = Vec::new();
|
||||
let mut cur: Option<Scenario> = None;
|
||||
for line in text.lines() {
|
||||
let f: Vec<&str> = line.split_whitespace().collect();
|
||||
if f.is_empty() || f[0].starts_with('#') {
|
||||
continue;
|
||||
}
|
||||
let s = cur.get_or_insert_with(Scenario::default);
|
||||
match f[0] {
|
||||
"scenario" => {
|
||||
s.name = f[1].to_owned();
|
||||
s.time = 6000;
|
||||
for kv in &f[2..] {
|
||||
let (k, v) = kv.split_once('=').unwrap_or((kv, ""));
|
||||
match k {
|
||||
"ticks" => s.ticks = v.parse().unwrap(),
|
||||
"time" => s.time = v.parse().unwrap(),
|
||||
"rain" => s.rain = true,
|
||||
"thunder" => s.thunder = true,
|
||||
"levelseed" => {}
|
||||
_ => panic!("unknown scenario option {kv}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
"origin" => s.origin = [f[1].parse().unwrap(), f[2].parse().unwrap()],
|
||||
"fill" | "block" => s.blocks.push(f.iter().map(|x| (*x).to_owned()).collect()),
|
||||
"player" => {
|
||||
s.player = Some([
|
||||
f[1].parse().unwrap(),
|
||||
f[2].parse().unwrap(),
|
||||
f[3].parse().unwrap(),
|
||||
]);
|
||||
s.creative = f.get(4) == Some(&"creative");
|
||||
}
|
||||
"mob" => s.mobs.push(MobSpec {
|
||||
kind: f[1].to_owned(),
|
||||
pos: [
|
||||
f[2].parse().unwrap(),
|
||||
f[3].parse().unwrap(),
|
||||
f[4].parse().unwrap(),
|
||||
],
|
||||
yaw: f[5].parse().unwrap(),
|
||||
seed: f[6].parse().unwrap(),
|
||||
baby: f[7..].contains(&"baby"),
|
||||
flags: f[7..]
|
||||
.iter()
|
||||
.filter(|x| **x != "baby")
|
||||
.map(|x| (*x).to_owned())
|
||||
.collect(),
|
||||
}),
|
||||
"end" => out.push(cur.take().unwrap()),
|
||||
other => panic!("unknown scenario line {other}"),
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// `minecraft:name[key=value,...]`
|
||||
fn state(registry: &BlockRegistry, text: &str) -> BlockStateId {
|
||||
let (name, props) = match text.split_once('[') {
|
||||
Some((n, rest)) => (n, rest.trim_end_matches(']')),
|
||||
None => (text, ""),
|
||||
};
|
||||
let mut properties = BTreeMap::new();
|
||||
for kv in props.split(',').filter(|s| !s.is_empty()) {
|
||||
let (k, v) = kv.split_once('=').expect("property");
|
||||
properties.insert(k.to_owned(), v.to_owned());
|
||||
}
|
||||
registry
|
||||
.resolve(&BlockState {
|
||||
name: name.to_owned(),
|
||||
properties,
|
||||
})
|
||||
.unwrap_or_else(|e| panic!("{text}: {e}"))
|
||||
}
|
||||
|
||||
fn d(v: f64) -> String {
|
||||
format!("{:x}", v.to_bits())
|
||||
}
|
||||
fn f(v: f32) -> String {
|
||||
format!("{:x}", v.to_bits())
|
||||
}
|
||||
fn who(w: Option<Who>) -> String {
|
||||
match w {
|
||||
None => "none".into(),
|
||||
Some(Who::Player) => "player".into(),
|
||||
Some(Who::Mob(id)) => format!("e{id}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Vanilla's goal class for a Horizon goal (most share the name).
|
||||
fn goal_name(m: &Mob, name: &str) -> String {
|
||||
let vanilla = match name {
|
||||
"Float" => "FloatGoal",
|
||||
"Stroll" => "WaterAvoidingRandomStrollGoal",
|
||||
"RandomStroll" => "RandomStrollGoal",
|
||||
"RandomSwimming" => "RandomSwimmingGoal",
|
||||
"LookAtPlayer" => "LookAtPlayerGoal",
|
||||
"RandomLookAround" => "RandomLookAroundGoal",
|
||||
"HurtByTarget" => "HurtByTargetGoal",
|
||||
"NearestTarget" => "NearestAttackableTargetGoal",
|
||||
"Panic" => "PanicGoal",
|
||||
"Breed" => "BreedGoal",
|
||||
"Tempt" => "TemptGoal",
|
||||
"FollowParent" => "FollowParentGoal",
|
||||
"MeleeAttack" => match m.species() {
|
||||
Species::Zombie | Species::Husk | Species::ZombieVillager => "ZombieAttackGoal",
|
||||
_ => "MeleeAttackGoal",
|
||||
},
|
||||
"MoveThroughVillage" => "MoveThroughVillageGoal",
|
||||
"RestrictSun" => "RestrictSunGoal",
|
||||
"FleeSun" => "FleeSunGoal",
|
||||
"AvoidEntity" => "AvoidEntityGoal",
|
||||
"BowAttack" => "RangedBowAttackGoal",
|
||||
"Swell" => "SwellGoal",
|
||||
"LeapAtTarget" => "LeapAtTargetGoal",
|
||||
"EatBlock" => "EatBlockGoal",
|
||||
"MoveTowardsRestriction" => "MoveTowardsRestrictionGoal",
|
||||
"TradeWithPlayer" => "TradeWithPlayerGoal",
|
||||
"LookAtTradingPlayer" => "LookAtTradingPlayerGoal",
|
||||
"WanderToPosition" => "WanderToPositionGoal",
|
||||
"DrinkItem" => "UseItemGoal",
|
||||
"BreathAir" => "BreathAirGoal",
|
||||
"TryFindWater" => "TryFindWaterGoal",
|
||||
"RangedAttack" => "RangedAttackGoal",
|
||||
"MoveTowardsTarget" => "MoveTowardsTargetGoal",
|
||||
"DrownedGoToWater" => "DrownedGoToWaterGoal",
|
||||
"TakeBlock" => "EndermanTakeBlockGoal",
|
||||
"LeaveBlock" => "EndermanLeaveBlockGoal",
|
||||
"FreezeWhenLookedAt" => "EndermanFreezeWhenLookedAt",
|
||||
"LookForPlayer" => "EndermanLookForPlayerGoal",
|
||||
"HealRaiders" => "NearestHealableRaiderTargetGoal",
|
||||
other => return other.to_owned(),
|
||||
};
|
||||
vanilla.to_owned()
|
||||
}
|
||||
|
||||
fn goals(m: &Mob, names: Vec<&'static str>) -> String {
|
||||
if names.is_empty() {
|
||||
return "-".into();
|
||||
}
|
||||
names
|
||||
.iter()
|
||||
.map(|n| goal_name(m, n))
|
||||
.collect::<Vec<_>>()
|
||||
.join("|")
|
||||
}
|
||||
|
||||
fn seen(m: &Mob) -> Seen {
|
||||
Seen {
|
||||
id: m.id,
|
||||
species: m.species(),
|
||||
position: m.position,
|
||||
bounds: m.bounds(),
|
||||
eye_y: m.eye_y(),
|
||||
alive: m.alive(),
|
||||
baby: m.is_baby(),
|
||||
in_love: if m.species() == Species::Villager {
|
||||
horizon_client::mobs::villager::can_breed(m)
|
||||
} else {
|
||||
m.extra.in_love > 0
|
||||
},
|
||||
tame: m.extra.tame,
|
||||
hostile: m.hostile(),
|
||||
target: m.ai.target,
|
||||
health: m.health,
|
||||
variant: m.extra.variant,
|
||||
color: m.extra.color,
|
||||
job: (m.species() == Species::Villager)
|
||||
.then_some(m.extra.home)
|
||||
.flatten(),
|
||||
bed: (m.species() == Species::Villager)
|
||||
.then_some(m.extra.bed)
|
||||
.flatten(),
|
||||
yaw: m.yaw,
|
||||
velocity: m.velocity,
|
||||
leash: m.extra.leash,
|
||||
caravan: horizon_client::mobs::caravan::head_of(m),
|
||||
delay: m.extra.ints[5],
|
||||
hurt_by: m.ai.last_hurt_by.map(|w| (w, m.ai.last_hurt_by_time)),
|
||||
active_raid: horizon_client::mobs::raider::has_active_raid(m),
|
||||
player_rep: 0,
|
||||
pushable: m.pushable(),
|
||||
category: m.category(),
|
||||
potential_job: horizon_client::mobs::villager_brain::potential_job_site(m),
|
||||
meeting: (m.species() == Species::Villager)
|
||||
.then_some(m.extra.meeting)
|
||||
.flatten(),
|
||||
profession: m.extra.variant2,
|
||||
xp: horizon_client::mobs::villager::xp(m),
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether an event hurts the scenario's player (survival, normal difficulty,
|
||||
/// no armour, made hurtable again every tick by the oracle).
|
||||
fn player_hurt(player: Option<&PlayerView>, e: &MobEvent) -> bool {
|
||||
let Some(p) = player.filter(|p| !p.creative) else {
|
||||
return false;
|
||||
};
|
||||
match e {
|
||||
MobEvent::AttackPlayer(amount, _) | MobEvent::DamagePlayer { amount, .. } => *amount > 0.0,
|
||||
// ServerExplosion.hurtEntities: within twice the radius every hit does damage.
|
||||
MobEvent::Explode(at, radius) => {
|
||||
let d = Vec3::new(at[0], at[1], at[2])
|
||||
.distance_squared(p.position)
|
||||
.sqrt();
|
||||
let r = 2.0 * f64::from(*radius);
|
||||
d / r <= 1.0 && d != 0.0
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn print(out: &mut String, t: i32, i: usize, m: &Mob) {
|
||||
let _ = write!(out, "{t} {i}");
|
||||
if m.removed {
|
||||
// Entity.RemovalReason: the end of the death animation, else a discard.
|
||||
let reason = if m.alive() { "DISCARDED" } else { "KILLED" };
|
||||
let _ = writeln!(out, " removed {reason}");
|
||||
return;
|
||||
}
|
||||
let p = m.position;
|
||||
let v = m.velocity;
|
||||
let _ = write!(
|
||||
out,
|
||||
"\tp {},{},{}\tv {},{},{}",
|
||||
d(p.x),
|
||||
d(p.y),
|
||||
d(p.z),
|
||||
d(v.x),
|
||||
d(v.y),
|
||||
d(v.z)
|
||||
);
|
||||
let _ = write!(
|
||||
out,
|
||||
"\tr {},{},{},{}",
|
||||
f(m.yaw),
|
||||
f(m.head_pitch),
|
||||
f(m.head_yaw),
|
||||
f(m.body_yaw)
|
||||
);
|
||||
let _ = write!(
|
||||
out,
|
||||
"\tg {}{}\thp {}",
|
||||
u8::from(m.on_ground),
|
||||
u8::from(m.horizontal_collision),
|
||||
f(m.health)
|
||||
);
|
||||
let _ = write!(
|
||||
out,
|
||||
"\tgoals {}\ttargets {}\ttarget {}",
|
||||
goals(m, m.ai.goals.running_names()),
|
||||
goals(m, m.ai.targets.running_names()),
|
||||
who(m.ai.target)
|
||||
);
|
||||
let _ = write!(out, "\tnav ");
|
||||
match &m.ai.nav.path {
|
||||
None => out.push_str("none"),
|
||||
Some(path) => {
|
||||
let _ = write!(
|
||||
out,
|
||||
"{},{},{} {}/{}{}",
|
||||
path.target[0],
|
||||
path.target[1],
|
||||
path.target[2],
|
||||
path.next,
|
||||
path.nodes.len(),
|
||||
if path.next >= path.nodes.len() {
|
||||
" done"
|
||||
} else {
|
||||
""
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
let mv = &m.ai.mov;
|
||||
let op = match mv.operation {
|
||||
Operation::Wait => "WAIT",
|
||||
Operation::MoveTo => "MOVE_TO",
|
||||
Operation::Strafe => "STRAFE",
|
||||
Operation::Jumping => "JUMPING",
|
||||
};
|
||||
let _ = write!(
|
||||
out,
|
||||
"\tmove {op} {},{},{} {}",
|
||||
d(mv.wanted[0]),
|
||||
d(mv.wanted[1]),
|
||||
d(mv.wanted[2]),
|
||||
d(mv.speed_modifier)
|
||||
);
|
||||
let lk = &m.ai.look;
|
||||
let _ = write!(
|
||||
out,
|
||||
"\tlook {} {},{},{}",
|
||||
lk.cooldown,
|
||||
d(lk.wanted[0]),
|
||||
d(lk.wanted[1]),
|
||||
d(lk.wanted[2])
|
||||
);
|
||||
let _ = write!(
|
||||
out,
|
||||
"\tin {},{},{},{}",
|
||||
f(m.ai.xxa),
|
||||
f(m.ai.zza),
|
||||
f(m.ai.speed),
|
||||
u8::from(m.ai.jumping)
|
||||
);
|
||||
let _ = write!(out, "\trng {:x}", m.random.state());
|
||||
if let Some((acts, mem)) = horizon_client::mobs::villager_brain::describe(m) {
|
||||
let _ = write!(out, "\tact {acts}\tmem {mem}");
|
||||
}
|
||||
out.push('\n');
|
||||
}
|
||||
|
||||
fn main() -> horizon_client::Result<()> {
|
||||
let args: Vec<String> = std::env::args().skip(1).collect();
|
||||
if args.len() != 3 {
|
||||
return Err("usage: mob_ai_probe RESOURCES SCENARIOS OUTPUT".into());
|
||||
}
|
||||
let root = Path::new(&args[0]);
|
||||
let assets = Assets::load(root)?;
|
||||
let registry = assets.registry;
|
||||
let facts = BlockFacts::load(root, registry)?;
|
||||
let data = MobData::load(root, &facts);
|
||||
let types = MobTypes::load(root)?;
|
||||
let biomes = Biomes::load(root)?;
|
||||
let items = Items::load(root, registry, &facts)?;
|
||||
let tables = LightTables::load(root, registry, &facts)?;
|
||||
let collision = collisions::load(root, registry)?;
|
||||
let air = registry.default_state("minecraft:air").ok_or("no air")?;
|
||||
let layers = [
|
||||
state(registry, "minecraft:bedrock"),
|
||||
state(registry, "minecraft:dirt"),
|
||||
state(registry, "minecraft:dirt"),
|
||||
state(registry, "minecraft:grass_block"),
|
||||
];
|
||||
let plains = biomes.id("minecraft:plains");
|
||||
let mut out = String::new();
|
||||
for s in parse(&fs::read_to_string(&args[1])?) {
|
||||
let _ = writeln!(out, "== {}", s.name);
|
||||
let mut world = World::new(air, 0);
|
||||
let (ocx, ocz) = (s.origin[0] >> 4, s.origin[1] >> 4);
|
||||
const R: i32 = 4;
|
||||
for cz in ocz - R..=ocz + R {
|
||||
for cx in ocx - R..=ocx + R {
|
||||
let mut chunk = Chunk::empty(cx, cz);
|
||||
for qy in 0..HEIGHT / 4 {
|
||||
for qz in 0..4 {
|
||||
for qx in 0..4 {
|
||||
chunk.set_biome_cell(qx, qy, qz, plains);
|
||||
}
|
||||
}
|
||||
}
|
||||
world.chunks.insert((cx, cz), Arc::new(chunk));
|
||||
for z in 0..16 {
|
||||
for x in 0..16 {
|
||||
for (y, layer) in layers.iter().enumerate() {
|
||||
world.set(cx * 16 + x, MIN_Y + y as i32, cz * 16 + z, *layer)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for b in &s.blocks {
|
||||
let n = |i: usize| b[i].parse::<i32>().unwrap();
|
||||
if b[0] == "block" {
|
||||
world.set(n(1), n(2), n(3), state(registry, &b[4]))?;
|
||||
} else {
|
||||
let st = state(registry, &b[7]);
|
||||
for x in n(1).min(n(4))..=n(1).max(n(4)) {
|
||||
for y in n(2).min(n(5))..=n(2).max(n(5)) {
|
||||
for z in n(3).min(n(6))..=n(3).max(n(6)) {
|
||||
world.set(x, y, z, st)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let keys: Vec<(i32, i32)> = world.chunks.keys().copied().collect();
|
||||
let lit: Vec<((i32, i32), Chunk)> = keys
|
||||
.iter()
|
||||
.map(|&(x, z)| {
|
||||
let region: [[Option<&Chunk>; 3]; 3] = std::array::from_fn(|dz| {
|
||||
std::array::from_fn(|dx| {
|
||||
world
|
||||
.chunks
|
||||
.get(&(x + dx as i32 - 1, z + dz as i32 - 1))
|
||||
.map(|c| &**c)
|
||||
})
|
||||
});
|
||||
let mut c = (*world.chunks[&(x, z)]).clone();
|
||||
c.sections = light_chunk(®ion, &tables, true);
|
||||
c.lit = true;
|
||||
((x, z), c)
|
||||
})
|
||||
.collect();
|
||||
for (k, c) in lit {
|
||||
world.chunks.insert(k, Arc::new(c));
|
||||
}
|
||||
world.day_time = s.time;
|
||||
world.game_time = 1000;
|
||||
let mut mobs: Vec<Mob> = Vec::new();
|
||||
for (i, spec) in s.mobs.iter().enumerate() {
|
||||
let kind = types
|
||||
.types
|
||||
.get(&spec.kind)
|
||||
.ok_or_else(|| format!("unknown mob {}", spec.kind))?;
|
||||
let mut m = Mob::new(
|
||||
100_000 + i as u32,
|
||||
Arc::new(kind.clone()),
|
||||
Vec3::new(spec.pos[0], spec.pos[1], spec.pos[2]),
|
||||
spec.yaw,
|
||||
);
|
||||
if spec.baby {
|
||||
m.extra.age = -24000;
|
||||
}
|
||||
for flag in &spec.flags {
|
||||
match flag.as_str() {
|
||||
"persistent" => m.persistent = true,
|
||||
"noai" => m.no_ai = true,
|
||||
other => panic!("unknown mob flag {other}"),
|
||||
}
|
||||
}
|
||||
m.random = LegacyRandom::new(spec.seed);
|
||||
if m.species() == Species::Villager {
|
||||
// The oracle seeds the random after the brain is built, then
|
||||
// delays the sensors again and seeds the gates' shuffles.
|
||||
horizon_client::mobs::villager_brain::init(
|
||||
&mut m,
|
||||
world.game_time,
|
||||
s.time,
|
||||
spec.seed,
|
||||
);
|
||||
}
|
||||
mobs.push(m);
|
||||
}
|
||||
let player = s.player.map(|p| {
|
||||
let position = Vec3::new(p[0], p[1], p[2]);
|
||||
PlayerView {
|
||||
position,
|
||||
bounds: Aabb::new(
|
||||
Vec3::new(p[0] - 0.3, p[1], p[2] - 0.3),
|
||||
Vec3::new(p[0] + 0.3, p[1] + 1.8, p[2] + 0.3),
|
||||
),
|
||||
eye: [p[0], p[1] + f64::from(1.62_f32), p[2]],
|
||||
alive: true,
|
||||
creative: s.creative,
|
||||
sneaking: false,
|
||||
look: [0.0, 0.0, 1.0],
|
||||
main_hand: None,
|
||||
off_hand: None,
|
||||
head: None,
|
||||
armor_cover: 0.0,
|
||||
spectator: false,
|
||||
gold_armor: false,
|
||||
hurt_by: None,
|
||||
last_hurt: None,
|
||||
velocity: Vec3::ZERO,
|
||||
yaw: 0.0,
|
||||
hero: false,
|
||||
uuid: [0, 0x1234, 0, 0x5678],
|
||||
}
|
||||
});
|
||||
let mut scratch = LegacyRandom::new(0);
|
||||
for (i, m) in mobs.iter().enumerate() {
|
||||
print(&mut out, 0, i, m);
|
||||
}
|
||||
for t in 1..=s.ticks {
|
||||
world.game_time += 1;
|
||||
let snapshot: Vec<Seen> = mobs.iter().filter(|m| !m.removed).map(seen).collect();
|
||||
let w = MobWorld {
|
||||
world: &world,
|
||||
registry,
|
||||
facts: &facts,
|
||||
collision: &collision,
|
||||
data: &data,
|
||||
player: player.clone(),
|
||||
others: &snapshot,
|
||||
daylight: (s.time.rem_euclid(24000)) < 12000,
|
||||
game_time: world.game_time,
|
||||
day_time: world.day_time,
|
||||
raining: s.rain,
|
||||
thundering: s.thunder,
|
||||
nether: false,
|
||||
block_tags: &items.block_tags,
|
||||
biomes: &biomes,
|
||||
mob_griefing: true,
|
||||
difficulty: LocalDifficulty::new(2, world.day_time, 0, 0.0),
|
||||
end: false,
|
||||
world_seed: 0,
|
||||
// FlatLevelSource.getSeaLevel.
|
||||
sea_level: -63,
|
||||
};
|
||||
let mut events = Vec::new();
|
||||
#[allow(unused_mut)] // filled only with the rng-trace feature
|
||||
let mut traces: Vec<String> = Vec::new();
|
||||
let mut pushes = Vec::new();
|
||||
for (i, m) in mobs.iter_mut().enumerate() {
|
||||
if m.removed {
|
||||
continue;
|
||||
}
|
||||
horizon_client::mobs::apply_mob_pushes(m, &mut pushes);
|
||||
let from = events.len();
|
||||
#[cfg(feature = "rng-trace")]
|
||||
horizon_core::random::trace::set(true);
|
||||
let alive = m.tick(&w, &mut scratch, &mut events);
|
||||
#[cfg(feature = "rng-trace")]
|
||||
{
|
||||
horizon_core::random::trace::set(false);
|
||||
for line in horizon_core::random::trace::take() {
|
||||
traces.push(format!("{t} trace {i} {line}"));
|
||||
}
|
||||
}
|
||||
horizon_client::mobs::take_mob_pushes(&mut events, from, &mut pushes);
|
||||
let _ = i;
|
||||
if !alive {
|
||||
m.removed = true;
|
||||
}
|
||||
}
|
||||
for m in &mut mobs {
|
||||
horizon_client::mobs::apply_mob_pushes(m, &mut pushes);
|
||||
}
|
||||
for (i, m) in mobs.iter().enumerate() {
|
||||
print(&mut out, t, i, m);
|
||||
}
|
||||
for e in &events {
|
||||
if let MobEvent::Sound(name, _, volume, pitch) = e {
|
||||
let _ = writeln!(
|
||||
out,
|
||||
"{t} sound minecraft:{name} {} {}",
|
||||
f(*volume),
|
||||
f(*pitch)
|
||||
);
|
||||
} else if let MobEvent::SoundId(name, _, volume, pitch) = e {
|
||||
let _ = writeln!(out, "{t} sound {name} {} {}", f(*volume), f(*pitch));
|
||||
} else if player_hurt(player.as_ref(), e) {
|
||||
// The oracle's survival player, healed and made hurtable every tick,
|
||||
// takes the damage (LivingEntity.playHurtSound).
|
||||
let _ = writeln!(
|
||||
out,
|
||||
"{t} sound minecraft:entity.player.hurt 3f800000 3f800000"
|
||||
);
|
||||
}
|
||||
}
|
||||
for line in traces {
|
||||
let _ = writeln!(out, "{line}");
|
||||
}
|
||||
}
|
||||
}
|
||||
fs::write(&args[2], out)?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
//! Replays `tools/test_music_parity.py` scripts through Horizon's
|
||||
//! `MusicManager` over its `SoundEngine`: `music_fixture ROOT INPUT OUTPUT`.
|
||||
#[path = "audio_common/mod.rs"]
|
||||
mod common;
|
||||
use common::{Fake, describe, f, flush};
|
||||
use horizon_client::music::{Music, MusicFrequency, MusicManager};
|
||||
use horizon_client::sound_engine::{Bound, MUSIC, SoundEngine, TickContext};
|
||||
use horizon_client::sound_events::SoundRegistry;
|
||||
use std::io::Write;
|
||||
|
||||
fn music(spec: &str) -> Option<Music> {
|
||||
if spec == "-" {
|
||||
return None;
|
||||
}
|
||||
let p: Vec<&str> = spec.split(',').collect();
|
||||
Some(Music::new(
|
||||
&horizon_client::sound_events::normalize(p[0]),
|
||||
p[1].parse().expect("min"),
|
||||
p[2].parse().expect("max"),
|
||||
p[3] == "1",
|
||||
))
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let args: Vec<String> = std::env::args().collect();
|
||||
let mut engine = SoundEngine::new(SoundRegistry::load(std::path::Path::new(&args[1])));
|
||||
engine.listening = true;
|
||||
let mut backend = Fake::default();
|
||||
let mut manager = MusicManager::new(0);
|
||||
let (mut situation, mut volume) = (None::<Music>, 1.0f32);
|
||||
let input = std::fs::read_to_string(&args[2]).expect("input");
|
||||
let mut out = std::io::BufWriter::new(std::fs::File::create(&args[3]).expect("output"));
|
||||
for line in input.lines() {
|
||||
writeln!(out, "{line}").expect("write");
|
||||
let a: Vec<&str> = line.split(' ').collect();
|
||||
match a[0] {
|
||||
"start" => {
|
||||
if let Some(id) = manager.current_instance() {
|
||||
engine.release(id);
|
||||
}
|
||||
manager = MusicManager::new(a[1].parse().expect("seed"));
|
||||
manager.frequency = MusicFrequency::parse(a[2]);
|
||||
engine.stop_all(&mut backend);
|
||||
}
|
||||
"situation" => {
|
||||
situation = music(a[1]);
|
||||
volume = f(a[2]);
|
||||
}
|
||||
"tick" => {
|
||||
let mut sounds = Bound {
|
||||
engine: &mut engine,
|
||||
backend: &mut backend,
|
||||
};
|
||||
manager.tick(situation.as_ref(), volume, a[1] == "1", &mut sounds);
|
||||
engine.tick(a[2] == "1", &TickContext::default(), &mut backend);
|
||||
}
|
||||
"finish" => {
|
||||
if let Some(c) = manager.current_instance().and_then(|id| engine.channel(id)) {
|
||||
horizon_client::sound_engine::Backend::stop(&mut backend, c);
|
||||
}
|
||||
}
|
||||
"stop" => manager.stop_playing(
|
||||
situation.as_ref(),
|
||||
&mut Bound {
|
||||
engine: &mut engine,
|
||||
backend: &mut backend,
|
||||
},
|
||||
),
|
||||
"stopmusic" => {
|
||||
let m = music(a[1]).expect("music");
|
||||
manager.stop_playing_music(
|
||||
&m,
|
||||
situation.as_ref(),
|
||||
&mut Bound {
|
||||
engine: &mut engine,
|
||||
backend: &mut backend,
|
||||
},
|
||||
);
|
||||
}
|
||||
"freq" => manager.set_frequency(MusicFrequency::parse(a[1]), situation.as_ref()),
|
||||
"toast" => manager.show_now_playing_toast_if_needed(),
|
||||
"volume" => {
|
||||
let source: usize = a[1].parse().expect("source");
|
||||
engine.source_volumes[source] = f(a[2]);
|
||||
engine.refresh_category_volume(source, &mut backend);
|
||||
}
|
||||
"stopall" => engine.stop_all(&mut backend),
|
||||
_ => panic!("{line}"),
|
||||
}
|
||||
writeln!(
|
||||
out,
|
||||
" current={} delay={} gain={:08x} toastShown={} musicGain={:08x}",
|
||||
manager.current(&engine).unwrap_or("-"),
|
||||
manager.next_song_delay(),
|
||||
manager.current_gain().to_bits(),
|
||||
manager.toast_shown(),
|
||||
engine.category_gain(MUSIC).to_bits()
|
||||
)
|
||||
.expect("write");
|
||||
for show in std::mem::take(&mut manager.toast_requests) {
|
||||
writeln!(out, " toast {}", if show { "show" } else { "hide" }).expect("write");
|
||||
}
|
||||
if let Some(id) = manager.current_instance() {
|
||||
writeln!(out, "{}", describe(&engine, &backend, "music", id)).expect("write");
|
||||
}
|
||||
flush(&mut engine, &mut out);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,688 @@
|
||||
//! Natural spawning decisions for differential tests against vanilla
|
||||
//! (tools/test_natural_spawn_parity.py): reads the box-world cases of
|
||||
//! `VanillaNaturalSpawnOracle` and prints what `mobs::spawn_selection` does in
|
||||
//! the same order and format.
|
||||
//!
|
||||
//! The oracle runs `finalizeSpawn` with a fixed random (nextInt 0, nextFloat
|
||||
//! 0.75); `neutral_box` is the box that leaves a mob with: polar bears,
|
||||
//! ocelots and rabbits after the first of their group are babies (chance
|
||||
//! 1.0), foxes and axolotls from the third, sulfur cubes have size 2, salmon
|
||||
//! are small, slimes and magma cubes keep size 1, tropical fish school.
|
||||
//!
|
||||
//! usage: natural_spawn_fixture RESOURCES INPUT OUTPUT
|
||||
use horizon_client::{
|
||||
assets::Assets,
|
||||
biome::{Biomes, SpawnEntry},
|
||||
blocks::BlockFacts,
|
||||
collisions::{self, LookupCollisions, no_collision_empty},
|
||||
items::Items,
|
||||
mobs::{
|
||||
MobTypes, Species,
|
||||
data::MobData,
|
||||
spawn_rules::{Dimension, SpawnLevel},
|
||||
spawn_selection::{
|
||||
self, Category, Counted, Event, GenEvent, Heightmap, NaturalLevel, SpawnCost,
|
||||
SpawnPlayer, SpawnRequest, SpawnState, Spawned, StructureAt, StructureSpawns,
|
||||
spawn_box,
|
||||
},
|
||||
species::{self, Baby},
|
||||
},
|
||||
};
|
||||
use horizon_core::{
|
||||
geometry::Aabb,
|
||||
random::{LegacyRandom, RandomSource},
|
||||
};
|
||||
use horizon_world::{
|
||||
block_collision::BlockCollisionRegistry, chunk::BlockState, registry::BlockStateId,
|
||||
};
|
||||
use serde_json::Value;
|
||||
use std::{
|
||||
cell::RefCell,
|
||||
collections::{BTreeMap, HashMap, HashSet},
|
||||
fmt::Write as _,
|
||||
fs,
|
||||
path::Path,
|
||||
};
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
struct Bx([i32; 6]);
|
||||
impl Bx {
|
||||
fn parse(s: &str) -> Self {
|
||||
let v: Vec<i32> = s.split(',').map(|n| n.parse().unwrap_or(0)).collect();
|
||||
Self([v[0], v[1], v[2], v[3], v[4], v[5]])
|
||||
}
|
||||
fn has(&self, x: i32, y: i32, z: i32) -> bool {
|
||||
let b = self.0;
|
||||
x >= b[0] && x <= b[3] && y >= b[1] && y <= b[4] && z >= b[2] && z <= b[5]
|
||||
}
|
||||
}
|
||||
struct Start {
|
||||
id: String,
|
||||
pieces: Vec<Bx>,
|
||||
/// The start's bounding box (pieces encapsulated, inflated by 12 for
|
||||
/// terrain adaptation).
|
||||
bounds: Bx,
|
||||
}
|
||||
|
||||
struct Shared<'a> {
|
||||
facts: &'a BlockFacts,
|
||||
data: &'a MobData,
|
||||
block_tags: &'a HashMap<String, HashSet<String>>,
|
||||
types: &'a MobTypes,
|
||||
biomes: &'a Biomes,
|
||||
structures: &'a StructureSpawns,
|
||||
collision: &'a BlockCollisionRegistry<'a>,
|
||||
air: BlockStateId,
|
||||
}
|
||||
struct World<'a> {
|
||||
s: &'a Shared<'a>,
|
||||
blocks: Vec<(Bx, BlockStateId)>,
|
||||
lights: Vec<(Bx, i32, i32)>,
|
||||
biomes: Vec<(Bx, String)>,
|
||||
biome: String,
|
||||
sky: i32,
|
||||
light: i32,
|
||||
darken: i32,
|
||||
thunder: bool,
|
||||
sea: i32,
|
||||
seed: i64,
|
||||
peaceful: bool,
|
||||
dimension: Dimension,
|
||||
dimension_id: String,
|
||||
slime: f32,
|
||||
starts: Vec<Start>,
|
||||
players: Vec<([f64; 3], String)>,
|
||||
respawn: Option<[i32; 3]>,
|
||||
unloaded: HashSet<(i32, i32)>,
|
||||
spawn_mobs: bool,
|
||||
/// Boxes of living entities (case entities, then spawned mobs).
|
||||
entities: RefCell<Vec<Aabb>>,
|
||||
}
|
||||
impl World<'_> {
|
||||
fn biome_at(&self, x: i32, y: i32, z: i32) -> &str {
|
||||
self.biomes
|
||||
.iter()
|
||||
.rev()
|
||||
.find(|(b, _)| b.has(x, y, z))
|
||||
.map_or(&self.biome, |(_, n)| n)
|
||||
}
|
||||
fn biome_info(&self, x: i32, y: i32, z: i32) -> Option<&horizon_client::biome::BiomeInfo> {
|
||||
let i = *self.s.biomes.index.get(self.biome_at(x, y, z))?;
|
||||
Some(self.s.biomes.get(i))
|
||||
}
|
||||
fn lights_at(&self, x: i32, y: i32, z: i32) -> (i32, i32) {
|
||||
self.lights
|
||||
.iter()
|
||||
.rev()
|
||||
.find(|(b, ..)| b.has(x, y, z))
|
||||
.map_or((self.sky, self.light), |(_, s, b)| (*s, *b))
|
||||
}
|
||||
}
|
||||
impl SpawnLevel for World<'_> {
|
||||
fn state(&self, x: i32, y: i32, z: i32) -> BlockStateId {
|
||||
self.blocks
|
||||
.iter()
|
||||
.rev()
|
||||
.find(|(b, _)| b.has(x, y, z))
|
||||
.map_or(self.s.air, |(_, s)| *s)
|
||||
}
|
||||
fn facts(&self) -> &BlockFacts {
|
||||
self.s.facts
|
||||
}
|
||||
fn mob_data(&self) -> &MobData {
|
||||
self.s.data
|
||||
}
|
||||
fn block_tag(&self, tag: &str, block: &str) -> bool {
|
||||
self.s
|
||||
.block_tags
|
||||
.get(tag)
|
||||
.is_some_and(|s| s.contains(block))
|
||||
}
|
||||
fn biome_tag(&self, tag: &str, x: i32, y: i32, z: i32) -> bool {
|
||||
self.s
|
||||
.data
|
||||
.biome_tags
|
||||
.get(tag)
|
||||
.is_some_and(|s| s.contains(self.biome_at(x, y, z)))
|
||||
}
|
||||
fn sky_light(&self, x: i32, y: i32, z: i32) -> i32 {
|
||||
self.lights_at(x, y, z).0
|
||||
}
|
||||
fn block_light(&self, x: i32, y: i32, z: i32) -> i32 {
|
||||
self.lights_at(x, y, z).1
|
||||
}
|
||||
fn sky_darken(&self) -> i32 {
|
||||
self.darken
|
||||
}
|
||||
fn thundering(&self) -> bool {
|
||||
self.thunder
|
||||
}
|
||||
fn sea_level(&self) -> i32 {
|
||||
self.sea
|
||||
}
|
||||
fn world_surface(&self, x: i32, z: i32) -> i32 {
|
||||
self.heightmap(Heightmap::WorldSurface, x, z)
|
||||
}
|
||||
fn peaceful(&self) -> bool {
|
||||
self.peaceful
|
||||
}
|
||||
fn dimension(&self) -> Dimension {
|
||||
self.dimension
|
||||
}
|
||||
fn surface_slime_chance(&self, _: i32, _: i32, _: i32) -> f32 {
|
||||
self.slime
|
||||
}
|
||||
fn world_seed(&self) -> i64 {
|
||||
self.seed
|
||||
}
|
||||
fn player_near(&self, x: f64, y: f64, z: f64, distance: f64) -> bool {
|
||||
self.players.iter().any(|(p, mode)| {
|
||||
let d = (p[0] - x).powi(2) + (p[1] - y).powi(2) + (p[2] - z).powi(2);
|
||||
(mode == "survival" || mode == "adventure") && d < distance * distance
|
||||
})
|
||||
}
|
||||
}
|
||||
impl NaturalLevel for World<'_> {
|
||||
fn min_y(&self) -> i32 {
|
||||
if self.dimension == Dimension::Overworld {
|
||||
-64
|
||||
} else {
|
||||
0
|
||||
}
|
||||
}
|
||||
fn max_y(&self) -> i32 {
|
||||
if self.dimension == Dimension::Overworld {
|
||||
319
|
||||
} else {
|
||||
255
|
||||
}
|
||||
}
|
||||
fn ambient_light(&self) -> f32 {
|
||||
match self.dimension {
|
||||
Dimension::Overworld => 0.0,
|
||||
Dimension::Nether => 0.1,
|
||||
Dimension::End => 0.25,
|
||||
}
|
||||
}
|
||||
fn has_ceiling(&self) -> bool {
|
||||
self.dimension == Dimension::Nether
|
||||
}
|
||||
fn mob_type(&self, entity: &str) -> Option<(Species, f32, f32, Category)> {
|
||||
let t = self.s.types.types.get(entity)?;
|
||||
Some((
|
||||
t.species,
|
||||
t.width as f32,
|
||||
t.height as f32,
|
||||
Category::from_name(&t.category),
|
||||
))
|
||||
}
|
||||
fn biome_spawns(&self, x: i32, y: i32, z: i32) -> Option<&HashMap<String, Vec<SpawnEntry>>> {
|
||||
self.biome_info(x, y, z).map(|b| &b.spawns)
|
||||
}
|
||||
fn spawn_cost(&self, entity: &str, x: i32, y: i32, z: i32) -> Option<SpawnCost> {
|
||||
self.biome_info(x, y, z)?.spawn_costs.get(entity).copied()
|
||||
}
|
||||
fn creature_probability(&self, x: i32, y: i32, z: i32) -> f32 {
|
||||
self.biome_info(x, y, z)
|
||||
.map_or(0.1, |b| b.creature_probability)
|
||||
}
|
||||
fn structures_at(&self, x: i32, y: i32, z: i32) -> Vec<StructureAt> {
|
||||
let (cx, cz) = (x >> 4, z >> 4);
|
||||
let mut out: Vec<StructureAt> = Vec::new();
|
||||
for start in &self.starts {
|
||||
let b = start.bounds.0;
|
||||
// Referenced: the start's box reaches the chunk's columns.
|
||||
if b[3] < cx * 16 || b[0] > cx * 16 + 15 || b[5] < cz * 16 || b[2] > cz * 16 + 15 {
|
||||
continue;
|
||||
}
|
||||
let in_piece = start.pieces.iter().any(|p| p.has(x, y, z));
|
||||
let in_bounds = start.bounds.has(x, y, z);
|
||||
match out.iter_mut().find(|s| s.id == start.id) {
|
||||
Some(s) => {
|
||||
s.in_piece |= in_piece;
|
||||
s.in_bounds |= in_bounds;
|
||||
}
|
||||
None => out.push(StructureAt {
|
||||
id: start.id.clone(),
|
||||
in_piece,
|
||||
in_bounds,
|
||||
}),
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
fn structure_spawns(&self) -> &StructureSpawns {
|
||||
self.s.structures
|
||||
}
|
||||
fn nearest_player_distance_sqr(&self, x: f64, y: f64, z: f64) -> Option<f64> {
|
||||
let mut best: Option<f64> = None;
|
||||
for (p, mode) in &self.players {
|
||||
if mode == "spectator" {
|
||||
continue;
|
||||
}
|
||||
let d = [p[0] - x, p[1] - y, p[2] - z];
|
||||
let d = d[0] * d[0] + d[1] * d[1] + d[2] * d[2];
|
||||
if best.is_none_or(|b| d < b) {
|
||||
best = Some(d);
|
||||
}
|
||||
}
|
||||
best
|
||||
}
|
||||
fn respawn(&self) -> Option<[i32; 3]> {
|
||||
self.respawn
|
||||
}
|
||||
fn can_spawn_in_chunk(&self, cx: i32, cz: i32) -> bool {
|
||||
!self.unloaded.contains(&(cx, cz))
|
||||
}
|
||||
fn no_collision(&self, bounds: Aabb) -> bool {
|
||||
let lookup = |x: i32, y: i32, z: i32| self.state(x, y, z);
|
||||
let mut world = LookupCollisions {
|
||||
block: &lookup,
|
||||
binding: self.s.collision,
|
||||
};
|
||||
no_collision_empty(&mut world, bounds)
|
||||
}
|
||||
fn unobstructed(&self, bounds: Aabb) -> bool {
|
||||
!self.entities.borrow().iter().any(|b| b.intersects(bounds))
|
||||
}
|
||||
fn spawn_mobs(&self) -> bool {
|
||||
self.spawn_mobs
|
||||
}
|
||||
fn heightmap(&self, kind: Heightmap, x: i32, z: i32) -> i32 {
|
||||
(self.min_y()..=self.max_y())
|
||||
.rev()
|
||||
.find(|&y| spawn_selection::heightmap_blocks(self, kind, x, y, z))
|
||||
.map_or(self.min_y(), |y| y + 1)
|
||||
}
|
||||
fn pathfindable_land(&self, x: i32, y: i32, z: i32) -> bool {
|
||||
self.s.data.state(self.state(x, y, z)).flags & horizon_client::mobs::data::LAND != 0
|
||||
}
|
||||
}
|
||||
|
||||
/// The box a mob is left with by `finalizeSpawn` under the oracle's fixed random.
|
||||
fn neutral_box(l: &World, r: &SpawnRequest) -> Aabb {
|
||||
let t = &l.s.types.types[r.entity];
|
||||
let (mut w, mut h) = (t.width as f32, t.height as f32);
|
||||
let baby = match r.species {
|
||||
Species::PolarBear | Species::Ocelot | Species::Rabbit => r.group_index > 0,
|
||||
Species::Fox | Species::Axolotl => r.group_index >= 2,
|
||||
_ => false,
|
||||
};
|
||||
if baby {
|
||||
match species::def(r.species).baby {
|
||||
Baby::Abs(bw, bh, _) => (w, h) = (bw as f32, bh as f32),
|
||||
Baby::Scale(s, _) => (w, h) = (w * s as f32, h * s as f32),
|
||||
Baby::None => {}
|
||||
}
|
||||
}
|
||||
match r.species {
|
||||
Species::SulfurCube => (w, h) = (w * 2.0, h * 2.0),
|
||||
Species::Salmon => (w, h) = (w * 0.5, h * 0.5),
|
||||
_ => {}
|
||||
}
|
||||
let [x, y, z] = r.position;
|
||||
spawn_box(x, y, z, w, h)
|
||||
}
|
||||
|
||||
fn parse_state(spec: &str) -> BlockState {
|
||||
let (name, props) = match spec.split_once('[') {
|
||||
Some((n, rest)) => (n, rest.trim_end_matches(']')),
|
||||
None => (spec, ""),
|
||||
};
|
||||
BlockState {
|
||||
name: name.to_owned(),
|
||||
properties: props
|
||||
.split(',')
|
||||
.filter(|p| !p.is_empty())
|
||||
.filter_map(|p| p.split_once('='))
|
||||
.map(|(k, v)| (k.to_owned(), v.to_owned()))
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
fn bits(v: f64) -> String {
|
||||
format!("{:x}", v.to_bits())
|
||||
}
|
||||
/// The yaw as the oracle prints it: a cube mob grown by `finalizeSpawn`
|
||||
/// (sulfur cubes, size 2 under the fixed random) takes its constructor's
|
||||
/// head rotation from the entity's unseeded random.
|
||||
fn yaw_text(entity: &str, v: f32) -> String {
|
||||
if entity == "minecraft:sulfur_cube" {
|
||||
"head".to_owned()
|
||||
} else {
|
||||
format!("{:x}", v.to_bits())
|
||||
}
|
||||
}
|
||||
|
||||
fn main() -> horizon_client::Result<()> {
|
||||
let args: Vec<String> = std::env::args().collect();
|
||||
let root = Path::new(
|
||||
args.get(1)
|
||||
.ok_or("usage: natural_spawn_fixture RESOURCES INPUT OUTPUT")?,
|
||||
);
|
||||
let input = fs::read_to_string(args.get(2).ok_or("missing INPUT")?)?;
|
||||
let assets = Assets::load(root)?;
|
||||
let registry = assets.registry;
|
||||
let facts = BlockFacts::load(root, registry)?;
|
||||
let data = MobData::load(root, &facts);
|
||||
let items = Items::tags_only(root)?;
|
||||
let types = MobTypes::load(root)?;
|
||||
let biomes = Biomes::load(root)?;
|
||||
let structures = StructureSpawns::load(root);
|
||||
let collision = collisions::load(root, registry)?;
|
||||
let shared = Shared {
|
||||
facts: &facts,
|
||||
data: &data,
|
||||
block_tags: &items.block_tags,
|
||||
types: &types,
|
||||
biomes: &biomes,
|
||||
structures: &structures,
|
||||
collision: &collision,
|
||||
air: facts.air,
|
||||
};
|
||||
let mut adaptation: HashMap<String, bool> = HashMap::new();
|
||||
let mut out = String::new();
|
||||
for raw in input.lines() {
|
||||
if raw.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
let mut c = BTreeMap::new();
|
||||
let mut name = "";
|
||||
for token in raw.split_whitespace() {
|
||||
match token.split_once('=') {
|
||||
Some((k, v)) => {
|
||||
c.insert(k, v);
|
||||
}
|
||||
None => name = token,
|
||||
}
|
||||
}
|
||||
let get =
|
||||
|k: &str, d: &'static str| -> String { c.get(k).copied().unwrap_or(d).to_owned() };
|
||||
let indexed = |prefix: &str| -> Vec<&str> {
|
||||
(0..)
|
||||
.map_while(|i| c.get(format!("{prefix}{i}").as_str()).copied())
|
||||
.collect()
|
||||
};
|
||||
let mut blocks = Vec::new();
|
||||
for k in indexed("k") {
|
||||
let (b, s) = k.split_once('/').ok_or("bad block box")?;
|
||||
blocks.push((Bx::parse(b), registry.resolve(&parse_state(s))?));
|
||||
}
|
||||
let lights = indexed("l")
|
||||
.into_iter()
|
||||
.filter_map(|l| {
|
||||
let (b, v) = l.split_once('/')?;
|
||||
let (s, k) = v.split_once(',')?;
|
||||
Some((Bx::parse(b), s.parse().ok()?, k.parse().ok()?))
|
||||
})
|
||||
.collect();
|
||||
let biome_boxes = indexed("b")
|
||||
.into_iter()
|
||||
.filter_map(|l| {
|
||||
let (b, n) = l.split_once('/')?;
|
||||
Some((Bx::parse(b), n.to_owned()))
|
||||
})
|
||||
.collect();
|
||||
let mut starts = Vec::new();
|
||||
for s in indexed("s") {
|
||||
let (id, pieces) = s.split_once('/').ok_or("bad structure")?;
|
||||
let pieces: Vec<Bx> = pieces.split(';').map(Bx::parse).collect();
|
||||
let mut b = pieces[0].0;
|
||||
for p in &pieces[1..] {
|
||||
for i in 0..3 {
|
||||
b[i] = b[i].min(p.0[i]);
|
||||
b[i + 3] = b[i + 3].max(p.0[i + 3]);
|
||||
}
|
||||
}
|
||||
let adapts = *adaptation.entry(id.to_owned()).or_insert_with(|| {
|
||||
let path = root.join(format!(
|
||||
"data/minecraft/worldgen/structure/{}.json",
|
||||
id.trim_start_matches("minecraft:")
|
||||
));
|
||||
fs::read(path)
|
||||
.ok()
|
||||
.and_then(|b| serde_json::from_slice::<Value>(&b).ok())
|
||||
.and_then(|v| v["terrain_adaptation"].as_str().map(|a| a != "none"))
|
||||
.unwrap_or(false)
|
||||
});
|
||||
if adapts {
|
||||
for i in 0..3 {
|
||||
b[i] -= 12;
|
||||
b[i + 3] += 12;
|
||||
}
|
||||
}
|
||||
starts.push(Start {
|
||||
id: id.to_owned(),
|
||||
pieces,
|
||||
bounds: Bx(b),
|
||||
});
|
||||
}
|
||||
let players = indexed("p")
|
||||
.into_iter()
|
||||
.map(|p| {
|
||||
let mut parts = p.split('/');
|
||||
let v: Vec<f64> = parts
|
||||
.next()
|
||||
.unwrap_or("")
|
||||
.split(',')
|
||||
.map(|n| n.parse().unwrap_or(0.0))
|
||||
.collect();
|
||||
(
|
||||
[v[0], v[1], v[2]],
|
||||
parts.next().unwrap_or("survival").to_owned(),
|
||||
)
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let dimension_id = get("dim", "minecraft:overworld");
|
||||
let dimension = match dimension_id.as_str() {
|
||||
"minecraft:the_nether" => Dimension::Nether,
|
||||
"minecraft:the_end" => Dimension::End,
|
||||
_ => Dimension::Overworld,
|
||||
};
|
||||
let respawn = {
|
||||
let r = get("respawn", "0,-1000,0/minecraft:overworld");
|
||||
let (p, d) = r.split_once('/').ok_or("bad respawn")?;
|
||||
let v: Vec<i32> = p.split(',').map(|n| n.parse().unwrap_or(0)).collect();
|
||||
(d == dimension_id).then_some([v[0], v[1], v[2]])
|
||||
};
|
||||
let unloaded = c
|
||||
.get("unloaded")
|
||||
.map(|u| {
|
||||
u.split(';')
|
||||
.filter_map(|p| {
|
||||
let (x, z) = p.split_once(',')?;
|
||||
Some((x.parse().ok()?, z.parse().ok()?))
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
// Case entities: (type, position, persistent).
|
||||
let mut case_entities = Vec::new();
|
||||
for e in indexed("e") {
|
||||
let parts: Vec<&str> = e.split('/').collect();
|
||||
let v: Vec<f64> = parts[1]
|
||||
.split(',')
|
||||
.map(|n| n.parse().unwrap_or(0.0))
|
||||
.collect();
|
||||
// EntityType.create makes no monsters in peaceful.
|
||||
if get("difficulty", "normal") == "peaceful"
|
||||
&& types.types.get(parts[0]).is_some_and(|t| {
|
||||
!horizon_client::mobs::spawn_rules::allowed_in_peaceful(t.species)
|
||||
})
|
||||
{
|
||||
continue;
|
||||
}
|
||||
case_entities.push((
|
||||
parts[0].to_owned(),
|
||||
[v[0], v[1], v[2]],
|
||||
parts.get(2) == Some(&"p"),
|
||||
));
|
||||
}
|
||||
let world = World {
|
||||
s: &shared,
|
||||
blocks,
|
||||
lights,
|
||||
biomes: biome_boxes,
|
||||
biome: get("biome", "minecraft:plains"),
|
||||
sky: get("sky", "15").parse()?,
|
||||
light: get("light", "0").parse()?,
|
||||
darken: get("darken", "0").parse()?,
|
||||
thunder: get("thunder", "0") == "1",
|
||||
sea: get("sea", "63").parse()?,
|
||||
seed: get("wseed", "0").parse()?,
|
||||
peaceful: get("difficulty", "normal") == "peaceful",
|
||||
dimension,
|
||||
dimension_id: dimension_id.clone(),
|
||||
slime: get("slime", "0.0").parse()?,
|
||||
starts,
|
||||
players,
|
||||
respawn,
|
||||
unloaded,
|
||||
spawn_mobs: get("rules", "1") != "0",
|
||||
entities: RefCell::new(
|
||||
case_entities
|
||||
.iter()
|
||||
.filter_map(|(t, p, _)| {
|
||||
let t = types.types.get(t)?;
|
||||
Some(spawn_box(p[0], p[1], p[2], t.width as f32, t.height as f32))
|
||||
})
|
||||
.collect(),
|
||||
),
|
||||
};
|
||||
let _ = &world.dimension_id;
|
||||
let chunk = {
|
||||
let v: Vec<i32> = get("chunk", "0,0")
|
||||
.split(',')
|
||||
.map(|n| n.parse().unwrap_or(0))
|
||||
.collect();
|
||||
(v[0], v[1])
|
||||
};
|
||||
let mut random = LegacyRandom::new(get("seed", "0").parse()?);
|
||||
let mut log: Vec<String> = Vec::new();
|
||||
let mut spawn = |l: &World, r: &SpawnRequest| -> Option<Spawned> {
|
||||
let bounds = neutral_box(l, r);
|
||||
l.entities.borrow_mut().push(bounds);
|
||||
Some(Spawned {
|
||||
bounds,
|
||||
school: true,
|
||||
})
|
||||
};
|
||||
let mode = get("mode", "chunk");
|
||||
if mode == "gen" {
|
||||
let mut gen_random = LegacyRandom::new(get("gseed", "0").parse()?);
|
||||
// The level random of the spawn predicates: no creature's predicate
|
||||
// draws from it (the oracle's answers fixed values).
|
||||
let mut rules = LegacyRandom::new(0);
|
||||
let mut spawns = Vec::new();
|
||||
let source = [chunk.0 * 16, world.max_y(), chunk.1 * 16];
|
||||
spawn_selection::spawn_mobs_for_chunk_generation(
|
||||
&world,
|
||||
source,
|
||||
chunk,
|
||||
&mut gen_random,
|
||||
&mut rules,
|
||||
&mut spawn,
|
||||
&mut |e| match e {
|
||||
GenEvent::Column(x, z) => log.push(format!("col {x} {z}")),
|
||||
GenEvent::Spawn(t, p, yaw) => spawns.push(format!(
|
||||
"spawn {t} {} {} {} {}",
|
||||
bits(p[0]),
|
||||
bits(p[1]),
|
||||
bits(p[2]),
|
||||
yaw_text(&t, yaw)
|
||||
)),
|
||||
},
|
||||
);
|
||||
log.extend(spawns);
|
||||
log.push(format!(
|
||||
"next {}",
|
||||
gen_random.next_int_bound(1000).unwrap_or(0)
|
||||
));
|
||||
} else {
|
||||
let counted: Vec<Counted> = case_entities
|
||||
.iter()
|
||||
.filter_map(|(t, p, persistent)| {
|
||||
let kind = types.types.get(t)?;
|
||||
Some(Counted {
|
||||
entity: t,
|
||||
category: Category::from_name(&kind.category),
|
||||
block: [
|
||||
p[0].floor() as i32,
|
||||
p[1].floor() as i32,
|
||||
p[2].floor() as i32,
|
||||
],
|
||||
is_mob: true,
|
||||
persistent: *persistent,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
let players = world
|
||||
.players
|
||||
.iter()
|
||||
.map(|(p, mode)| SpawnPlayer {
|
||||
position: *p,
|
||||
spectator: mode == "spectator",
|
||||
})
|
||||
.collect();
|
||||
let mut state = SpawnState::new(
|
||||
&world,
|
||||
get("chunkcount", "289").parse()?,
|
||||
players,
|
||||
&counted,
|
||||
|cx, cz| !world.unloaded.contains(&(cx, cz)),
|
||||
);
|
||||
let mut trace = |e: Event| {
|
||||
log.push(match e {
|
||||
Event::Categories(list) => format!(
|
||||
"cats {}",
|
||||
list.iter().map(|c| c.name()).collect::<Vec<_>>().join(",")
|
||||
),
|
||||
Event::Local(c, ok) => format!("local {} {ok}", c.name()),
|
||||
Event::Start(c, p) => format!("start {} {} {} {}", c.name(), p[0], p[1], p[2]),
|
||||
Event::Try(x, z) => format!("try {x} {z}"),
|
||||
Event::Test(t, p, ok) => format!("test {t} {} {} {} {ok}", p[0], p[1], p[2]),
|
||||
Event::Spawn(t, p, yaw) => format!(
|
||||
"spawn {t} {} {} {} {}",
|
||||
bits(p[0]),
|
||||
bits(p[1]),
|
||||
bits(p[2]),
|
||||
yaw_text(&t, yaw)
|
||||
),
|
||||
})
|
||||
};
|
||||
if mode == "pos" {
|
||||
let category = Category::from_name(&get("cat", "monster"));
|
||||
let v: Vec<i32> = get("start", "0,0,0")
|
||||
.split(',')
|
||||
.map(|n| n.parse().unwrap_or(0))
|
||||
.collect();
|
||||
spawn_selection::spawn_category_for_position(
|
||||
&world,
|
||||
&mut random,
|
||||
&mut state,
|
||||
category,
|
||||
chunk,
|
||||
[v[0], v[1], v[2]],
|
||||
&mut spawn,
|
||||
&mut trace,
|
||||
);
|
||||
} else {
|
||||
let categories = state
|
||||
.filtered_categories(get("enemies", "1") == "1", get("persistent", "1") == "1");
|
||||
trace(Event::Categories(categories.clone()));
|
||||
spawn_selection::spawn_for_chunk(
|
||||
&world,
|
||||
&mut random,
|
||||
&mut state,
|
||||
&categories,
|
||||
chunk,
|
||||
&mut spawn,
|
||||
&mut trace,
|
||||
);
|
||||
}
|
||||
log.push(format!("next {}", random.next_int_bound(1000).unwrap_or(0)));
|
||||
}
|
||||
let _ = writeln!(out, "{name} {}", log.join(" ; "));
|
||||
}
|
||||
fs::write(args.get(3).ok_or("missing OUTPUT")?, out)?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
//! `getSituationalMusic`/`getMusicVolume` through Horizon's `Situation` for
|
||||
//! `tools/test_situational_music_parity.py`:
|
||||
//! `situational_music_fixture ROOT INPUT OUTPUT`.
|
||||
use horizon_client::music::{BackgroundMusic, Music, MusicTables, Situation};
|
||||
use horizon_client::sound_events::normalize;
|
||||
use std::io::Write;
|
||||
|
||||
fn main() {
|
||||
let args: Vec<String> = std::env::args().collect();
|
||||
let tables = MusicTables::load(std::path::Path::new(&args[1]));
|
||||
let input = std::fs::read_to_string(&args[2]).expect("input");
|
||||
let mut out = std::io::BufWriter::new(std::fs::File::create(&args[3]).expect("output"));
|
||||
for line in input.lines() {
|
||||
let f: Vec<&str> = line.split(' ').collect();
|
||||
let in_world = f[1] == "1";
|
||||
let (background, probe_volume) = if in_world {
|
||||
(tables.background(f[2], f[3]), tables.volume(f[2], f[3]))
|
||||
} else {
|
||||
(BackgroundMusic::default(), 1.0)
|
||||
};
|
||||
let situation = Situation {
|
||||
screen_music: (f[0] == "win").then(Music::credits),
|
||||
in_world,
|
||||
in_end: f[2] == "minecraft:the_end",
|
||||
boss_music: f[4].contains("music"),
|
||||
background: &background,
|
||||
probe_volume,
|
||||
creative: f[5] == "1" && f[6] == "1",
|
||||
underwater: f[7] == "1",
|
||||
};
|
||||
let music = situation.music().map_or("-".to_owned(), |m| {
|
||||
format!(
|
||||
"{},{},{},{}",
|
||||
normalize(&m.sound),
|
||||
m.min_delay,
|
||||
m.max_delay,
|
||||
m.replace_current_music
|
||||
)
|
||||
});
|
||||
writeln!(
|
||||
out,
|
||||
"{line} music={music} volume={:08x}",
|
||||
situation.volume().to_bits()
|
||||
)
|
||||
.expect("write");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
//! Replays `tools/test_sound_engine_parity.py` scripts through Horizon's
|
||||
//! `SoundEngine` with a backend recording what an OpenAL source would hold:
|
||||
//! `sound_engine_fixture ROOT INPUT OUTPUT`.
|
||||
#[path = "audio_common/mod.rs"]
|
||||
mod common;
|
||||
use common::{Fake, d, describe, f, flush, result_name};
|
||||
use horizon_client::sound_engine::{
|
||||
Attenuation, Backend, InstanceId, SoundEngine, SoundInstance, TickContext,
|
||||
};
|
||||
use horizon_client::sound_events::SoundRegistry;
|
||||
use horizon_core::random::LegacyRandom;
|
||||
use std::collections::BTreeMap;
|
||||
use std::io::Write;
|
||||
|
||||
fn main() {
|
||||
let args: Vec<String> = std::env::args().collect();
|
||||
let mut engine = SoundEngine::new(SoundRegistry::load(std::path::Path::new(&args[1])));
|
||||
engine.listening = true;
|
||||
let mut backend = Fake::default();
|
||||
let mut ctx = TickContext::default();
|
||||
let mut instances: BTreeMap<String, InstanceId> = BTreeMap::new();
|
||||
let input = std::fs::read_to_string(&args[2]).expect("input");
|
||||
let mut out = std::io::BufWriter::new(std::fs::File::create(&args[3]).expect("output"));
|
||||
for line in input.lines() {
|
||||
writeln!(out, "{line}").expect("write");
|
||||
let a: Vec<&str> = line.split(' ').collect();
|
||||
let source = |i: usize| a[i].parse::<usize>().expect("source");
|
||||
match a[0] {
|
||||
"play" => {
|
||||
let event = horizon_client::sound_events::normalize(a[3]);
|
||||
let random = LegacyRandom::new(a[14].parse().expect("seed"));
|
||||
let instance = match a[2] {
|
||||
"simple" => SoundInstance::new(
|
||||
&event,
|
||||
source(4),
|
||||
f(a[5]),
|
||||
f(a[6]),
|
||||
random,
|
||||
a[10] == "1",
|
||||
a[11].parse().expect("delay"),
|
||||
if a[12] == "linear" {
|
||||
Attenuation::Linear
|
||||
} else {
|
||||
Attenuation::None
|
||||
},
|
||||
[d(a[7]), d(a[8]), d(a[9])],
|
||||
a[13] == "1",
|
||||
),
|
||||
"loop" => SoundInstance::biome_loop(&event, random),
|
||||
"uloop" => SoundInstance::under_liquid_loop(&event, random),
|
||||
"usub" => SoundInstance::under_liquid_sub(&event, random),
|
||||
_ => panic!("{line}"),
|
||||
};
|
||||
if let Some(old) = instances.remove(a[1]) {
|
||||
engine.release(old);
|
||||
}
|
||||
let (id, result) = engine.play_new(instance, true, &mut backend);
|
||||
instances.insert(a[1].to_owned(), id);
|
||||
writeln!(out, " result {}", result_name(result)).expect("write");
|
||||
}
|
||||
"replay" => {
|
||||
let result = engine.play(instances[a[1]], &mut backend);
|
||||
writeln!(out, " result {}", result_name(result)).expect("write");
|
||||
}
|
||||
"queue" => engine.queue_ticking(instances[a[1]]),
|
||||
"delayed" => engine.play_delayed(instances[a[1]], a[2].parse().expect("delay")),
|
||||
"fade" => {
|
||||
let i = engine.instance_mut(instances[a[1]]).expect("instance");
|
||||
if a[2] == "in" {
|
||||
i.fade_in();
|
||||
} else {
|
||||
i.fade_out();
|
||||
}
|
||||
}
|
||||
"stop" => engine.stop(instances[a[1]], &mut backend),
|
||||
"stopmatch" => {
|
||||
let sound = (a[1] != "-").then(|| horizon_client::sound_events::normalize(a[1]));
|
||||
let src = (a[2] != "-").then(|| source(2));
|
||||
engine.stop_matching(sound.as_deref(), src, &mut backend);
|
||||
}
|
||||
"finish" => {
|
||||
if let Some(c) = engine.channel(instances[a[1]]) {
|
||||
backend.stop(c);
|
||||
}
|
||||
}
|
||||
"gain" => engine.update_category_volume(source(1), f(a[2]), &mut backend),
|
||||
"volume" => engine.source_volumes[source(1)] = f(a[2]),
|
||||
"refresh" => engine.refresh_category_volume(source(1), &mut backend),
|
||||
"pause" => engine.pause_all_except(&[1, 10], &mut backend),
|
||||
"resume" => engine.resume(&mut backend),
|
||||
"player" => {
|
||||
ctx.player_underwater = a[1] == "1";
|
||||
ctx.player_removed = a[2] == "1";
|
||||
}
|
||||
"tick" => engine.tick(a[1] == "1", &ctx, &mut backend),
|
||||
"forget" => {
|
||||
if let Some(id) = instances.remove(a[1]) {
|
||||
engine.release(id);
|
||||
}
|
||||
}
|
||||
_ => panic!("{line}"),
|
||||
}
|
||||
for (name, id) in &instances {
|
||||
writeln!(out, "{}", describe(&engine, &backend, name, *id)).expect("write");
|
||||
}
|
||||
flush(&mut engine, &mut out);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
//! Resolves sound instances through Horizon's sound event registry for
|
||||
//! `tools/test_sound_event_parity.py`: `sound_event_fixture ROOT INPUT OUTPUT [NAMES]`.
|
||||
use horizon_client::sound_engine::{Attenuation, MASTER, SoundEngine, SoundInstance};
|
||||
use horizon_client::sound_events::{Sentinel, SoundRegistry};
|
||||
use horizon_core::random::{LegacyRandom, RandomSource};
|
||||
use std::io::Write;
|
||||
|
||||
fn bits(v: f32) -> String {
|
||||
format!("{:08x}", v.to_bits())
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let args: Vec<String> = std::env::args().collect();
|
||||
let registry = SoundRegistry::load(std::path::Path::new(&args[1]));
|
||||
let mut engine = SoundEngine::new(registry);
|
||||
let input = std::fs::read_to_string(&args[2]).expect("input");
|
||||
let mut out = std::io::BufWriter::new(std::fs::File::create(&args[3]).expect("output"));
|
||||
let mut names: Vec<String> = engine.registry.names().cloned().collect();
|
||||
names.sort();
|
||||
for name in names {
|
||||
writeln!(out, "registered {name}").expect("write");
|
||||
}
|
||||
for line in input.lines() {
|
||||
let f: Vec<&str> = line.split(' ').collect();
|
||||
let seed: i64 = f[1].parse().expect("seed");
|
||||
let volume = f32::from_bits(u32::from_str_radix(f[2], 16).expect("volume"));
|
||||
let pitch = f32::from_bits(u32::from_str_radix(f[3], 16).expect("pitch"));
|
||||
let event = horizon_client::sound_events::normalize(f[0]);
|
||||
let instance = SoundInstance::new(
|
||||
&event,
|
||||
MASTER,
|
||||
volume,
|
||||
pitch,
|
||||
LegacyRandom::new(seed),
|
||||
false,
|
||||
0,
|
||||
Attenuation::Linear,
|
||||
[0.0; 3],
|
||||
false,
|
||||
);
|
||||
let id = engine.add(instance, true);
|
||||
engine.resolve(id);
|
||||
let weight = engine.registry.weight(&event);
|
||||
let i = engine.instance_mut(id).expect("instance");
|
||||
let mut s = line.to_owned();
|
||||
match &i.event {
|
||||
None => s.push_str(" event=none"),
|
||||
Some(subtitle) => s.push_str(&format!(
|
||||
" event weight={weight} subtitle={}",
|
||||
subtitle.as_deref().unwrap_or("-")
|
||||
)),
|
||||
}
|
||||
let v = i.get_volume();
|
||||
let p = i.get_pitch();
|
||||
let sound = i.sound.clone().expect("resolved");
|
||||
let (namespace, path) = sound.path();
|
||||
let sentinel = match sound.sentinel {
|
||||
Sentinel::None => "-",
|
||||
Sentinel::Empty => "empty",
|
||||
Sentinel::IntentionallyEmpty => "intentionally_empty",
|
||||
};
|
||||
s.push_str(&format!(
|
||||
" sound={} path={namespace}:{path} sentinel={sentinel} weight={} type={} stream={} preload={} attenuation={} volume={} pitch={} range={} next={}",
|
||||
sound.location,
|
||||
sound.weight,
|
||||
if sound.event { "SOUND_EVENT" } else { "FILE" },
|
||||
sound.stream,
|
||||
sound.preload,
|
||||
sound.attenuation_distance,
|
||||
bits(v),
|
||||
bits(p),
|
||||
bits(sound.attenuation_distance(v)),
|
||||
i.random.next_long()
|
||||
));
|
||||
writeln!(out, "{s}").expect("write");
|
||||
engine.release(id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
//! Spawn placement and spawn predicate answers for differential tests against
|
||||
//! vanilla (tools/test_spawn_rules_parity.py): reads the column-world cases of
|
||||
//! `VanillaSpawnRulesOracle` and prints `mobs::spawn_rules`' answers.
|
||||
//!
|
||||
//! usage: spawn_rules_fixture RESOURCES INPUT OUTPUT
|
||||
use horizon_client::{
|
||||
assets::Assets,
|
||||
blocks::BlockFacts,
|
||||
items::Items,
|
||||
mobs::{
|
||||
MobTypes,
|
||||
data::MobData,
|
||||
spawn_rules::{self, Dimension, Reason, SpawnLevel},
|
||||
},
|
||||
};
|
||||
use horizon_core::random::{LegacyRandom, RandomSource};
|
||||
use horizon_world::{chunk::BlockState, registry::BlockStateId};
|
||||
use std::{
|
||||
collections::{BTreeMap, HashMap, HashSet},
|
||||
fmt::Write as _,
|
||||
fs,
|
||||
path::Path,
|
||||
};
|
||||
|
||||
struct Column<'a> {
|
||||
facts: &'a BlockFacts,
|
||||
data: &'a MobData,
|
||||
block_tags: &'a HashMap<String, HashSet<String>>,
|
||||
air: BlockStateId,
|
||||
blocks: HashMap<i32, BlockStateId>,
|
||||
x0: i32,
|
||||
z0: i32,
|
||||
sky: i32,
|
||||
light: i32,
|
||||
darken: i32,
|
||||
thunder: bool,
|
||||
sea: i32,
|
||||
surface: i32,
|
||||
seed: i64,
|
||||
peaceful: bool,
|
||||
dimension: Dimension,
|
||||
biome: String,
|
||||
slime: f32,
|
||||
/// A player position and whether it is survival or adventure.
|
||||
player: Option<([f64; 3], bool)>,
|
||||
}
|
||||
impl SpawnLevel for Column<'_> {
|
||||
fn state(&self, x: i32, y: i32, z: i32) -> BlockStateId {
|
||||
if x != self.x0 || z != self.z0 {
|
||||
return self.air;
|
||||
}
|
||||
self.blocks.get(&y).copied().unwrap_or(self.air)
|
||||
}
|
||||
fn facts(&self) -> &BlockFacts {
|
||||
self.facts
|
||||
}
|
||||
fn mob_data(&self) -> &MobData {
|
||||
self.data
|
||||
}
|
||||
fn block_tag(&self, tag: &str, block: &str) -> bool {
|
||||
self.block_tags.get(tag).is_some_and(|s| s.contains(block))
|
||||
}
|
||||
fn biome_tag(&self, tag: &str, _: i32, _: i32, _: i32) -> bool {
|
||||
self.data
|
||||
.biome_tags
|
||||
.get(tag)
|
||||
.is_some_and(|s| s.contains(&self.biome))
|
||||
}
|
||||
fn sky_light(&self, _: i32, _: i32, _: i32) -> i32 {
|
||||
self.sky
|
||||
}
|
||||
fn block_light(&self, _: i32, _: i32, _: i32) -> i32 {
|
||||
self.light
|
||||
}
|
||||
fn sky_darken(&self) -> i32 {
|
||||
self.darken
|
||||
}
|
||||
fn thundering(&self) -> bool {
|
||||
self.thunder
|
||||
}
|
||||
fn sea_level(&self) -> i32 {
|
||||
self.sea
|
||||
}
|
||||
fn world_surface(&self, _: i32, _: i32) -> i32 {
|
||||
self.surface
|
||||
}
|
||||
fn peaceful(&self) -> bool {
|
||||
self.peaceful
|
||||
}
|
||||
fn dimension(&self) -> Dimension {
|
||||
self.dimension
|
||||
}
|
||||
fn surface_slime_chance(&self, _: i32, _: i32, _: i32) -> f32 {
|
||||
self.slime
|
||||
}
|
||||
fn world_seed(&self) -> i64 {
|
||||
self.seed
|
||||
}
|
||||
fn player_near(&self, x: f64, y: f64, z: f64, distance: f64) -> bool {
|
||||
self.player.is_some_and(|(p, targetable)| {
|
||||
let d = (p[0] - x).powi(2) + (p[1] - y).powi(2) + (p[2] - z).powi(2);
|
||||
targetable && d < distance * distance
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// `name[key=value,...]`.
|
||||
fn parse_state(spec: &str) -> BlockState {
|
||||
let (name, props) = match spec.split_once('[') {
|
||||
Some((n, rest)) => (n, rest.trim_end_matches(']')),
|
||||
None => (spec, ""),
|
||||
};
|
||||
let properties: BTreeMap<String, String> = props
|
||||
.split(',')
|
||||
.filter(|p| !p.is_empty())
|
||||
.filter_map(|p| p.split_once('='))
|
||||
.map(|(k, v)| (k.to_owned(), v.to_owned()))
|
||||
.collect();
|
||||
BlockState {
|
||||
name: name.to_owned(),
|
||||
properties,
|
||||
}
|
||||
}
|
||||
|
||||
fn reason(name: &str) -> Reason {
|
||||
match name {
|
||||
"chunk_generation" => Reason::ChunkGeneration,
|
||||
"spawner" => Reason::Spawner,
|
||||
"trial_spawner" => Reason::TrialSpawner,
|
||||
"reinforcement" => Reason::Reinforcement,
|
||||
"patrol" => Reason::Patrol,
|
||||
"structure" => Reason::Structure,
|
||||
"event" => Reason::Event,
|
||||
"command" => Reason::Command,
|
||||
_ => Reason::Natural,
|
||||
}
|
||||
}
|
||||
|
||||
fn main() -> horizon_client::Result<()> {
|
||||
let args: Vec<String> = std::env::args().collect();
|
||||
let root = Path::new(
|
||||
args.get(1)
|
||||
.ok_or("usage: spawn_rules_fixture RESOURCES INPUT OUTPUT")?,
|
||||
);
|
||||
let input = fs::read_to_string(args.get(2).ok_or("missing INPUT")?)?;
|
||||
let assets = Assets::load(root)?;
|
||||
let registry = assets.registry;
|
||||
let facts = BlockFacts::load(root, registry)?;
|
||||
let data = MobData::load(root, &facts);
|
||||
let items = Items::tags_only(root)?;
|
||||
let types = MobTypes::load(root)?;
|
||||
let mut out = String::new();
|
||||
for raw in input.lines() {
|
||||
if raw.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
let mut c = BTreeMap::new();
|
||||
let mut name = "";
|
||||
for token in raw.split_whitespace() {
|
||||
match token.split_once('=') {
|
||||
Some((k, v)) => {
|
||||
c.insert(k, v);
|
||||
}
|
||||
None => name = token,
|
||||
}
|
||||
}
|
||||
let get =
|
||||
|k: &str, d: &'static str| -> String { c.get(k).copied().unwrap_or(d).to_owned() };
|
||||
let Some(kind) = types.types.get(c["type"]) else {
|
||||
let _ = writeln!(out, "{name} error unsupported {}", c["type"]);
|
||||
continue;
|
||||
};
|
||||
let y: i32 = get("y", "64").parse()?;
|
||||
let (x0, z0): (i32, i32) = (get("x", "8").parse()?, get("z", "8").parse()?);
|
||||
let mut blocks = HashMap::new();
|
||||
for (k, v) in &c {
|
||||
if let Some(offset) = k.strip_prefix("at") {
|
||||
let offset: i32 = offset.parse()?;
|
||||
blocks.insert(y + offset, registry.resolve(&parse_state(v))?);
|
||||
}
|
||||
}
|
||||
let player = c.get("player").map(|p| {
|
||||
let f: Vec<&str> = p.split(',').collect();
|
||||
let at = |i: usize, base: i32| f64::from(base) + f[i].parse::<f64>().unwrap_or(0.0);
|
||||
let mode = f.get(3).copied().unwrap_or("survival");
|
||||
(
|
||||
[at(0, x0), at(1, y), at(2, z0)],
|
||||
mode == "survival" || mode == "adventure",
|
||||
)
|
||||
});
|
||||
let level = Column {
|
||||
facts: &facts,
|
||||
data: &data,
|
||||
block_tags: &items.block_tags,
|
||||
air: facts.air,
|
||||
blocks,
|
||||
x0,
|
||||
z0,
|
||||
sky: get("sky", "15").parse()?,
|
||||
light: get("light", "0").parse()?,
|
||||
darken: get("darken", "0").parse()?,
|
||||
thunder: get("thunder", "0") == "1",
|
||||
sea: get("sea", "63").parse()?,
|
||||
surface: get("surface", "0").parse()?,
|
||||
seed: get("wseed", "0").parse()?,
|
||||
peaceful: get("difficulty", "normal") == "peaceful",
|
||||
dimension: match get("dim", "minecraft:overworld").as_str() {
|
||||
"minecraft:the_nether" => Dimension::Nether,
|
||||
"minecraft:the_end" => Dimension::End,
|
||||
_ => Dimension::Overworld,
|
||||
},
|
||||
biome: get("biome", "minecraft:plains"),
|
||||
slime: get("slime", "0.0").parse()?,
|
||||
player,
|
||||
};
|
||||
let mut random = LegacyRandom::new(get("seed", "0").parse()?);
|
||||
let s = kind.species;
|
||||
let ok = spawn_rules::is_spawn_position_ok(s, &level, x0, y, z0);
|
||||
let rules = spawn_rules::check_spawn_rules(
|
||||
s,
|
||||
&level,
|
||||
reason(&get("reason", "natural")),
|
||||
&mut random,
|
||||
x0,
|
||||
y,
|
||||
z0,
|
||||
);
|
||||
let next = random.next_int_bound(1000).unwrap_or(0);
|
||||
let _ = writeln!(out, "{name} {ok} {rules} {next}");
|
||||
}
|
||||
fs::write(args.get(3).ok_or("missing OUTPUT")?, out)?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
#![forbid(unsafe_code)]
|
||||
//! Runs tools/test_toggle_keys_parity.py's event sequences through
|
||||
//! `ToggleKeys`, the sneak/sprint/attack/use toggle mappings.
|
||||
use horizon_client::{
|
||||
Result,
|
||||
keymapping::{KeyAction, ToggleKeys},
|
||||
};
|
||||
use serde_json::Value;
|
||||
use std::{
|
||||
fs::File,
|
||||
io::{BufRead, BufReader, BufWriter, Write},
|
||||
};
|
||||
|
||||
fn main() -> Result<()> {
|
||||
let args: Vec<String> = std::env::args().collect();
|
||||
let input = BufReader::new(File::open(&args[1])?);
|
||||
let mut out = BufWriter::new(File::create(&args[2])?);
|
||||
for (n, line) in input.lines().enumerate() {
|
||||
let c: Value = serde_json::from_str(&line?)?;
|
||||
writeln!(out, "== {n}")?;
|
||||
let mut keys = ToggleKeys::default();
|
||||
let mut options = [false; 4];
|
||||
for op in c["ops"].as_array().unwrap() {
|
||||
let name = op[0].as_str().unwrap();
|
||||
let slot = || op[1].as_u64().unwrap() as usize;
|
||||
match name {
|
||||
"option" => options[slot()] = op[2].as_bool().unwrap(),
|
||||
"key" => {
|
||||
let action = match op[2].as_i64().unwrap() {
|
||||
0 => KeyAction::Release,
|
||||
1 => KeyAction::Press,
|
||||
_ => KeyAction::Repeat,
|
||||
};
|
||||
keys.key(slot(), action, op[3].as_bool().unwrap(), options[slot()]);
|
||||
}
|
||||
"button" => keys.button(
|
||||
slot(),
|
||||
op[2].as_bool().unwrap(),
|
||||
op[3].as_bool().unwrap(),
|
||||
options[slot()],
|
||||
),
|
||||
"open" => keys.screen_opened(options),
|
||||
"close" => keys.screen_closed(options),
|
||||
"respawn" => keys.respawn(),
|
||||
other => panic!("{other}"),
|
||||
}
|
||||
let mut line = name.to_string();
|
||||
for k in &keys.0 {
|
||||
line += &format!(" {} {}", k.is_down, k.pending_clicks());
|
||||
}
|
||||
writeln!(out, "{line}")?;
|
||||
}
|
||||
}
|
||||
out.flush()?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
//! The player's ambient sound handlers: `BiomeAmbientSoundsHandler` (biome
|
||||
//! loops with 40-tick cross-fades, random additions, cave mood) driven by the
|
||||
//! `minecraft:audio/ambient_sounds` attribute, `UnderwaterAmbientSoundHandler`
|
||||
//! and the underwater loop (`UnderLiquidAmbientSoundInstance`).
|
||||
//! and `LocalPlayer.updateIsUnderwater` (enter/exit sounds and the
|
||||
//! `UnderLiquidAmbientSoundInstance` loop), played through the sound engine.
|
||||
use crate::sound_engine::{AMBIENT, InstanceId, PLAYERS, SoundEngine, SoundInstance, Sounds};
|
||||
use horizon_core::random::{LegacyRandom, RandomSource};
|
||||
use serde_json::Value;
|
||||
use std::{collections::HashMap, fs, path::Path};
|
||||
@@ -80,87 +82,83 @@ impl AmbientTables {
|
||||
.unwrap_or_default()
|
||||
}
|
||||
}
|
||||
/// The sound engine side of the handlers.
|
||||
pub trait AmbientOutput {
|
||||
/// A relative tickable instance; None when it could not start.
|
||||
fn play_tracked(&mut self, event: &str, volume: f32, looping: bool) -> Option<u64>;
|
||||
fn set_volume(&mut self, id: u64, volume: f32);
|
||||
fn stop(&mut self, id: u64);
|
||||
fn is_active(&self, id: u64) -> bool;
|
||||
/// A positional one-shot (`SimpleSoundInstance`, linear attenuation).
|
||||
fn play_at(&mut self, event: &str, at: [f64; 3]);
|
||||
}
|
||||
impl AmbientOutput for crate::audio::Audio {
|
||||
fn play_tracked(&mut self, event: &str, volume: f32, looping: bool) -> Option<u64> {
|
||||
crate::audio::Audio::play_tracked(self, event, volume, looping)
|
||||
}
|
||||
fn set_volume(&mut self, id: u64, volume: f32) {
|
||||
self.set_tracked_volume(id, volume);
|
||||
}
|
||||
fn stop(&mut self, id: u64) {
|
||||
self.stop_tracked(id);
|
||||
}
|
||||
fn is_active(&self, id: u64) -> bool {
|
||||
self.tracked_active(id)
|
||||
}
|
||||
fn play_at(&mut self, event: &str, at: [f64; 3]) {
|
||||
self.play(event, Some(at), 1.0, 1.0);
|
||||
}
|
||||
}
|
||||
/// `BiomeAmbientSoundsHandler.LoopSoundInstance`.
|
||||
struct LoopSound {
|
||||
id: Option<u64>,
|
||||
fade: i32,
|
||||
direction: i32,
|
||||
stopped: bool,
|
||||
}
|
||||
impl LoopSound {
|
||||
fn tick(&mut self) -> f32 {
|
||||
if self.fade < 0 {
|
||||
self.stopped = true;
|
||||
}
|
||||
self.fade += self.direction;
|
||||
(self.fade as f32 / 40.0).clamp(0.0, 1.0)
|
||||
}
|
||||
fn fade_out(&mut self) {
|
||||
self.fade = self.fade.min(40);
|
||||
self.direction = -1;
|
||||
}
|
||||
fn fade_in(&mut self) {
|
||||
self.fade = self.fade.max(0);
|
||||
self.direction = 1;
|
||||
}
|
||||
}
|
||||
/// What the handlers read about the player and level each tick.
|
||||
pub struct AmbientContext<'a> {
|
||||
/// `AMBIENT_SOUNDS` at the player's position.
|
||||
pub sounds: &'a AmbientSounds,
|
||||
/// Feet and eye position.
|
||||
/// The player's position (feet) and eye height.
|
||||
pub position: [f64; 3],
|
||||
pub eye_y: f64,
|
||||
/// `isEyeInFluid(WATER)` this tick (`Entity.updateIsUnderwater`).
|
||||
pub underwater: bool,
|
||||
/// Raw (sky, block) light at a block.
|
||||
pub spectator: bool,
|
||||
/// `Level.getBrightness` (sky, block) at a block.
|
||||
pub light: &'a dyn Fn([i32; 3]) -> (u8, u8),
|
||||
/// The bubble column in the player's box (its `drag` property), from
|
||||
/// `bubble_column_in`.
|
||||
pub bubble_column: Option<bool>,
|
||||
}
|
||||
/// `BubbleColumnAmbientSoundHandler`'s lookup: the first bubble column
|
||||
/// (x fastest, then y, then z) in the player's bounding box inflated by
|
||||
/// (0, -0.4F, 0) and deflated by 1.0E-6, as `drag_down`. `state` gives the
|
||||
/// bubble column at a block (None for anything else).
|
||||
pub fn bubble_column_in(bb: [f64; 6], state: impl Fn([i32; 3]) -> Option<bool>) -> Option<bool> {
|
||||
// AABB keeps min <= max on every construction.
|
||||
let make = |a: [f64; 6]| {
|
||||
[
|
||||
a[0].min(a[3]),
|
||||
a[1].min(a[4]),
|
||||
a[2].min(a[5]),
|
||||
a[0].max(a[3]),
|
||||
a[1].max(a[4]),
|
||||
a[2].max(a[5]),
|
||||
]
|
||||
};
|
||||
let inflate = |b: [f64; 6], x: f64, y: f64, z: f64| {
|
||||
make([b[0] - x, b[1] - y, b[2] - z, b[3] + x, b[4] + y, b[5] + z])
|
||||
};
|
||||
let b = inflate(make(bb), 0.0, f64::from(-0.4f32), 0.0);
|
||||
let b = inflate(b, -1.0E-6, -1.0E-6, -1.0E-6);
|
||||
let (x0, y0, z0) = (
|
||||
b[0].floor() as i32,
|
||||
b[1].floor() as i32,
|
||||
b[2].floor() as i32,
|
||||
);
|
||||
let (x1, y1, z1) = (
|
||||
b[3].floor() as i32,
|
||||
b[4].floor() as i32,
|
||||
b[5].floor() as i32,
|
||||
);
|
||||
for z in z0..=z1 {
|
||||
for y in y0..=y1 {
|
||||
for x in x0..=x1 {
|
||||
if let Some(drag) = state([x, y, z]) {
|
||||
return Some(drag);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
/// The local player's ambient sound handlers.
|
||||
pub struct Ambience {
|
||||
/// The client level's random.
|
||||
random: LegacyRandom,
|
||||
loops: Vec<(String, LoopSound)>,
|
||||
pub random: LegacyRandom,
|
||||
/// `BiomeAmbientSoundsHandler.loopSounds` (insertion order).
|
||||
loops: Vec<(String, InstanceId)>,
|
||||
previous_loop: Option<String>,
|
||||
pub moodiness: f32,
|
||||
/// `UnderwaterAmbientSoundHandler.tickDelay`.
|
||||
underwater_delay: i32,
|
||||
/// The underwater loop instance and its fade.
|
||||
underwater_loop: Option<(u64, i32)>,
|
||||
/// UnderLiquidSubSound instances (stop when the player leaves the water).
|
||||
sub_sounds: Vec<u64>,
|
||||
/// `LocalPlayer.wasUnderwater`.
|
||||
was_underwater: bool,
|
||||
/// `BubbleColumnAmbientSoundHandler.wasInBubbleColumn` / `firstTick`.
|
||||
was_in_bubble_column: bool,
|
||||
first_tick: bool,
|
||||
}
|
||||
impl Default for Ambience {
|
||||
fn default() -> Self {
|
||||
let seed = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map_or(0, |d| d.as_nanos() as i64);
|
||||
Self::new(seed)
|
||||
Self::new(crate::sound_engine::unique_seed())
|
||||
}
|
||||
}
|
||||
impl Ambience {
|
||||
@@ -171,53 +169,95 @@ impl Ambience {
|
||||
previous_loop: None,
|
||||
moodiness: 0.0,
|
||||
underwater_delay: 0,
|
||||
underwater_loop: None,
|
||||
sub_sounds: Vec::new(),
|
||||
was_underwater: false,
|
||||
was_in_bubble_column: false,
|
||||
first_tick: true,
|
||||
}
|
||||
}
|
||||
/// Stop everything (leaving the world).
|
||||
pub fn clear(&mut self, out: &mut impl AmbientOutput) {
|
||||
for (_, l) in self.loops.drain(..) {
|
||||
if let Some(id) = l.id {
|
||||
out.stop(id);
|
||||
}
|
||||
}
|
||||
if let Some((id, _)) = self.underwater_loop.take() {
|
||||
out.stop(id);
|
||||
}
|
||||
for id in self.sub_sounds.drain(..) {
|
||||
out.stop(id);
|
||||
/// `LocalPlayer.isUnderWater`.
|
||||
pub fn was_underwater(&self) -> bool {
|
||||
self.was_underwater
|
||||
}
|
||||
/// `BiomeAmbientSoundsHandler.loopSounds`.
|
||||
pub fn loops(&self) -> &[(String, InstanceId)] {
|
||||
&self.loops
|
||||
}
|
||||
/// `UnderwaterAmbientSoundHandler.tickDelay`.
|
||||
pub fn underwater_delay(&self) -> i32 {
|
||||
self.underwater_delay
|
||||
}
|
||||
/// A new player (leaving or changing worlds): fresh handlers.
|
||||
pub fn clear(&mut self, sounds: &mut impl Sounds) {
|
||||
for (_, id) in self.loops.drain(..) {
|
||||
sounds.release(id);
|
||||
}
|
||||
self.previous_loop = None;
|
||||
self.moodiness = 0.0;
|
||||
self.underwater_delay = 0;
|
||||
self.was_underwater = false;
|
||||
self.was_in_bubble_column = false;
|
||||
self.first_tick = true;
|
||||
}
|
||||
/// `LocalPlayer.updateIsUnderwater` enter/exit sounds and loop, then
|
||||
/// `UnderwaterAmbientSoundHandler`, `BubbleColumn` (absent) and
|
||||
/// `BiomeAmbientSoundsHandler` ticks, then the tickable instances.
|
||||
pub fn tick(&mut self, ctx: &AmbientContext, out: &mut impl AmbientOutput) {
|
||||
let feet = ctx.position;
|
||||
if !self.was_underwater && ctx.underwater {
|
||||
out.play_at("minecraft:ambient.underwater.enter", feet);
|
||||
if let Some((id, _)) = self.underwater_loop.take() {
|
||||
out.stop(id);
|
||||
}
|
||||
self.underwater_loop = out
|
||||
.play_tracked("minecraft:ambient.underwater.loop", 0.0, true)
|
||||
.map(|id| (id, 0));
|
||||
}
|
||||
if self.was_underwater && !ctx.underwater {
|
||||
out.play_at("minecraft:ambient.underwater.exit", feet);
|
||||
}
|
||||
/// `ClientLevel.playLocalSound(x, y, z, sound, source, 1, 1, false)`.
|
||||
fn play_local(&mut self, event: &str, source: usize, at: [f64; 3], sounds: &mut impl Sounds) {
|
||||
let seed = self.random.next_long();
|
||||
let instance = SoundInstance::at(event, source, 1.0, 1.0, LegacyRandom::new(seed), at);
|
||||
sounds.play_instance(instance, false);
|
||||
}
|
||||
/// `LocalPlayer.updateIsUnderwater`, then the `ambientSoundHandlers`
|
||||
/// (underwater, bubble column, biome) as `LocalPlayer.tick` runs them.
|
||||
pub fn tick(&mut self, ctx: &AmbientContext, sounds: &mut impl Sounds) {
|
||||
let old = self.was_underwater;
|
||||
self.was_underwater = ctx.underwater;
|
||||
self.tick_underwater(ctx, out);
|
||||
self.tick_biome(ctx, out);
|
||||
self.tick_instances(ctx, out);
|
||||
if !ctx.spectator {
|
||||
if !old && ctx.underwater {
|
||||
self.play_local(
|
||||
"minecraft:ambient.underwater.enter",
|
||||
AMBIENT,
|
||||
ctx.position,
|
||||
sounds,
|
||||
);
|
||||
let instance = SoundInstance::under_liquid_loop(
|
||||
"minecraft:ambient.underwater.loop",
|
||||
SoundEngine::unseeded(),
|
||||
);
|
||||
sounds.play_instance(instance, false);
|
||||
}
|
||||
if old && !ctx.underwater {
|
||||
self.play_local(
|
||||
"minecraft:ambient.underwater.exit",
|
||||
AMBIENT,
|
||||
ctx.position,
|
||||
sounds,
|
||||
);
|
||||
}
|
||||
}
|
||||
self.tick_underwater(sounds);
|
||||
self.tick_bubble_column(ctx, sounds);
|
||||
self.tick_biome(ctx, sounds);
|
||||
}
|
||||
fn tick_underwater(&mut self, ctx: &AmbientContext, out: &mut impl AmbientOutput) {
|
||||
/// `BubbleColumnAmbientSoundHandler.tick`: entering a column plays its
|
||||
/// inside sound (`LocalPlayer.playSound`, on PLAYERS).
|
||||
fn tick_bubble_column(&mut self, ctx: &AmbientContext, sounds: &mut impl Sounds) {
|
||||
if let Some(drag_down) = ctx.bubble_column {
|
||||
if !self.was_in_bubble_column && !self.first_tick && !ctx.spectator {
|
||||
let event = if drag_down {
|
||||
"minecraft:block.bubble_column.whirlpool_inside"
|
||||
} else {
|
||||
"minecraft:block.bubble_column.upwards_inside"
|
||||
};
|
||||
self.play_local(event, PLAYERS, ctx.position, sounds);
|
||||
}
|
||||
self.was_in_bubble_column = true;
|
||||
} else {
|
||||
self.was_in_bubble_column = false;
|
||||
}
|
||||
self.first_tick = false;
|
||||
}
|
||||
/// `UnderwaterAmbientSoundHandler.tick`.
|
||||
fn tick_underwater(&mut self, sounds: &mut impl Sounds) {
|
||||
self.underwater_delay -= 1;
|
||||
if self.underwater_delay <= 0 && ctx.underwater {
|
||||
if self.underwater_delay <= 0 && self.was_underwater {
|
||||
let rand = self.random.next_float();
|
||||
let sound = if rand < 1.0E-4 {
|
||||
Some("minecraft:ambient.underwater.loop.additions.ultra_rare")
|
||||
@@ -230,147 +270,121 @@ impl Ambience {
|
||||
};
|
||||
if let Some(sound) = sound {
|
||||
self.underwater_delay = 0;
|
||||
if let Some(id) = out.play_tracked(sound, 1.0, false) {
|
||||
self.sub_sounds.push(id);
|
||||
}
|
||||
let instance = SoundInstance::under_liquid_sub(sound, SoundEngine::unseeded());
|
||||
sounds.play_instance(instance, false);
|
||||
}
|
||||
}
|
||||
}
|
||||
fn tick_biome(&mut self, ctx: &AmbientContext, out: &mut impl AmbientOutput) {
|
||||
self.loops.retain(|(_, l)| !l.stopped);
|
||||
let sounds = ctx.sounds;
|
||||
if sounds.r#loop != self.previous_loop {
|
||||
self.previous_loop = sounds.r#loop.clone();
|
||||
for (_, l) in &mut self.loops {
|
||||
l.fade_out();
|
||||
/// `BiomeAmbientSoundsHandler.tick`.
|
||||
fn tick_biome(&mut self, ctx: &AmbientContext, sounds: &mut impl Sounds) {
|
||||
let mut kept = Vec::new();
|
||||
for (event, id) in std::mem::take(&mut self.loops) {
|
||||
if sounds.engine().instance(id).is_some_and(|i| i.stopped) {
|
||||
sounds.release(id);
|
||||
} else {
|
||||
kept.push((event, id));
|
||||
}
|
||||
if let Some(event) = &sounds.r#loop {
|
||||
if !self.loops.iter().any(|(e, _)| e == event) {
|
||||
let id = out.play_tracked(event, 0.0, true);
|
||||
self.loops.push((
|
||||
event.clone(),
|
||||
LoopSound {
|
||||
id,
|
||||
fade: 0,
|
||||
direction: 0,
|
||||
stopped: false,
|
||||
},
|
||||
));
|
||||
}
|
||||
self.loops = kept;
|
||||
let current = ctx
|
||||
.sounds
|
||||
.r#loop
|
||||
.as_deref()
|
||||
.map(crate::sound_events::normalize);
|
||||
if current != self.previous_loop {
|
||||
self.previous_loop = current.clone();
|
||||
for (_, id) in &self.loops {
|
||||
if let Some(i) = sounds.engine().instance_mut(*id) {
|
||||
i.fade_out();
|
||||
}
|
||||
if let Some((_, l)) = self.loops.iter_mut().find(|(e, _)| e == event) {
|
||||
l.fade_in();
|
||||
}
|
||||
if let Some(event) = current {
|
||||
let id = match self.loops.iter().find(|(e, _)| *e == event) {
|
||||
Some((_, id)) => *id,
|
||||
None => {
|
||||
let instance = SoundInstance::biome_loop(&event, SoundEngine::unseeded());
|
||||
let id = sounds.engine().add(instance, true);
|
||||
sounds.play_id(id);
|
||||
self.loops.push((event, id));
|
||||
id
|
||||
}
|
||||
};
|
||||
if let Some(i) = sounds.engine().instance_mut(id) {
|
||||
i.fade_in();
|
||||
}
|
||||
}
|
||||
}
|
||||
for (sound, chance) in &sounds.additions {
|
||||
if self.random.next_double() < *chance
|
||||
&& let Some(id) = out.play_tracked(sound, 1.0, false)
|
||||
{
|
||||
// forAmbientAddition: a one-shot nobody ticks.
|
||||
let _ = id;
|
||||
for (sound, chance) in &ctx.sounds.additions {
|
||||
if self.random.next_double() < *chance {
|
||||
let event = crate::sound_events::normalize(sound);
|
||||
let instance = SoundInstance::for_ambient_addition(&event, SoundEngine::unseeded());
|
||||
sounds.play_instance(instance, false);
|
||||
}
|
||||
}
|
||||
let Some(mood) = &sounds.mood else { return };
|
||||
let Some(mood) = &ctx.sounds.mood else { return };
|
||||
let extent = mood.block_search_extent;
|
||||
let span = extent * 2 + 1;
|
||||
let mut offset = || self.random.next_int_bound(span).unwrap_or(0) - extent;
|
||||
let (dx, dy, dz) = (offset(), offset(), offset());
|
||||
let sample = [
|
||||
(ctx.position[0] + f64::from(dx)).floor() as i32,
|
||||
(ctx.eye_y + f64::from(dy)).floor() as i32,
|
||||
(ctx.position[2] + f64::from(dz)).floor() as i32,
|
||||
];
|
||||
let x = ctx.position[0] + f64::from(self.random.next_int_bound(span).unwrap_or(0))
|
||||
- f64::from(extent);
|
||||
let y = ctx.eye_y + f64::from(self.random.next_int_bound(span).unwrap_or(0))
|
||||
- f64::from(extent);
|
||||
let z = ctx.position[2] + f64::from(self.random.next_int_bound(span).unwrap_or(0))
|
||||
- f64::from(extent);
|
||||
let sample = [x.floor() as i32, y.floor() as i32, z.floor() as i32];
|
||||
let (sky, block) = (ctx.light)(sample);
|
||||
if sky > 0 {
|
||||
self.moodiness -= f32::from(sky) / 15.0 * 0.001;
|
||||
} else {
|
||||
self.moodiness -= (f32::from(block) - 1.0) / mood.tick_delay as f32;
|
||||
self.moodiness -= (i32::from(block) - 1) as f32 / mood.tick_delay as f32;
|
||||
}
|
||||
if self.moodiness >= 1.0 {
|
||||
let d = [
|
||||
f64::from(sample[0]) + 0.5 - ctx.position[0],
|
||||
f64::from(sample[1]) + 0.5 - ctx.eye_y,
|
||||
f64::from(sample[2]) + 0.5 - ctx.position[2],
|
||||
];
|
||||
let distance = (d[0] * d[0] + d[1] * d[1] + d[2] * d[2]).sqrt();
|
||||
let sample_x = f64::from(sample[0]) + 0.5;
|
||||
let sample_y = f64::from(sample[1]) + 0.5;
|
||||
let sample_z = f64::from(sample[2]) + 0.5;
|
||||
let dx = sample_x - ctx.position[0];
|
||||
let dy = sample_y - ctx.eye_y;
|
||||
let dz = sample_z - ctx.position[2];
|
||||
let distance = (dx * dx + dy * dy + dz * dz).sqrt();
|
||||
let source = distance + mood.offset;
|
||||
out.play_at(
|
||||
&mood.sound,
|
||||
[
|
||||
ctx.position[0] + d[0] / distance * source,
|
||||
ctx.eye_y + d[1] / distance * source,
|
||||
ctx.position[2] + d[2] / distance * source,
|
||||
],
|
||||
);
|
||||
let at = [
|
||||
ctx.position[0] + dx / distance * source,
|
||||
ctx.eye_y + dy / distance * source,
|
||||
ctx.position[2] + dz / distance * source,
|
||||
];
|
||||
// The mood sound draws from the level random itself.
|
||||
let event = crate::sound_events::normalize(&mood.sound);
|
||||
let instance = SoundInstance::for_ambient_mood(&event, self.random.clone(), at);
|
||||
let id = sounds.engine().add(instance, true);
|
||||
sounds.play_id(id);
|
||||
if let Some(i) = sounds.engine().instance(id) {
|
||||
self.random = i.random.clone();
|
||||
}
|
||||
sounds.release(id);
|
||||
self.moodiness = 0.0;
|
||||
} else {
|
||||
self.moodiness = self.moodiness.max(0.0);
|
||||
}
|
||||
}
|
||||
/// The tickable instances' own ticks (SoundEngine.tickInGameSound).
|
||||
fn tick_instances(&mut self, ctx: &AmbientContext, out: &mut impl AmbientOutput) {
|
||||
for (_, l) in &mut self.loops {
|
||||
let volume = l.tick();
|
||||
if let Some(id) = l.id {
|
||||
if l.stopped {
|
||||
out.stop(id);
|
||||
} else {
|
||||
out.set_volume(id, volume);
|
||||
}
|
||||
}
|
||||
}
|
||||
// UnderLiquidAmbientSoundInstance: +1 per tick under water, -2 out of it.
|
||||
if let Some((id, fade)) = self.underwater_loop.as_mut() {
|
||||
if *fade >= 0 {
|
||||
*fade += if ctx.underwater { 1 } else { -2 };
|
||||
*fade = (*fade).min(40);
|
||||
out.set_volume(*id, (*fade as f32 / 40.0).clamp(0.0, 1.0));
|
||||
} else {
|
||||
out.stop(*id);
|
||||
self.underwater_loop = None;
|
||||
}
|
||||
}
|
||||
// UnderLiquidSubSound: stops when the player leaves the water.
|
||||
self.sub_sounds.retain(|&id| {
|
||||
let keep = ctx.underwater && out.is_active(id);
|
||||
if !keep {
|
||||
out.stop(id);
|
||||
}
|
||||
keep
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::sound_engine::{Bound, TickContext, test_backend::Null};
|
||||
use crate::sound_events::SoundRegistry;
|
||||
|
||||
#[derive(Default)]
|
||||
struct Fake {
|
||||
next: u64,
|
||||
live: HashMap<u64, (String, f32)>,
|
||||
at: Vec<String>,
|
||||
}
|
||||
impl AmbientOutput for Fake {
|
||||
fn play_tracked(&mut self, event: &str, volume: f32, _looping: bool) -> Option<u64> {
|
||||
self.next += 1;
|
||||
self.live.insert(self.next, (event.to_owned(), volume));
|
||||
Some(self.next)
|
||||
}
|
||||
fn set_volume(&mut self, id: u64, volume: f32) {
|
||||
if let Some(v) = self.live.get_mut(&id) {
|
||||
v.1 = volume;
|
||||
}
|
||||
}
|
||||
fn stop(&mut self, id: u64) {
|
||||
self.live.remove(&id);
|
||||
}
|
||||
fn is_active(&self, id: u64) -> bool {
|
||||
self.live.contains_key(&id)
|
||||
}
|
||||
fn play_at(&mut self, event: &str, _at: [f64; 3]) {
|
||||
self.at.push(event.to_owned());
|
||||
}
|
||||
fn engine() -> SoundEngine {
|
||||
let mut registry = SoundRegistry::default();
|
||||
registry.add_file(
|
||||
"minecraft",
|
||||
br#"{"crimson": {"sounds": ["c"]}, "warped": {"sounds": ["w"]},
|
||||
"ambient.cave": {"sounds": ["a", "b"]},
|
||||
"ambient.underwater.loop": {"sounds": ["u"]},
|
||||
"ambient.underwater.enter": {"sounds": ["e"]},
|
||||
"ambient.underwater.exit": {"sounds": ["x"]}}"#,
|
||||
&|_, _| true,
|
||||
);
|
||||
SoundEngine::new(registry)
|
||||
}
|
||||
fn ctx<'a>(
|
||||
sounds: &'a AmbientSounds,
|
||||
@@ -382,49 +396,59 @@ mod tests {
|
||||
position: [0.5, 10.0, 0.5],
|
||||
eye_y: 11.62,
|
||||
underwater,
|
||||
spectator: false,
|
||||
light,
|
||||
bubble_column: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn loops_cross_fade_over_forty_ticks() {
|
||||
let (mut engine, mut backend) = (engine(), Null::default());
|
||||
let mut a = Ambience::new(1);
|
||||
let mut out = Fake::default();
|
||||
let dark = |_: [i32; 3]| (15u8, 0u8);
|
||||
let lit = |_: [i32; 3]| (15u8, 0u8);
|
||||
let crimson = AmbientSounds {
|
||||
r#loop: Some("crimson".into()),
|
||||
r#loop: Some("minecraft:crimson".into()),
|
||||
..Default::default()
|
||||
};
|
||||
for _ in 0..40 {
|
||||
a.tick(&ctx(&crimson, false, &dark), &mut out);
|
||||
}
|
||||
assert_eq!(out.live.values().map(|v| v.1).collect::<Vec<_>>(), [1.0]);
|
||||
let warped = AmbientSounds {
|
||||
r#loop: Some("warped".into()),
|
||||
r#loop: Some("minecraft:warped".into()),
|
||||
..Default::default()
|
||||
};
|
||||
let tick =
|
||||
|a: &mut Ambience, s: &AmbientSounds, engine: &mut SoundEngine, backend: &mut Null| {
|
||||
a.tick(
|
||||
&ctx(s, false, &lit),
|
||||
&mut Bound {
|
||||
engine: &mut *engine,
|
||||
backend: &mut *backend,
|
||||
},
|
||||
);
|
||||
engine.tick(false, &TickContext::default(), backend);
|
||||
};
|
||||
for _ in 0..40 {
|
||||
tick(&mut a, &crimson, &mut engine, &mut backend);
|
||||
}
|
||||
for _ in 0..20 {
|
||||
a.tick(&ctx(&warped, false, &dark), &mut out);
|
||||
tick(&mut a, &warped, &mut engine, &mut backend);
|
||||
}
|
||||
let mut volumes: Vec<_> = out.live.values().map(|v| (v.0.clone(), v.1)).collect();
|
||||
volumes.sort_by(|x, y| x.0.cmp(&y.0));
|
||||
assert_eq!(
|
||||
volumes,
|
||||
[("crimson".to_owned(), 0.5), ("warped".to_owned(), 0.5)]
|
||||
);
|
||||
let volumes: Vec<f32> = a
|
||||
.loops
|
||||
.iter()
|
||||
.map(|(_, id)| engine.instance(*id).unwrap().volume)
|
||||
.collect();
|
||||
assert_eq!(volumes, [0.5, 0.5]);
|
||||
for _ in 0..30 {
|
||||
a.tick(&ctx(&warped, false, &dark), &mut out);
|
||||
tick(&mut a, &warped, &mut engine, &mut backend);
|
||||
}
|
||||
assert_eq!(
|
||||
out.live.values().map(|v| v.0.as_str()).collect::<Vec<_>>(),
|
||||
["warped"]
|
||||
);
|
||||
assert_eq!(a.loops.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mood_builds_in_the_dark_and_plays_a_cave_sound() {
|
||||
let (mut engine, mut backend) = (engine(), Null::default());
|
||||
engine.listening = true;
|
||||
let mut a = Ambience::new(2);
|
||||
let mut out = Fake::default();
|
||||
let cave = |_: [i32; 3]| (0u8, 0u8);
|
||||
let sounds = AmbientSounds {
|
||||
mood: Some(Mood {
|
||||
@@ -435,31 +459,19 @@ mod tests {
|
||||
}),
|
||||
..Default::default()
|
||||
};
|
||||
// Block light 0: +1/6000 per tick (float), so the sound plays around tick 6000.
|
||||
let mut ticks = 0;
|
||||
while out.at.is_empty() && ticks < 7000 {
|
||||
a.tick(&ctx(&sounds, false, &cave), &mut out);
|
||||
while engine.notifications.is_empty() && ticks < 7000 {
|
||||
a.tick(
|
||||
&ctx(&sounds, false, &cave),
|
||||
&mut Bound {
|
||||
engine: &mut engine,
|
||||
backend: &mut backend,
|
||||
},
|
||||
);
|
||||
ticks += 1;
|
||||
}
|
||||
assert!((5990..=6010).contains(&ticks), "{ticks}");
|
||||
assert_eq!(out.at, ["minecraft:ambient.cave"]);
|
||||
assert_eq!(engine.notifications[0].identifier, "minecraft:ambient.cave");
|
||||
assert_eq!(a.moodiness, 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn entering_water_plays_enter_and_fades_the_loop_in() {
|
||||
let mut a = Ambience::new(3);
|
||||
let mut out = Fake::default();
|
||||
let lit = |_: [i32; 3]| (15u8, 0u8);
|
||||
let none = AmbientSounds::default();
|
||||
a.tick(&ctx(&none, true, &lit), &mut out);
|
||||
assert_eq!(out.at, ["minecraft:ambient.underwater.enter"]);
|
||||
let id = a.underwater_loop.unwrap().0;
|
||||
assert_eq!(out.live[&id].1, 1.0 / 40.0);
|
||||
a.tick(&ctx(&none, false, &lit), &mut out);
|
||||
assert_eq!(out.at.last().unwrap(), "minecraft:ambient.underwater.exit");
|
||||
// Fade 1 -> -1 stops on the next tick.
|
||||
a.tick(&ctx(&none, false, &lit), &mut out);
|
||||
assert!(!out.live.contains_key(&id));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,464 @@
|
||||
//! Atlas sprite sources (`SpriteSourceList`): the `atlases/*.json`
|
||||
//! definitions listing which sprites an atlas holds. `directory`, `single`,
|
||||
//! `filter`, `unstitch` and `paletted_permutations` sources run in order
|
||||
//! against the resource tree; a later source replaces an earlier sprite of
|
||||
//! the same id and a filter removes the matching ones.
|
||||
use crate::packs::pattern::Pattern;
|
||||
use horizon_assets::palette::apply_palette;
|
||||
use serde_json::Value;
|
||||
use std::{
|
||||
collections::BTreeMap,
|
||||
fs,
|
||||
path::{Path, PathBuf},
|
||||
};
|
||||
|
||||
/// One region of an `unstitch` source, in units of the divisors.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Region {
|
||||
pub sprite: String,
|
||||
pub x: f64,
|
||||
pub y: f64,
|
||||
pub width: f64,
|
||||
pub height: f64,
|
||||
}
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum SpriteSource {
|
||||
Directory {
|
||||
source: String,
|
||||
prefix: String,
|
||||
},
|
||||
Single {
|
||||
resource: String,
|
||||
sprite: Option<String>,
|
||||
},
|
||||
Filter {
|
||||
namespace: Option<Pattern>,
|
||||
path: Option<Pattern>,
|
||||
},
|
||||
Unstitch {
|
||||
resource: String,
|
||||
regions: Vec<Region>,
|
||||
divisor_x: f64,
|
||||
divisor_y: f64,
|
||||
},
|
||||
PalettedPermutations {
|
||||
textures: Vec<String>,
|
||||
palette_key: String,
|
||||
permutations: BTreeMap<String, String>,
|
||||
separator: String,
|
||||
},
|
||||
}
|
||||
/// Where a listed sprite's pixels come from.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum SpriteOrigin {
|
||||
/// A whole texture (`textures/<path>.png`), by texture identifier.
|
||||
File(String),
|
||||
/// `Unstitcher.RegionInstance`: part of a texture.
|
||||
Region {
|
||||
texture: String,
|
||||
region: Region,
|
||||
divisor_x: f64,
|
||||
divisor_y: f64,
|
||||
},
|
||||
/// `PalettedSpriteSupplier`: a texture with its colours remapped.
|
||||
Paletted {
|
||||
texture: String,
|
||||
palette_key: String,
|
||||
palette: String,
|
||||
},
|
||||
}
|
||||
|
||||
/// `Identifier.parse` default namespace.
|
||||
pub fn qualified(id: &str) -> String {
|
||||
if id.contains(':') {
|
||||
id.to_owned()
|
||||
} else {
|
||||
format!("minecraft:{id}")
|
||||
}
|
||||
}
|
||||
fn string(v: &Value, key: &str) -> Option<String> {
|
||||
v.get(key)?.as_str().map(str::to_owned)
|
||||
}
|
||||
fn number(v: &Value, key: &str, default: f64) -> Option<f64> {
|
||||
match v.get(key) {
|
||||
None => Some(default),
|
||||
Some(n) => n.as_f64(),
|
||||
}
|
||||
}
|
||||
|
||||
/// `SpriteSources.FILE_CODEC`: the `sources` list of an atlas definition.
|
||||
/// A malformed source fails the whole file, as the codec does.
|
||||
pub fn decode(text: &str) -> Option<Vec<SpriteSource>> {
|
||||
let json: Value = serde_json::from_str(text).ok()?;
|
||||
json.get("sources")?
|
||||
.as_array()?
|
||||
.iter()
|
||||
.map(decode_source)
|
||||
.collect()
|
||||
}
|
||||
fn decode_source(s: &Value) -> Option<SpriteSource> {
|
||||
Some(match qualified(s.get("type")?.as_str()?).as_str() {
|
||||
"minecraft:directory" => SpriteSource::Directory {
|
||||
source: string(s, "source")?,
|
||||
prefix: string(s, "prefix")?,
|
||||
},
|
||||
"minecraft:single" => SpriteSource::Single {
|
||||
resource: qualified(&string(s, "resource")?),
|
||||
sprite: match s.get("sprite") {
|
||||
None => None,
|
||||
Some(v) => Some(qualified(v.as_str()?)),
|
||||
},
|
||||
},
|
||||
"minecraft:filter" => {
|
||||
let p = s.get("pattern")?;
|
||||
let pattern = |key: &str| -> Option<Option<Pattern>> {
|
||||
match p.get(key) {
|
||||
None => Some(None),
|
||||
Some(v) => Pattern::new(v.as_str()?).map(Some),
|
||||
}
|
||||
};
|
||||
SpriteSource::Filter {
|
||||
namespace: pattern("namespace")?,
|
||||
path: pattern("path")?,
|
||||
}
|
||||
}
|
||||
"minecraft:unstitch" => {
|
||||
let regions = s
|
||||
.get("regions")?
|
||||
.as_array()?
|
||||
.iter()
|
||||
.map(|r| {
|
||||
Some(Region {
|
||||
sprite: qualified(&string(r, "sprite")?),
|
||||
x: r.get("x")?.as_f64()?,
|
||||
y: r.get("y")?.as_f64()?,
|
||||
width: r.get("width")?.as_f64()?,
|
||||
height: r.get("height")?.as_f64()?,
|
||||
})
|
||||
})
|
||||
.collect::<Option<Vec<_>>>()?;
|
||||
if regions.is_empty() {
|
||||
return None;
|
||||
}
|
||||
SpriteSource::Unstitch {
|
||||
resource: qualified(&string(s, "resource")?),
|
||||
regions,
|
||||
divisor_x: number(s, "divisor_x", 1.0)?,
|
||||
divisor_y: number(s, "divisor_y", 1.0)?,
|
||||
}
|
||||
}
|
||||
"minecraft:paletted_permutations" => SpriteSource::PalettedPermutations {
|
||||
textures: s
|
||||
.get("textures")?
|
||||
.as_array()?
|
||||
.iter()
|
||||
.map(|t| t.as_str().map(qualified))
|
||||
.collect::<Option<_>>()?,
|
||||
palette_key: qualified(&string(s, "palette_key")?),
|
||||
permutations: s
|
||||
.get("permutations")?
|
||||
.as_object()?
|
||||
.iter()
|
||||
.map(|(k, v)| Some((k.clone(), qualified(v.as_str()?))))
|
||||
.collect::<Option<_>>()?,
|
||||
separator: match s.get("separator") {
|
||||
None => "_".to_owned(),
|
||||
Some(v) => v.as_str()?.to_owned(),
|
||||
},
|
||||
},
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
/// The texture files of a resource tree: `namespace:path` for every
|
||||
/// `assets/<namespace>/textures/<path>.png`.
|
||||
pub fn list_textures(root: &Path) -> Vec<String> {
|
||||
let mut out = Vec::new();
|
||||
let Ok(namespaces) = fs::read_dir(root.join("assets")) else {
|
||||
return out;
|
||||
};
|
||||
for ns in namespaces.flatten() {
|
||||
let namespace = ns.file_name().to_string_lossy().into_owned();
|
||||
let base = ns.path().join("textures");
|
||||
let mut stack = vec![base.clone()];
|
||||
while let Some(dir) = stack.pop() {
|
||||
let Ok(entries) = fs::read_dir(&dir) else {
|
||||
continue;
|
||||
};
|
||||
for e in entries.flatten() {
|
||||
let p = e.path();
|
||||
if p.is_dir() {
|
||||
stack.push(p);
|
||||
} else if p.extension().is_some_and(|x| x == "png")
|
||||
&& let Ok(rel) = p.strip_prefix(&base)
|
||||
{
|
||||
let rel = rel.with_extension("").to_string_lossy().replace('\\', "/");
|
||||
out.push(format!("{namespace}:{rel}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
out.sort();
|
||||
out
|
||||
}
|
||||
/// The file of a texture identifier.
|
||||
pub fn texture_path(root: &Path, texture: &str, directory: &str) -> PathBuf {
|
||||
let (namespace, path) = texture.split_once(':').unwrap_or(("minecraft", texture));
|
||||
root.join(format!("assets/{namespace}/{directory}/{path}.png"))
|
||||
}
|
||||
|
||||
/// `SpriteSourceList.list`: every sprite id of the atlas and its origin.
|
||||
/// `textures` are the available texture identifiers ([`list_textures`]).
|
||||
pub fn list(sources: &[SpriteSource], textures: &[String]) -> BTreeMap<String, SpriteOrigin> {
|
||||
let mut out = BTreeMap::new();
|
||||
let exists = |t: &str| textures.binary_search_by(|x| x.as_str().cmp(t)).is_ok();
|
||||
for source in sources {
|
||||
match source {
|
||||
SpriteSource::Directory { source, prefix } => {
|
||||
// FileToIdConverter("textures/" + source, ".png").
|
||||
let dir = format!("{source}/");
|
||||
for t in textures {
|
||||
let (namespace, path) = t.split_once(':').unwrap_or(("minecraft", t));
|
||||
if let Some(rest) = path.strip_prefix(&dir) {
|
||||
out.insert(
|
||||
format!("{namespace}:{prefix}{rest}"),
|
||||
SpriteOrigin::File(t.clone()),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
SpriteSource::Single { resource, sprite } => {
|
||||
if exists(resource) {
|
||||
out.insert(
|
||||
sprite.clone().unwrap_or_else(|| resource.clone()),
|
||||
SpriteOrigin::File(resource.clone()),
|
||||
);
|
||||
}
|
||||
}
|
||||
SpriteSource::Filter { namespace, path } => {
|
||||
out.retain(|id: &String, _| {
|
||||
let (ns, p) = id.split_once(':').unwrap_or(("minecraft", id));
|
||||
!(namespace.as_ref().is_none_or(|m| m.is_match(ns))
|
||||
&& path.as_ref().is_none_or(|m| m.is_match(p)))
|
||||
});
|
||||
}
|
||||
SpriteSource::Unstitch {
|
||||
resource,
|
||||
regions,
|
||||
divisor_x,
|
||||
divisor_y,
|
||||
} => {
|
||||
if exists(resource) {
|
||||
for region in regions {
|
||||
out.insert(
|
||||
region.sprite.clone(),
|
||||
SpriteOrigin::Region {
|
||||
texture: resource.clone(),
|
||||
region: region.clone(),
|
||||
divisor_x: *divisor_x,
|
||||
divisor_y: *divisor_y,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
SpriteSource::PalettedPermutations {
|
||||
textures: bases,
|
||||
palette_key,
|
||||
permutations,
|
||||
separator,
|
||||
} => {
|
||||
for base in bases {
|
||||
if !exists(base) {
|
||||
continue;
|
||||
}
|
||||
for (suffix, palette) in permutations {
|
||||
out.insert(
|
||||
format!("{base}{separator}{suffix}"),
|
||||
SpriteOrigin::Paletted {
|
||||
texture: base.clone(),
|
||||
palette_key: palette_key.clone(),
|
||||
palette: palette.clone(),
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// An RGBA image.
|
||||
pub type Image = (u32, u32, Vec<u8>);
|
||||
|
||||
fn argb(rgba: &[u8]) -> Vec<u32> {
|
||||
rgba.as_chunks::<4>()
|
||||
.0
|
||||
.iter()
|
||||
.map(|p| u32::from_be_bytes([p[3], p[0], p[1], p[2]]))
|
||||
.collect()
|
||||
}
|
||||
/// The pixels of a generated sprite (`Region` or `Paletted`); `None` for a
|
||||
/// plain file, which loads as itself.
|
||||
pub fn generate(
|
||||
origin: &SpriteOrigin,
|
||||
load: &dyn Fn(&str, &str) -> Option<Image>,
|
||||
) -> Option<Image> {
|
||||
match origin {
|
||||
SpriteOrigin::File(_) => None,
|
||||
SpriteOrigin::Region {
|
||||
texture,
|
||||
region,
|
||||
divisor_x,
|
||||
divisor_y,
|
||||
} => {
|
||||
let (w, h, pixels) = load(texture, "textures")?;
|
||||
let x_scale = f64::from(w) / divisor_x;
|
||||
let y_scale = f64::from(h) / divisor_y;
|
||||
let x = (region.x * x_scale).floor() as i64;
|
||||
let y = (region.y * y_scale).floor() as i64;
|
||||
let width = (region.width * x_scale).floor() as i64;
|
||||
let height = (region.height * y_scale).floor() as i64;
|
||||
if width <= 0 || height <= 0 {
|
||||
return None;
|
||||
}
|
||||
let mut out = vec![0u8; (width * height * 4) as usize];
|
||||
for row in 0..height {
|
||||
for col in 0..width {
|
||||
let (sx, sy) = (x + col, y + row);
|
||||
if sx < 0 || sy < 0 || sx >= i64::from(w) || sy >= i64::from(h) {
|
||||
continue;
|
||||
}
|
||||
let s = ((sy * i64::from(w) + sx) * 4) as usize;
|
||||
let d = ((row * width + col) * 4) as usize;
|
||||
out[d..d + 4].copy_from_slice(&pixels[s..s + 4]);
|
||||
}
|
||||
}
|
||||
Some((width as u32, height as u32, out))
|
||||
}
|
||||
SpriteOrigin::Paletted {
|
||||
texture,
|
||||
palette_key,
|
||||
palette,
|
||||
} => {
|
||||
let (w, h, pixels) = load(texture, "textures")?;
|
||||
let (_, _, key) = load(palette_key, "textures/palettes")?;
|
||||
let (_, _, target) = load(palette, "textures/palettes")?;
|
||||
let mut mapped = argb(&pixels);
|
||||
apply_palette(&mut mapped, &argb(&key), &argb(&target))?;
|
||||
let out = mapped
|
||||
.iter()
|
||||
.flat_map(|p| {
|
||||
let [a, r, g, b] = p.to_be_bytes();
|
||||
[r, g, b, a]
|
||||
})
|
||||
.collect();
|
||||
Some((w, h, out))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Every atlas definition of a resource tree (`atlases/*.json` in every
|
||||
/// namespace), by atlas id.
|
||||
pub fn atlases(root: &Path) -> BTreeMap<String, Vec<SpriteSource>> {
|
||||
let mut out = BTreeMap::new();
|
||||
let Ok(namespaces) = fs::read_dir(root.join("assets")) else {
|
||||
return out;
|
||||
};
|
||||
for ns in namespaces.flatten() {
|
||||
let namespace = ns.file_name().to_string_lossy().into_owned();
|
||||
let Ok(files) = fs::read_dir(ns.path().join("atlases")) else {
|
||||
continue;
|
||||
};
|
||||
for f in files.flatten() {
|
||||
let path = f.path();
|
||||
if path.extension().is_none_or(|e| e != "json") {
|
||||
continue;
|
||||
}
|
||||
let name = path.file_stem().unwrap_or_default().to_string_lossy();
|
||||
if let Some(sources) = fs::read_to_string(&path).ok().and_then(|t| decode(&t)) {
|
||||
out.insert(format!("{namespace}:{name}"), sources);
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn textures() -> Vec<String> {
|
||||
let mut t: Vec<String> = [
|
||||
"minecraft:item/apple",
|
||||
"minecraft:item/stick",
|
||||
"minecraft:trims/items/boots_trim",
|
||||
"minecraft:block/stone",
|
||||
"minecraft:entity/sheet",
|
||||
]
|
||||
.iter()
|
||||
.map(|s| s.to_string())
|
||||
.collect();
|
||||
t.sort();
|
||||
t
|
||||
}
|
||||
#[test]
|
||||
fn sources_run_in_order() {
|
||||
let sources = decode(
|
||||
r#"{"sources":[
|
||||
{"type":"directory","source":"item","prefix":"item/"},
|
||||
{"type":"minecraft:single","resource":"block/stone","sprite":"item/stone"},
|
||||
{"type":"filter","pattern":{"path":"stick"}},
|
||||
{"type":"unstitch","resource":"entity/sheet","divisor_x":2,"regions":[{"sprite":"a","x":1,"y":0,"width":1,"height":1}]},
|
||||
{"type":"paletted_permutations","textures":["trims/items/boots_trim"],"palette_key":"trims/color_palettes/trim_palette","permutations":{"quartz":"trims/color_palettes/quartz"}}
|
||||
]}"#,
|
||||
)
|
||||
.unwrap();
|
||||
let listed = list(&sources, &textures());
|
||||
let ids: Vec<&str> = listed.keys().map(String::as_str).collect();
|
||||
assert_eq!(
|
||||
ids,
|
||||
[
|
||||
"minecraft:a",
|
||||
"minecraft:item/apple",
|
||||
"minecraft:item/stone",
|
||||
"minecraft:trims/items/boots_trim_quartz"
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
listed["minecraft:item/stone"],
|
||||
SpriteOrigin::File("minecraft:block/stone".into())
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn malformed_sources_fail_the_file() {
|
||||
assert!(decode(r#"{"sources":[{"type":"directory","source":"item"}]}"#).is_none());
|
||||
assert!(
|
||||
decode(r#"{"sources":[{"type":"unstitch","resource":"x","regions":[]}]}"#).is_none()
|
||||
);
|
||||
assert!(decode(r#"{"sources":[{"type":"horizon"}]}"#).is_none());
|
||||
}
|
||||
#[test]
|
||||
fn unstitch_cuts_by_divisor() {
|
||||
let pixels: Vec<u8> = (0..16u8).flat_map(|i| [i, i, i, 255]).collect();
|
||||
let load = |_: &str, _: &str| Some((4u32, 4u32, pixels.clone()));
|
||||
let origin = SpriteOrigin::Region {
|
||||
texture: "t".into(),
|
||||
region: Region {
|
||||
sprite: "s".into(),
|
||||
x: 1.0,
|
||||
y: 1.0,
|
||||
width: 1.0,
|
||||
height: 1.0,
|
||||
},
|
||||
divisor_x: 2.0,
|
||||
divisor_y: 2.0,
|
||||
};
|
||||
let (w, h, out) = generate(&origin, &load).unwrap();
|
||||
assert_eq!((w, h), (2, 2));
|
||||
assert_eq!(out[0], 10);
|
||||
assert_eq!(out[4], 11);
|
||||
assert_eq!(out[8], 14);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
//! The player's attack strength (`Player.attackStrengthTicker`,
|
||||
//! `itemSwapTicker`, `getAttackStrengthScale`, `getCurrentItemAttackStrengthDelay`):
|
||||
//! the charge the attack indicator shows and attacks scale their damage by.
|
||||
|
||||
/// `Attributes.ATTACK_SPEED` base value.
|
||||
pub const BASE_ATTACK_SPEED: f64 = 4.0;
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct AttackStrength {
|
||||
/// `LivingEntity.attackStrengthTicker`.
|
||||
pub ticker: i32,
|
||||
/// `LivingEntity.itemSwapTicker`.
|
||||
pub swap_ticker: i32,
|
||||
/// `Player.lastItemInMainHand`: the stack as last compared.
|
||||
last_item: Option<HeldStack>,
|
||||
}
|
||||
/// What `ItemStack.matches` / `isSameItem` compare of the main-hand stack:
|
||||
/// its item id and the rest of the stack (count and components) as a key.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct HeldStack {
|
||||
pub item: String,
|
||||
pub rest: String,
|
||||
}
|
||||
/// `Player.getCurrentItemAttackStrengthDelay` for an ATTACK_SPEED value.
|
||||
pub fn delay(attack_speed: f64) -> f32 {
|
||||
(1.0 / attack_speed * 20.0) as f32
|
||||
}
|
||||
impl AttackStrength {
|
||||
/// The attack-strength part of `Player.tick`: both tickers advance, and a
|
||||
/// main-hand change to a different item resets them
|
||||
/// (`!ItemStack.matches` and `!ItemStack.isSameItem`).
|
||||
pub fn tick(&mut self, main_hand: Option<HeldStack>) {
|
||||
self.ticker += 1;
|
||||
self.swap_ticker += 1;
|
||||
if self.last_item != main_hand {
|
||||
let same_item = match (&self.last_item, &main_hand) {
|
||||
(Some(a), Some(b)) => a.item == b.item,
|
||||
(None, None) => true,
|
||||
_ => false,
|
||||
};
|
||||
if !same_item {
|
||||
self.reset();
|
||||
}
|
||||
self.last_item = main_hand;
|
||||
}
|
||||
}
|
||||
/// `resetAttackStrengthTicker`: swinging at nothing, attacking or
|
||||
/// stopping a block break.
|
||||
pub fn reset(&mut self) {
|
||||
self.ticker = 0;
|
||||
self.swap_ticker = 0;
|
||||
}
|
||||
/// `resetOnlyAttackStrengthTicker` (`Player.onAttack`).
|
||||
pub fn reset_attack(&mut self) {
|
||||
self.ticker = 0;
|
||||
}
|
||||
/// `getAttackStrengthScale(a)` with the ATTACK_SPEED attribute value.
|
||||
pub fn scale(&self, a: f32, attack_speed: f64) -> f32 {
|
||||
((self.ticker as f32 + a) / delay(attack_speed)).clamp(0.0, 1.0)
|
||||
}
|
||||
/// `getItemSwapScale(a)`.
|
||||
pub fn swap_scale(&self, a: f32, attack_speed: f64) -> f32 {
|
||||
((self.swap_ticker as f32 + a) / delay(attack_speed)).clamp(0.0, 1.0)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
fn held(item: &str, rest: &str) -> Option<HeldStack> {
|
||||
Some(HeldStack {
|
||||
item: item.into(),
|
||||
rest: rest.into(),
|
||||
})
|
||||
}
|
||||
#[test]
|
||||
fn charges_over_the_attack_delay() {
|
||||
let mut s = AttackStrength::default();
|
||||
// A sword's 1.6 attacks per second: 12.5 ticks to full strength.
|
||||
assert_eq!(delay(1.6), 12.5);
|
||||
for _ in 0..6 {
|
||||
s.tick(None);
|
||||
}
|
||||
assert_eq!(s.scale(0.25, 1.6), 6.25 / 12.5);
|
||||
for _ in 0..10 {
|
||||
s.tick(None);
|
||||
}
|
||||
assert_eq!(s.scale(0.0, 1.6), 1.0);
|
||||
}
|
||||
#[test]
|
||||
fn switching_items_resets_but_restacking_does_not() {
|
||||
let mut s = AttackStrength::default();
|
||||
s.tick(held("minecraft:stone", "64"));
|
||||
assert_eq!(s.ticker, 0, "picking up an item resets");
|
||||
s.tick(held("minecraft:stone", "63"));
|
||||
assert_eq!(
|
||||
s.ticker, 1,
|
||||
"the same item with another count keeps charging"
|
||||
);
|
||||
s.tick(held("minecraft:diamond_sword", ""));
|
||||
assert_eq!((s.ticker, s.swap_ticker), (0, 0));
|
||||
s.tick(held("minecraft:diamond_sword", ""));
|
||||
s.reset_attack();
|
||||
assert_eq!((s.ticker, s.swap_ticker), (0, 1));
|
||||
}
|
||||
}
|
||||
+452
-436
@@ -1,9 +1,16 @@
|
||||
//! Sound playback: `sounds.json` events with weighted variants, Ogg/Vorbis
|
||||
//! decoding, and a small mixer with vanilla's linear distance attenuation
|
||||
//! (`SoundInstance.Attenuation.LINEAR`, default range 16 blocks) and stereo panning.
|
||||
//! Sound playback: vanilla's `SoundEngine` bookkeeping (`sound_engine`) over a
|
||||
//! small mixer standing in for OpenAL: one channel per source with its gain,
|
||||
//! pitch, linear distance attenuation (`AL_LINEAR_DISTANCE`, reference 0,
|
||||
//! rolloff 1), looping, relative flag and source state, Ogg/Vorbis decoding
|
||||
//! (streams on a worker thread) and stereo panning.
|
||||
use crate::Result;
|
||||
use crate::sound_engine::{
|
||||
Attenuation, Backend, Bound, ChannelId, InstanceId, PlayResult, SoundEngine, SoundInstance,
|
||||
Sounds, TickContext,
|
||||
};
|
||||
use crate::sound_events::{Sound, SoundRegistry};
|
||||
use cpal::traits::{DeviceTrait, HostTrait, StreamTrait};
|
||||
use serde_json::Value;
|
||||
use horizon_core::random::{LegacyRandom, RandomSource};
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
fs,
|
||||
@@ -11,46 +18,32 @@ use std::{
|
||||
sync::{Arc, Mutex},
|
||||
};
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
struct Variant {
|
||||
name: String,
|
||||
volume: f32,
|
||||
pitch: f32,
|
||||
weight: u32,
|
||||
attenuation: f32,
|
||||
is_event: bool,
|
||||
/// An OpenAL source's state.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
enum State {
|
||||
Initial,
|
||||
Playing,
|
||||
Paused,
|
||||
Stopped,
|
||||
}
|
||||
struct Voice {
|
||||
samples: Arc<Vec<f32>>,
|
||||
/// An OpenAL source.
|
||||
struct Channel {
|
||||
samples: Option<(Arc<Vec<f32>>, u32)>,
|
||||
position: f64,
|
||||
step: f64,
|
||||
left: f32,
|
||||
right: f32,
|
||||
/// The sound event and `SoundSource` index (what `/stopsound` matches).
|
||||
event: Arc<str>,
|
||||
source: usize,
|
||||
state: State,
|
||||
gain: f32,
|
||||
pitch: f32,
|
||||
/// `AL_LINEAR_DISTANCE` max distance, or no attenuation.
|
||||
linear: Option<f32>,
|
||||
looping: bool,
|
||||
at: [f32; 3],
|
||||
relative: bool,
|
||||
}
|
||||
#[derive(Default)]
|
||||
struct Mixer {
|
||||
voices: Vec<Voice>,
|
||||
/// The streaming music track (`SoundSource.MUSIC`), kept apart from the
|
||||
/// voice cap and scaled by `music_gain` (MusicManager's fade).
|
||||
music: Option<Voice>,
|
||||
music_gain: f32,
|
||||
/// Tickable sound instances the game keeps a handle to (ambient loops).
|
||||
tracked: Vec<Tracked>,
|
||||
}
|
||||
struct Tracked {
|
||||
id: u64,
|
||||
voice: Voice,
|
||||
looping: bool,
|
||||
volume: f32,
|
||||
}
|
||||
/// The current music track: decoding on a worker until the voice exists.
|
||||
struct MusicTrack {
|
||||
event: String,
|
||||
/// Set by the decoder thread once the voice is in the mixer (or failed).
|
||||
ready: Arc<std::sync::atomic::AtomicBool>,
|
||||
channels: HashMap<ChannelId, Channel>,
|
||||
listener: [f64; 3],
|
||||
listener_yaw: f32,
|
||||
}
|
||||
/// A sound the player could have heard (`SoundEventListener.onPlaySound`):
|
||||
/// what the subtitle overlay needs.
|
||||
@@ -58,30 +51,31 @@ struct MusicTrack {
|
||||
pub struct Heard {
|
||||
/// The `subtitle` translation key of the sound event.
|
||||
pub subtitle: String,
|
||||
/// Where it played (None = relative to the listener, e.g. UI sounds).
|
||||
/// The instance's position (relative sounds report their raw coordinates).
|
||||
pub at: Option<[f64; 3]>,
|
||||
/// `SoundEngine.getRange`: infinite for relative and non-attenuated sounds.
|
||||
pub range: f32,
|
||||
pub looping: bool,
|
||||
}
|
||||
/// The mixer side of the engine (`Backend`).
|
||||
pub struct MixerBackend {
|
||||
mixer: Arc<Mutex<Mixer>>,
|
||||
root: PathBuf,
|
||||
cache: HashMap<String, Option<(Arc<Vec<f32>>, u32)>>,
|
||||
next: ChannelId,
|
||||
live: bool,
|
||||
}
|
||||
pub struct Audio {
|
||||
_stream: Option<cpal::Stream>,
|
||||
/// `subtitle` keys of `sounds.json` events.
|
||||
subtitles: HashMap<String, String>,
|
||||
pub engine: SoundEngine,
|
||||
backend: MixerBackend,
|
||||
/// Record `Heard` sounds (the subtitle overlay is listening).
|
||||
pub record_heard: bool,
|
||||
heard: Vec<Heard>,
|
||||
mixer: Arc<Mutex<Mixer>>,
|
||||
rate: u32,
|
||||
root: PathBuf,
|
||||
events: HashMap<String, Vec<Variant>>,
|
||||
cache: HashMap<String, Option<(Arc<Vec<f32>>, u32)>>,
|
||||
seed: u64,
|
||||
pub listener: [f64; 3],
|
||||
pub listener_yaw: f32,
|
||||
pub master: f32,
|
||||
music: Option<MusicTrack>,
|
||||
next_tracked: u64,
|
||||
/// Seeds of positional plays (the client level random's `nextLong`).
|
||||
random: LegacyRandom,
|
||||
/// A `SoundSource` index the next sound plays on (`/playsound`).
|
||||
source_override: Option<usize>,
|
||||
/// Positional sounds played since the last drain, kept while the world
|
||||
@@ -130,75 +124,247 @@ fn decode(path: &Path) -> Option<(Vec<f32>, u32)> {
|
||||
}
|
||||
Some((mono, rate))
|
||||
}
|
||||
impl Audio {
|
||||
pub fn new(root: &Path) -> Self {
|
||||
let mut events = HashMap::new();
|
||||
let mut subtitles = HashMap::new();
|
||||
if let Ok(text) = fs::read(root.join("assets/minecraft/sounds.json"))
|
||||
&& let Ok(Value::Object(map)) = serde_json::from_slice::<Value>(&text)
|
||||
impl MixerBackend {
|
||||
fn with(&self, channel: ChannelId, f: impl FnOnce(&mut Channel)) {
|
||||
if let Ok(mut m) = self.mixer.lock()
|
||||
&& let Some(c) = m.channels.get_mut(&channel)
|
||||
{
|
||||
for (name, def) in map {
|
||||
let variants = def["sounds"]
|
||||
.as_array()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.map(|s| match s {
|
||||
Value::String(n) => Variant {
|
||||
name: n.clone(),
|
||||
volume: 1.0,
|
||||
pitch: 1.0,
|
||||
weight: 1,
|
||||
attenuation: 16.0,
|
||||
is_event: false,
|
||||
},
|
||||
_ => Variant {
|
||||
name: s["name"].as_str().unwrap_or("").to_owned(),
|
||||
volume: s["volume"].as_f64().unwrap_or(1.0) as f32,
|
||||
pitch: s["pitch"].as_f64().unwrap_or(1.0) as f32,
|
||||
weight: s["weight"].as_u64().unwrap_or(1) as u32,
|
||||
attenuation: s["attenuation_distance"].as_f64().unwrap_or(16.0) as f32,
|
||||
is_event: s["type"].as_str() == Some("event"),
|
||||
},
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
if let Some(key) = def["subtitle"].as_str() {
|
||||
subtitles.insert(format!("minecraft:{name}"), key.to_owned());
|
||||
}
|
||||
events.insert(format!("minecraft:{name}"), variants);
|
||||
}
|
||||
f(c);
|
||||
}
|
||||
let mixer = Arc::new(Mutex::new(Mixer {
|
||||
music_gain: 1.0,
|
||||
..Mixer::default()
|
||||
}));
|
||||
let (stream, rate) = match Self::open(mixer.clone()) {
|
||||
Ok(v) => (Some(v.0), v.1),
|
||||
Err(e) => {
|
||||
eprintln!("Horizon: audio unavailable: {e}");
|
||||
(None, 48_000)
|
||||
}
|
||||
}
|
||||
impl Backend for MixerBackend {
|
||||
fn create(&mut self, _streaming: bool) -> Option<ChannelId> {
|
||||
self.next += 1;
|
||||
let id = self.next;
|
||||
self.mixer.lock().ok()?.channels.insert(
|
||||
id,
|
||||
Channel {
|
||||
samples: None,
|
||||
position: 0.0,
|
||||
state: State::Initial,
|
||||
gain: 1.0,
|
||||
pitch: 1.0,
|
||||
linear: None,
|
||||
looping: false,
|
||||
at: [0.0; 3],
|
||||
relative: false,
|
||||
},
|
||||
);
|
||||
Some(id)
|
||||
}
|
||||
fn destroy(&mut self, channel: ChannelId) {
|
||||
if let Ok(mut m) = self.mixer.lock() {
|
||||
m.channels.remove(&channel);
|
||||
}
|
||||
}
|
||||
fn set_pitch(&mut self, channel: ChannelId, pitch: f32) {
|
||||
self.with(channel, |c| c.pitch = pitch);
|
||||
}
|
||||
fn set_volume(&mut self, channel: ChannelId, volume: f32) {
|
||||
self.with(channel, |c| c.gain = volume);
|
||||
}
|
||||
fn set_attenuation(&mut self, channel: ChannelId, linear: Option<f32>) {
|
||||
self.with(channel, |c| c.linear = linear);
|
||||
}
|
||||
fn set_looping(&mut self, channel: ChannelId, looping: bool) {
|
||||
self.with(channel, |c| c.looping = looping);
|
||||
}
|
||||
fn set_position(&mut self, channel: ChannelId, p: [f64; 3]) {
|
||||
self.with(channel, |c| c.at = [p[0] as f32, p[1] as f32, p[2] as f32]);
|
||||
}
|
||||
fn set_relative(&mut self, channel: ChannelId, relative: bool) {
|
||||
self.with(channel, |c| c.relative = relative);
|
||||
}
|
||||
/// `getCompleteBuffer` (decoded now, cached) or `getStream` (decoded on a
|
||||
/// worker), then attach and `alSourcePlay`. A sound that cannot load
|
||||
/// leaves its source initial, as a failed buffer future does in vanilla.
|
||||
fn start(&mut self, channel: ChannelId, sound: &Sound, looping: bool) {
|
||||
if !self.live {
|
||||
return;
|
||||
}
|
||||
let (namespace, path) = sound.path();
|
||||
let file = self.root.join("assets").join(namespace).join(&path);
|
||||
let attach = move |mixer: &Mutex<Mixer>, samples: Option<(Arc<Vec<f32>>, u32)>| {
|
||||
if let Some(samples) = samples
|
||||
&& let Ok(mut m) = mixer.lock()
|
||||
&& let Some(c) = m.channels.get_mut(&channel)
|
||||
{
|
||||
c.samples = Some(samples);
|
||||
c.position = 0.0;
|
||||
c.state = State::Playing;
|
||||
}
|
||||
};
|
||||
if sound.stream {
|
||||
// A streamed sound loops by restarting its stream.
|
||||
let mixer = self.mixer.clone();
|
||||
std::thread::spawn(move || {
|
||||
let samples = decode(&file).map(|(s, r)| (Arc::new(s), r));
|
||||
if looping
|
||||
&& let Ok(mut m) = mixer.lock()
|
||||
&& let Some(c) = m.channels.get_mut(&channel)
|
||||
{
|
||||
c.looping = true;
|
||||
}
|
||||
attach(&mixer, samples);
|
||||
});
|
||||
} else {
|
||||
let samples = self
|
||||
.cache
|
||||
.entry(format!("{namespace}:{path}"))
|
||||
.or_insert_with(|| decode(&file).map(|(s, r)| (Arc::new(s), r)))
|
||||
.clone();
|
||||
attach(&self.mixer, samples);
|
||||
}
|
||||
}
|
||||
fn stop(&mut self, channel: ChannelId) {
|
||||
self.with(channel, |c| {
|
||||
if c.state != State::Initial {
|
||||
c.state = State::Stopped;
|
||||
}
|
||||
});
|
||||
}
|
||||
fn pause(&mut self, channel: ChannelId) {
|
||||
self.with(channel, |c| {
|
||||
if c.state == State::Playing {
|
||||
c.state = State::Paused;
|
||||
}
|
||||
});
|
||||
}
|
||||
fn unpause(&mut self, channel: ChannelId) {
|
||||
self.with(channel, |c| {
|
||||
if c.state == State::Paused {
|
||||
c.state = State::Playing;
|
||||
}
|
||||
});
|
||||
}
|
||||
fn stopped(&mut self, channel: ChannelId) -> bool {
|
||||
self.mixer
|
||||
.lock()
|
||||
.map(|m| {
|
||||
m.channels
|
||||
.get(&channel)
|
||||
.is_none_or(|c| c.state == State::Stopped)
|
||||
})
|
||||
.unwrap_or(true)
|
||||
}
|
||||
}
|
||||
/// The gain OpenAL applies for distance (`AL_LINEAR_DISTANCE` with reference
|
||||
/// distance 0 and rolloff 1: the distance clamps to the max distance).
|
||||
fn distance_gain(linear: Option<f32>, distance: f32) -> f32 {
|
||||
match linear {
|
||||
None => 1.0,
|
||||
Some(max) if max > 0.0 => (1.0 - distance.min(max) / max).clamp(0.0, 1.0),
|
||||
Some(_) => 0.0,
|
||||
}
|
||||
}
|
||||
fn mix(mixer: &mut Mixer, data: &mut [f32], channels: usize, rate: u32) {
|
||||
data.fill(0.0);
|
||||
let listener = mixer.listener;
|
||||
let yaw = mixer.listener_yaw.to_radians();
|
||||
// Gains and pans per source for this buffer.
|
||||
let mut voices: Vec<(ChannelId, f32, f32, f64)> = Vec::new();
|
||||
for (id, c) in &mixer.channels {
|
||||
let Some((_, source_rate)) = &c.samples else {
|
||||
continue;
|
||||
};
|
||||
if c.state != State::Playing {
|
||||
continue;
|
||||
}
|
||||
let d = if c.relative {
|
||||
[f64::from(c.at[0]), f64::from(c.at[1]), f64::from(c.at[2])]
|
||||
} else {
|
||||
[
|
||||
f64::from(c.at[0]) - listener[0],
|
||||
f64::from(c.at[1]) - listener[1],
|
||||
f64::from(c.at[2]) - listener[2],
|
||||
]
|
||||
};
|
||||
let distance = (d[0] * d[0] + d[1] * d[1] + d[2] * d[2]).sqrt() as f32;
|
||||
let gain = c.gain.clamp(0.0, 1.0) * distance_gain(c.linear, distance);
|
||||
// The listener's right vector for a Minecraft yaw.
|
||||
let side = if c.relative {
|
||||
(d[0] as f32) / distance.max(0.001)
|
||||
} else {
|
||||
(-(yaw.cos()) as f64 * d[0] - (yaw.sin()) as f64 * d[2]) as f32 / distance.max(0.001)
|
||||
};
|
||||
let pan = side.clamp(-1.0, 1.0);
|
||||
let left = gain * (1.0 - pan.max(0.0) * 0.6);
|
||||
let right = gain * (1.0 + pan.min(0.0) * 0.6);
|
||||
let step = f64::from(*source_rate) / f64::from(rate) * f64::from(c.pitch.max(0.0));
|
||||
voices.push((*id, left, right, step));
|
||||
}
|
||||
for (id, left, right, step) in voices {
|
||||
let Some(c) = mixer.channels.get_mut(&id) else {
|
||||
continue;
|
||||
};
|
||||
let Some((samples, _)) = c.samples.clone() else {
|
||||
continue;
|
||||
};
|
||||
for frame in data.chunks_mut(channels) {
|
||||
let mut i = c.position as usize;
|
||||
if i + 1 >= samples.len() {
|
||||
if c.looping && samples.len() > 1 {
|
||||
c.position = 0.0;
|
||||
i = 0;
|
||||
} else {
|
||||
c.state = State::Stopped;
|
||||
break;
|
||||
}
|
||||
}
|
||||
let t = (c.position - i as f64) as f32;
|
||||
let s = samples[i] * (1.0 - t) + samples[i + 1] * t;
|
||||
if channels >= 2 {
|
||||
frame[0] += s * left;
|
||||
frame[1] += s * right;
|
||||
} else {
|
||||
frame[0] += s * (left + right) / 2.0;
|
||||
}
|
||||
c.position += step;
|
||||
}
|
||||
}
|
||||
for s in data.iter_mut() {
|
||||
*s = s.clamp(-1.0, 1.0);
|
||||
}
|
||||
}
|
||||
impl Audio {
|
||||
pub fn new(root: &Path) -> Self {
|
||||
let registry = SoundRegistry::load(root);
|
||||
let mixer = Arc::new(Mutex::new(Mixer::default()));
|
||||
let (stream, live) = match Self::open(mixer.clone()) {
|
||||
Ok(v) => (Some(v), true),
|
||||
Err(e) => {
|
||||
eprintln!("Horizon: audio unavailable: {e}");
|
||||
(None, false)
|
||||
}
|
||||
};
|
||||
let mut engine = SoundEngine::new(registry);
|
||||
engine.loaded = live;
|
||||
Self {
|
||||
_stream: stream,
|
||||
subtitles,
|
||||
engine,
|
||||
backend: MixerBackend {
|
||||
mixer,
|
||||
root: root.to_owned(),
|
||||
cache: HashMap::new(),
|
||||
next: 0,
|
||||
live,
|
||||
},
|
||||
record_heard: false,
|
||||
heard: Vec::new(),
|
||||
mixer,
|
||||
rate,
|
||||
root: root.to_owned(),
|
||||
events,
|
||||
cache: HashMap::new(),
|
||||
seed: 0x5DEE_CE66,
|
||||
listener: [0.0; 3],
|
||||
listener_yaw: 0.0,
|
||||
master: 1.0,
|
||||
music: None,
|
||||
next_tracked: 0,
|
||||
random: LegacyRandom::new(crate::sound_engine::unique_seed()),
|
||||
source_override: None,
|
||||
capture: None,
|
||||
}
|
||||
}
|
||||
fn open(mixer: Arc<Mutex<Mixer>>) -> Result<(cpal::Stream, u32)> {
|
||||
fn open(mixer: Arc<Mutex<Mixer>>) -> Result<cpal::Stream> {
|
||||
// Test runs (harnesses, agents) launch the game on a virtual screen;
|
||||
// keep them off the user's speakers.
|
||||
if std::env::var_os("HORIZON_NO_AUDIO").is_some() {
|
||||
return Err("audio output disabled by HORIZON_NO_AUDIO".into());
|
||||
}
|
||||
let host = cpal::default_host();
|
||||
let device = host
|
||||
.default_output_device()
|
||||
@@ -209,269 +375,110 @@ impl Audio {
|
||||
let stream = device.build_output_stream(
|
||||
&config.into(),
|
||||
move |data: &mut [f32], _| {
|
||||
data.fill(0.0);
|
||||
let Ok(mut mixer) = mixer.lock() else { return };
|
||||
for frame in data.chunks_mut(channels) {
|
||||
let (mut l, mut r) = (0.0f32, 0.0f32);
|
||||
for v in mixer.voices.iter_mut() {
|
||||
let i = v.position as usize;
|
||||
if i + 1 >= v.samples.len() {
|
||||
continue;
|
||||
}
|
||||
let t = (v.position - i as f64) as f32;
|
||||
let s = v.samples[i] * (1.0 - t) + v.samples[i + 1] * t;
|
||||
l += s * v.left;
|
||||
r += s * v.right;
|
||||
v.position += v.step;
|
||||
}
|
||||
for t in mixer.tracked.iter_mut() {
|
||||
let v = &mut t.voice;
|
||||
let mut i = v.position as usize;
|
||||
if t.looping && i + 1 >= v.samples.len() {
|
||||
v.position = 0.0;
|
||||
i = 0;
|
||||
}
|
||||
if i + 1 < v.samples.len() {
|
||||
let f = (v.position - i as f64) as f32;
|
||||
let s = (v.samples[i] * (1.0 - f) + v.samples[i + 1] * f) * t.volume;
|
||||
l += s * v.left;
|
||||
r += s * v.right;
|
||||
v.position += v.step;
|
||||
}
|
||||
}
|
||||
let gain = mixer.music_gain;
|
||||
if let Some(v) = mixer.music.as_mut() {
|
||||
let i = v.position as usize;
|
||||
if i + 1 < v.samples.len() {
|
||||
let t = (v.position - i as f64) as f32;
|
||||
let s = (v.samples[i] * (1.0 - t) + v.samples[i + 1] * t) * gain;
|
||||
l += s * v.left;
|
||||
r += s * v.right;
|
||||
v.position += v.step;
|
||||
}
|
||||
}
|
||||
if channels >= 2 {
|
||||
frame[0] = l.clamp(-1.0, 1.0);
|
||||
frame[1] = r.clamp(-1.0, 1.0);
|
||||
} else {
|
||||
frame[0] = ((l + r) / 2.0).clamp(-1.0, 1.0);
|
||||
}
|
||||
}
|
||||
mixer
|
||||
.voices
|
||||
.retain(|v| (v.position as usize) + 1 < v.samples.len());
|
||||
mixer.tracked.retain(|t| {
|
||||
t.looping || (t.voice.position as usize) + 1 < t.voice.samples.len()
|
||||
});
|
||||
if mixer
|
||||
.music
|
||||
.as_ref()
|
||||
.is_some_and(|v| (v.position as usize) + 1 >= v.samples.len())
|
||||
{
|
||||
mixer.music = None;
|
||||
if let Ok(mut m) = mixer.lock() {
|
||||
mix(&mut m, data, channels, rate);
|
||||
} else {
|
||||
data.fill(0.0);
|
||||
}
|
||||
},
|
||||
|e| eprintln!("Horizon: audio stream error: {e}"),
|
||||
None,
|
||||
)?;
|
||||
stream.play()?;
|
||||
Ok((stream, rate))
|
||||
Ok(stream)
|
||||
}
|
||||
fn random(&mut self) -> u64 {
|
||||
self.seed ^= self.seed << 13;
|
||||
self.seed ^= self.seed >> 7;
|
||||
self.seed ^= self.seed << 17;
|
||||
self.seed
|
||||
}
|
||||
fn resolve(&mut self, event: &str, depth: u32) -> Option<Variant> {
|
||||
let variants = self.events.get(event)?.clone();
|
||||
let total: u32 = variants.iter().map(|v| v.weight).sum();
|
||||
if total == 0 {
|
||||
return None;
|
||||
fn bound(&mut self) -> Bound<'_, MixerBackend> {
|
||||
Bound {
|
||||
engine: &mut self.engine,
|
||||
backend: &mut self.backend,
|
||||
}
|
||||
let mut pick = (self.random() % u64::from(total)) as u32;
|
||||
let chosen = variants.into_iter().find(|v| {
|
||||
if pick < v.weight {
|
||||
true
|
||||
} else {
|
||||
pick -= v.weight;
|
||||
false
|
||||
}
|
||||
/// `SoundManager.updateSource`: the camera position and yaw.
|
||||
pub fn set_listener(&mut self, position: [f64; 3], yaw: f32) {
|
||||
self.listener = position;
|
||||
self.listener_yaw = yaw;
|
||||
if let Ok(mut m) = self.backend.mixer.lock() {
|
||||
m.listener = position;
|
||||
m.listener_yaw = yaw;
|
||||
}
|
||||
}
|
||||
/// Sync the volume options, refreshing the categories that changed
|
||||
/// (the option sliders' `refreshCategoryVolume`).
|
||||
fn sync_volumes(&mut self) {
|
||||
let mut volumes = [self.master; 11];
|
||||
for (i, v) in volumes.iter_mut().enumerate().skip(1) {
|
||||
*v = crate::settings::source_volume(i);
|
||||
}
|
||||
for source in 0..11 {
|
||||
if self.engine.source_volumes[source] != volumes[source] {
|
||||
self.engine.source_volumes[source] = volumes[source];
|
||||
self.engine
|
||||
.refresh_category_volume(source, &mut self.backend);
|
||||
}
|
||||
})?;
|
||||
if chosen.is_event && depth < 4 {
|
||||
let name = if chosen.name.contains(':') {
|
||||
chosen.name.clone()
|
||||
} else {
|
||||
format!("minecraft:{}", chosen.name)
|
||||
};
|
||||
let mut inner = self.resolve(&name, depth + 1)?;
|
||||
inner.volume *= chosen.volume;
|
||||
inner.pitch *= chosen.pitch;
|
||||
return Some(inner);
|
||||
}
|
||||
Some(chosen)
|
||||
self.engine.listening = self.record_heard;
|
||||
}
|
||||
/// Play a sound event at a world position (None = non-positional UI sound).
|
||||
/// `SoundManager.tick(paused)`, once per client tick.
|
||||
pub fn tick(&mut self, paused: bool, ctx: &TickContext) {
|
||||
self.sync_volumes();
|
||||
self.engine.tick(paused, ctx, &mut self.backend);
|
||||
}
|
||||
/// `SoundManager.pauseAllExcept(MUSIC, UI)`.
|
||||
pub fn pause(&mut self) {
|
||||
self.engine.pause_all_except(
|
||||
&[crate::sound_engine::MUSIC, crate::sound_engine::UI],
|
||||
&mut self.backend,
|
||||
);
|
||||
}
|
||||
/// `SoundManager.resume`.
|
||||
pub fn resume(&mut self) {
|
||||
self.engine.resume(&mut self.backend);
|
||||
}
|
||||
/// `SoundManager.stop()`: every sound.
|
||||
pub fn stop_all(&mut self) {
|
||||
self.engine.stop_all(&mut self.backend);
|
||||
}
|
||||
/// Play a sound event at a world position, as `ClientLevel.playLocalSound`
|
||||
/// (a `SimpleSoundInstance` seeded from the level random), or relative to
|
||||
/// the listener without attenuation (`None`).
|
||||
pub fn play(&mut self, event: &str, at: Option<[f64; 3]>, volume: f32, pitch: f32) {
|
||||
let seed = self.random.next_long();
|
||||
self.play_seeded(event, at, volume, pitch, seed);
|
||||
}
|
||||
/// `play` with the seed a sound packet carries.
|
||||
pub fn play_seeded(
|
||||
&mut self,
|
||||
event: &str,
|
||||
at: Option<[f64; 3]>,
|
||||
volume: f32,
|
||||
pitch: f32,
|
||||
seed: i64,
|
||||
) {
|
||||
if let (Some(capture), Some(at)) = (self.capture.as_mut(), at) {
|
||||
capture.push((event.to_owned(), at, volume, pitch));
|
||||
}
|
||||
let Some(variant) = self.resolve(event, 0) else {
|
||||
return;
|
||||
};
|
||||
if self.record_heard
|
||||
&& let Some(key) = self.subtitles.get(event)
|
||||
{
|
||||
let range = if at.is_some() {
|
||||
variant.attenuation * volume.max(1.0)
|
||||
} else {
|
||||
f32::INFINITY
|
||||
};
|
||||
self.heard.push(Heard {
|
||||
subtitle: key.clone(),
|
||||
at,
|
||||
range,
|
||||
looping: false,
|
||||
});
|
||||
}
|
||||
if self._stream.is_none() {
|
||||
return;
|
||||
}
|
||||
let file = variant.name.trim_start_matches("minecraft:").to_owned();
|
||||
let entry = self.cache.entry(file.clone()).or_insert_with(|| {
|
||||
decode(
|
||||
&self
|
||||
.root
|
||||
.join(format!("assets/minecraft/sounds/{file}.ogg")),
|
||||
)
|
||||
.map(|(s, r)| (Arc::new(s), r))
|
||||
});
|
||||
let Some((samples, rate)) = entry.clone() else {
|
||||
return;
|
||||
};
|
||||
self.sync_volumes();
|
||||
let event = crate::sound_events::normalize(event);
|
||||
let source = self
|
||||
.source_override
|
||||
.unwrap_or_else(|| crate::settings::sound_source(event));
|
||||
let volume = (volume * variant.volume).clamp(0.0, 1.0)
|
||||
* self.master
|
||||
* crate::settings::source_volume(source);
|
||||
let pitch = (pitch * variant.pitch).clamp(0.5, 2.0);
|
||||
let (mut left, mut right) = (volume, volume);
|
||||
if let Some(p) = at {
|
||||
let d = [
|
||||
p[0] - self.listener[0],
|
||||
p[1] - self.listener[1],
|
||||
p[2] - self.listener[2],
|
||||
];
|
||||
let distance = (d[0] * d[0] + d[1] * d[1] + d[2] * d[2]).sqrt() as f32;
|
||||
let range = variant.attenuation * volume.max(1.0);
|
||||
let gain = (1.0 - distance / range).clamp(0.0, 1.0);
|
||||
if gain <= 0.0 {
|
||||
return;
|
||||
}
|
||||
let yaw = self.listener_yaw.to_radians();
|
||||
// Right vector of a Minecraft yaw.
|
||||
let side = (-(yaw.cos()) as f64 * d[0] - (yaw.sin()) as f64 * d[2]) as f32
|
||||
/ distance.max(0.001);
|
||||
let pan = side.clamp(-1.0, 1.0);
|
||||
left = volume * gain * (1.0 - pan.max(0.0) * 0.6);
|
||||
right = volume * gain * (1.0 + pan.min(0.0) * 0.6);
|
||||
}
|
||||
let step = f64::from(rate) / f64::from(self.rate) * f64::from(pitch);
|
||||
if let Ok(mut m) = self.mixer.lock() {
|
||||
if m.voices.len() > 64 {
|
||||
m.voices.remove(0);
|
||||
}
|
||||
m.voices.push(Voice {
|
||||
samples,
|
||||
position: 0.0,
|
||||
step,
|
||||
left,
|
||||
right,
|
||||
event: event.into(),
|
||||
.unwrap_or_else(|| crate::settings::sound_source(&event));
|
||||
let random = LegacyRandom::new(seed);
|
||||
let instance = match at {
|
||||
Some(at) => SoundInstance::at(&event, source, volume, pitch, random, at),
|
||||
None => SoundInstance::new(
|
||||
&event,
|
||||
source,
|
||||
});
|
||||
}
|
||||
}
|
||||
/// `SoundManager.play` of a music instance (`SimpleSoundInstance.forMusic`:
|
||||
/// non-positional, streamed). Decoding runs on a worker thread; the track
|
||||
/// counts as active from now on. Returns false when nothing can play.
|
||||
pub fn play_music(&mut self, event: &str) -> bool {
|
||||
self.stop_music();
|
||||
if self._stream.is_none() {
|
||||
return false;
|
||||
}
|
||||
let Some(variant) = self.resolve(event, 0) else {
|
||||
return false;
|
||||
volume,
|
||||
pitch,
|
||||
random,
|
||||
false,
|
||||
0,
|
||||
Attenuation::None,
|
||||
[0.0; 3],
|
||||
true,
|
||||
),
|
||||
};
|
||||
let path = self.root.join(format!(
|
||||
"assets/minecraft/sounds/{}.ogg",
|
||||
variant.name.trim_start_matches("minecraft:")
|
||||
));
|
||||
let volume =
|
||||
variant.volume.clamp(0.0, 1.0) * self.master * crate::settings::event_volume(event);
|
||||
let step_scale = f64::from(variant.pitch.clamp(0.5, 2.0)) / f64::from(self.rate);
|
||||
let ready = Arc::new(std::sync::atomic::AtomicBool::new(false));
|
||||
let (mixer, done) = (self.mixer.clone(), ready.clone());
|
||||
let music_event = event.to_owned();
|
||||
std::thread::spawn(move || {
|
||||
if let Some((samples, rate)) = decode(&path)
|
||||
&& let Ok(mut m) = mixer.lock()
|
||||
&& !done.load(std::sync::atomic::Ordering::Acquire)
|
||||
{
|
||||
m.music = Some(Voice {
|
||||
samples: Arc::new(samples),
|
||||
position: 0.0,
|
||||
step: f64::from(rate) * step_scale,
|
||||
left: volume,
|
||||
right: volume,
|
||||
event: music_event.as_str().into(),
|
||||
source: 1,
|
||||
});
|
||||
}
|
||||
done.store(true, std::sync::atomic::Ordering::Release);
|
||||
});
|
||||
self.music = Some(MusicTrack {
|
||||
event: event.to_owned(),
|
||||
ready,
|
||||
});
|
||||
true
|
||||
}
|
||||
/// `SoundManager.stop` of the music instance.
|
||||
pub fn stop_music(&mut self) {
|
||||
if let Some(track) = self.music.take() {
|
||||
// A decoder still running sees the flag and drops its result.
|
||||
track
|
||||
.ready
|
||||
.store(true, std::sync::atomic::Ordering::Release);
|
||||
}
|
||||
if let Ok(mut m) = self.mixer.lock() {
|
||||
m.music = None;
|
||||
}
|
||||
}
|
||||
/// `SoundManager.isActive`: decoding or still sounding.
|
||||
pub fn music_active(&mut self) -> bool {
|
||||
let Some(track) = &self.music else {
|
||||
return false;
|
||||
};
|
||||
if !track.ready.load(std::sync::atomic::Ordering::Acquire) {
|
||||
return true;
|
||||
}
|
||||
let playing = self.mixer.lock().is_ok_and(|m| m.music.is_some());
|
||||
if !playing {
|
||||
self.music = None;
|
||||
}
|
||||
playing
|
||||
}
|
||||
/// The event of the current music track.
|
||||
pub fn music_event(&self) -> Option<&str> {
|
||||
self.music.as_ref().map(|t| t.event.as_str())
|
||||
}
|
||||
/// Every sound event id (for command completion).
|
||||
pub fn event_names(&self) -> Vec<String> {
|
||||
let mut v: Vec<String> = self.events.keys().cloned().collect();
|
||||
v.sort();
|
||||
v
|
||||
self.play_instance(instance, false);
|
||||
}
|
||||
/// Play a sound on a given `SoundSource` index (`/playsound`).
|
||||
pub fn play_on(
|
||||
@@ -486,102 +493,111 @@ impl Audio {
|
||||
self.play(event, at, volume, pitch);
|
||||
self.source_override = None;
|
||||
}
|
||||
/// `SimpleSoundInstance.forJukeboxSong` at a jukebox; the handle stays
|
||||
/// until `release`.
|
||||
pub fn play_jukebox_song(&mut self, event: &str, pos: [i32; 3]) -> InstanceId {
|
||||
let at = [
|
||||
f64::from(pos[0]) + 0.5,
|
||||
f64::from(pos[1]) + 0.5,
|
||||
f64::from(pos[2]) + 0.5,
|
||||
];
|
||||
let event = crate::sound_events::normalize(event);
|
||||
let instance = SoundInstance::for_jukebox_song(&event, at, SoundEngine::unseeded());
|
||||
self.play_instance(instance, true).0
|
||||
}
|
||||
/// Stop and drop a held instance.
|
||||
pub fn stop_and_release(&mut self, id: InstanceId) {
|
||||
self.stop_instance(id);
|
||||
self.release(id);
|
||||
}
|
||||
/// Every sound event id (for command completion).
|
||||
pub fn event_names(&self) -> Vec<String> {
|
||||
let mut v: Vec<String> = self.engine.registry.names().cloned().collect();
|
||||
v.sort();
|
||||
v
|
||||
}
|
||||
/// `ClientboundStopSoundPacket`: stop the playing sounds of an event
|
||||
/// and/or a source.
|
||||
pub fn stop_sounds(&mut self, event: Option<&str>, source: Option<usize>) {
|
||||
let matches = |v: &Voice| {
|
||||
event.is_none_or(|e| &*v.event == e) && source.is_none_or(|s| v.source == s)
|
||||
};
|
||||
if let Ok(mut m) = self.mixer.lock() {
|
||||
m.voices.retain(|v| !matches(v));
|
||||
m.tracked.retain(|t| !matches(&t.voice));
|
||||
if m.music.as_ref().is_some_and(matches) {
|
||||
m.music = None;
|
||||
}
|
||||
}
|
||||
if self
|
||||
.music
|
||||
.as_ref()
|
||||
.is_some_and(|t| event.is_none_or(|e| t.event == e) && source.is_none_or(|s| s == 1))
|
||||
{
|
||||
self.music = None;
|
||||
}
|
||||
let event = event.map(crate::sound_events::normalize);
|
||||
self.engine
|
||||
.stop_matching(event.as_deref(), source, &mut self.backend);
|
||||
}
|
||||
/// The sounds played since the last call, for the subtitle overlay.
|
||||
pub fn take_heard(&mut self) -> Vec<Heard> {
|
||||
std::mem::take(&mut self.heard)
|
||||
}
|
||||
/// `SoundManager.updateCategoryVolume(MUSIC, gain)`.
|
||||
pub fn set_music_gain(&mut self, gain: f32) {
|
||||
if let Ok(mut m) = self.mixer.lock() {
|
||||
m.music_gain = gain;
|
||||
}
|
||||
}
|
||||
/// Play a relative (non-positional) tickable sound instance the caller
|
||||
/// keeps a handle to: ambient loops (`looping`) and sub-sounds.
|
||||
pub fn play_tracked(&mut self, event: &str, volume: f32, looping: bool) -> Option<u64> {
|
||||
let variant = self.resolve(event, 0)?;
|
||||
if self.record_heard
|
||||
&& let Some(key) = self.subtitles.get(event)
|
||||
{
|
||||
self.heard.push(Heard {
|
||||
subtitle: key.clone(),
|
||||
at: None,
|
||||
range: f32::INFINITY,
|
||||
looping,
|
||||
});
|
||||
}
|
||||
self._stream.as_ref()?;
|
||||
let file = variant.name.trim_start_matches("minecraft:").to_owned();
|
||||
let root = &self.root;
|
||||
let (samples, rate) = self
|
||||
.cache
|
||||
.entry(file.clone())
|
||||
.or_insert_with(|| {
|
||||
decode(&root.join(format!("assets/minecraft/sounds/{file}.ogg")))
|
||||
.map(|(s, r)| (Arc::new(s), r))
|
||||
std::mem::take(&mut self.engine.notifications)
|
||||
.into_iter()
|
||||
.filter_map(|n| {
|
||||
Some(Heard {
|
||||
subtitle: n.subtitle?,
|
||||
at: Some(n.position),
|
||||
range: n.range,
|
||||
looping: n.looping,
|
||||
})
|
||||
})
|
||||
.clone()?;
|
||||
let gain =
|
||||
variant.volume.clamp(0.0, 1.0) * self.master * crate::settings::event_volume(event);
|
||||
let step =
|
||||
f64::from(rate) / f64::from(self.rate) * f64::from(variant.pitch.clamp(0.5, 2.0));
|
||||
self.next_tracked += 1;
|
||||
let id = self.next_tracked;
|
||||
let mut m = self.mixer.lock().ok()?;
|
||||
m.tracked.push(Tracked {
|
||||
id,
|
||||
voice: Voice {
|
||||
samples,
|
||||
position: 0.0,
|
||||
step,
|
||||
left: gain,
|
||||
right: gain,
|
||||
event: event.into(),
|
||||
source: crate::settings::sound_source(event),
|
||||
},
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
impl Sounds for Audio {
|
||||
fn engine(&mut self) -> &mut SoundEngine {
|
||||
&mut self.engine
|
||||
}
|
||||
fn play_instance(&mut self, instance: SoundInstance, retain: bool) -> (InstanceId, PlayResult) {
|
||||
self.sync_volumes();
|
||||
self.bound().play_instance(instance, retain)
|
||||
}
|
||||
fn play_id(&mut self, id: InstanceId) -> PlayResult {
|
||||
self.sync_volumes();
|
||||
self.bound().play_id(id)
|
||||
}
|
||||
fn stop_instance(&mut self, id: InstanceId) {
|
||||
self.bound().stop_instance(id);
|
||||
}
|
||||
fn update_category_volume(&mut self, source: usize, gain: f32) {
|
||||
self.bound().update_category_volume(source, gain);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn channel(samples: Vec<f32>, linear: Option<f32>, at: [f32; 3], looping: bool) -> Channel {
|
||||
Channel {
|
||||
samples: Some((Arc::new(samples), 48_000)),
|
||||
position: 0.0,
|
||||
state: State::Playing,
|
||||
gain: 1.0,
|
||||
pitch: 1.0,
|
||||
linear,
|
||||
looping,
|
||||
volume: volume.clamp(0.0, 1.0),
|
||||
});
|
||||
Some(id)
|
||||
}
|
||||
/// A tracked instance's volume (`AbstractTickableSoundInstance.volume`).
|
||||
pub fn set_tracked_volume(&mut self, id: u64, volume: f32) {
|
||||
if let Ok(mut m) = self.mixer.lock()
|
||||
&& let Some(t) = m.tracked.iter_mut().find(|t| t.id == id)
|
||||
{
|
||||
t.volume = volume.clamp(0.0, 1.0);
|
||||
at,
|
||||
relative: false,
|
||||
}
|
||||
}
|
||||
pub fn stop_tracked(&mut self, id: u64) {
|
||||
if let Ok(mut m) = self.mixer.lock() {
|
||||
m.tracked.retain(|t| t.id != id);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn linear_attenuation_reaches_zero_at_the_max_distance() {
|
||||
assert_eq!(distance_gain(None, 100.0), 1.0);
|
||||
assert_eq!(distance_gain(Some(16.0), 0.0), 1.0);
|
||||
assert_eq!(distance_gain(Some(16.0), 8.0), 0.5);
|
||||
assert_eq!(distance_gain(Some(16.0), 16.0), 0.0);
|
||||
assert_eq!(distance_gain(Some(16.0), 40.0), 0.0);
|
||||
}
|
||||
/// Still sounding (a finished one-shot is gone).
|
||||
pub fn tracked_active(&self, id: u64) -> bool {
|
||||
self.mixer
|
||||
.lock()
|
||||
.is_ok_and(|m| m.tracked.iter().any(|t| t.id == id))
|
||||
|
||||
#[test]
|
||||
fn sources_play_attenuated_then_stop_or_loop() {
|
||||
let mut m = Mixer::default();
|
||||
m.channels
|
||||
.insert(1, channel(vec![1.0; 4], Some(16.0), [8.0, 0.0, 0.0], false));
|
||||
m.channels
|
||||
.insert(2, channel(vec![1.0; 4], None, [0.0; 3], true));
|
||||
let mut data = [0.0f32; 16];
|
||||
mix(&mut m, &mut data, 2, 48_000);
|
||||
assert_eq!(m.channels[&1].state, State::Stopped);
|
||||
assert_eq!(m.channels[&2].state, State::Playing);
|
||||
// Mono sum of the two at the first frame: the far one is half as loud.
|
||||
let first = data[0] + data[1];
|
||||
assert!(first > 1.4 && first < 3.1, "{first}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
//! Auto-jump (`LocalPlayer.updateAutoJump` and `canAutoJump`): after the
|
||||
//! local player's own move, look ahead along the movement for a step that
|
||||
//! the player can clear with a jump and is not walled in above; if found, the
|
||||
//! next `aiStep` presses jump for one tick (`autoJumpTime = 1`).
|
||||
use horizon_core::{
|
||||
geometry::{Aabb, Axis, Vec3, VoxelShape, trig},
|
||||
position::BlockPos,
|
||||
};
|
||||
|
||||
/// `Float.MIN_VALUE`, the "no obstacle" marker.
|
||||
const NO_OBSTACLE: f32 = f32::from_bits(1);
|
||||
|
||||
/// What `updateAutoJump` reads from the player.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct AutoJumper {
|
||||
pub position: Vec3,
|
||||
/// `getBoundingBox().maxY`.
|
||||
pub max_y: f64,
|
||||
pub x_rot: f32,
|
||||
pub y_rot: f32,
|
||||
/// `getSpeed()`.
|
||||
pub speed: f32,
|
||||
/// `input.getMoveVector()` (x: strafe, y: forward).
|
||||
pub move_vector: [f32; 2],
|
||||
/// `getBbWidth()`, `getBbHeight()`.
|
||||
pub width: f32,
|
||||
pub height: f32,
|
||||
/// The jump boost amplifier, when the effect is active.
|
||||
pub jump_boost: Option<i32>,
|
||||
}
|
||||
|
||||
/// The level queries, with the player's collision context.
|
||||
pub trait AutoJumpWorld {
|
||||
type Error;
|
||||
/// `getBlockState(p).getCollisionShape(level, p, CollisionContext.of(player))`.
|
||||
fn collision_shape(&mut self, p: BlockPos) -> Result<VoxelShape, Self::Error>;
|
||||
/// `level.getCollisions(player, bounds)` flattened with `toAabbs`, in order.
|
||||
fn collision_boxes(&mut self, bounds: Aabb) -> Result<Vec<Aabb>, Self::Error>;
|
||||
}
|
||||
|
||||
/// `LocalPlayer.canAutoJump`. `staying_on_surface` is
|
||||
/// `isStayingOnGroundSurface()` (the shift key).
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn can_auto_jump(
|
||||
enabled: bool,
|
||||
auto_jump_time: i32,
|
||||
on_ground: bool,
|
||||
staying_on_surface: bool,
|
||||
passenger: bool,
|
||||
move_vector: [f32; 2],
|
||||
block_jump_factor: f32,
|
||||
) -> bool {
|
||||
enabled
|
||||
&& auto_jump_time <= 0
|
||||
&& on_ground
|
||||
&& !staying_on_surface
|
||||
&& !passenger
|
||||
&& move_vector[0] * move_vector[0] + move_vector[1] * move_vector[1] > 0.0
|
||||
&& f64::from(block_jump_factor) >= 1.0
|
||||
}
|
||||
|
||||
/// `Vec3.directionFromRotation(rotX, rotY)`.
|
||||
pub fn direction_from_rotation(rot_x: f32, rot_y: f32) -> Vec3 {
|
||||
let rad = trig::DEG_TO_RAD;
|
||||
let pi = std::f32::consts::PI;
|
||||
let y_cos = trig::cos(f64::from(-rot_y * rad - pi));
|
||||
let y_sin = trig::sin(f64::from(-rot_y * rad - pi));
|
||||
let x_cos = -trig::cos(f64::from(-rot_x * rad));
|
||||
let x_sin = trig::sin(f64::from(-rot_x * rad));
|
||||
Vec3::new(
|
||||
f64::from(y_sin * x_cos),
|
||||
f64::from(x_sin),
|
||||
f64::from(y_cos * x_cos),
|
||||
)
|
||||
}
|
||||
|
||||
/// `AABB.intersects(Vec3, Vec3)`: the box overlaps the segment's bounds.
|
||||
fn intersects_segment(b: Aabb, from: Vec3, to: Vec3) -> bool {
|
||||
b.intersects(Aabb::new(from, to))
|
||||
}
|
||||
|
||||
/// `LocalPlayer.updateAutoJump(xa, za)` once `canAutoJump` holds: whether
|
||||
/// it sets `autoJumpTime = 1`. `xa`/`za` are the float horizontal
|
||||
/// displacement of the move.
|
||||
pub fn update_auto_jump<W: AutoJumpWorld>(
|
||||
world: &mut W,
|
||||
p: &AutoJumper,
|
||||
xa: f32,
|
||||
za: f32,
|
||||
) -> Result<bool, W::Error> {
|
||||
let move_begin = p.position;
|
||||
let move_end = move_begin.add_vector(Vec3::new(f64::from(xa), 0.0, f64::from(za)));
|
||||
let mut move_diff = Vec3::new(f64::from(xa), 0.0, f64::from(za));
|
||||
let current_speed = p.speed;
|
||||
let mut move_dist_sq = move_diff.length_squared() as f32;
|
||||
if move_dist_sq <= 0.001 {
|
||||
let input_xa = current_speed * p.move_vector[0];
|
||||
let input_za = current_speed * p.move_vector[1];
|
||||
let sin = trig::sin(f64::from(p.y_rot * trig::DEG_TO_RAD));
|
||||
let cos = trig::cos(f64::from(p.y_rot * trig::DEG_TO_RAD));
|
||||
move_diff = Vec3::new(
|
||||
f64::from(input_xa * cos - input_za * sin),
|
||||
move_diff.y,
|
||||
f64::from(input_za * cos + input_xa * sin),
|
||||
);
|
||||
move_dist_sq = move_diff.length_squared() as f32;
|
||||
if move_dist_sq <= 0.001 {
|
||||
return Ok(false);
|
||||
}
|
||||
}
|
||||
// Mth.invSqrt: 1.0F / (float) Math.sqrt(x).
|
||||
let inverted = 1.0 / move_dist_sq.sqrt();
|
||||
let move_dir = move_diff.scale(f64::from(inverted));
|
||||
let facing = direction_from_rotation(p.x_rot, p.y_rot);
|
||||
let facing_vs_moving = (facing.x * move_dir.x + facing.z * move_dir.z) as f32;
|
||||
if facing_vs_moving < -0.15 {
|
||||
return Ok(false);
|
||||
}
|
||||
let mut ceiling = BlockPos::containing(Vec3::new(move_begin.x, p.max_y, move_begin.z));
|
||||
if !world.collision_shape(ceiling)?.is_empty() {
|
||||
return Ok(false);
|
||||
}
|
||||
ceiling = ceiling.offset(0, 1, 0);
|
||||
if !world.collision_shape(ceiling)?.is_empty() {
|
||||
return Ok(false);
|
||||
}
|
||||
let mut jump_height = 1.2f32;
|
||||
if let Some(amplifier) = p.jump_boost {
|
||||
jump_height += (amplifier + 1) as f32 * 0.75;
|
||||
}
|
||||
let look_ahead = crate::camera::java_max(current_speed * 7.0, 1.0 / inverted);
|
||||
let seg_begin = move_begin;
|
||||
let seg_end = move_end.add_vector(move_dir.scale(f64::from(look_ahead)));
|
||||
let (width, height) = (p.width, p.height);
|
||||
let test_box = Aabb::new(
|
||||
seg_begin,
|
||||
seg_end.add_vector(Vec3::new(0.0, f64::from(height), 0.0)),
|
||||
)
|
||||
.inflate(Vec3::new(f64::from(width), 0.0, f64::from(width)));
|
||||
let lift = Vec3::new(0.0, f64::from(0.51f32), 0.0);
|
||||
let seg_begin = seg_begin.add_vector(lift);
|
||||
let seg_end = seg_end.add_vector(lift);
|
||||
let right_dir = move_dir.cross(Vec3::new(0.0, 1.0, 0.0));
|
||||
let right_offset = right_dir.scale(f64::from(width * 0.5));
|
||||
let left_begin = seg_begin.subtract(right_offset);
|
||||
let left_end = seg_end.subtract(right_offset);
|
||||
let right_begin = seg_begin.add_vector(right_offset);
|
||||
let right_end = seg_end.add_vector(right_offset);
|
||||
let mut obstacle = NO_OBSTACLE;
|
||||
for b in world.collision_boxes(test_box)? {
|
||||
if intersects_segment(b, left_begin, left_end)
|
||||
|| intersects_segment(b, right_begin, right_end)
|
||||
{
|
||||
obstacle = b.max_position().y as f32;
|
||||
let obstacle_pos = BlockPos::containing(b.center());
|
||||
let mut steps = 1i32;
|
||||
while (steps as f32) < jump_height {
|
||||
let above = obstacle_pos.offset(0, steps, 0);
|
||||
let shape = world.collision_shape(above)?;
|
||||
if !shape.is_empty() {
|
||||
obstacle = shape.max(Axis::Y) as f32 + above.y as f32;
|
||||
if f64::from(obstacle) - p.position.y > f64::from(jump_height) {
|
||||
return Ok(false);
|
||||
}
|
||||
}
|
||||
if steps > 1 {
|
||||
ceiling = ceiling.offset(0, 1, 0);
|
||||
if !world.collision_shape(ceiling)?.is_empty() {
|
||||
return Ok(false);
|
||||
}
|
||||
}
|
||||
steps += 1;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
if obstacle != NO_OBSTACLE {
|
||||
let y_delta = (f64::from(obstacle) - p.position.y) as f32;
|
||||
// Negated as in LocalPlayer.updateAutoJump, so a NaN delta still jumps.
|
||||
#[allow(clippy::neg_cmp_op_on_partial_ord)]
|
||||
let jumps = !(y_delta <= 0.5) && !(y_delta > jump_height);
|
||||
if jumps {
|
||||
return Ok(true);
|
||||
}
|
||||
}
|
||||
Ok(false)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
struct Step;
|
||||
impl AutoJumpWorld for Step {
|
||||
type Error = ();
|
||||
fn collision_shape(&mut self, p: BlockPos) -> Result<VoxelShape, ()> {
|
||||
Ok(if p.x == 1 && p.y == 0 {
|
||||
VoxelShape::block()
|
||||
} else {
|
||||
VoxelShape::empty()
|
||||
})
|
||||
}
|
||||
fn collision_boxes(&mut self, bounds: Aabb) -> Result<Vec<Aabb>, ()> {
|
||||
let b = Aabb::new(Vec3::new(1.0, 0.0, -5.0), Vec3::new(2.0, 1.0, 5.0));
|
||||
Ok(if b.intersects(bounds) {
|
||||
vec![b]
|
||||
} else {
|
||||
Vec::new()
|
||||
})
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn walking_into_a_one_block_step_jumps() {
|
||||
let p = AutoJumper {
|
||||
position: Vec3::new(0.6, 0.0, 0.5),
|
||||
max_y: 1.8,
|
||||
x_rot: 0.0,
|
||||
y_rot: -90.0,
|
||||
speed: 0.1,
|
||||
move_vector: [0.0, 1.0],
|
||||
width: 0.6,
|
||||
height: 1.8,
|
||||
jump_boost: None,
|
||||
};
|
||||
assert!(update_auto_jump(&mut Step, &p, 0.1, 0.0).unwrap());
|
||||
// Moving away from the step does not.
|
||||
assert!(!update_auto_jump(&mut Step, &p, -0.1, 0.0).unwrap());
|
||||
}
|
||||
#[test]
|
||||
fn sneaking_or_standing_still_cannot_auto_jump() {
|
||||
assert!(can_auto_jump(true, 0, true, false, false, [0.0, 1.0], 1.0));
|
||||
assert!(!can_auto_jump(true, 0, true, true, false, [0.0, 1.0], 1.0));
|
||||
assert!(!can_auto_jump(true, 0, true, false, false, [0.0, 0.0], 1.0));
|
||||
assert!(!can_auto_jump(true, 0, true, false, false, [0.0, 1.0], 0.5));
|
||||
}
|
||||
}
|
||||
@@ -17,11 +17,16 @@ pub struct SpawnEntry {
|
||||
pub weight: u32,
|
||||
pub min: u32,
|
||||
pub max: u32,
|
||||
/// A `ConstantInt` count (sampling draws nothing).
|
||||
pub constant: bool,
|
||||
}
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct BiomeInfo {
|
||||
/// `natural_mob_spawns` by category (creature, monster, ...).
|
||||
pub spawns: HashMap<String, Vec<SpawnEntry>>,
|
||||
/// `natural_mob_spawns` spawn costs by entity type.
|
||||
pub spawn_costs: HashMap<String, crate::mobs::spawn_selection::SpawnCost>,
|
||||
/// The `gameplay/creature_world_gen_spawn_probability` attribute.
|
||||
pub creature_probability: f32,
|
||||
pub name: String,
|
||||
pub temperature: f32,
|
||||
@@ -103,37 +108,26 @@ impl Biomes {
|
||||
_ => GrassModifier::None,
|
||||
};
|
||||
let mut spawns: HashMap<String, Vec<SpawnEntry>> = HashMap::new();
|
||||
let categories = &attributes["minecraft:gameplay/natural_mob_spawns"]["argument"]["spawns_by_category"];
|
||||
let natural = &attributes["minecraft:gameplay/natural_mob_spawns"];
|
||||
// A modifier object ({argument, modifier: overlay}) or the plain value;
|
||||
// the overlay over the dimension's empty default is the biome's value.
|
||||
let natural = if natural["argument"].is_object() {
|
||||
&natural["argument"]
|
||||
} else {
|
||||
natural
|
||||
};
|
||||
let categories = &natural["spawns_by_category"];
|
||||
for (category, entries) in categories.as_object().into_iter().flatten() {
|
||||
let list = entries
|
||||
.as_array()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter_map(|e| {
|
||||
let (min, max) = match &e["count"] {
|
||||
Value::Number(n) => {
|
||||
let n = n.as_u64().unwrap_or(1) as u32;
|
||||
(n, n)
|
||||
}
|
||||
c => (
|
||||
c["min_inclusive"].as_u64().unwrap_or(1) as u32,
|
||||
c["max_inclusive"].as_u64().unwrap_or(1) as u32,
|
||||
),
|
||||
};
|
||||
Some(SpawnEntry {
|
||||
entity: e["type"].as_str()?.to_owned(),
|
||||
weight: e["weight"].as_u64().unwrap_or(1) as u32,
|
||||
min,
|
||||
max,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
let list = crate::mobs::spawn_selection::parse_entries(entries);
|
||||
spawns.insert(category.clone(), list);
|
||||
}
|
||||
list.push(BiomeInfo {
|
||||
spawns,
|
||||
creature_probability: v["creature_spawn_probability"].as_f64().unwrap_or(0.1)
|
||||
as f32,
|
||||
spawn_costs: crate::mobs::spawn_selection::parse_costs(&natural["spawn_costs"]),
|
||||
creature_probability:
|
||||
attributes["minecraft:gameplay/creature_world_gen_spawn_probability"]
|
||||
.as_f64()
|
||||
.unwrap_or(0.1) as f32,
|
||||
temperature,
|
||||
downfall,
|
||||
precipitation: v["has_precipitation"].as_bool().unwrap_or(true),
|
||||
|
||||
@@ -0,0 +1,905 @@
|
||||
//! Client-ticked block-entity animation state, as vanilla's block entities
|
||||
//! keep it: `ChestLidController` (chests, trapped and copper chests, ender
|
||||
//! chests), `EnchantingTableBlockEntity.bookAnimationTick`,
|
||||
//! `ShulkerBoxBlockEntity` (open count, animation status, progress),
|
||||
//! `BellBlockEntity` (ring ticks, shake direction, resonation),
|
||||
//! `ConduitBlockEntity.clientTick` (tick count, shape check, rotation,
|
||||
//! hunting, nautilus particles), `SkullBlockEntity.animation` (powered dragon
|
||||
//! and piglin heads) and `DecoratedPotBlockEntity` wobbles.
|
||||
//!
|
||||
//! [`BlockEntityAnims`] holds the state per position and turns it into the
|
||||
//! renderer's [`BlockAnim`]. Its tick methods take what the level answers
|
||||
//! (blocks, entities, game time) as closures, so the parity fixture can drive
|
||||
//! them with scripted inputs (`tools/test_block_entity_anim_parity.py`).
|
||||
use crate::block_entity_render::BlockAnim;
|
||||
use horizon_core::{
|
||||
geometry::trig::{self, Mth},
|
||||
random::{LegacyRandom, RandomSource},
|
||||
};
|
||||
use std::collections::HashMap;
|
||||
|
||||
/// `Direction` names by `get3DDataValue`.
|
||||
pub const DIRECTIONS: [&str; 6] = ["down", "up", "north", "south", "west", "east"];
|
||||
|
||||
fn step(direction: u8) -> [i32; 3] {
|
||||
match direction {
|
||||
0 => [0, -1, 0],
|
||||
1 => [0, 1, 0],
|
||||
2 => [0, 0, -1],
|
||||
3 => [0, 0, 1],
|
||||
4 => [-1, 0, 0],
|
||||
_ => [1, 0, 0],
|
||||
}
|
||||
}
|
||||
|
||||
/// `Direction.from3DDataValue`: `BY_3D_DATA[Mth.abs(data % 6)]`.
|
||||
pub fn from_3d_data_value(data: i32) -> u8 {
|
||||
(data % 6).unsigned_abs() as u8
|
||||
}
|
||||
|
||||
/// The 3D data value of a direction name.
|
||||
pub fn direction_index(name: &str) -> Option<u8> {
|
||||
DIRECTIONS.iter().position(|d| *d == name).map(|i| i as u8)
|
||||
}
|
||||
|
||||
/// `ChestLidController`, shared by `ChestBlockEntity` (chests, trapped and
|
||||
/// copper chests) and `EnderChestBlockEntity`.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq)]
|
||||
pub struct ChestLid {
|
||||
pub should_be_open: bool,
|
||||
pub openness: f32,
|
||||
pub o_openness: f32,
|
||||
}
|
||||
|
||||
impl ChestLid {
|
||||
/// `triggerEvent(1, openCount)`: the lid opens while anyone has it open.
|
||||
pub fn trigger_event(&mut self, b0: i32, b1: i32) -> bool {
|
||||
if b0 == 1 {
|
||||
self.should_be_open = b1 > 0;
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
/// `tickLid` (the client ticker `lidAnimateTick`).
|
||||
pub fn tick(&mut self) {
|
||||
self.o_openness = self.openness;
|
||||
if !self.should_be_open && self.openness > 0.0 {
|
||||
self.openness = (self.openness - 0.1).max(0.0);
|
||||
} else if self.should_be_open && self.openness < 1.0 {
|
||||
self.openness = (self.openness + 0.1).min(1.0);
|
||||
}
|
||||
}
|
||||
/// `getOpenness(a)`: `Mth.lerp(a, oOpenness, openness)`.
|
||||
pub fn get_openness(&self, a: f32) -> f32 {
|
||||
self.o_openness + a * (self.openness - self.o_openness)
|
||||
}
|
||||
}
|
||||
|
||||
/// `ChestBlock.opennessCombiner`: a double chest opens as far as its more
|
||||
/// open half (`Math.max(first, second)`, first being the `right` half).
|
||||
pub fn combined_openness(first: f32, second: Option<f32>) -> f32 {
|
||||
match second {
|
||||
Some(second) => java_max(first, second),
|
||||
None => first,
|
||||
}
|
||||
}
|
||||
|
||||
/// `Math.max(float, float)`.
|
||||
fn java_max(a: f32, b: f32) -> f32 {
|
||||
if a.is_nan() {
|
||||
a
|
||||
} else if a == 0.0 && b == 0.0 && a.to_bits() != b.to_bits() {
|
||||
0.0
|
||||
} else if a >= b {
|
||||
a
|
||||
} else {
|
||||
b
|
||||
}
|
||||
}
|
||||
|
||||
/// `ChestRenderer.submit` and `ChestModel.setupAnim`: the lid and lock
|
||||
/// `xRot` for a render-state openness.
|
||||
pub fn chest_lid_x_rot(open: f32) -> f32 {
|
||||
let open = 1.0 - open;
|
||||
let open = 1.0 - open * open * open;
|
||||
-(open * std::f32::consts::FRAC_PI_2)
|
||||
}
|
||||
|
||||
/// `EnchantingTableBlockEntity` book fields.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq)]
|
||||
pub struct EnchantingBook {
|
||||
pub time: i32,
|
||||
pub flip: f32,
|
||||
pub o_flip: f32,
|
||||
pub flip_t: f32,
|
||||
pub flip_a: f32,
|
||||
pub open: f32,
|
||||
pub o_open: f32,
|
||||
pub rot: f32,
|
||||
pub o_rot: f32,
|
||||
pub t_rot: f32,
|
||||
}
|
||||
|
||||
/// The `while (a >= PI) a -= 2PI; while (a < -PI) a += 2PI` loops.
|
||||
fn wrap_pi(mut a: f32) -> f32 {
|
||||
use std::f32::consts::PI;
|
||||
while a >= PI {
|
||||
a -= 2.0 * PI;
|
||||
}
|
||||
while a < -PI {
|
||||
a += 2.0 * PI;
|
||||
}
|
||||
a
|
||||
}
|
||||
|
||||
/// `Mth.floor(float)`.
|
||||
fn mth_floor(v: f32) -> i32 {
|
||||
let i = v as i32;
|
||||
if v < i as f32 { i - 1 } else { i }
|
||||
}
|
||||
|
||||
/// `Mth.frac(float)`.
|
||||
pub fn mth_frac(v: f32) -> f32 {
|
||||
v - mth_floor(v) as f32
|
||||
}
|
||||
|
||||
/// `EnchantTableRenderState` (`EnchantTableRenderer.extractRenderState`) and
|
||||
/// the `BookModel.State` its `submit` builds.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq)]
|
||||
pub struct BookRender {
|
||||
pub flip: f32,
|
||||
pub open: f32,
|
||||
pub time: f32,
|
||||
pub y_rot: f32,
|
||||
/// `BookModel.State.forAnimation`: openness, pageFlip1, pageFlip2.
|
||||
pub openness: f32,
|
||||
pub page_flip1: f32,
|
||||
pub page_flip2: f32,
|
||||
}
|
||||
|
||||
impl EnchantingBook {
|
||||
/// `bookAnimationTick`. `players` are the feet positions of the level's
|
||||
/// non-spectator players (`getNearestPlayer(centre, 3.0, false)`);
|
||||
/// `random` is the block entity class's shared static `RANDOM`.
|
||||
pub fn tick(&mut self, pos: [i32; 3], players: &[[f64; 3]], random: &mut impl RandomSource) {
|
||||
self.o_open = self.open;
|
||||
self.o_rot = self.rot;
|
||||
let c = [
|
||||
f64::from(pos[0]) + 0.5,
|
||||
f64::from(pos[1]) + 0.5,
|
||||
f64::from(pos[2]) + 0.5,
|
||||
];
|
||||
let mut best = -1.0;
|
||||
let mut nearest = None;
|
||||
for p in players {
|
||||
let (dx, dy, dz) = (p[0] - c[0], p[1] - c[1], p[2] - c[2]);
|
||||
let dist = dx * dx + dy * dy + dz * dz;
|
||||
if dist < 9.0 && (best == -1.0 || dist < best) {
|
||||
best = dist;
|
||||
nearest = Some(*p);
|
||||
}
|
||||
}
|
||||
if let Some(p) = nearest {
|
||||
let xd = p[0] - c[0];
|
||||
let zd = p[2] - c[2];
|
||||
self.t_rot = trig::atan2(zd, xd) as f32;
|
||||
self.open += 0.1;
|
||||
if self.open < 0.5 || random.next_int_bound(40).unwrap_or(1) == 0 {
|
||||
let old = self.flip_t;
|
||||
loop {
|
||||
let a = random.next_int_bound(4).unwrap_or(0);
|
||||
let b = random.next_int_bound(4).unwrap_or(0);
|
||||
self.flip_t += (a - b) as f32;
|
||||
if old != self.flip_t {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
self.t_rot += 0.02;
|
||||
self.open -= 0.1;
|
||||
}
|
||||
self.rot = wrap_pi(self.rot);
|
||||
self.t_rot = wrap_pi(self.t_rot);
|
||||
let dir = wrap_pi(self.t_rot - self.rot);
|
||||
self.rot += dir * 0.4;
|
||||
self.open = self.open.clamp(0.0, 1.0);
|
||||
self.time += 1;
|
||||
self.o_flip = self.flip;
|
||||
let diff = ((self.flip_t - self.flip) * 0.4).clamp(-0.2, 0.2);
|
||||
self.flip_a += (diff - self.flip_a) * 0.9;
|
||||
self.flip += self.flip_a;
|
||||
}
|
||||
/// `EnchantTableRenderer.extractRenderState` and the book state of its
|
||||
/// `submit`.
|
||||
pub fn render(&self, a: f32) -> BookRender {
|
||||
let flip = self.o_flip + a * (self.flip - self.o_flip);
|
||||
let open = self.o_open + a * (self.open - self.o_open);
|
||||
let time = self.time as f32 + a;
|
||||
let y_rot = self.o_rot + wrap_pi(self.rot - self.o_rot) * a;
|
||||
let ff1 = mth_frac(flip + 0.25) * 1.6 - 0.3;
|
||||
let ff2 = mth_frac(flip + 0.75) * 1.6 - 0.3;
|
||||
BookRender {
|
||||
flip,
|
||||
open,
|
||||
time,
|
||||
y_rot,
|
||||
openness: ((time * 0.02).msin() * 0.1 + 1.25) * open,
|
||||
page_flip1: ff1.clamp(0.0, 1.0),
|
||||
page_flip2: ff2.clamp(0.0, 1.0),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `ShulkerBoxBlockEntity.AnimationStatus`.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub enum ShulkerStatus {
|
||||
#[default]
|
||||
Closed,
|
||||
Opening,
|
||||
Opened,
|
||||
Closing,
|
||||
}
|
||||
|
||||
/// `ShulkerBoxBlockEntity` lid state.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct Shulker {
|
||||
pub open_count: i32,
|
||||
pub status: ShulkerStatus,
|
||||
pub progress: f32,
|
||||
pub progress_old: f32,
|
||||
}
|
||||
|
||||
/// Side effects of one [`Shulker::tick`].
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct ShulkerTick {
|
||||
/// `doNeighborUpdates` calls (shape and neighbour updates around the box).
|
||||
pub neighbor_updates: u32,
|
||||
/// `moveCollidedEntities`: the swept lid box (min x, y, z, max x, y, z);
|
||||
/// entities in it move out along the facing.
|
||||
pub push: Option<[f64; 6]>,
|
||||
}
|
||||
|
||||
/// `Shulker.getProgressDeltaAabb(size, direction, from, to, position)`.
|
||||
pub fn progress_delta_aabb(
|
||||
size: f32,
|
||||
direction: u8,
|
||||
from: f32,
|
||||
to: f32,
|
||||
position: [f64; 3],
|
||||
) -> [f64; 6] {
|
||||
let size_d = f64::from(size);
|
||||
let mut b = [
|
||||
-size_d * 0.5,
|
||||
0.0,
|
||||
-size_d * 0.5,
|
||||
size_d * 0.5,
|
||||
size_d,
|
||||
size_d * 0.5,
|
||||
];
|
||||
let max = f64::from(from.max(to));
|
||||
let min = f64::from(from.min(to));
|
||||
let s = step(direction);
|
||||
for axis in 0..3 {
|
||||
// expandTowards
|
||||
let a = f64::from(s[axis]) * max * size_d;
|
||||
if a < 0.0 {
|
||||
b[axis] += a;
|
||||
} else if a > 0.0 {
|
||||
b[axis + 3] += a;
|
||||
}
|
||||
}
|
||||
for axis in 0..3 {
|
||||
// contract
|
||||
let a = -f64::from(s[axis]) * (1.0 + min) * size_d;
|
||||
if a < 0.0 {
|
||||
b[axis] -= a;
|
||||
} else if a > 0.0 {
|
||||
b[axis + 3] -= a;
|
||||
}
|
||||
}
|
||||
// new AABB keeps min <= max; then move to the position.
|
||||
let mut out = [0.0; 6];
|
||||
for axis in 0..3 {
|
||||
out[axis] = b[axis].min(b[axis + 3]) + position[axis];
|
||||
out[axis + 3] = b[axis].max(b[axis + 3]) + position[axis];
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
impl Shulker {
|
||||
/// `triggerEvent(1, openCount)`.
|
||||
pub fn trigger_event(&mut self, b0: i32, b1: i32) -> bool {
|
||||
if b0 != 1 {
|
||||
return false;
|
||||
}
|
||||
self.open_count = b1;
|
||||
if b1 == 0 {
|
||||
self.status = ShulkerStatus::Closing;
|
||||
}
|
||||
if b1 == 1 {
|
||||
self.status = ShulkerStatus::Opening;
|
||||
}
|
||||
true
|
||||
}
|
||||
/// `startOpen`/`stopOpen` for a non-spectator user: the new open count to
|
||||
/// send as block event 1, and whether this was the first opener (open) or
|
||||
/// the last closer (close), which play the sound and game event.
|
||||
pub fn start_open(&mut self) -> (i32, bool) {
|
||||
if self.open_count < 0 {
|
||||
self.open_count = 0;
|
||||
}
|
||||
self.open_count += 1;
|
||||
(self.open_count, self.open_count == 1)
|
||||
}
|
||||
pub fn stop_open(&mut self) -> (i32, bool) {
|
||||
self.open_count -= 1;
|
||||
(self.open_count, self.open_count <= 0)
|
||||
}
|
||||
/// `updateAnimation` (`ShulkerBoxBlockEntity.tick`); `facing` is the
|
||||
/// block's facing as a 3D data value.
|
||||
pub fn tick(&mut self, pos: [i32; 3], facing: u8) -> ShulkerTick {
|
||||
let mut out = ShulkerTick::default();
|
||||
self.progress_old = self.progress;
|
||||
match self.status {
|
||||
ShulkerStatus::Closed => self.progress = 0.0,
|
||||
ShulkerStatus::Opening => {
|
||||
self.progress += 0.1;
|
||||
if self.progress_old == 0.0 {
|
||||
out.neighbor_updates += 1;
|
||||
}
|
||||
if self.progress >= 1.0 {
|
||||
self.status = ShulkerStatus::Opened;
|
||||
self.progress = 1.0;
|
||||
out.neighbor_updates += 1;
|
||||
}
|
||||
let bottom = [
|
||||
f64::from(pos[0]) + 0.5,
|
||||
f64::from(pos[1]),
|
||||
f64::from(pos[2]) + 0.5,
|
||||
];
|
||||
out.push = Some(progress_delta_aabb(
|
||||
1.0,
|
||||
facing,
|
||||
self.progress_old,
|
||||
self.progress,
|
||||
bottom,
|
||||
));
|
||||
}
|
||||
ShulkerStatus::Opened => self.progress = 1.0,
|
||||
ShulkerStatus::Closing => {
|
||||
self.progress -= 0.1;
|
||||
if self.progress_old == 1.0 {
|
||||
out.neighbor_updates += 1;
|
||||
}
|
||||
if self.progress <= 0.0 {
|
||||
self.status = ShulkerStatus::Closed;
|
||||
self.progress = 0.0;
|
||||
out.neighbor_updates += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
/// `getProgress(a)`: `Mth.lerp(a, progressOld, progress)`.
|
||||
pub fn get_progress(&self, a: f32) -> f32 {
|
||||
self.progress_old + a * (self.progress - self.progress_old)
|
||||
}
|
||||
/// `ShulkerBoxBlock.canOpen`: a closed box only opens when its lid has room
|
||||
/// (`level.noCollision` of the half-block lid box, deflated by 1e-6).
|
||||
pub fn lid_box(pos: [i32; 3], facing: u8) -> [f64; 6] {
|
||||
let bottom = [
|
||||
f64::from(pos[0]) + 0.5,
|
||||
f64::from(pos[1]),
|
||||
f64::from(pos[2]) + 0.5,
|
||||
];
|
||||
let mut b = progress_delta_aabb(1.0, facing, 0.0, 0.5, bottom);
|
||||
for axis in 0..3 {
|
||||
b[axis] += 1.0e-6;
|
||||
b[axis + 3] -= 1.0e-6;
|
||||
}
|
||||
b
|
||||
}
|
||||
}
|
||||
|
||||
/// What the bell's tick asks about an entity it gathered when rung.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct BellEntity {
|
||||
pub position: [f64; 3],
|
||||
/// `isAlive() && !isRemoved()`.
|
||||
pub alive: bool,
|
||||
/// In `EntityTypeTags.RAIDERS`.
|
||||
pub raider: bool,
|
||||
}
|
||||
|
||||
/// `BellBlockEntity`.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct Bell {
|
||||
pub ticks: i32,
|
||||
pub shaking: bool,
|
||||
/// `clickDirection` as a 3D data value.
|
||||
pub click_direction: Option<u8>,
|
||||
pub last_ring_timestamp: i64,
|
||||
/// `nearbyEntities`: the living entities around the bell when it was last
|
||||
/// gathered, as caller-defined handles.
|
||||
pub nearby: Option<Vec<u64>>,
|
||||
pub resonating: bool,
|
||||
pub resonation_ticks: i32,
|
||||
}
|
||||
|
||||
/// Side effects of one [`Bell::tick`].
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct BellTick {
|
||||
/// Resonation started: `BELL_RESONATE` (played by the server).
|
||||
pub resonate: bool,
|
||||
/// Resonation ended (`ResonationEndAction`): the client's
|
||||
/// `showBellParticles` entity-effect particles (position and colour) and
|
||||
/// the server's `makeRaidersGlow` targets.
|
||||
pub ended: Option<(Vec<([f64; 3], i32)>, Vec<u64>)>,
|
||||
}
|
||||
|
||||
fn closer_to_center_than(pos: [i32; 3], p: [f64; 3], distance: f64) -> bool {
|
||||
let dx = f64::from(pos[0]) + 0.5 - p[0];
|
||||
let dy = f64::from(pos[1]) + 0.5 - p[1];
|
||||
let dz = f64::from(pos[2]) + 0.5 - p[2];
|
||||
dx * dx + dy * dy + dz * dz < distance * distance
|
||||
}
|
||||
|
||||
impl Bell {
|
||||
/// `triggerEvent(1, direction)`. `gather` lists the living entities whose
|
||||
/// position is inside the bell's block box inflated by 48
|
||||
/// (`updateEntities`, refreshed at most every 60 ticks).
|
||||
pub fn trigger_event(
|
||||
&mut self,
|
||||
b0: i32,
|
||||
b1: i32,
|
||||
game_time: i64,
|
||||
gather: impl FnOnce() -> Vec<u64>,
|
||||
) -> bool {
|
||||
if b0 != 1 {
|
||||
return false;
|
||||
}
|
||||
if game_time > self.last_ring_timestamp + 60 || self.nearby.is_none() {
|
||||
self.last_ring_timestamp = game_time;
|
||||
self.nearby = Some(gather());
|
||||
}
|
||||
self.resonation_ticks = 0;
|
||||
self.click_direction = Some(from_3d_data_value(b1));
|
||||
self.ticks = 0;
|
||||
self.shaking = true;
|
||||
true
|
||||
}
|
||||
/// `BellBlockEntity.tick` with the client's resonation end action.
|
||||
pub fn tick(&mut self, pos: [i32; 3], entity: impl Fn(u64) -> Option<BellEntity>) -> BellTick {
|
||||
let mut out = BellTick::default();
|
||||
if self.shaking {
|
||||
self.ticks += 1;
|
||||
}
|
||||
if self.ticks >= 50 {
|
||||
self.shaking = false;
|
||||
self.ticks = 0;
|
||||
}
|
||||
let nearby = self.nearby.clone().unwrap_or_default();
|
||||
let raider_in = |e: &BellEntity, distance: f64| {
|
||||
e.alive && closer_to_center_than(pos, e.position, distance) && e.raider
|
||||
};
|
||||
if self.ticks >= 5
|
||||
&& self.resonation_ticks == 0
|
||||
&& nearby
|
||||
.iter()
|
||||
.filter_map(|&h| entity(h))
|
||||
.any(|e| raider_in(&e, 32.0))
|
||||
{
|
||||
self.resonating = true;
|
||||
out.resonate = true;
|
||||
}
|
||||
if self.resonating {
|
||||
if self.resonation_ticks < 40 {
|
||||
self.resonation_ticks += 1;
|
||||
} else {
|
||||
// showBellParticles / makeRaidersGlow.
|
||||
let mut color = 16_700_985;
|
||||
let views: Vec<(u64, BellEntity)> = nearby
|
||||
.iter()
|
||||
.filter_map(|&h| Some((h, entity(h)?)))
|
||||
.collect();
|
||||
let count = views
|
||||
.iter()
|
||||
.filter(|(_, e)| closer_to_center_than(pos, e.position, 48.0))
|
||||
.count() as i32;
|
||||
let per = ((count - 21) / -2).clamp(3, 15);
|
||||
let mut particles = Vec::new();
|
||||
let mut glow = Vec::new();
|
||||
for (h, e) in &views {
|
||||
if !raider_in(e, 48.0) {
|
||||
continue;
|
||||
}
|
||||
glow.push(*h);
|
||||
let (bx, bz) = (f64::from(pos[0]), f64::from(pos[2]));
|
||||
let dist = ((e.position[0] - bx) * (e.position[0] - bx)
|
||||
+ (e.position[2] - bz) * (e.position[2] - bz))
|
||||
.sqrt();
|
||||
let x3 = bx + f64::from(0.5f32) + 1.0 / dist * (e.position[0] - bx);
|
||||
let z3 = bz + f64::from(0.5f32) + 1.0 / dist * (e.position[2] - bz);
|
||||
for _ in 0..per {
|
||||
color += 5;
|
||||
particles.push(([x3, f64::from(pos[1]) + 0.5, z3], color));
|
||||
}
|
||||
}
|
||||
out.ended = Some((particles, glow));
|
||||
self.resonating = false;
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
/// The conduit's `destroyTarget` as the client sees it.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct ConduitTarget {
|
||||
pub position: [f64; 3],
|
||||
pub eye_y: f64,
|
||||
pub width: f32,
|
||||
pub height: f32,
|
||||
}
|
||||
|
||||
/// `ConduitBlockEntity` (client side).
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct Conduit {
|
||||
pub tick_count: i32,
|
||||
pub active_rotation: f32,
|
||||
pub is_active: bool,
|
||||
pub is_hunting: bool,
|
||||
pub effect_blocks: Vec<[i32; 3]>,
|
||||
}
|
||||
|
||||
/// One nautilus particle: position and velocity.
|
||||
pub type Particle = ([f64; 3], [f64; 3]);
|
||||
|
||||
impl Conduit {
|
||||
/// `updateShape`: water all around, then the frame blocks
|
||||
/// (`BlockTags.CONDUIT_EFFECT_BLOCK`) in the three 5x5 rings.
|
||||
fn update_shape(
|
||||
&mut self,
|
||||
pos: [i32; 3],
|
||||
is_water: &impl Fn([i32; 3]) -> bool,
|
||||
is_frame: &impl Fn([i32; 3]) -> bool,
|
||||
) -> bool {
|
||||
self.effect_blocks.clear();
|
||||
for ox in -1..=1 {
|
||||
for oy in -1..=1 {
|
||||
for oz in -1..=1 {
|
||||
if !is_water([pos[0] + ox, pos[1] + oy, pos[2] + oz]) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for ox in -2i32..=2 {
|
||||
for oy in -2i32..=2 {
|
||||
for oz in -2i32..=2 {
|
||||
let (ax, ay, az) = (ox.abs(), oy.abs(), oz.abs());
|
||||
if (ax > 1 || ay > 1 || az > 1)
|
||||
&& (ox == 0 && (ay == 2 || az == 2)
|
||||
|| oy == 0 && (ax == 2 || az == 2)
|
||||
|| oz == 0 && (ax == 2 || ay == 2))
|
||||
{
|
||||
let p = [pos[0] + ox, pos[1] + oy, pos[2] + oz];
|
||||
if is_frame(p) {
|
||||
self.effect_blocks.push(p);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
self.effect_blocks.len() >= 16
|
||||
}
|
||||
/// `ConduitBlockEntity.clientTick`: returns the nautilus particles of
|
||||
/// `animationTick`, drawn from the client level's random.
|
||||
pub fn client_tick(
|
||||
&mut self,
|
||||
pos: [i32; 3],
|
||||
game_time: i64,
|
||||
is_water: impl Fn([i32; 3]) -> bool,
|
||||
is_frame: impl Fn([i32; 3]) -> bool,
|
||||
target: Option<ConduitTarget>,
|
||||
random: &mut impl RandomSource,
|
||||
) -> Vec<Particle> {
|
||||
self.tick_count += 1;
|
||||
if game_time % 40 == 0 {
|
||||
self.is_active = self.update_shape(pos, &is_water, &is_frame);
|
||||
self.is_hunting = self.effect_blocks.len() >= 42;
|
||||
}
|
||||
let particles = self.animation_tick(pos, target, random);
|
||||
if self.is_active {
|
||||
self.active_rotation += 1.0;
|
||||
}
|
||||
particles
|
||||
}
|
||||
fn animation_tick(
|
||||
&self,
|
||||
pos: [i32; 3],
|
||||
target: Option<ConduitTarget>,
|
||||
random: &mut impl RandomSource,
|
||||
) -> Vec<Particle> {
|
||||
let mut out = Vec::new();
|
||||
let hh = f64::from(((self.tick_count + 35) as f32 * 0.1).msin() / 2.0 + 0.5);
|
||||
let hh = (hh * hh + hh) * f64::from(0.3f32);
|
||||
let end = [
|
||||
f64::from(pos[0]) + 0.5,
|
||||
f64::from(pos[1]) + 1.5 + hh,
|
||||
f64::from(pos[2]) + 0.5,
|
||||
];
|
||||
for p in &self.effect_blocks {
|
||||
if random.next_int_bound(50).unwrap_or(1) == 0 {
|
||||
let dx = -0.5 + random.next_float() + (p[0] - pos[0]) as f32;
|
||||
let dy = -2.0 + random.next_float() + (p[1] - pos[1]) as f32;
|
||||
let dz = -0.5 + random.next_float() + (p[2] - pos[2]) as f32;
|
||||
out.push((end, [f64::from(dx), f64::from(dy), f64::from(dz)]));
|
||||
}
|
||||
}
|
||||
if let Some(t) = target {
|
||||
let randx = (-0.5 + random.next_float()) * (3.0 + t.width);
|
||||
let randy = -1.0 + random.next_float() * t.height;
|
||||
let randz = (-0.5 + random.next_float()) * (3.0 + t.width);
|
||||
out.push((
|
||||
[t.position[0], t.eye_y, t.position[2]],
|
||||
[f64::from(randx), f64::from(randy), f64::from(randz)],
|
||||
));
|
||||
}
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
/// `SkullBlockEntity` animation (client ticker of dragon and piglin heads).
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq)]
|
||||
pub struct Skull {
|
||||
pub animating: bool,
|
||||
pub tick_count: i32,
|
||||
}
|
||||
impl Skull {
|
||||
/// `SkullBlockEntity.animation`.
|
||||
pub fn tick(&mut self, powered: bool) {
|
||||
if powered {
|
||||
self.animating = true;
|
||||
self.tick_count += 1;
|
||||
} else {
|
||||
self.animating = false;
|
||||
}
|
||||
}
|
||||
/// `getAnimation(a)`.
|
||||
pub fn get_animation(&self, a: f32) -> f32 {
|
||||
if self.animating {
|
||||
self.tick_count as f32 + a
|
||||
} else {
|
||||
self.tick_count as f32
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `DecoratedPotBlockEntity.WobbleStyle`.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum WobbleStyle {
|
||||
Positive,
|
||||
Negative,
|
||||
}
|
||||
impl WobbleStyle {
|
||||
pub fn duration(self) -> i32 {
|
||||
match self {
|
||||
Self::Positive => 7,
|
||||
Self::Negative => 10,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `DecoratedPotBlockEntity` wobble fields.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq)]
|
||||
pub struct PotWobble {
|
||||
pub started_at: i64,
|
||||
pub style: Option<WobbleStyle>,
|
||||
}
|
||||
impl PotWobble {
|
||||
/// `triggerEvent(1, style)`.
|
||||
pub fn trigger_event(&mut self, event: i32, data: i32, game_time: i64) -> bool {
|
||||
if event == 1 && (0..2).contains(&data) {
|
||||
self.started_at = game_time;
|
||||
self.style = Some(if data == 0 {
|
||||
WobbleStyle::Positive
|
||||
} else {
|
||||
WobbleStyle::Negative
|
||||
});
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
/// `DecoratedPotRenderer.extractRenderState`'s `wobbleProgress`.
|
||||
pub fn progress(&self, game_time: i64, partial: f32) -> f32 {
|
||||
match self.style {
|
||||
Some(style) => {
|
||||
((game_time - self.started_at) as f32 + partial) / style.duration() as f32
|
||||
}
|
||||
None => 0.0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The client ticker a block entity runs (`getTicker` on a client level),
|
||||
/// or [`Ticker::Pot`] for decorated pots, which keep wobble state unticked.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Ticker {
|
||||
/// `ChestBlockEntity` / `EnderChestBlockEntity.lidAnimateTick`.
|
||||
Lid,
|
||||
/// `EnchantingTableBlockEntity.bookAnimationTick`.
|
||||
Book,
|
||||
/// `ShulkerBoxBlockEntity.tick`, with the box's facing.
|
||||
Shulker {
|
||||
facing: u8,
|
||||
},
|
||||
/// `BellBlockEntity.clientTick`.
|
||||
Bell,
|
||||
/// `ConduitBlockEntity.clientTick`.
|
||||
Conduit,
|
||||
/// `SkullBlockEntity.animation` (dragon and piglin heads only).
|
||||
Skull {
|
||||
powered: bool,
|
||||
},
|
||||
Pot,
|
||||
}
|
||||
|
||||
/// The [`Ticker`] of a block state, by block name and property lookup.
|
||||
pub fn ticker(name: &str, prop: impl Fn(&str) -> Option<String>) -> Option<Ticker> {
|
||||
let name = name.strip_prefix("minecraft:").unwrap_or(name);
|
||||
let unwaxed = name.strip_prefix("waxed_").unwrap_or(name);
|
||||
Some(match unwaxed {
|
||||
"chest"
|
||||
| "trapped_chest"
|
||||
| "ender_chest"
|
||||
| "copper_chest"
|
||||
| "exposed_copper_chest"
|
||||
| "weathered_copper_chest"
|
||||
| "oxidized_copper_chest" => Ticker::Lid,
|
||||
"enchanting_table" => Ticker::Book,
|
||||
"bell" => Ticker::Bell,
|
||||
"conduit" => Ticker::Conduit,
|
||||
"decorated_pot" => Ticker::Pot,
|
||||
"dragon_head" | "dragon_wall_head" | "piglin_head" | "piglin_wall_head" => Ticker::Skull {
|
||||
powered: prop("powered").as_deref() == Some("true"),
|
||||
},
|
||||
n if n.ends_with("shulker_box") => Ticker::Shulker {
|
||||
facing: prop("facing")
|
||||
.as_deref()
|
||||
.and_then(direction_index)
|
||||
.unwrap_or(1),
|
||||
},
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
/// The animated block entities of a level, by position.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct BlockEntityAnims {
|
||||
pub chests: HashMap<[i32; 3], ChestLid>,
|
||||
pub books: HashMap<[i32; 3], EnchantingBook>,
|
||||
/// `EnchantingTableBlockEntity.RANDOM`, shared by every table.
|
||||
pub book_random: LegacyRandom,
|
||||
pub shulkers: HashMap<[i32; 3], Shulker>,
|
||||
pub bells: HashMap<[i32; 3], Bell>,
|
||||
pub conduits: HashMap<[i32; 3], Conduit>,
|
||||
pub skulls: HashMap<[i32; 3], Skull>,
|
||||
pub pots: HashMap<[i32; 3], PotWobble>,
|
||||
/// The client level's random (conduit particles).
|
||||
pub random: LegacyRandom,
|
||||
/// [`Ticker`] by block state id (see [`BlockEntityAnims::classify`]).
|
||||
pub kinds: Vec<Option<Ticker>>,
|
||||
}
|
||||
impl Default for BlockEntityAnims {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
chests: HashMap::new(),
|
||||
books: HashMap::new(),
|
||||
book_random: LegacyRandom::new(0x626f6f6b),
|
||||
shulkers: HashMap::new(),
|
||||
bells: HashMap::new(),
|
||||
conduits: HashMap::new(),
|
||||
skulls: HashMap::new(),
|
||||
pots: HashMap::new(),
|
||||
random: LegacyRandom::new(0x6c6576656c),
|
||||
kinds: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
impl BlockEntityAnims {
|
||||
/// Fills [`Self::kinds`] for every state of the registry.
|
||||
pub fn classify(&mut self, registry: &horizon_world::registry::BlockRegistry) {
|
||||
self.kinds = (0..registry.len() as u32)
|
||||
.map(|id| {
|
||||
registry
|
||||
.state(horizon_world::registry::BlockStateId(id))
|
||||
.and_then(|s| ticker(&s.name, |k| s.properties.get(k).cloned()))
|
||||
})
|
||||
.collect();
|
||||
}
|
||||
/// The ticker of a block state.
|
||||
pub fn ticker(&self, state: u32) -> Option<Ticker> {
|
||||
self.kinds.get(state as usize).copied().flatten()
|
||||
}
|
||||
/// Drops the state of block entities that are gone or changed kind:
|
||||
/// `present` maps the loaded block entities to their tickers.
|
||||
pub fn retain_present(&mut self, present: &HashMap<[i32; 3], Ticker>) {
|
||||
let is = |p: &[i32; 3], f: fn(&Ticker) -> bool| present.get(p).is_some_and(f);
|
||||
self.chests.retain(|p, _| is(p, |t| *t == Ticker::Lid));
|
||||
self.books.retain(|p, _| is(p, |t| *t == Ticker::Book));
|
||||
self.shulkers
|
||||
.retain(|p, _| is(p, |t| matches!(t, Ticker::Shulker { .. })));
|
||||
self.bells.retain(|p, _| is(p, |t| *t == Ticker::Bell));
|
||||
self.conduits
|
||||
.retain(|p, _| is(p, |t| *t == Ticker::Conduit));
|
||||
self.skulls
|
||||
.retain(|p, _| is(p, |t| matches!(t, Ticker::Skull { .. })));
|
||||
self.pots.retain(|p, _| is(p, |t| *t == Ticker::Pot));
|
||||
}
|
||||
/// Drops the state of positions whose block entity is gone.
|
||||
pub fn retain(&mut self, keep: impl Fn([i32; 3]) -> bool) {
|
||||
self.chests.retain(|p, _| keep(*p));
|
||||
self.books.retain(|p, _| keep(*p));
|
||||
self.shulkers.retain(|p, _| keep(*p));
|
||||
self.bells.retain(|p, _| keep(*p));
|
||||
self.conduits.retain(|p, _| keep(*p));
|
||||
self.skulls.retain(|p, _| keep(*p));
|
||||
self.pots.retain(|p, _| keep(*p));
|
||||
}
|
||||
/// `getOpenNess(partial)` of the chest or ender chest at `pos`.
|
||||
pub fn lid(&self, pos: [i32; 3], partial: f32) -> f32 {
|
||||
self.chests
|
||||
.get(&pos)
|
||||
.map_or(0.0, |l| l.get_openness(partial))
|
||||
}
|
||||
/// The renderer's view of the block entity at `pos`; `sherds` are the
|
||||
/// pot's decorations (back, left, right, front), when it is a pot.
|
||||
pub fn block_anim(
|
||||
&self,
|
||||
pos: [i32; 3],
|
||||
game_time: i64,
|
||||
partial: f32,
|
||||
sherds: [Option<String>; 4],
|
||||
) -> BlockAnim {
|
||||
let mut anim = BlockAnim {
|
||||
sherds,
|
||||
// End gateway ages and cooldowns are not tracked on the client yet:
|
||||
// gateways render settled (no beam).
|
||||
gateway: (i64::MAX, 0),
|
||||
..BlockAnim::default()
|
||||
};
|
||||
if let Some(b) = self.books.get(&pos) {
|
||||
anim.book = *b;
|
||||
}
|
||||
if let Some(s) = self.shulkers.get(&pos) {
|
||||
anim.shulker = (s.progress, s.progress_old);
|
||||
}
|
||||
if let Some(b) = self.bells.get(&pos) {
|
||||
anim.bell_ticks = b.ticks;
|
||||
anim.bell_shake = b
|
||||
.shaking
|
||||
.then_some(b.click_direction)
|
||||
.flatten()
|
||||
.map(|d| DIRECTIONS[usize::from(d)]);
|
||||
}
|
||||
if let Some(c) = self.conduits.get(&pos) {
|
||||
anim.conduit = (c.tick_count, c.is_active, c.is_hunting, c.active_rotation);
|
||||
}
|
||||
if let Some(s) = self.skulls.get(&pos) {
|
||||
anim.skull = (s.animating, s.tick_count);
|
||||
}
|
||||
if let Some(w) = self.pots.get(&pos)
|
||||
&& w.style.is_some()
|
||||
{
|
||||
// extractRenderState never copies lastWobbleStyle into the render
|
||||
// state, so DecoratedPotRenderer.submit always takes the
|
||||
// non-POSITIVE branch (with the style's own duration).
|
||||
anim.wobble = Some((false, w.progress(game_time, partial)));
|
||||
}
|
||||
anim
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,783 @@
|
||||
//! The camera (`Camera.tick`, `tickFov`, `calculateFov`, `alignWithEntity`,
|
||||
//! `getMaxZoom`, `setRotation`, `move`), view and hurt bobbing
|
||||
//! (`GameRenderer.bobView`, `bobHurt`), the walk and bob history they read
|
||||
//! (`ClientAvatarState`), and `AbstractClientPlayer.getFieldOfViewModifier`.
|
||||
//!
|
||||
//! The float arithmetic follows vanilla and JOML's default (non-FMA,
|
||||
//! non-fast-math) code paths operation by operation.
|
||||
use horizon_core::geometry::trig;
|
||||
|
||||
/// `(float) Math.PI`.
|
||||
const PI: f32 = std::f32::consts::PI;
|
||||
/// `(float) (Math.PI / 180.0)`.
|
||||
const DEG_TO_RAD: f32 = trig::DEG_TO_RAD;
|
||||
|
||||
/// `Math.min(float, float)`: NaN wins and -0.0 is below 0.0.
|
||||
pub fn java_min(a: f32, b: f32) -> f32 {
|
||||
if a.is_nan() || b.is_nan() {
|
||||
f32::NAN
|
||||
} else if a == 0.0 && b == 0.0 {
|
||||
f32::from_bits(a.to_bits() | b.to_bits())
|
||||
} else if a <= b {
|
||||
a
|
||||
} else {
|
||||
b
|
||||
}
|
||||
}
|
||||
/// `Math.max(float, float)`.
|
||||
pub fn java_max(a: f32, b: f32) -> f32 {
|
||||
if a.is_nan() || b.is_nan() {
|
||||
f32::NAN
|
||||
} else if a == 0.0 && b == 0.0 {
|
||||
f32::from_bits(a.to_bits() & b.to_bits())
|
||||
} else if a >= b {
|
||||
a
|
||||
} else {
|
||||
b
|
||||
}
|
||||
}
|
||||
/// `Mth.lerp(float, float, float)`.
|
||||
pub fn lerp(alpha: f32, p0: f32, p1: f32) -> f32 {
|
||||
p0 + alpha * (p1 - p0)
|
||||
}
|
||||
/// `Mth.lerp(double, double, double)`.
|
||||
pub fn lerp_d(alpha: f64, p0: f64, p1: f64) -> f64 {
|
||||
p0 + alpha * (p1 - p0)
|
||||
}
|
||||
/// `Mth.wrapDegrees(float)`.
|
||||
pub fn wrap_degrees(angle: f32) -> f32 {
|
||||
let mut normalized = angle % 360.0;
|
||||
if normalized >= 180.0 {
|
||||
normalized -= 360.0;
|
||||
}
|
||||
if normalized < -180.0 {
|
||||
normalized += 360.0;
|
||||
}
|
||||
normalized
|
||||
}
|
||||
/// `Mth.clamp(float, float, float)`.
|
||||
pub fn clamp(value: f32, min: f32, max: f32) -> f32 {
|
||||
if value < min {
|
||||
min
|
||||
} else {
|
||||
java_min(value, max)
|
||||
}
|
||||
}
|
||||
|
||||
/// JOML's float trigonometry (`org.joml.Math` without fast math).
|
||||
pub mod joml {
|
||||
use super::PI;
|
||||
const TAU: f32 = std::f32::consts::TAU;
|
||||
const HALF_PI: f32 = std::f32::consts::FRAC_PI_2;
|
||||
|
||||
/// `Math.sin(float)`: `java.lang.Math.sin` narrowed.
|
||||
pub fn sin(rad: f32) -> f32 {
|
||||
f64::from(rad).sin() as f32
|
||||
}
|
||||
/// `Math.cosFromSin(float, float)`.
|
||||
pub fn cos_from_sin(sin: f32, angle: f32) -> f32 {
|
||||
let cos = (1.0 - sin * sin).sqrt();
|
||||
let a = angle + HALF_PI;
|
||||
let mut b = a - (a / TAU) as i32 as f32 * TAU;
|
||||
if f64::from(b) < 0.0 {
|
||||
b += TAU;
|
||||
}
|
||||
if b >= PI { -cos } else { cos }
|
||||
}
|
||||
|
||||
/// A JOML `Quaternionf` (x, y, z, w).
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct Quat {
|
||||
pub x: f32,
|
||||
pub y: f32,
|
||||
pub z: f32,
|
||||
pub w: f32,
|
||||
}
|
||||
impl Quat {
|
||||
pub const IDENTITY: Self = Self {
|
||||
x: 0.0,
|
||||
y: 0.0,
|
||||
z: 0.0,
|
||||
w: 1.0,
|
||||
};
|
||||
/// `Quaternionf.rotationYXZ(angleY, angleX, angleZ)`.
|
||||
pub fn rotation_yxz(angle_y: f32, angle_x: f32, angle_z: f32) -> Self {
|
||||
let sx = sin(angle_x * 0.5);
|
||||
let cx = cos_from_sin(sx, angle_x * 0.5);
|
||||
let sy = sin(angle_y * 0.5);
|
||||
let cy = cos_from_sin(sy, angle_y * 0.5);
|
||||
let sz = sin(angle_z * 0.5);
|
||||
let cz = cos_from_sin(sz, angle_z * 0.5);
|
||||
let x = cy * sx;
|
||||
let y = sy * cx;
|
||||
let z = sy * sx;
|
||||
let w = cy * cx;
|
||||
Self {
|
||||
x: x * cz + y * sz,
|
||||
y: y * cz - x * sz,
|
||||
z: w * sz - z * cz,
|
||||
w: w * cz + z * sz,
|
||||
}
|
||||
}
|
||||
/// `Quaternionf.transform(x, y, z, dest)` (`Vector3f.rotate`).
|
||||
pub fn transform(&self, v: [f32; 3]) -> [f32; 3] {
|
||||
let [x, y, z] = v;
|
||||
let xx = self.x * self.x;
|
||||
let yy = self.y * self.y;
|
||||
let zz = self.z * self.z;
|
||||
let ww = self.w * self.w;
|
||||
let xy = self.x * self.y;
|
||||
let xz = self.x * self.z;
|
||||
let yz = self.y * self.z;
|
||||
let xw = self.x * self.w;
|
||||
let zw = self.z * self.w;
|
||||
let yw = self.y * self.w;
|
||||
let k = 1.0 / (xx + yy + zz + ww);
|
||||
[
|
||||
(xx - yy - zz + ww) * k * x + (2.0 * (xy - zw) * k * y + 2.0 * (xz + yw) * k * z),
|
||||
2.0 * (xy + zw) * k * x + ((yy - xx - zz + ww) * k * y + 2.0 * (yz - xw) * k * z),
|
||||
2.0 * (xz - yw) * k * x + (2.0 * (yz + xw) * k * y + (zz - xx - yy + ww) * k * z),
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
const PERSPECTIVE: u8 = 1;
|
||||
const IDENTITY: u8 = 4;
|
||||
const TRANSLATION: u8 = 8;
|
||||
|
||||
/// A JOML `Matrix4f` with its property flags, which select the code
|
||||
/// paths (and so the rounding) of later operations. `m[column * 4 + row]`.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct Mat4 {
|
||||
pub m: [f32; 16],
|
||||
pub properties: u8,
|
||||
}
|
||||
impl Default for Mat4 {
|
||||
fn default() -> Self {
|
||||
Self::IDENTITY
|
||||
}
|
||||
}
|
||||
impl Mat4 {
|
||||
/// `new Matrix4f()`.
|
||||
pub const IDENTITY: Self = Self {
|
||||
m: [
|
||||
1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0,
|
||||
],
|
||||
properties: 30,
|
||||
};
|
||||
fn get(&self, column: usize, row: usize) -> f32 {
|
||||
self.m[column * 4 + row]
|
||||
}
|
||||
fn set(&mut self, column: usize, row: usize, v: f32) {
|
||||
self.m[column * 4 + row] = v;
|
||||
}
|
||||
fn reset_unless_identity(&mut self) {
|
||||
if self.properties & IDENTITY == 0 {
|
||||
self.m = Self::IDENTITY.m;
|
||||
}
|
||||
}
|
||||
/// `Matrix4f.translation(x, y, z)`.
|
||||
pub fn translation(&mut self, x: f32, y: f32, z: f32) {
|
||||
self.reset_unless_identity();
|
||||
self.set(3, 0, x);
|
||||
self.set(3, 1, y);
|
||||
self.set(3, 2, z);
|
||||
self.properties = 26;
|
||||
}
|
||||
/// `Matrix4f.setTranslation(x, y, z)`.
|
||||
pub fn set_translation(&mut self, x: f32, y: f32, z: f32) {
|
||||
self.set(3, 0, x);
|
||||
self.set(3, 1, y);
|
||||
self.set(3, 2, z);
|
||||
self.properties &= !(PERSPECTIVE | IDENTITY);
|
||||
}
|
||||
/// `Matrix4f.translate(x, y, z)`.
|
||||
pub fn translate(&mut self, x: f32, y: f32, z: f32) {
|
||||
if self.properties & IDENTITY != 0 {
|
||||
self.translation(x, y, z);
|
||||
return;
|
||||
}
|
||||
for row in 0..4 {
|
||||
let v = self.get(0, row) * x
|
||||
+ (self.get(1, row) * y + (self.get(2, row) * z + self.get(3, row)));
|
||||
self.set(3, row, v);
|
||||
}
|
||||
self.properties &= !(PERSPECTIVE | IDENTITY);
|
||||
}
|
||||
fn rotation_axis(&mut self, axis: usize, ang: f32) {
|
||||
let sin = sin(ang);
|
||||
let cos = cos_from_sin(sin, ang);
|
||||
self.reset_unless_identity();
|
||||
match axis {
|
||||
0 => {
|
||||
self.set(1, 1, cos);
|
||||
self.set(1, 2, sin);
|
||||
self.set(2, 1, -sin);
|
||||
self.set(2, 2, cos);
|
||||
}
|
||||
1 => {
|
||||
self.set(0, 0, cos);
|
||||
self.set(0, 2, -sin);
|
||||
self.set(2, 0, sin);
|
||||
self.set(2, 2, cos);
|
||||
}
|
||||
_ => {
|
||||
self.set(0, 0, cos);
|
||||
self.set(0, 1, sin);
|
||||
self.set(1, 0, -sin);
|
||||
self.set(1, 1, cos);
|
||||
}
|
||||
}
|
||||
self.properties = 18;
|
||||
}
|
||||
/// `Matrix4f.rotateX/rotateY/rotateZ(ang)`, with the identity and
|
||||
/// pure-translation shortcuts.
|
||||
fn rotate_axis(&mut self, axis: usize, ang: f32) {
|
||||
if self.properties & IDENTITY != 0 {
|
||||
self.rotation_axis(axis, ang);
|
||||
return;
|
||||
}
|
||||
if self.properties & TRANSLATION != 0 {
|
||||
let (x, y, z) = (self.get(3, 0), self.get(3, 1), self.get(3, 2));
|
||||
self.rotation_axis(axis, ang);
|
||||
self.set_translation(x, y, z);
|
||||
return;
|
||||
}
|
||||
let sin = sin(ang);
|
||||
let cos = cos_from_sin(sin, ang);
|
||||
let s = *self;
|
||||
match axis {
|
||||
0 => {
|
||||
for row in 0..4 {
|
||||
let (l1, l2) = (s.get(1, row), s.get(2, row));
|
||||
self.set(2, row, l1 * -sin + l2 * cos);
|
||||
self.set(1, row, l1 * cos + l2 * sin);
|
||||
}
|
||||
}
|
||||
1 => {
|
||||
for row in 0..4 {
|
||||
let (l0, l2) = (s.get(0, row), s.get(2, row));
|
||||
self.set(0, row, l0 * cos + l2 * -sin);
|
||||
self.set(2, row, l0 * sin + l2 * cos);
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
// rotateTowardsXY(sin, cos).
|
||||
let (dx, dy) = (sin, cos);
|
||||
for row in 0..4 {
|
||||
let (l0, l1) = (s.get(0, row), s.get(1, row));
|
||||
self.set(0, row, l0 * dy + l1 * dx);
|
||||
self.set(1, row, l0 * -dx + l1 * dy);
|
||||
}
|
||||
}
|
||||
}
|
||||
self.properties &= !(PERSPECTIVE | IDENTITY | TRANSLATION);
|
||||
}
|
||||
pub fn rotate_x(&mut self, ang: f32) {
|
||||
self.rotate_axis(0, ang);
|
||||
}
|
||||
pub fn rotate_y(&mut self, ang: f32) {
|
||||
self.rotate_axis(1, ang);
|
||||
}
|
||||
pub fn rotate_z(&mut self, ang: f32) {
|
||||
// rotateTowardsXY also short-cuts on identity, but rotateZ has
|
||||
// already taken that branch.
|
||||
self.rotate_axis(2, ang);
|
||||
}
|
||||
}
|
||||
}
|
||||
use joml::{Mat4, Quat};
|
||||
|
||||
/// `CameraType`.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub enum CameraType {
|
||||
#[default]
|
||||
FirstPerson,
|
||||
ThirdPersonBack,
|
||||
ThirdPersonFront,
|
||||
}
|
||||
impl CameraType {
|
||||
pub fn is_first_person(self) -> bool {
|
||||
self == Self::FirstPerson
|
||||
}
|
||||
pub fn is_mirrored(self) -> bool {
|
||||
self == Self::ThirdPersonFront
|
||||
}
|
||||
/// `CameraType.cycle` (F5).
|
||||
pub fn cycle(self) -> Self {
|
||||
match self {
|
||||
Self::FirstPerson => Self::ThirdPersonBack,
|
||||
Self::ThirdPersonBack => Self::ThirdPersonFront,
|
||||
Self::ThirdPersonFront => Self::FirstPerson,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What `AbstractClientPlayer.getFieldOfViewModifier` reads.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct FovPlayer {
|
||||
/// `Abilities.flying`.
|
||||
pub flying: bool,
|
||||
/// `Abilities.getWalkingSpeed()`.
|
||||
pub walking_speed: f32,
|
||||
/// `getAttributeValue(MOVEMENT_SPEED)`.
|
||||
pub movement_speed: f64,
|
||||
/// The item in use and its use ticks, when using one.
|
||||
pub using: Option<(UseItem, i32)>,
|
||||
}
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum UseItem {
|
||||
Bow,
|
||||
Spyglass,
|
||||
Other,
|
||||
}
|
||||
/// `AbstractClientPlayer.getFieldOfViewModifier(firstPerson, effectScale)`.
|
||||
pub fn field_of_view_modifier(p: &FovPlayer, first_person: bool, effect_scale: f32) -> f32 {
|
||||
let mut modifier = 1.0f32;
|
||||
if p.flying {
|
||||
modifier *= 1.1;
|
||||
}
|
||||
let walking_speed = p.walking_speed;
|
||||
if walking_speed != 0.0 {
|
||||
let speed_factor = p.movement_speed as f32 / walking_speed;
|
||||
modifier *= (speed_factor + 1.0) / 2.0;
|
||||
}
|
||||
if let Some((item, ticks)) = p.using {
|
||||
match item {
|
||||
UseItem::Bow => {
|
||||
let scale = java_min(ticks as f32 / 20.0, 1.0);
|
||||
modifier *= 1.0 - scale * scale * 0.15;
|
||||
}
|
||||
// isScoping: using a spyglass.
|
||||
UseItem::Spyglass if first_person => return 0.1,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
lerp(effect_scale, 1.0, modifier)
|
||||
}
|
||||
|
||||
/// The camera entity as `Camera.alignWithEntity` and the field-of-view
|
||||
/// death and hurt effects see it.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct CameraEntity {
|
||||
pub position: [f64; 3],
|
||||
/// `xo`, `yo`, `zo`.
|
||||
pub previous: [f64; 3],
|
||||
pub x_rot: f32,
|
||||
pub y_rot: f32,
|
||||
pub x_rot_o: f32,
|
||||
pub y_rot_o: f32,
|
||||
/// `Entity.getEyeHeight()`.
|
||||
pub eye_height: f32,
|
||||
/// A living entity's `getScale()` and camera distance attribute.
|
||||
pub living: Option<LivingCamera>,
|
||||
/// The vehicle's scale and camera distance when it is living.
|
||||
pub mount: Option<(f32, f64)>,
|
||||
}
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct LivingCamera {
|
||||
pub scale: f32,
|
||||
pub camera_distance: f64,
|
||||
/// `isSleeping()` with `getBedOrientation()` as its `toYRot()`.
|
||||
pub sleeping: Option<Option<f32>>,
|
||||
/// `isDeadOrDying()` and `deathTime`.
|
||||
pub dead: Option<i32>,
|
||||
}
|
||||
impl CameraEntity {
|
||||
/// `Entity.getXRot(partialTicks)`.
|
||||
pub fn view_x_rot(&self, partial: f32) -> f32 {
|
||||
if partial == 1.0 {
|
||||
self.x_rot
|
||||
} else {
|
||||
lerp(partial, self.x_rot_o, self.x_rot)
|
||||
}
|
||||
}
|
||||
/// `LocalPlayer.getViewYRot` (`getYRot(partialTicks)`, a `Mth.rotLerp`).
|
||||
pub fn view_y_rot(&self, partial: f32) -> f32 {
|
||||
if partial == 1.0 {
|
||||
self.y_rot
|
||||
} else {
|
||||
self.y_rot_o + partial * wrap_degrees(self.y_rot - self.y_rot_o)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The fluid `Camera.getFluidInCamera` reports, as the field of view sees it.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub enum CameraFluid {
|
||||
#[default]
|
||||
None,
|
||||
Water,
|
||||
Lava,
|
||||
PowderSnow,
|
||||
}
|
||||
|
||||
/// `Camera`: the eye-height and field-of-view easing and the view placement.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct Camera {
|
||||
pub eye_height: f32,
|
||||
pub eye_height_old: f32,
|
||||
pub fov_modifier: f32,
|
||||
pub old_fov_modifier: f32,
|
||||
pub position: [f64; 3],
|
||||
pub x_rot: f32,
|
||||
pub y_rot: f32,
|
||||
pub rotation: Quat,
|
||||
pub forwards: [f32; 3],
|
||||
pub up: [f32; 3],
|
||||
pub left: [f32; 3],
|
||||
pub detached: bool,
|
||||
}
|
||||
impl Default for Camera {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
eye_height: 0.0,
|
||||
eye_height_old: 0.0,
|
||||
fov_modifier: 0.0,
|
||||
old_fov_modifier: 0.0,
|
||||
position: [0.0; 3],
|
||||
x_rot: 0.0,
|
||||
y_rot: 0.0,
|
||||
rotation: Quat::IDENTITY,
|
||||
forwards: [0.0, 0.0, -1.0],
|
||||
up: [0.0, 1.0, 0.0],
|
||||
left: [-1.0, 0.0, 0.0],
|
||||
detached: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
impl Camera {
|
||||
/// `Camera.tick`: the eye height eases halfway toward the entity's
|
||||
/// (with a level and an entity), then `tickFov` with the camera entity's
|
||||
/// field-of-view modifier target (1 when it is not a player).
|
||||
pub fn tick(&mut self, entity_eye_height: Option<f32>, target_fov_modifier: f32) {
|
||||
if let Some(eye) = entity_eye_height {
|
||||
self.eye_height_old = self.eye_height;
|
||||
self.eye_height += (eye - self.eye_height) * 0.5;
|
||||
}
|
||||
self.old_fov_modifier = self.fov_modifier;
|
||||
self.fov_modifier += (target_fov_modifier - self.fov_modifier) * 0.5;
|
||||
self.fov_modifier = clamp(self.fov_modifier, 0.1, 1.5);
|
||||
}
|
||||
/// `Camera.setRotation`.
|
||||
pub fn set_rotation(&mut self, y_rot: f32, x_rot: f32) {
|
||||
self.x_rot = x_rot;
|
||||
self.y_rot = y_rot;
|
||||
self.rotation = Quat::rotation_yxz(PI - y_rot * DEG_TO_RAD, -x_rot * DEG_TO_RAD, 0.0);
|
||||
self.forwards = self.rotation.transform([0.0, 0.0, -1.0]);
|
||||
self.up = self.rotation.transform([0.0, 1.0, 0.0]);
|
||||
self.left = self.rotation.transform([-1.0, 0.0, 0.0]);
|
||||
}
|
||||
/// `Camera.move(forwards, up, right)`.
|
||||
pub fn move_by(&mut self, forwards: f32, up: f32, right: f32) {
|
||||
let offset = self.rotation.transform([right, up, -forwards]);
|
||||
self.position = [
|
||||
self.position[0] + f64::from(offset[0]),
|
||||
self.position[1] + f64::from(offset[1]),
|
||||
self.position[2] + f64::from(offset[2]),
|
||||
];
|
||||
}
|
||||
/// `Camera.alignWithEntity` (not riding a minecart with the new
|
||||
/// movement): the entity's view rotation and interpolated eye, then a
|
||||
/// detached camera backed off along the view (`getMaxZoom`) or a sleeping
|
||||
/// one turned to the bed. `clip` is `Level.clip` with
|
||||
/// `ClipContext.Block.VISUAL` and no fluids; it returns the hit location
|
||||
/// unless the ray misses.
|
||||
pub fn align_with_entity(
|
||||
&mut self,
|
||||
entity: &CameraEntity,
|
||||
partial: f32,
|
||||
camera_type: CameraType,
|
||||
mut clip: impl FnMut([f64; 3], [f64; 3]) -> Option<[f64; 3]>,
|
||||
) {
|
||||
self.set_rotation(entity.view_y_rot(partial), entity.view_x_rot(partial));
|
||||
let p = f64::from(partial);
|
||||
self.position = [
|
||||
lerp_d(p, entity.previous[0], entity.position[0]),
|
||||
lerp_d(p, entity.previous[1], entity.position[1])
|
||||
+ f64::from(lerp(partial, self.eye_height_old, self.eye_height)),
|
||||
lerp_d(p, entity.previous[2], entity.position[2]),
|
||||
];
|
||||
self.detached = !camera_type.is_first_person();
|
||||
if self.detached {
|
||||
if camera_type.is_mirrored() {
|
||||
self.set_rotation(self.y_rot + 180.0, -self.x_rot);
|
||||
}
|
||||
let (mut distance, mut scale) = (4.0f32, 1.0f32);
|
||||
if let Some(living) = &entity.living {
|
||||
scale = living.scale;
|
||||
distance = living.camera_distance as f32;
|
||||
}
|
||||
let (mut mount_scale, mut mount_distance) = (scale, distance);
|
||||
if let Some((s, d)) = entity.mount {
|
||||
mount_scale = s;
|
||||
mount_distance = d as f32;
|
||||
}
|
||||
let zoom = self.max_zoom(
|
||||
java_max(scale * distance, mount_scale * mount_distance),
|
||||
&mut clip,
|
||||
);
|
||||
self.move_by(-zoom, 0.0, 0.0);
|
||||
} else if let Some(LivingCamera {
|
||||
sleeping: Some(bed),
|
||||
..
|
||||
}) = entity.living
|
||||
{
|
||||
self.set_rotation(bed.map_or(0.0, |yaw| yaw - 180.0), 0.0);
|
||||
self.move_by(0.0, 0.15, 0.0);
|
||||
}
|
||||
}
|
||||
/// `Camera.getMaxZoom`: eight rays from the corners of a 0.2 cube about
|
||||
/// the eye, shortening the distance to the nearest visual-shape hit.
|
||||
pub fn max_zoom(
|
||||
&self,
|
||||
mut distance: f32,
|
||||
clip: &mut impl FnMut([f64; 3], [f64; 3]) -> Option<[f64; 3]>,
|
||||
) -> f32 {
|
||||
let at = self.position;
|
||||
for i in 0..8 {
|
||||
let offset = [
|
||||
((i & 1) * 2 - 1) as f32,
|
||||
((i >> 1 & 1) * 2 - 1) as f32,
|
||||
((i >> 2 & 1) * 2 - 1) as f32,
|
||||
];
|
||||
let from: [f64; 3] = std::array::from_fn(|a| at[a] + f64::from(offset[a] * 0.1));
|
||||
let back = f64::from(-distance);
|
||||
let to: [f64; 3] =
|
||||
std::array::from_fn(|a| from[a] + f64::from(self.forwards[a]) * back);
|
||||
if let Some(hit) = clip(from, to) {
|
||||
let d = [hit[0] - at[0], hit[1] - at[1], hit[2] - at[2]];
|
||||
let dist_sq = (d[0] * d[0] + d[1] * d[1] + d[2] * d[2]) as f32;
|
||||
if dist_sq < distance * distance {
|
||||
distance = dist_sq.sqrt();
|
||||
}
|
||||
}
|
||||
}
|
||||
distance
|
||||
}
|
||||
/// `Camera.calculateFov`: the option scaled by the eased modifier, then
|
||||
/// the death and fluid effects.
|
||||
pub fn calculate_fov(
|
||||
&self,
|
||||
partial: f32,
|
||||
fov_option: i32,
|
||||
effect_scale: f32,
|
||||
dead: Option<i32>,
|
||||
fluid: CameraFluid,
|
||||
panoramic: bool,
|
||||
) -> f32 {
|
||||
if panoramic {
|
||||
return 90.0;
|
||||
}
|
||||
let fov = fov_option as f32 * lerp(partial, self.old_fov_modifier, self.fov_modifier);
|
||||
modify_fov_based_on_death_or_fluid(partial, fov, effect_scale, dead, fluid)
|
||||
}
|
||||
/// `Camera.calculateHudFov`.
|
||||
pub fn calculate_hud_fov(
|
||||
&self,
|
||||
partial: f32,
|
||||
effect_scale: f32,
|
||||
dead: Option<i32>,
|
||||
fluid: CameraFluid,
|
||||
) -> f32 {
|
||||
modify_fov_based_on_death_or_fluid(partial, 70.0, effect_scale, dead, fluid)
|
||||
}
|
||||
}
|
||||
/// `Camera.modifyFovBasedOnDeathOrFluid`. `dead` is the camera entity's
|
||||
/// `deathTime` while it is a living entity that is dead or dying.
|
||||
pub fn modify_fov_based_on_death_or_fluid(
|
||||
partial: f32,
|
||||
mut fov: f32,
|
||||
effect_scale: f32,
|
||||
dead: Option<i32>,
|
||||
fluid: CameraFluid,
|
||||
) -> f32 {
|
||||
if let Some(death_time) = dead {
|
||||
let duration = java_min(death_time as f32 + partial, 20.0);
|
||||
fov /= (1.0 - 500.0 / (duration + 500.0)) * 2.0 + 1.0;
|
||||
}
|
||||
if matches!(fluid, CameraFluid::Lava | CameraFluid::Water) {
|
||||
fov *= lerp(effect_scale, 1.0, 0.857_142_87);
|
||||
}
|
||||
fov
|
||||
}
|
||||
|
||||
/// `ClientAvatarState`: the walked distance and view bob of a player.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq)]
|
||||
pub struct AvatarBob {
|
||||
pub walk_dist: f32,
|
||||
pub walk_dist_o: f32,
|
||||
pub bob: f32,
|
||||
pub bob_o: f32,
|
||||
}
|
||||
impl AvatarBob {
|
||||
/// `ClientAvatarState.tick` (at the start of `AbstractClientPlayer.tick`).
|
||||
pub fn tick(&mut self) {
|
||||
self.walk_dist_o = self.walk_dist;
|
||||
}
|
||||
/// `LocalPlayer.move`: `addWalkedDistance(Mth.length(dx, dz) * 0.6F)`
|
||||
/// with the float horizontal displacement.
|
||||
pub fn add_walked(&mut self, dx: f32, dz: f32) {
|
||||
self.walk_dist += (dx * dx + dz * dz).sqrt() * 0.6;
|
||||
}
|
||||
/// `AbstractClientPlayer.updateBob`: toward the horizontal speed (at most
|
||||
/// 0.1) on the ground, toward 0 otherwise.
|
||||
pub fn update_bob(&mut self, on_ground_alive_not_swimming: bool, velocity: [f64; 3]) {
|
||||
let target = if on_ground_alive_not_swimming {
|
||||
java_min(
|
||||
0.1,
|
||||
(velocity[0] * velocity[0] + velocity[2] * velocity[2]).sqrt() as f32,
|
||||
)
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
self.bob_o = self.bob;
|
||||
self.bob += (target - self.bob) * 0.4;
|
||||
}
|
||||
/// `ClientAvatarState.resetBob` (`AbstractClientPlayer.rideTick`).
|
||||
pub fn reset_bob(&mut self) {
|
||||
self.bob_o = self.bob;
|
||||
self.bob = 0.0;
|
||||
}
|
||||
pub fn interpolated_bob(&self, partial: f32) -> f32 {
|
||||
lerp(partial, self.bob_o, self.bob)
|
||||
}
|
||||
pub fn backwards_interpolated_walk_distance(&self, partial: f32) -> f32 {
|
||||
let wda = self.walk_dist - self.walk_dist_o;
|
||||
-(self.walk_dist + wda * partial)
|
||||
}
|
||||
}
|
||||
|
||||
/// The parts of `CameraRenderState.entityRenderState` the bobbing reads.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq)]
|
||||
pub struct BobState {
|
||||
/// A living camera entity: `hurtTime - partial`, `hurtDuration`,
|
||||
/// `getHurtDir()`, and `deathTime + partial` when dead or dying.
|
||||
pub living: Option<HurtBob>,
|
||||
/// A player camera entity: the backwards-interpolated walk distance and
|
||||
/// the interpolated bob.
|
||||
pub player: Option<(f32, f32)>,
|
||||
}
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq)]
|
||||
pub struct HurtBob {
|
||||
pub hurt_time: f32,
|
||||
pub hurt_duration: i32,
|
||||
pub hurt_dir: f32,
|
||||
pub death_time: Option<f32>,
|
||||
}
|
||||
/// `PoseStack.rotateDegrees` on the pose with an `Axis`.
|
||||
fn rotate_degrees(m: &mut Mat4, axis: usize, degrees: f32) {
|
||||
let ang = degrees * DEG_TO_RAD;
|
||||
match axis {
|
||||
0 => m.rotate_x(ang),
|
||||
1 => m.rotate_y(ang),
|
||||
_ => m.rotate_z(ang),
|
||||
}
|
||||
}
|
||||
/// `GameRenderer.bobHurt`.
|
||||
pub fn bob_hurt(state: &BobState, damage_tilt_strength: f64, pose: &mut Mat4) {
|
||||
let Some(living) = state.living else { return };
|
||||
let mut hurt = living.hurt_time;
|
||||
if let Some(death_time) = living.death_time {
|
||||
let duration = java_min(death_time, 20.0);
|
||||
rotate_degrees(pose, 2, 40.0 - 8000.0 / (duration + 200.0));
|
||||
}
|
||||
if hurt < 0.0 {
|
||||
return;
|
||||
}
|
||||
hurt /= living.hurt_duration as f32;
|
||||
hurt = trig::sin(f64::from(hurt * hurt * hurt * hurt * PI));
|
||||
let rr = living.hurt_dir;
|
||||
rotate_degrees(pose, 1, -rr);
|
||||
let tilt = (f64::from(-hurt) * 14.0 * damage_tilt_strength) as f32;
|
||||
rotate_degrees(pose, 2, tilt);
|
||||
rotate_degrees(pose, 1, rr);
|
||||
}
|
||||
/// `GameRenderer.bobView`.
|
||||
pub fn bob_view(state: &BobState, pose: &mut Mat4) {
|
||||
let Some((walk, bob)) = state.player else {
|
||||
return;
|
||||
};
|
||||
pose.translate(
|
||||
trig::sin(f64::from(walk * PI)) * bob * 0.5,
|
||||
-(trig::cos(f64::from(walk * PI)) * bob).abs(),
|
||||
0.0,
|
||||
);
|
||||
rotate_degrees(pose, 2, trig::sin(f64::from(walk * PI)) * bob * 3.0);
|
||||
rotate_degrees(
|
||||
pose,
|
||||
0,
|
||||
(trig::cos(f64::from(walk * PI - 0.2)) * bob).abs() * 5.0,
|
||||
);
|
||||
}
|
||||
/// The bob matrix `GameRenderer.renderLevel` multiplies into the projection
|
||||
/// (and `renderItemInHand` into the hand's pose).
|
||||
pub fn bob_matrix(state: &BobState, damage_tilt_strength: f64, bob_view_option: bool) -> Mat4 {
|
||||
let mut pose = Mat4::IDENTITY;
|
||||
bob_hurt(state, damage_tilt_strength, &mut pose);
|
||||
if bob_view_option {
|
||||
bob_view(state, &mut pose);
|
||||
}
|
||||
pose
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn camera_eases_eye_height_and_fov() {
|
||||
let mut camera = Camera::default();
|
||||
camera.tick(Some(1.62), 1.0);
|
||||
assert_eq!(camera.eye_height, 0.81);
|
||||
assert_eq!(camera.fov_modifier, 0.5);
|
||||
camera.tick(None, 1.0);
|
||||
assert_eq!(camera.eye_height, 0.81);
|
||||
assert_eq!(camera.fov_modifier, 0.75);
|
||||
}
|
||||
#[test]
|
||||
fn rotation_faces_south_at_zero_yaw() {
|
||||
let mut camera = Camera::default();
|
||||
camera.set_rotation(0.0, 0.0);
|
||||
assert!((camera.forwards[2] - 1.0).abs() < 1e-6);
|
||||
camera.set_rotation(90.0, 0.0);
|
||||
assert!((camera.forwards[0] + 1.0).abs() < 1e-6);
|
||||
}
|
||||
#[test]
|
||||
fn detached_camera_backs_off_until_blocked() {
|
||||
let entity = CameraEntity {
|
||||
position: [0.0; 3],
|
||||
previous: [0.0; 3],
|
||||
x_rot: 0.0,
|
||||
y_rot: 0.0,
|
||||
x_rot_o: 0.0,
|
||||
y_rot_o: 0.0,
|
||||
eye_height: 1.62,
|
||||
living: None,
|
||||
mount: None,
|
||||
};
|
||||
let mut camera = Camera::default();
|
||||
camera.align_with_entity(&entity, 1.0, CameraType::ThirdPersonBack, |_, _| None);
|
||||
assert!((camera.position[2] + 4.0).abs() < 1e-6);
|
||||
camera.align_with_entity(&entity, 1.0, CameraType::ThirdPersonBack, |_, _| {
|
||||
Some([0.0, 0.0, -1.0])
|
||||
});
|
||||
assert!(camera.position[2] > -1.1 && camera.position[2] < -0.9);
|
||||
}
|
||||
#[test]
|
||||
fn fov_modifier_combines_speed_flight_and_bow() {
|
||||
let mut p = FovPlayer {
|
||||
flying: false,
|
||||
walking_speed: 0.1,
|
||||
movement_speed: 0.13,
|
||||
using: None,
|
||||
};
|
||||
assert_eq!(
|
||||
field_of_view_modifier(&p, true, 1.0),
|
||||
(0.13f32 / 0.1 + 1.0) / 2.0
|
||||
);
|
||||
assert_eq!(field_of_view_modifier(&p, true, 0.0), 1.0);
|
||||
p.using = Some((UseItem::Spyglass, 3));
|
||||
assert_eq!(field_of_view_modifier(&p, true, 1.0), 0.1);
|
||||
assert_ne!(field_of_view_modifier(&p, false, 1.0), 0.1);
|
||||
}
|
||||
}
|
||||
+124
-110
@@ -5,6 +5,7 @@
|
||||
use crate::{
|
||||
command::{Dispatcher, ParseResults, Registries, Suggestion},
|
||||
hud::{HudAtlas, HudBatch, rgba},
|
||||
hud_layout::{self, GuiDraw, PoseOp},
|
||||
text::{self, Style, Text},
|
||||
ui::{EditBox, char_len},
|
||||
};
|
||||
@@ -12,15 +13,16 @@ use crate::{
|
||||
/// GuiMessageTag.systemSinglePlayer's indicator colour.
|
||||
pub const SYSTEM_INDICATOR: u32 = 0xD0D0D0;
|
||||
|
||||
/// The chat options (doubles, as vanilla keeps them).
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct ChatOptions {
|
||||
pub opacity: f32,
|
||||
pub background_opacity: f32,
|
||||
pub scale: f32,
|
||||
pub width: f32,
|
||||
pub height_focused: f32,
|
||||
pub height_unfocused: f32,
|
||||
pub line_spacing: f32,
|
||||
pub opacity: f64,
|
||||
pub background_opacity: f64,
|
||||
pub scale: f64,
|
||||
pub width: f64,
|
||||
pub height_focused: f64,
|
||||
pub height_unfocused: f64,
|
||||
pub line_spacing: f64,
|
||||
}
|
||||
impl Default for ChatOptions {
|
||||
fn default() -> Self {
|
||||
@@ -37,20 +39,21 @@ impl Default for ChatOptions {
|
||||
}
|
||||
impl ChatOptions {
|
||||
/// ChatComponent.getWidth/getHeight.
|
||||
pub fn width_px(&self) -> f32 {
|
||||
(self.width * 280.0 + 40.0).floor()
|
||||
pub fn width_px(&self) -> i32 {
|
||||
(self.width * 280.0 + 40.0).floor() as i32
|
||||
}
|
||||
pub fn height_px(&self, focused: bool) -> f32 {
|
||||
pub fn height_px(&self, focused: bool) -> i32 {
|
||||
((if focused {
|
||||
self.height_focused
|
||||
} else {
|
||||
self.height_unfocused
|
||||
}) * 160.0
|
||||
+ 20.0)
|
||||
.floor()
|
||||
.floor() as i32
|
||||
}
|
||||
pub fn line_height(&self) -> f32 {
|
||||
(9.0 * (self.line_spacing + 1.0)).floor()
|
||||
/// `getLineHeight`.
|
||||
pub fn line_height(&self) -> i32 {
|
||||
(9.0 * (self.line_spacing + 1.0)) as i32
|
||||
}
|
||||
}
|
||||
#[derive(Clone, Debug)]
|
||||
@@ -59,12 +62,25 @@ struct Message {
|
||||
content: Text,
|
||||
indicator: Option<u32>,
|
||||
}
|
||||
/// A wrapped line (`GuiMessage.Line`).
|
||||
#[derive(Clone, Debug)]
|
||||
struct Line {
|
||||
added: u64,
|
||||
content: Text,
|
||||
indicator: Option<u32>,
|
||||
}
|
||||
|
||||
/// `ChatComponent.AlphaCalculator.timeBased`: opaque for 180 ticks, then
|
||||
/// fading out quadratically over 20.
|
||||
fn time_alpha(ticks: u64, added: u64) -> f32 {
|
||||
let delta = ticks as i64 - added as i64;
|
||||
let mut t = delta as f64 / 200.0;
|
||||
t = 1.0 - t;
|
||||
t *= 10.0;
|
||||
t = t.clamp(0.0, 1.0);
|
||||
t *= t;
|
||||
t as f32
|
||||
}
|
||||
#[derive(Default)]
|
||||
pub struct Chat {
|
||||
messages: Vec<Message>,
|
||||
@@ -80,10 +96,11 @@ pub struct Chat {
|
||||
}
|
||||
impl Chat {
|
||||
pub fn lines_per_page(&self, focused: bool) -> usize {
|
||||
(self.options.height_px(focused) / self.options.line_height().max(1.0)) as usize
|
||||
(self.options.height_px(focused) / self.options.line_height().max(1)) as usize
|
||||
}
|
||||
fn max_width(&self) -> f32 {
|
||||
(self.options.width_px() / self.options.scale.max(0.01)).floor()
|
||||
/// The wrapping width, `Mth.floor(getWidth() / getScale())`.
|
||||
fn max_width(&self) -> i32 {
|
||||
(f64::from(self.options.width_px()) / self.options.scale).floor() as i32
|
||||
}
|
||||
/// ChatComponent.addMessage: wrap, prepend and trim to 100 lines.
|
||||
pub fn add(
|
||||
@@ -108,7 +125,7 @@ impl Chat {
|
||||
}
|
||||
fn add_lines(&mut self, atlas: &HudAtlas, message: &Message, focused: bool) {
|
||||
let width = self.max_width();
|
||||
for line in atlas.font.split(&message.content, width) {
|
||||
for line in atlas.font.wrap_components(&message.content, width) {
|
||||
if focused && self.scroll > 0 {
|
||||
self.new_since_scroll = true;
|
||||
self.scroll_by(1, focused);
|
||||
@@ -164,116 +181,113 @@ impl Chat {
|
||||
self.scroll = pos as usize;
|
||||
}
|
||||
}
|
||||
/// ChatComponent.extractRenderState.
|
||||
pub fn draw(&self, batch: &mut HudBatch, atlas: &HudAtlas, ticks: u64, focused: bool) {
|
||||
if self.lines.is_empty() {
|
||||
return;
|
||||
/// `ChatComponent.extractRenderState` (background mode unless `focused`)
|
||||
/// on a screen `height` GUI pixels tall at `Hud.tickCount` `ticks`.
|
||||
pub fn layout(&self, height: i32, ticks: u64, focused: bool) -> Vec<GuiDraw> {
|
||||
let total = self.lines.len();
|
||||
if total == 0 {
|
||||
return Vec::new();
|
||||
}
|
||||
let o = &self.options;
|
||||
let scale = o.scale;
|
||||
if scale <= 0.0 {
|
||||
return;
|
||||
}
|
||||
let max_width = (o.width_px() / scale).ceil();
|
||||
let chat_bottom = ((batch.height - 40.0) / scale).floor();
|
||||
let text_opacity = o.opacity * 0.9 + 0.1;
|
||||
let scale = o.scale as f32;
|
||||
let max_width = hud_layout::ceil(o.width_px() as f32 / scale);
|
||||
let chat_bottom = hud_layout::floor((height - 40) as f32 / scale);
|
||||
let text_opacity = o.opacity as f32 * 0.9 + 0.1;
|
||||
let background_opacity = o.background_opacity as f32;
|
||||
let entry_height = o.line_height();
|
||||
let bottom_to_text = (8.0 * (o.line_spacing + 1.0) - 4.0 * o.line_spacing).round();
|
||||
let per_page = self.lines_per_page(focused);
|
||||
let alpha_of = |line: &Line| -> f32 {
|
||||
let bottom_to_text =
|
||||
(8.0 * (o.line_spacing + 1.0) - 4.0 * o.line_spacing + 0.5).floor() as i32;
|
||||
let alpha_of = |line: &Line| {
|
||||
if focused {
|
||||
return 1.0;
|
||||
1.0
|
||||
} else {
|
||||
time_alpha(ticks, line.added)
|
||||
}
|
||||
let t =
|
||||
((1.0 - (ticks.saturating_sub(line.added)) as f64 / 200.0) * 10.0).clamp(0.0, 1.0);
|
||||
(t * t) as f32
|
||||
};
|
||||
let visible = self.lines.len().saturating_sub(self.scroll).min(per_page);
|
||||
let mut count = 0;
|
||||
// Pass 1: backgrounds; pass 2: text (same order as vanilla).
|
||||
let s = |v: f32| v * scale;
|
||||
for i in (0..visible).rev() {
|
||||
let line = &self.lines[i + self.scroll];
|
||||
let alpha = alpha_of(line);
|
||||
if alpha <= 1.0e-5 {
|
||||
continue;
|
||||
}
|
||||
count += 1;
|
||||
let bottom = chat_bottom - i as f32 * entry_height;
|
||||
let top = bottom - entry_height;
|
||||
batch.fill(
|
||||
atlas,
|
||||
0.0,
|
||||
s(top),
|
||||
s(max_width + 8.0),
|
||||
s(entry_height),
|
||||
rgba(0, 0, 0, (alpha * o.background_opacity * 255.0) as u8),
|
||||
);
|
||||
let visible = total
|
||||
.saturating_sub(self.scroll)
|
||||
.min(self.lines_per_page(focused));
|
||||
let lines: Vec<(usize, &Line, f32)> = (0..visible)
|
||||
.rev()
|
||||
.map(|i| (i, &self.lines[i + self.scroll]))
|
||||
.map(|(i, line)| (i, line, alpha_of(line)))
|
||||
.filter(|(_, _, alpha)| *alpha > 1.0e-5)
|
||||
.collect();
|
||||
let mut out = vec![GuiDraw::Push(vec![
|
||||
PoseOp::Scale(scale, scale),
|
||||
PoseOp::Translate(4.0, 0.0),
|
||||
])];
|
||||
for &(i, _, alpha) in &lines {
|
||||
let bottom = chat_bottom - i as i32 * entry_height;
|
||||
out.push(hud_layout::fill(
|
||||
-4,
|
||||
bottom - entry_height,
|
||||
max_width + 4 + 4,
|
||||
bottom,
|
||||
hud_layout::as8(alpha * background_opacity) << 24,
|
||||
));
|
||||
}
|
||||
for i in (0..visible).rev() {
|
||||
let line = &self.lines[i + self.scroll];
|
||||
let alpha = alpha_of(line);
|
||||
if alpha <= 1.0e-5 {
|
||||
continue;
|
||||
for &(i, line, alpha) in &lines {
|
||||
let bottom = chat_bottom - i as i32 * entry_height;
|
||||
let opacity = alpha * text_opacity;
|
||||
let color = hud_layout::white(opacity);
|
||||
if color >> 24 != 0 {
|
||||
out.push(GuiDraw::Text {
|
||||
text: line.content.clone(),
|
||||
x: 0,
|
||||
y: bottom - bottom_to_text,
|
||||
color,
|
||||
shadow: true,
|
||||
});
|
||||
}
|
||||
let bottom = chat_bottom - i as f32 * entry_height;
|
||||
let top = bottom - entry_height;
|
||||
let text_top = bottom - bottom_to_text;
|
||||
let a = (alpha * text_opacity * 255.0) as u8;
|
||||
if let Some(c) = line.indicator {
|
||||
batch.fill(
|
||||
atlas,
|
||||
0.0,
|
||||
s(top),
|
||||
s(2.0),
|
||||
s(entry_height),
|
||||
rgba((c >> 16) as u8, (c >> 8) as u8, c as u8, a),
|
||||
);
|
||||
}
|
||||
if a > 3 {
|
||||
batch.text_styled(
|
||||
atlas,
|
||||
&line.content,
|
||||
s(4.0),
|
||||
s(text_top),
|
||||
rgba(255, 255, 255, a),
|
||||
true,
|
||||
);
|
||||
if let Some(indicator) = line.indicator {
|
||||
let color = hud_layout::as8(opacity) << 24 | indicator & 0xFF_FFFF;
|
||||
out.push(hud_layout::fill(
|
||||
-4,
|
||||
bottom - entry_height,
|
||||
-2,
|
||||
bottom,
|
||||
color,
|
||||
));
|
||||
}
|
||||
}
|
||||
// Scroll bar while focused and scrolled content exists.
|
||||
if focused && count > 0 {
|
||||
let total = self.lines.len() as f32;
|
||||
let chat_height = count as f32 * entry_height;
|
||||
let virtual_height = total * entry_height;
|
||||
if focused {
|
||||
let count = lines.len() as i32;
|
||||
let chat_height = count * entry_height;
|
||||
let virtual_height = total as i32 * entry_height;
|
||||
let y = self.scroll as i32 * chat_height / total as i32 - chat_bottom;
|
||||
let height = chat_height * chat_height / virtual_height;
|
||||
if virtual_height != chat_height {
|
||||
let y = self.scroll as f32 * chat_height / total - chat_bottom;
|
||||
let height = chat_height * chat_height / virtual_height;
|
||||
let alpha = if y > 0.0 { 170 } else { 96 };
|
||||
let c = if self.new_since_scroll {
|
||||
let alpha: u32 = if y > 0 { 170 } else { 96 };
|
||||
let color = if self.new_since_scroll {
|
||||
0xCC3333
|
||||
} else {
|
||||
0x3333AA
|
||||
};
|
||||
let x = max_width + 4.0 + 4.0;
|
||||
batch.fill(
|
||||
atlas,
|
||||
s(x),
|
||||
s(-y - height),
|
||||
s(2.0),
|
||||
s(height),
|
||||
rgba((c >> 16) as u8, (c >> 8) as u8, c as u8, alpha),
|
||||
);
|
||||
batch.fill(
|
||||
atlas,
|
||||
s(x + 1.0),
|
||||
s(-y - height),
|
||||
s(1.0),
|
||||
s(height),
|
||||
rgba(0xCC, 0xCC, 0xCC, alpha),
|
||||
);
|
||||
let x = max_width + 4;
|
||||
out.push(hud_layout::fill(
|
||||
x,
|
||||
-y,
|
||||
x + 2,
|
||||
-y - height,
|
||||
alpha << 24 | color,
|
||||
));
|
||||
out.push(hud_layout::fill(
|
||||
x + 2,
|
||||
-y,
|
||||
x + 1,
|
||||
-y - height,
|
||||
alpha << 24 | 0xCCCCCC,
|
||||
));
|
||||
}
|
||||
}
|
||||
out.push(GuiDraw::Pop);
|
||||
out
|
||||
}
|
||||
/// Draws [`Chat::layout`] into the HUD batch.
|
||||
pub fn draw(&self, batch: &mut HudBatch, atlas: &HudAtlas, ticks: u64, focused: bool) {
|
||||
let draws = self.layout(batch.height as i32, ticks, focused);
|
||||
hud_layout::paint(batch, atlas, &draws);
|
||||
}
|
||||
/// Plain text of the newest messages (for tests and the control socket).
|
||||
pub fn recent_lines(&self, n: usize) -> Vec<String> {
|
||||
|
||||
@@ -78,6 +78,23 @@ impl SceneCollisions<'_, '_> {
|
||||
)?)
|
||||
}
|
||||
}
|
||||
/// `noCollision(null, box)`: block collisions under the empty collision
|
||||
/// context, as entity-less queries such as spawn checks use.
|
||||
pub fn no_collision_empty<W, E>(world: &mut W, bounds: Aabb) -> bool
|
||||
where
|
||||
W: BlockCollisionWorld<Error = E> + EntityMovementWorld<Error = E>,
|
||||
{
|
||||
no_world_collision(
|
||||
world,
|
||||
CollisionCheckRequest {
|
||||
entity: EntityIdentity(0),
|
||||
context: CollisionContext::empty(false),
|
||||
bounds,
|
||||
candidate_budget: 1_000_000,
|
||||
},
|
||||
)
|
||||
.unwrap_or(false)
|
||||
}
|
||||
impl BlockCollisionWorld for SceneCollisions<'_, '_> {
|
||||
type Error = io::Error;
|
||||
type Chunk = ChunkPos;
|
||||
@@ -166,3 +183,91 @@ impl PowderSnowWorld for SceneCollisions<'_, '_> {
|
||||
Ok(false)
|
||||
}
|
||||
}
|
||||
|
||||
/// Collision queries over any block lookup (test worlds without chunks):
|
||||
/// every chunk is present, nothing is suffocating, no entities collide.
|
||||
pub struct LookupCollisions<'w, 'r> {
|
||||
pub block: &'w dyn Fn(i32, i32, i32) -> horizon_world::registry::BlockStateId,
|
||||
pub binding: &'w BlockCollisionRegistry<'r>,
|
||||
}
|
||||
impl BlockCollisionWorld for LookupCollisions<'_, '_> {
|
||||
type Error = io::Error;
|
||||
type Chunk = ChunkPos;
|
||||
fn collision_chunk(&mut self, p: ChunkPos) -> io::Result<Option<ChunkPos>> {
|
||||
Ok(Some(p))
|
||||
}
|
||||
fn collision_block(&mut self, _: ChunkPos, p: BlockPos) -> io::Result<CollisionBlock> {
|
||||
self.binding.collision_block((self.block)(p.x, p.y, p.z))
|
||||
}
|
||||
fn is_suffocating(&mut self, _: ChunkPos, _: CollisionBlock, _: BlockPos) -> io::Result<bool> {
|
||||
Ok(false)
|
||||
}
|
||||
fn context_shape(
|
||||
&mut self,
|
||||
block: CollisionBlock,
|
||||
p: BlockPos,
|
||||
context: &CollisionContext<'_>,
|
||||
) -> io::Result<VoxelShape> {
|
||||
self.binding
|
||||
.collision_shape_with_context(self, block.state, p, context)
|
||||
.map_err(io::Error::other)
|
||||
}
|
||||
}
|
||||
impl EntityMovementWorld for LookupCollisions<'_, '_> {
|
||||
type Error = io::Error;
|
||||
fn entity_collisions(&mut self, _: EntityIdentity, _: Aabb) -> io::Result<Vec<VoxelShape>> {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
fn movement_border(&mut self, _: EntityIdentity, _: Aabb) -> io::Result<Option<VoxelShape>> {
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
impl DynamicSupportWorld for LookupCollisions<'_, '_> {
|
||||
type Error = io::Error;
|
||||
fn shulker_is_closed(&mut self, _: [i32; 3]) -> io::Result<Option<bool>> {
|
||||
Ok(None)
|
||||
}
|
||||
fn moving_piston_shape(&mut self, _: [i32; 3]) -> io::Result<Option<VoxelShape>> {
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
impl DynamicCollisionWorld for LookupCollisions<'_, '_> {
|
||||
fn shulker_collision_state(
|
||||
&mut self,
|
||||
_: [i32; 3],
|
||||
) -> io::Result<Option<ShulkerCollisionState>> {
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
impl LiquidCollisionWorld for LookupCollisions<'_, '_> {
|
||||
type Error = io::Error;
|
||||
fn entity_liquid_shape(&mut self, _: EntityIdentity) -> io::Result<VoxelShape> {
|
||||
Ok(VoxelShape::empty())
|
||||
}
|
||||
fn fluid(&mut self, p: BlockPos) -> io::Result<FluidState> {
|
||||
FluidState::from_block(
|
||||
self.binding
|
||||
.registry()
|
||||
.state((self.block)(p.x, p.y, p.z))
|
||||
.ok_or_else(|| io::Error::other("fluid state outside registry"))?,
|
||||
)
|
||||
}
|
||||
fn entity_can_stand(&mut self, _: EntityIdentity, _: FluidState) -> io::Result<bool> {
|
||||
Ok(false)
|
||||
}
|
||||
}
|
||||
impl PowderSnowWorld for LookupCollisions<'_, '_> {
|
||||
type Error = io::Error;
|
||||
fn powder_snow_entity(&mut self, _: EntityIdentity) -> io::Result<PowderSnowEntity> {
|
||||
Ok(PowderSnowEntity {
|
||||
fall_distance: 0.0,
|
||||
falling_block: false,
|
||||
})
|
||||
}
|
||||
fn powder_snow_walkable_tag(&mut self, _: EntityIdentity) -> io::Result<bool> {
|
||||
Ok(false)
|
||||
}
|
||||
fn feet_are_leather_boots(&mut self, _: EntityIdentity) -> io::Result<bool> {
|
||||
Ok(false)
|
||||
}
|
||||
}
|
||||
@@ -540,9 +540,9 @@ pub fn parse_identifier(raw: &str) -> Option<String> {
|
||||
&& ns
|
||||
.chars()
|
||||
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || matches!(c, '_' | '.' | '-'));
|
||||
let path_ok = path
|
||||
.chars()
|
||||
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || matches!(c, '_' | '.' | '-' | '/'));
|
||||
let path_ok = path.chars().all(|c| {
|
||||
c.is_ascii_lowercase() || c.is_ascii_digit() || matches!(c, '_' | '.' | '-' | '/')
|
||||
});
|
||||
(ns_ok && path_ok).then(|| format!("{ns}:{path}"))
|
||||
}
|
||||
pub fn normalize(id: &str) -> String {
|
||||
@@ -690,7 +690,11 @@ pub fn read_double_bounds(reader: &mut Reader) -> CmdResult<DoubleRange> {
|
||||
}
|
||||
/// MinMaxBounds.FloatDegrees.fromReader (no swap check).
|
||||
pub fn read_float_bounds(reader: &mut Reader) -> CmdResult<(Option<f32>, Option<f32>)> {
|
||||
read_bounds(reader, |s| java_parse_double(s).map(|v| v as f32), "parsing.float.invalid")
|
||||
read_bounds(
|
||||
reader,
|
||||
|s| java_parse_double(s).map(|v| v as f32),
|
||||
"parsing.float.invalid",
|
||||
)
|
||||
}
|
||||
/// Integer.parseInt over number characters.
|
||||
pub fn java_parse_int(s: &str) -> Option<i32> {
|
||||
@@ -886,7 +890,12 @@ fn java_split(s: &str) -> Vec<&str> {
|
||||
}
|
||||
/// SharedSuggestionProvider.suggestCoordinates / suggest2DCoordinates: the
|
||||
/// candidates `ty` parses completely.
|
||||
fn suggest_coordinates(builder: &mut SuggestionsBuilder, reg: &Registries, ty: &ArgType, relevant: bool) {
|
||||
fn suggest_coordinates(
|
||||
builder: &mut SuggestionsBuilder,
|
||||
reg: &Registries,
|
||||
ty: &ArgType,
|
||||
relevant: bool,
|
||||
) {
|
||||
let remaining = builder.remaining().to_owned();
|
||||
let three = matches!(ty, ArgType::BlockPos | ArgType::Vec3 { .. });
|
||||
let coords: [String; 3] = if remaining.starts_with('^') {
|
||||
@@ -903,7 +912,11 @@ fn suggest_coordinates(builder: &mut SuggestionsBuilder, reg: &Registries, ty: &
|
||||
let mut result: Vec<String> = Vec::new();
|
||||
let [x, y, z] = &coords;
|
||||
if remaining.is_empty() {
|
||||
let full = if three { format!("{x} {y} {z}") } else { format!("{x} {z}") };
|
||||
let full = if three {
|
||||
format!("{x} {y} {z}")
|
||||
} else {
|
||||
format!("{x} {z}")
|
||||
};
|
||||
if valid(&full) {
|
||||
result.push(x.clone());
|
||||
if three {
|
||||
@@ -965,7 +978,11 @@ fn parse_nbt_path(reader: &mut Reader) -> CmdResult<()> {
|
||||
// StringReader.peek past the end (StringIndexOutOfBoundsException).
|
||||
let len = reader.string.encode_utf16().count();
|
||||
let message = format!("Index {len} out of bounds for length {len}");
|
||||
return Err(CommandError::tr_at("command.exception", &[&message], reader));
|
||||
return Err(CommandError::tr_at(
|
||||
"command.exception",
|
||||
&[&message],
|
||||
reader,
|
||||
));
|
||||
}
|
||||
match reader.peek() {
|
||||
'{' => {
|
||||
@@ -988,7 +1005,10 @@ fn parse_nbt_path(reader: &mut Reader) -> CmdResult<()> {
|
||||
_ => {
|
||||
let s = reader.cursor;
|
||||
while reader.can_read()
|
||||
&& !matches!(reader.peek(), ' ' | '"' | '\'' | '[' | ']' | '.' | '{' | '}')
|
||||
&& !matches!(
|
||||
reader.peek(),
|
||||
' ' | '"' | '\'' | '[' | ']' | '.' | '{' | '}'
|
||||
)
|
||||
{
|
||||
reader.skip();
|
||||
}
|
||||
@@ -1049,10 +1069,9 @@ fn particle_options(id: &str, t: &horizon_world::nbt::Tag) -> Result<(), String>
|
||||
{
|
||||
// withAlternative(BlockState.CODEC, the block id codec).
|
||||
let first = match state {
|
||||
horizon_world::nbt::Tag::Compound(_) => super::codec::either(
|
||||
"Not a string".into(),
|
||||
no_key("id", state),
|
||||
),
|
||||
horizon_world::nbt::Tag::Compound(_) => {
|
||||
super::codec::either("Not a string".into(), no_key("id", state))
|
||||
}
|
||||
_ => not_map(state),
|
||||
};
|
||||
let _ = snbt;
|
||||
@@ -1121,7 +1140,10 @@ impl ArgType {
|
||||
reader.cursor = start;
|
||||
return Err(CommandError::tr_at(
|
||||
"argument.double.low",
|
||||
&[&crate::snbt::java_double(*min), &crate::snbt::java_double(v)],
|
||||
&[
|
||||
&crate::snbt::java_double(*min),
|
||||
&crate::snbt::java_double(v),
|
||||
],
|
||||
reader,
|
||||
));
|
||||
}
|
||||
@@ -1129,7 +1151,10 @@ impl ArgType {
|
||||
reader.cursor = start;
|
||||
return Err(CommandError::tr_at(
|
||||
"argument.double.big",
|
||||
&[&crate::snbt::java_double(*max), &crate::snbt::java_double(v)],
|
||||
&[
|
||||
&crate::snbt::java_double(*max),
|
||||
&crate::snbt::java_double(v),
|
||||
],
|
||||
reader,
|
||||
));
|
||||
}
|
||||
@@ -1146,10 +1171,7 @@ impl ArgType {
|
||||
// MessageArgument.Message.parseText.
|
||||
let len = reader.remaining().encode_utf16().count();
|
||||
if len > 256 {
|
||||
return Err(CommandError::tr(
|
||||
"argument.message.too_long",
|
||||
&[&len, &256],
|
||||
));
|
||||
return Err(CommandError::tr("argument.message.too_long", &[&len, &256]));
|
||||
}
|
||||
let s = reader.remaining().to_owned();
|
||||
if reg.permission >= 2 {
|
||||
@@ -1329,7 +1351,11 @@ impl ArgType {
|
||||
ArgType::HexColor => {
|
||||
let text = reader.read_unquoted_string();
|
||||
if !matches!(text.len(), 3 | 6) {
|
||||
return Err(CommandError::tr_at("argument.hexcolor.invalid", &[&text], reader));
|
||||
return Err(CommandError::tr_at(
|
||||
"argument.hexcolor.invalid",
|
||||
&[&text],
|
||||
reader,
|
||||
));
|
||||
}
|
||||
// Integer.parseInt(s, begin, end, 16) per channel: a bad digit
|
||||
// escapes Brigadier as a dispatcher parse exception.
|
||||
@@ -1341,7 +1367,11 @@ impl ArgType {
|
||||
if let Some(i) = digits.find(|ch: char| !ch.is_ascii_hexdigit()) {
|
||||
let index = i + usize::from(signed);
|
||||
let message = format!("Error at index {index} in: \"{c}\"");
|
||||
return Err(CommandError::tr_at("command.exception", &[&message], reader));
|
||||
return Err(CommandError::tr_at(
|
||||
"command.exception",
|
||||
&[&message],
|
||||
reader,
|
||||
));
|
||||
}
|
||||
}
|
||||
let v = i64::from_str_radix(text, 16).unwrap_or(0);
|
||||
@@ -1361,7 +1391,10 @@ impl ArgType {
|
||||
.take_while(|c| c.is_ascii_hexdigit() || *c == '-')
|
||||
.map(char::len_utf8)
|
||||
.sum();
|
||||
match (run > 0).then(|| super::selector::java_uuid(&rest[..run])).flatten() {
|
||||
match (run > 0)
|
||||
.then(|| super::selector::java_uuid(&rest[..run]))
|
||||
.flatten()
|
||||
{
|
||||
Some(_) => {
|
||||
let text = rest[..run].to_owned();
|
||||
reader.cursor += run;
|
||||
@@ -1391,7 +1424,11 @@ impl ArgType {
|
||||
let v = reader.read_float()?;
|
||||
let unit = reader.read_unquoted_string();
|
||||
let Some(m) = time_unit(unit) else {
|
||||
return Err(CommandError::tr_at("argument.time.invalid_unit", &[], reader));
|
||||
return Err(CommandError::tr_at(
|
||||
"argument.time.invalid_unit",
|
||||
&[],
|
||||
reader,
|
||||
));
|
||||
};
|
||||
// Math.round(float): floor(x + 0.5).
|
||||
let ticks = (v * m as f32 + 0.5).floor() as i32;
|
||||
@@ -1425,7 +1462,10 @@ impl ArgType {
|
||||
// ParserBasedArgument.withCodec: SNBT, then the codec (errors at the start).
|
||||
let tag = super::snbt::parse_tag_argument(reader)?;
|
||||
let (decoded, key) = if matches!(self, ArgType::Style) {
|
||||
(super::component::decode_style(&tag), "argument.style.invalid")
|
||||
(
|
||||
super::component::decode_style(&tag),
|
||||
"argument.style.invalid",
|
||||
)
|
||||
} else {
|
||||
(super::component::decode(&tag), "argument.component.invalid")
|
||||
};
|
||||
|
||||
@@ -379,7 +379,8 @@ impl<'r, 'a> BlockParser<'r, 'a> {
|
||||
}
|
||||
let prefix = b.remaining_lower();
|
||||
for (name, _) in definition {
|
||||
if !self.properties.iter().any(|(n, _)| n == name) && name.starts_with(&prefix) {
|
||||
if !self.properties.iter().any(|(n, _)| n == name) && name.starts_with(&prefix)
|
||||
{
|
||||
b.suggest(format!("{name}="));
|
||||
}
|
||||
}
|
||||
@@ -439,11 +440,7 @@ impl<'r, 'a> BlockParser<'r, 'a> {
|
||||
}
|
||||
|
||||
/// BlockStateParser.parseForBlock / parseForTesting (the reader is rewound on errors).
|
||||
pub fn parse(
|
||||
reader: &mut Reader,
|
||||
reg: &Registries,
|
||||
for_testing: bool,
|
||||
) -> CmdResult<BlockInput> {
|
||||
pub fn parse(reader: &mut Reader, reg: &Registries, for_testing: bool) -> CmdResult<BlockInput> {
|
||||
let cursor = reader.cursor;
|
||||
let r = BlockParser::new(reader, reg, for_testing, true).parse();
|
||||
if r.is_err() {
|
||||
|
||||
@@ -86,10 +86,7 @@ fn provider_fields(id: &str, map: &Compound, t: &Tag) -> Result<(), String> {
|
||||
}
|
||||
}
|
||||
/// A holder list codec: a single element, a `#tag`, or a list.
|
||||
fn element_or_list(
|
||||
t: &Tag,
|
||||
element: &dyn Fn(&Tag) -> Result<(), String>,
|
||||
) -> Result<(), String> {
|
||||
fn element_or_list(t: &Tag, element: &dyn Fn(&Tag) -> Result<(), String>) -> Result<(), String> {
|
||||
let first = match element(t) {
|
||||
Ok(()) => return Ok(()),
|
||||
Err(e) => e,
|
||||
@@ -179,7 +176,6 @@ pub fn resource_or_id(reader: &mut Reader, reg: &Registries, registry: &str) ->
|
||||
}
|
||||
reader.cursor = start;
|
||||
let tag = super::snbt::parse_tag_argument(reader)?;
|
||||
decode(registry, &tag, reg).map_err(|m| {
|
||||
CommandError::tr_at("argument.resource_or_id.failed_to_parse", &[&m], reader)
|
||||
})
|
||||
decode(registry, &tag, reg)
|
||||
.map_err(|m| CommandError::tr_at("argument.resource_or_id.failed_to_parse", &[&m], reader))
|
||||
}
|
||||
@@ -47,7 +47,11 @@ fn is_string(t: &Tag) -> bool {
|
||||
matches!(t, Tag::String(_))
|
||||
}
|
||||
fn string(t: &Tag) -> R {
|
||||
if is_string(t) { Ok(()) } else { err("Not a string") }
|
||||
if is_string(t) {
|
||||
Ok(())
|
||||
} else {
|
||||
err("Not a string")
|
||||
}
|
||||
}
|
||||
fn string_of(t: &Tag) -> Option<String> {
|
||||
match t {
|
||||
@@ -131,9 +135,9 @@ pub fn identifier(t: &Tag) -> Result<(), String> {
|
||||
"Not a valid resource location: {s} Non [a-z0-9_.-] character in namespace of location: {ns}:{path}"
|
||||
));
|
||||
}
|
||||
let path_ok = path
|
||||
.chars()
|
||||
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || matches!(c, '_' | '.' | '-' | '/'));
|
||||
let path_ok = path.chars().all(|c| {
|
||||
c.is_ascii_lowercase() || c.is_ascii_digit() || matches!(c, '_' | '.' | '-' | '/')
|
||||
});
|
||||
if !path_ok {
|
||||
return Err(format!(
|
||||
"Not a valid resource location: {s} Non [a-z0-9/._-] character in path of location: {ns}:{path}"
|
||||
@@ -208,7 +212,13 @@ fn style_fields(map: &Compound) -> R {
|
||||
if let Some(c) = get(map, "color") {
|
||||
color(c)?;
|
||||
}
|
||||
for key in ["bold", "italic", "underlined", "strikethrough", "obfuscated"] {
|
||||
for key in [
|
||||
"bold",
|
||||
"italic",
|
||||
"underlined",
|
||||
"strikethrough",
|
||||
"obfuscated",
|
||||
] {
|
||||
if let Some(v) = get(map, key)
|
||||
&& bool_value(v).is_err()
|
||||
{
|
||||
@@ -262,12 +272,13 @@ fn content(kind: &str, map: &Compound) -> bool {
|
||||
_ => false,
|
||||
},
|
||||
"selector" => {
|
||||
get(map, "selector").is_some_and(selector_string) && optional_component(map, "separator")
|
||||
get(map, "selector").is_some_and(selector_string)
|
||||
&& optional_component(map, "separator")
|
||||
}
|
||||
"nbt" => {
|
||||
let path_ok = get(map, "nbt").and_then(string_of).is_some_and(|p| {
|
||||
crate::nbt_path::NbtPath::parse(&p).is_ok()
|
||||
});
|
||||
let path_ok = get(map, "nbt")
|
||||
.and_then(string_of)
|
||||
.is_some_and(|p| crate::nbt_path::NbtPath::parse(&p).is_ok());
|
||||
let source = if let Some(e) = get(map, "entity") {
|
||||
selector_string(e)
|
||||
} else if let Some(b) = get(map, "block") {
|
||||
@@ -336,10 +347,9 @@ fn full(t: &Tag) -> R {
|
||||
style_fields(map)
|
||||
}
|
||||
fn component(t: &Tag) -> R {
|
||||
either(
|
||||
either(string(t), || non_empty_list(t, &component)),
|
||||
|| full(t),
|
||||
)
|
||||
either(either(string(t), || non_empty_list(t, &component)), || {
|
||||
full(t)
|
||||
})
|
||||
}
|
||||
/// ComponentSerialization.CODEC.parse: the error message on failure.
|
||||
pub fn decode(t: &Tag) -> Result<(), String> {
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
//! ItemParser (item stacks with components, `stone[damage=5]`) and
|
||||
//! ItemPredicateArgument (ComponentPredicateParser's packrat grammar:
|
||||
//! `#logs[count~1..|!damage]`), with vanilla's errors and suggestions.
|
||||
use super::args::{ItemInput, Registries, is_id_char, parse_identifier, read_identifier, suggest_resources};
|
||||
use super::args::{
|
||||
ItemInput, Registries, is_id_char, parse_identifier, read_identifier, suggest_resources,
|
||||
};
|
||||
use super::codec::{either, is_number, not_map, snbt};
|
||||
use super::packrat::{Grammar, Reason, State, Sugg, Term, Value, alt, ch, opt, seq};
|
||||
use super::{CmdResult, CommandError, Reader, SuggestionsBuilder};
|
||||
@@ -159,7 +161,10 @@ impl ItemState<'_, '_> {
|
||||
self.sugg = ISugg::Component;
|
||||
let ty = self.component_type()?;
|
||||
if visited.contains(&ty) {
|
||||
return Err(CommandError::tr("arguments.item.component.repeated", &[&ty]));
|
||||
return Err(CommandError::tr(
|
||||
"arguments.item.component.repeated",
|
||||
&[&ty],
|
||||
));
|
||||
}
|
||||
visited.push(ty);
|
||||
self.sugg = ISugg::Nothing;
|
||||
@@ -167,7 +172,10 @@ impl ItemState<'_, '_> {
|
||||
} else {
|
||||
let ty = self.component_type()?;
|
||||
if visited.contains(&ty) {
|
||||
return Err(CommandError::tr("arguments.item.component.repeated", &[&ty]));
|
||||
return Err(CommandError::tr(
|
||||
"arguments.item.component.repeated",
|
||||
&[&ty],
|
||||
));
|
||||
}
|
||||
visited.push(ty.clone());
|
||||
self.sugg = ISugg::Assignment;
|
||||
@@ -401,10 +409,12 @@ fn predicate_grammar() -> Grammar<IV> {
|
||||
"type",
|
||||
alt(vec![element, seq(vec![ch('#'), Term::Cut, tag]), any]),
|
||||
|s| {
|
||||
Some(IV::Type(match s.scope.get_any(&["element_type", "tag_type"]) {
|
||||
Some(IV::Elem(t)) => Some(t),
|
||||
_ => None,
|
||||
}))
|
||||
Some(IV::Type(
|
||||
match s.scope.get_any(&["element_type", "tag_type"]) {
|
||||
Some(IV::Elem(t)) => Some(t),
|
||||
_ => None,
|
||||
},
|
||||
))
|
||||
},
|
||||
);
|
||||
g.put_term("any_type", ch('*'), |_| Some(IV::Unit));
|
||||
@@ -415,7 +425,11 @@ fn predicate_grammar() -> Grammar<IV> {
|
||||
if r.ids("minecraft:item").iter().any(|i| i == id) {
|
||||
Ok(IV::Elem(TypeRes::Item(id.to_owned())))
|
||||
} else {
|
||||
Err(CommandError::tr_at("argument.item.id.invalid", &[&id], reader))
|
||||
Err(CommandError::tr_at(
|
||||
"argument.item.id.invalid",
|
||||
&[&id],
|
||||
reader,
|
||||
))
|
||||
}
|
||||
})
|
||||
});
|
||||
@@ -426,7 +440,11 @@ fn predicate_grammar() -> Grammar<IV> {
|
||||
if r.tags("minecraft:item").iter().any(|i| i == id) {
|
||||
Ok(IV::Elem(TypeRes::Tag(id.to_owned())))
|
||||
} else {
|
||||
Err(CommandError::tr_at("arguments.item.tag.unknown", &[&id], reader))
|
||||
Err(CommandError::tr_at(
|
||||
"arguments.item.tag.unknown",
|
||||
&[&id],
|
||||
reader,
|
||||
))
|
||||
}
|
||||
})
|
||||
});
|
||||
@@ -509,29 +527,49 @@ fn predicate_grammar() -> Grammar<IV> {
|
||||
if id == "minecraft:count" {
|
||||
return Ok(IV::Comp(id.to_owned()));
|
||||
}
|
||||
let known = r.ids("minecraft:data_component_type").iter().any(|c| c == id);
|
||||
let known = r
|
||||
.ids("minecraft:data_component_type")
|
||||
.iter()
|
||||
.any(|c| c == id);
|
||||
if known && r.persistent_components.iter().any(|c| c == id) {
|
||||
Ok(IV::Comp(id.to_owned()))
|
||||
} else {
|
||||
Err(CommandError::tr_at("arguments.item.component.unknown", &[&id], reader))
|
||||
Err(CommandError::tr_at(
|
||||
"arguments.item.component.unknown",
|
||||
&[&id],
|
||||
reader,
|
||||
))
|
||||
}
|
||||
})
|
||||
});
|
||||
g.put_custom("predicate_type", move |s| {
|
||||
let r = reg(s);
|
||||
let mut list = vec!["minecraft:count".to_owned()];
|
||||
list.extend(r.ids("minecraft:data_component_predicate_type").iter().cloned());
|
||||
list.extend(
|
||||
r.ids("minecraft:data_component_predicate_type")
|
||||
.iter()
|
||||
.cloned(),
|
||||
);
|
||||
lookup(s, id, Rc::new(list), &|id, reader| {
|
||||
if id == "minecraft:count"
|
||||
|| r.ids("minecraft:data_component_predicate_type").iter().any(|c| c == id)
|
||||
|| r.ids("minecraft:data_component_predicate_type")
|
||||
.iter()
|
||||
.any(|c| c == id)
|
||||
{
|
||||
return Ok(IV::Pred(id.to_owned()));
|
||||
}
|
||||
if r.ids("minecraft:data_component_type").iter().any(|c| c == id) {
|
||||
if r.ids("minecraft:data_component_type")
|
||||
.iter()
|
||||
.any(|c| c == id)
|
||||
{
|
||||
// A component existence predicate (decodes Unit).
|
||||
return Ok(IV::Pred(format!("exists:{id}")));
|
||||
}
|
||||
Err(CommandError::tr_at("arguments.item.predicate.unknown", &[&id], reader))
|
||||
Err(CommandError::tr_at(
|
||||
"arguments.item.predicate.unknown",
|
||||
&[&id],
|
||||
reader,
|
||||
))
|
||||
})
|
||||
});
|
||||
g.put_custom("tag", |s| super::snbt::rule_in(s, IV::Tag));
|
||||
|
||||
@@ -616,7 +616,8 @@ pub fn merge_suggestions(input: &str, list: Vec<Suggestion>) -> (Range, Vec<Sugg
|
||||
// A stable insertion sort (the comparator mixes numeric and text order).
|
||||
let mut sorted: Vec<Suggestion> = Vec::with_capacity(out.len());
|
||||
for s in out {
|
||||
let pos = sorted.partition_point(|x| compare_suggestions(x, &s) != std::cmp::Ordering::Greater);
|
||||
let pos =
|
||||
sorted.partition_point(|x| compare_suggestions(x, &s) != std::cmp::Ordering::Greater);
|
||||
sorted.insert(pos, s);
|
||||
}
|
||||
((start, end), sorted)
|
||||
@@ -644,7 +645,8 @@ impl Suggests {
|
||||
for id in ®.summonable {
|
||||
if args::resource_matches(&contents, id) {
|
||||
let (ns, path) = id.split_once(':').unwrap_or(("minecraft", id));
|
||||
let name = crate::lang::get(&format!("entity.{ns}.{}", path.replace('/', ".")));
|
||||
let name =
|
||||
crate::lang::get(&format!("entity.{ns}.{}", path.replace('/', ".")));
|
||||
builder.suggest_tooltip(id.clone(), name);
|
||||
}
|
||||
}
|
||||
@@ -673,7 +675,9 @@ impl Suggests {
|
||||
.trigger_scores
|
||||
.iter()
|
||||
.filter(|(_, scores)| {
|
||||
targets.iter().any(|t| scores.get(t).is_none_or(|locked| *locked))
|
||||
targets
|
||||
.iter()
|
||||
.any(|t| scores.get(t).is_none_or(|locked| *locked))
|
||||
})
|
||||
.map(|(o, _)| o.as_str())
|
||||
.collect();
|
||||
|
||||
@@ -94,13 +94,11 @@ impl<V: Clone> Scope<V> {
|
||||
let current = self.top_marker;
|
||||
let n = self.top_entry - self.top_marker;
|
||||
self.setup_new_frame();
|
||||
let mut source = current + 1;
|
||||
let mut target = self.top_entry;
|
||||
for _ in 0..n {
|
||||
for source in current + 1..current + 1 + n {
|
||||
target += 1;
|
||||
let key = self.stack[source].0;
|
||||
self.set(target, key, None);
|
||||
source += 1;
|
||||
}
|
||||
self.top_entry = target;
|
||||
}
|
||||
@@ -139,11 +137,13 @@ impl<V: Clone> Scope<V> {
|
||||
}
|
||||
}
|
||||
pub fn get(&self, atom: Atom) -> Option<V> {
|
||||
self.value_index(&[atom]).and_then(|i| self.stack[i].1.clone())
|
||||
self.value_index(&[atom])
|
||||
.and_then(|i| self.stack[i].1.clone())
|
||||
}
|
||||
/// Scope.getAny: the newest of several atoms.
|
||||
pub fn get_any(&self, atoms: &[Atom]) -> Option<V> {
|
||||
self.value_index(atoms).and_then(|i| self.stack[i].1.clone())
|
||||
self.value_index(atoms)
|
||||
.and_then(|i| self.stack[i].1.clone())
|
||||
}
|
||||
/// Whether the atom has an entry (even a null one).
|
||||
pub fn has(&self, atom: Atom) -> bool {
|
||||
@@ -385,7 +385,7 @@ impl<'g, V: Value> State<'g, V> {
|
||||
r
|
||||
}
|
||||
fn first_error(&self) -> CommandError {
|
||||
for (_, reason) in &self.errors {
|
||||
if let Some((_, reason)) = self.errors.first() {
|
||||
return match reason {
|
||||
Reason::Delayed(key, args) => {
|
||||
let args: Vec<&dyn std::fmt::Display> =
|
||||
|
||||
@@ -143,7 +143,12 @@ pub fn java_uuid(name: &str) -> Option<u128> {
|
||||
Some((u128::from(msb) << 64) | u128::from(lsb))
|
||||
}
|
||||
|
||||
fn rollback(reader: &mut Reader, start: usize, key: &str, args: &[&dyn std::fmt::Display]) -> CommandError {
|
||||
fn rollback(
|
||||
reader: &mut Reader,
|
||||
start: usize,
|
||||
key: &str,
|
||||
args: &[&dyn std::fmt::Display],
|
||||
) -> CommandError {
|
||||
reader.cursor = start;
|
||||
CommandError::tr_at(key, args, reader)
|
||||
}
|
||||
|
||||
@@ -153,7 +153,9 @@ fn parse_small(s: &str, radix: u32, lo: i32, hi: i32) -> Result<i32, String> {
|
||||
}
|
||||
fn parse_unsigned_int(s: &str, radix: u32) -> Result<i32, String> {
|
||||
if s.starts_with('-') {
|
||||
return Err(format!("Illegal leading minus sign on unsigned string {s}."));
|
||||
return Err(format!(
|
||||
"Illegal leading minus sign on unsigned string {s}."
|
||||
));
|
||||
}
|
||||
let len = s.chars().count();
|
||||
if len <= 5 || (radix == 10 && len <= 9) {
|
||||
@@ -168,7 +170,9 @@ fn parse_unsigned_int(s: &str, radix: u32) -> Result<i32, String> {
|
||||
}
|
||||
fn parse_unsigned_long(s: &str, radix: u32) -> Result<i64, String> {
|
||||
if s.starts_with('-') {
|
||||
return Err(format!("Illegal leading minus sign on unsigned string {s}."));
|
||||
return Err(format!(
|
||||
"Illegal leading minus sign on unsigned string {s}."
|
||||
));
|
||||
}
|
||||
let digits = s.strip_prefix('+').unwrap_or(s);
|
||||
match u64::from_str_radix(digits, radix) {
|
||||
@@ -338,7 +342,11 @@ pub fn grammar() -> Grammar<SV> {
|
||||
|s| s.scope.get("integer_suffix"),
|
||||
);
|
||||
g.put_custom("binary_numeral", |s| {
|
||||
number_run(s, |c| matches!(c, '0' | '1' | '_'), "snbt.parser.expected_binary_numeral")
|
||||
number_run(
|
||||
s,
|
||||
|c| matches!(c, '0' | '1' | '_'),
|
||||
"snbt.parser.expected_binary_numeral",
|
||||
)
|
||||
});
|
||||
g.put_custom("decimal_numeral", |s| {
|
||||
number_run(
|
||||
@@ -394,7 +402,10 @@ pub fn grammar() -> Grammar<SV> {
|
||||
} else if let Some(h) = get_str(s, "hex_numeral") {
|
||||
(Base::Hex, h)
|
||||
} else {
|
||||
(Base::Binary, get_str(s, "binary_numeral").unwrap_or_default())
|
||||
(
|
||||
Base::Binary,
|
||||
get_str(s, "binary_numeral").unwrap_or_default(),
|
||||
)
|
||||
};
|
||||
Some(SV::Int(IntLit {
|
||||
minus,
|
||||
@@ -408,8 +419,14 @@ pub fn grammar() -> Grammar<SV> {
|
||||
g.put_term(
|
||||
"float_type_suffix",
|
||||
alt(vec![
|
||||
seq(vec![chs('f', 'F'), Term::Marker("float_type_suffix", SV::Type(TypeSuffix::Float))]),
|
||||
seq(vec![chs('d', 'D'), Term::Marker("float_type_suffix", SV::Type(TypeSuffix::Double))]),
|
||||
seq(vec![
|
||||
chs('f', 'F'),
|
||||
Term::Marker("float_type_suffix", SV::Type(TypeSuffix::Float)),
|
||||
]),
|
||||
seq(vec![
|
||||
chs('d', 'D'),
|
||||
Term::Marker("float_type_suffix", SV::Type(TypeSuffix::Double)),
|
||||
]),
|
||||
]),
|
||||
|s| s.scope.get("float_type_suffix"),
|
||||
);
|
||||
@@ -420,7 +437,10 @@ pub fn grammar() -> Grammar<SV> {
|
||||
seq(vec![chs('e', 'E'), opt(sign), decimal]),
|
||||
|s| {
|
||||
let minus = matches!(s.scope.get("sign"), Some(SV::Sign(true)));
|
||||
Some(SV::Exp(minus, get_str(s, "decimal_numeral").unwrap_or_default()))
|
||||
Some(SV::Exp(
|
||||
minus,
|
||||
get_str(s, "decimal_numeral").unwrap_or_default(),
|
||||
))
|
||||
},
|
||||
);
|
||||
let sign = g.named("sign");
|
||||
@@ -470,7 +490,11 @@ pub fn grammar() -> Grammar<SV> {
|
||||
float_tag(&text, ty, s).map(SV::Tag)
|
||||
},
|
||||
);
|
||||
for (name, n) in [("string_hex_2", 2), ("string_hex_4", 4), ("string_hex_8", 8)] {
|
||||
for (name, n) in [
|
||||
("string_hex_2", 2),
|
||||
("string_hex_4", 4),
|
||||
("string_hex_8", 8),
|
||||
] {
|
||||
g.put_custom(name, move |s| {
|
||||
greedy(
|
||||
s,
|
||||
@@ -521,7 +545,9 @@ pub fn grammar() -> Grammar<SV> {
|
||||
if let Some(SV::Str(plain)) = s.scope.get_any(&["string_escape_sequence"]) {
|
||||
return Some(SV::Str(plain));
|
||||
}
|
||||
if let Some(SV::Str(h)) = s.scope.get_any(&["string_hex_2", "string_hex_4", "string_hex_8"])
|
||||
if let Some(SV::Str(h)) =
|
||||
s.scope
|
||||
.get_any(&["string_hex_2", "string_hex_4", "string_hex_8"])
|
||||
{
|
||||
let cp = u32::from_str_radix(&h, 16).unwrap_or(u32::MAX);
|
||||
if cp > 0x10ffff {
|
||||
@@ -531,7 +557,9 @@ pub fn grammar() -> Grammar<SV> {
|
||||
));
|
||||
return None;
|
||||
}
|
||||
return Some(SV::Str(char::from_u32(cp).unwrap_or('\u{fffd}').to_string()));
|
||||
return Some(SV::Str(
|
||||
char::from_u32(cp).unwrap_or('\u{fffd}').to_string(),
|
||||
));
|
||||
}
|
||||
// Character.codePointOf: Unicode names are not tabulated here.
|
||||
s.store_at_mark(delayed("snbt.parser.invalid_character_name"));
|
||||
@@ -663,7 +691,8 @@ pub fn grammar() -> Grammar<SV> {
|
||||
let qs = g.named("quoted_string_literal");
|
||||
let uq = g.named("unquoted_string");
|
||||
g.put_term("map_key", alt(vec![qs, uq]), |s| {
|
||||
s.scope.get_any(&["quoted_string_literal", "unquoted_string"])
|
||||
s.scope
|
||||
.get_any(&["quoted_string_literal", "unquoted_string"])
|
||||
});
|
||||
let key = g.named("map_key");
|
||||
let lit = g.named("literal");
|
||||
@@ -712,9 +741,18 @@ pub fn grammar() -> Grammar<SV> {
|
||||
g.put_term(
|
||||
"array_prefix",
|
||||
alt(vec![
|
||||
seq(vec![ch('B'), Term::Marker("array_prefix", SV::Prefix(ArrayPrefix::Byte))]),
|
||||
seq(vec![ch('L'), Term::Marker("array_prefix", SV::Prefix(ArrayPrefix::Long))]),
|
||||
seq(vec![ch('I'), Term::Marker("array_prefix", SV::Prefix(ArrayPrefix::Int))]),
|
||||
seq(vec![
|
||||
ch('B'),
|
||||
Term::Marker("array_prefix", SV::Prefix(ArrayPrefix::Byte)),
|
||||
]),
|
||||
seq(vec![
|
||||
ch('L'),
|
||||
Term::Marker("array_prefix", SV::Prefix(ArrayPrefix::Long)),
|
||||
]),
|
||||
seq(vec![
|
||||
ch('I'),
|
||||
Term::Marker("array_prefix", SV::Prefix(ArrayPrefix::Int)),
|
||||
]),
|
||||
]),
|
||||
|s| s.scope.get("array_prefix"),
|
||||
);
|
||||
@@ -890,7 +928,11 @@ thread_local! {
|
||||
pub fn parse_tag_argument(reader: &mut Reader) -> CmdResult<Tag> {
|
||||
GRAMMAR.with(|g| match g.parse_for_commands(reader)? {
|
||||
SV::Tag(t) => Ok(t),
|
||||
_ => Err(CommandError::tr_at("argument.nbt.expected.compound", &[], reader)),
|
||||
_ => Err(CommandError::tr_at(
|
||||
"argument.nbt.expected.compound",
|
||||
&[],
|
||||
reader,
|
||||
)),
|
||||
})
|
||||
}
|
||||
/// TagParser.parseCompoundAsArgument.
|
||||
|
||||
@@ -37,9 +37,18 @@ fn reg() -> Registries {
|
||||
},
|
||||
);
|
||||
};
|
||||
registry("minecraft:worldgen/biome", &["minecraft:desert", "minecraft:plains"]);
|
||||
registry("minecraft:damage_type", &["minecraft:fall", "minecraft:magic"]);
|
||||
registry("minecraft:attribute", &["minecraft:max_health", "minecraft:scale"]);
|
||||
registry(
|
||||
"minecraft:worldgen/biome",
|
||||
&["minecraft:desert", "minecraft:plains"],
|
||||
);
|
||||
registry(
|
||||
"minecraft:damage_type",
|
||||
&["minecraft:fall", "minecraft:magic"],
|
||||
);
|
||||
registry(
|
||||
"minecraft:attribute",
|
||||
&["minecraft:max_health", "minecraft:scale"],
|
||||
);
|
||||
registry("minecraft:item_modifier", &["a:b"]);
|
||||
registry("minecraft:predicate", &["a:b"]);
|
||||
registry(
|
||||
@@ -54,7 +63,14 @@ fn reg() -> Registries {
|
||||
"minecraft:entity_type",
|
||||
&["minecraft:pig", "minecraft:player", "minecraft:zombie"],
|
||||
);
|
||||
registry("minecraft:item", &["minecraft:diamond", "minecraft:diamond_sword", "minecraft:stone"]);
|
||||
registry(
|
||||
"minecraft:item",
|
||||
&[
|
||||
"minecraft:diamond",
|
||||
"minecraft:diamond_sword",
|
||||
"minecraft:stone",
|
||||
],
|
||||
);
|
||||
r
|
||||
}
|
||||
fn run(input: &str) -> CmdResult<(String, Vec<String>)> {
|
||||
@@ -436,7 +452,10 @@ fn world_command_trees() {
|
||||
"particle minecraft:dust{color:[1.0,0.0,0.0],scale:2.0} ~ ~ ~ 0.1 0.1 0.1 0 10 force @a",
|
||||
"particle",
|
||||
),
|
||||
("particle minecraft:block{block_state:\"minecraft:stone\"} ~ ~ ~", "particle"),
|
||||
(
|
||||
"particle minecraft:block{block_state:\"minecraft:stone\"} ~ ~ ~",
|
||||
"particle",
|
||||
),
|
||||
("attribute @s minecraft:max_health get 2", "attribute.get"),
|
||||
(
|
||||
"attribute @s minecraft:max_health base set 40",
|
||||
|
||||
@@ -52,7 +52,11 @@ pub fn register(d: &mut Dispatcher, root: usize) {
|
||||
d.requires(compute, GAMEMASTERS);
|
||||
context_sources(d, compute, &mut |d, node| {
|
||||
let f = d.literal(node, "float");
|
||||
let p = d.argument(f, "provider", ArgType::ResourceOrId("minecraft:context_float_provider"));
|
||||
let p = d.argument(
|
||||
f,
|
||||
"provider",
|
||||
ArgType::ResourceOrId("minecraft:context_float_provider"),
|
||||
);
|
||||
d.executes(p, "compute");
|
||||
let scale = d.argument(
|
||||
p,
|
||||
@@ -64,7 +68,11 @@ pub fn register(d: &mut Dispatcher, root: usize) {
|
||||
);
|
||||
d.executes(scale, "compute");
|
||||
let i = d.literal(node, "integer");
|
||||
let p = d.argument(i, "provider", ArgType::ResourceOrId("minecraft:context_int_provider"));
|
||||
let p = d.argument(
|
||||
i,
|
||||
"provider",
|
||||
ArgType::ResourceOrId("minecraft:context_int_provider"),
|
||||
);
|
||||
d.executes(p, "compute");
|
||||
});
|
||||
|
||||
|
||||
@@ -48,7 +48,11 @@ pub fn register(d: &mut Dispatcher, root: usize) {
|
||||
let t = d.argument(as_, "targets", ENTITIES);
|
||||
d.redirect(t, ex);
|
||||
let over = d.literal(positioned, "over");
|
||||
let hm = d.argument(over, "heightmap", ArgType::StrEnum(HEIGHTMAPS, "argument.enum.invalid"));
|
||||
let hm = d.argument(
|
||||
over,
|
||||
"heightmap",
|
||||
ArgType::StrEnum(HEIGHTMAPS, "argument.enum.invalid"),
|
||||
);
|
||||
d.redirect(hm, ex);
|
||||
let rotated = d.literal(ex, "rotated");
|
||||
let rot = d.argument(rotated, "rot", ArgType::Rotation);
|
||||
@@ -179,7 +183,11 @@ fn conditionals(d: &mut Dispatcher, m: usize, ex: usize) {
|
||||
conditional(d, targets, ex);
|
||||
|
||||
let predicate = d.literal(m, "predicate");
|
||||
let p = d.argument(predicate, "predicate", ArgType::ResourceOrId("minecraft:predicate"));
|
||||
let p = d.argument(
|
||||
predicate,
|
||||
"predicate",
|
||||
ArgType::ResourceOrId("minecraft:predicate"),
|
||||
);
|
||||
conditional(d, p, ex);
|
||||
|
||||
let stopwatch = d.literal(m, "stopwatch");
|
||||
|
||||
@@ -169,7 +169,11 @@ fn item(d: &mut Dispatcher, root: usize) {
|
||||
d.argument(p, "targetPos", ArgType::BlockPos)
|
||||
};
|
||||
let slot = d.argument(target, "slot", ArgType::SlotSource);
|
||||
let modifier = d.argument(slot, "modifier", ArgType::ResourceOrId("minecraft:item_modifier"));
|
||||
let modifier = d.argument(
|
||||
slot,
|
||||
"modifier",
|
||||
ArgType::ResourceOrId("minecraft:item_modifier"),
|
||||
);
|
||||
d.executes(modifier, "item.modify");
|
||||
}
|
||||
for mode in ["replace", "fill", "override"] {
|
||||
|
||||
@@ -56,19 +56,31 @@ pub fn register(d: &mut Dispatcher, root: usize) {
|
||||
let place = d.literal(root, "place");
|
||||
d.requires(place, GAMEMASTERS);
|
||||
let feature = d.literal(place, "feature");
|
||||
let f = d.argument(feature, "feature", ArgType::ResourceOrId("minecraft:worldgen/feature"));
|
||||
let f = d.argument(
|
||||
feature,
|
||||
"feature",
|
||||
ArgType::ResourceOrId("minecraft:worldgen/feature"),
|
||||
);
|
||||
d.executes(f, "place.feature");
|
||||
let pos = d.argument(f, "pos", ArgType::BlockPos);
|
||||
d.executes(pos, "place.feature");
|
||||
let jigsaw = d.literal(place, "jigsaw");
|
||||
let pool = d.argument(jigsaw, "pool", ArgType::ResKey("minecraft:worldgen/template_pool"));
|
||||
let pool = d.argument(
|
||||
jigsaw,
|
||||
"pool",
|
||||
ArgType::ResKey("minecraft:worldgen/template_pool"),
|
||||
);
|
||||
let target = d.argument(pool, "target", ArgType::Id("none"));
|
||||
let depth = d.argument(target, "max_depth", ArgType::Integer { min: 1, max: 20 });
|
||||
d.executes(depth, "place.jigsaw");
|
||||
let position = d.argument(depth, "position", ArgType::BlockPos);
|
||||
d.executes(position, "place.jigsaw");
|
||||
let structure = d.literal(place, "structure");
|
||||
let st = d.argument(structure, "structure", ArgType::ResKey("minecraft:worldgen/structure"));
|
||||
let st = d.argument(
|
||||
structure,
|
||||
"structure",
|
||||
ArgType::ResKey("minecraft:worldgen/structure"),
|
||||
);
|
||||
d.executes(st, "place.structure");
|
||||
let pos = d.argument(st, "pos", ArgType::BlockPos);
|
||||
d.executes(pos, "place.structure");
|
||||
@@ -89,7 +101,10 @@ pub fn register(d: &mut Dispatcher, root: usize) {
|
||||
let mirror = d.argument(
|
||||
rotation,
|
||||
"mirror",
|
||||
ArgType::StrEnum(&["none", "left_right", "front_back"], "argument.enum.invalid"),
|
||||
ArgType::StrEnum(
|
||||
&["none", "left_right", "front_back"],
|
||||
"argument.enum.invalid",
|
||||
),
|
||||
);
|
||||
d.executes(mirror, "place");
|
||||
let integrity = d.argument(mirror, "integrity", ArgType::Float { min: 0.0, max: 1.0 });
|
||||
@@ -160,7 +175,14 @@ fn rotate_swing(d: &mut Dispatcher, root: usize) {
|
||||
for hand in ["mainhand", "offhand"] {
|
||||
let h = d.literal(targets, hand);
|
||||
d.executes(h, "swing");
|
||||
let animation = d.argument(h, "animation", ArgType::StrEnum(&["none", "whack", "stab"], "argument.swing_animation.invalid"));
|
||||
let animation = d.argument(
|
||||
h,
|
||||
"animation",
|
||||
ArgType::StrEnum(
|
||||
&["none", "whack", "stab"],
|
||||
"argument.swing_animation.invalid",
|
||||
),
|
||||
);
|
||||
d.executes(animation, "swing");
|
||||
let duration = d.argument(animation, "duration", ArgType::Time { min: 1 });
|
||||
d.executes(duration, "swing");
|
||||
@@ -285,7 +307,11 @@ fn fillbiome(d: &mut Dispatcher, root: usize) {
|
||||
let biome = d.argument(to, "biome", ArgType::Res("minecraft:worldgen/biome"));
|
||||
d.executes(biome, "fillbiome");
|
||||
let replace = d.literal(biome, "replace");
|
||||
let filter = d.argument(replace, "filter", ArgType::ResOrTag("minecraft:worldgen/biome"));
|
||||
let filter = d.argument(
|
||||
replace,
|
||||
"filter",
|
||||
ArgType::ResOrTag("minecraft:worldgen/biome"),
|
||||
);
|
||||
d.executes(filter, "fillbiome");
|
||||
}
|
||||
|
||||
@@ -357,7 +383,11 @@ fn loot(d: &mut Dispatcher, root: usize) {
|
||||
// Sources.
|
||||
for t in targets {
|
||||
let fish = d.literal(t, "fish");
|
||||
let table = d.argument(fish, "loot_table", ArgType::ResourceOrId("minecraft:loot_table"));
|
||||
let table = d.argument(
|
||||
fish,
|
||||
"loot_table",
|
||||
ArgType::ResourceOrId("minecraft:loot_table"),
|
||||
);
|
||||
let pos = d.argument(table, "pos", ArgType::BlockPos);
|
||||
d.executes(pos, "loot.fish");
|
||||
for hand in ["mainhand", "offhand"] {
|
||||
@@ -367,7 +397,11 @@ fn loot(d: &mut Dispatcher, root: usize) {
|
||||
let tool = d.argument(pos, "tool", ArgType::Item);
|
||||
d.executes(tool, "loot.fish");
|
||||
let l = d.literal(t, "loot");
|
||||
let lt = d.argument(l, "loot_table", ArgType::ResourceOrId("minecraft:loot_table"));
|
||||
let lt = d.argument(
|
||||
l,
|
||||
"loot_table",
|
||||
ArgType::ResourceOrId("minecraft:loot_table"),
|
||||
);
|
||||
d.executes(lt, "loot.loot");
|
||||
let kill = d.literal(t, "kill");
|
||||
let target = d.argument(kill, "target", ENTITY);
|
||||
|
||||
@@ -322,13 +322,21 @@ pub fn build_with(dev: bool) -> Dispatcher {
|
||||
let locate = d.literal(root, "locate");
|
||||
d.requires(locate, GAMEMASTERS);
|
||||
let s = d.literal(locate, "structure");
|
||||
let a = d.argument(s, "structure", ArgType::ResOrTagKey("minecraft:worldgen/structure"));
|
||||
let a = d.argument(
|
||||
s,
|
||||
"structure",
|
||||
ArgType::ResOrTagKey("minecraft:worldgen/structure"),
|
||||
);
|
||||
d.executes(a, "locate.structure");
|
||||
let b = d.literal(locate, "biome");
|
||||
let a = d.argument(b, "biome", ArgType::ResOrTag("minecraft:worldgen/biome"));
|
||||
d.executes(a, "locate.biome");
|
||||
let p = d.literal(locate, "poi");
|
||||
let a = d.argument(p, "poi", ArgType::ResOrTag("minecraft:point_of_interest_type"));
|
||||
let a = d.argument(
|
||||
p,
|
||||
"poi",
|
||||
ArgType::ResOrTag("minecraft:point_of_interest_type"),
|
||||
);
|
||||
d.executes(a, "locate.poi");
|
||||
|
||||
if dev {
|
||||
|
||||
@@ -152,6 +152,23 @@ impl Parser<'_> {
|
||||
Err("unterminated string".into())
|
||||
}
|
||||
}
|
||||
/// The style fields of a component object.
|
||||
fn object_style(o: &serde_json::Map<String, Value>) -> Style {
|
||||
let mut style = Style::default();
|
||||
if let Some(c) = o.get("color").and_then(Value::as_str) {
|
||||
style.color = named_color(c).or_else(|| {
|
||||
c.strip_prefix('#')
|
||||
.and_then(|h| u32::from_str_radix(h, 16).ok())
|
||||
});
|
||||
}
|
||||
let flag = |k: &str| o.get(k).and_then(Value::as_bool);
|
||||
style.bold = flag("bold");
|
||||
style.italic = flag("italic");
|
||||
style.underlined = flag("underlined");
|
||||
style.strikethrough = flag("strikethrough");
|
||||
style.obfuscated = flag("obfuscated");
|
||||
style
|
||||
}
|
||||
/// `ComponentSerialization.CODEC` decoding into text. `selector` components
|
||||
/// are resolved by `resolve` (ComponentUtils.updateForEntity).
|
||||
pub fn to_text(v: &Value, resolve: &mut dyn FnMut(&str) -> Text) -> Text {
|
||||
@@ -165,30 +182,23 @@ pub fn to_text(v: &Value, resolve: &mut dyn FnMut(&str) -> Text) -> Text {
|
||||
return Text::empty();
|
||||
};
|
||||
let mut base = to_text(first, resolve);
|
||||
let style = base
|
||||
.spans
|
||||
.first()
|
||||
.map(|s| s.style.clone())
|
||||
.unwrap_or_default();
|
||||
// The siblings take the first component's own style, even when
|
||||
// its text is empty.
|
||||
let style = match first {
|
||||
Value::Object(o) => object_style(o),
|
||||
_ => base
|
||||
.spans
|
||||
.first()
|
||||
.map(|s| s.style.clone())
|
||||
.unwrap_or_default(),
|
||||
};
|
||||
for sibling in rest {
|
||||
base.push(to_text(sibling, resolve).with_style(&style));
|
||||
}
|
||||
base
|
||||
}
|
||||
Value::Object(o) => {
|
||||
let mut style = Style::default();
|
||||
if let Some(c) = o.get("color").and_then(Value::as_str) {
|
||||
style.color = named_color(c).or_else(|| {
|
||||
c.strip_prefix('#')
|
||||
.and_then(|h| u32::from_str_radix(h, 16).ok())
|
||||
});
|
||||
}
|
||||
let flag = |k: &str| o.get(k).and_then(Value::as_bool);
|
||||
style.bold = flag("bold");
|
||||
style.italic = flag("italic");
|
||||
style.underlined = flag("underlined");
|
||||
style.strikethrough = flag("strikethrough");
|
||||
style.obfuscated = flag("obfuscated");
|
||||
let style = object_style(o);
|
||||
let content = if let Some(t) = o.get("text") {
|
||||
match t {
|
||||
Value::String(s) => Text::literal(s.clone()),
|
||||
|
||||
@@ -75,6 +75,9 @@ pub struct Crafter {
|
||||
pub disabled: [bool; 9],
|
||||
pub triggered: bool,
|
||||
pub crafting_ticks: i32,
|
||||
/// The block state's `triggered` when last ticked (not saved): the block
|
||||
/// entity flag follows it only when it changes (`CrafterBlock.neighborChanged`).
|
||||
pub state_triggered: Option<bool>,
|
||||
}
|
||||
impl Crafter {
|
||||
/// `slotCanBeDisabled`: only empty slots.
|
||||
@@ -184,7 +187,12 @@ impl BlockEntity {
|
||||
return Some(Self::Sculk(Box::new(sculk)));
|
||||
}
|
||||
if name == "minecraft:crafter" {
|
||||
return Some(Self::Crafter(Crafter::default()));
|
||||
// CrafterBlock.newBlockEntity copies the placed state's flag: the
|
||||
// first tick sees it as a change from untriggered.
|
||||
return Some(Self::Crafter(Crafter {
|
||||
state_triggered: Some(false),
|
||||
..Crafter::default()
|
||||
}));
|
||||
}
|
||||
if name.ends_with("_banner") {
|
||||
return Some(Self::Banner(Banner::default()));
|
||||
|
||||
@@ -11,6 +11,10 @@ struct Globals {
|
||||
sun: vec4<f32>,
|
||||
// x: sky fog end, y: cloud fog end
|
||||
fog_ends: vec4<f32>,
|
||||
// Atlas rects (u0, v0, width, height) of end_sky.png and end_portal.png.
|
||||
end_portal_sprites: array<vec4<f32>, 2>,
|
||||
// x: vanilla's GameTime ((game time % 24000 + partial tick) / 24000)
|
||||
time: vec4<f32>,
|
||||
}
|
||||
@group(0) @binding(0) var<uniform> globals: Globals;
|
||||
@group(0) @binding(1) var atlas: texture_2d<f32>;
|
||||
@@ -27,11 +31,14 @@ struct Out {
|
||||
@location(4) @interpolate(flat) overlay: u32,
|
||||
// Overlay 6: the sprite's v origin and height, for v repeated inside it.
|
||||
@location(6) @interpolate(flat) wrap: vec2<f32>,
|
||||
// Overlays 9 and 10: projection_from_position(gl_Position), the End portal's texProj0.
|
||||
@location(7) proj: vec4<f32>,
|
||||
}
|
||||
@vertex fn vs_entity(@location(0) position: vec3<f32>, @location(1) uv: vec2<f32>,
|
||||
@location(2) overlay: u32, @location(3) color: vec4<f32>, @location(4) light: vec4<u32>) -> Out {
|
||||
var out: Out;
|
||||
out.clip = globals.view_proj * vec4(position, 1.0);
|
||||
out.proj = vec4(out.clip.xy * 0.5 + out.clip.w * 0.5, out.clip.zw);
|
||||
out.uv = uv;
|
||||
let l = vec2(f32(light.y), f32(light.x));
|
||||
let lm = textureSampleLevel(lightmap, lightmap_sampler,
|
||||
@@ -52,6 +59,41 @@ struct Out {
|
||||
if overlay == 4u { out.spherical = length(position); }
|
||||
return out;
|
||||
}
|
||||
// rendertype_end_portal.fsh: the layer colours.
|
||||
const PORTAL_COLORS = array<vec3<f32>, 16>(
|
||||
vec3(0.022087, 0.098399, 0.110818),
|
||||
vec3(0.011892, 0.095924, 0.089485),
|
||||
vec3(0.027636, 0.101689, 0.100326),
|
||||
vec3(0.046564, 0.109883, 0.114838),
|
||||
vec3(0.064901, 0.117696, 0.097189),
|
||||
vec3(0.063761, 0.086895, 0.123646),
|
||||
vec3(0.084817, 0.111994, 0.166380),
|
||||
vec3(0.097489, 0.154120, 0.091064),
|
||||
vec3(0.106152, 0.131144, 0.195191),
|
||||
vec3(0.097721, 0.110188, 0.187229),
|
||||
vec3(0.133516, 0.138278, 0.148582),
|
||||
vec3(0.070006, 0.243332, 0.235792),
|
||||
vec3(0.196766, 0.142899, 0.214696),
|
||||
vec3(0.047281, 0.315338, 0.321970),
|
||||
vec3(0.204675, 0.390010, 0.302066),
|
||||
vec3(0.080955, 0.314821, 0.661491),
|
||||
);
|
||||
// textureProj on a repeating texture that lives in the atlas at `rect`.
|
||||
fn portal_sample(rect: vec4<f32>, p: vec4<f32>) -> vec3<f32> {
|
||||
let st = fract(p.xy / p.w);
|
||||
return textureSampleLevel(atlas, atlas_sampler, rect.xy + st * rect.zw, 0.0).rgb;
|
||||
}
|
||||
// texProj0 * end_portal_layer(layer): the row vector through mat4(scale * rotate),
|
||||
// translate and SCALE_TRANSLATE in turn.
|
||||
fn portal_layer(p: vec4<f32>, layer: f32) -> vec4<f32> {
|
||||
let a = radians((layer * layer * 4321.0 + layer * 9.0) * 2.0);
|
||||
let c = cos(a);
|
||||
let s = sin(a);
|
||||
let k = (4.5 - layer / 4.0) * 2.0;
|
||||
var v = vec4(k * (c * p.x - s * p.y), k * (s * p.x + c * p.y), p.z, p.w);
|
||||
v = vec4(v.x + 17.0 / layer * v.w, v.y + (2.0 + layer / 1.5) * (globals.time.x * 1.5) * v.w, v.z, v.w);
|
||||
return vec4(0.5 * v.x + 0.25 * v.w, 0.5 * v.y + 0.25 * v.w, v.z, v.w);
|
||||
}
|
||||
fn linear_fog(d: f32, start: f32, end: f32) -> f32 {
|
||||
if d <= start { return 0.0; }
|
||||
if d >= end { return 1.0; }
|
||||
@@ -61,7 +103,16 @@ fn linear_fog(d: f32, start: f32, end: f32) -> f32 {
|
||||
var uv = in.uv;
|
||||
if in.overlay == 6u { uv.y = in.wrap.x + fract(in.uv.y) * in.wrap.y; }
|
||||
var color = textureSample(atlas, atlas_sampler, uv) * in.color;
|
||||
if in.overlay == 4u {
|
||||
if in.overlay == 9u || in.overlay == 10u {
|
||||
// rendertype_end_portal: end_sky and PORTAL_LAYERS (15 for portals, 16
|
||||
// for gateways) layers of end_portal.png projected from the screen.
|
||||
let layers = select(15, 16, in.overlay == 10u);
|
||||
var c = portal_sample(globals.end_portal_sprites[0], in.proj) * PORTAL_COLORS[0];
|
||||
for (var i = 0; i < layers; i++) {
|
||||
c += portal_sample(globals.end_portal_sprites[1], portal_layer(in.proj, f32(i + 1))) * PORTAL_COLORS[i];
|
||||
}
|
||||
color = vec4(c, 1.0);
|
||||
} else if in.overlay == 4u {
|
||||
// clouds.fsh: alpha fades out towards the cloud fog end.
|
||||
return vec4(color.rgb, color.a * (1.0 - linear_fog(in.spherical, 0.0, globals.fog_ends.y)));
|
||||
}
|
||||
|
||||
@@ -253,17 +253,37 @@ pub fn mob_to_nbt(m: &Mob, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
|
||||
} else {
|
||||
map.insert("equipment".into(), Tag::compound(equipment));
|
||||
}
|
||||
let attr = |id: &str, v: f64| {
|
||||
Tag::compound([("id", string(id)), ("base", Tag::Double(v.to_bits()))])
|
||||
let modelled = [
|
||||
("minecraft:max_health", f64::from(m.attrs.max_health)),
|
||||
("minecraft:movement_speed", m.attrs.speed),
|
||||
("minecraft:jump_strength", m.extra.vector[0]),
|
||||
];
|
||||
// AttributeMap.pack writes the instances that exist: the ones the
|
||||
// entity was loaded with, or all three randomized by finalizeSpawn.
|
||||
let attributes = match map.get(&"attributes".into()) {
|
||||
Some(Tag::List(list)) => list
|
||||
.iter()
|
||||
.map(|a| {
|
||||
let id = text(a.get("id"));
|
||||
let value = modelled.iter().find(|(m, _)| Some(*m) == id.as_deref());
|
||||
match (a, value) {
|
||||
(Tag::Compound(c), Some((_, v))) => {
|
||||
let mut c = c.clone();
|
||||
c.insert("base".into(), Tag::Double(v.to_bits()));
|
||||
Tag::Compound(c)
|
||||
}
|
||||
_ => a.clone(),
|
||||
}
|
||||
})
|
||||
.collect(),
|
||||
_ => modelled
|
||||
.iter()
|
||||
.map(|(id, v)| {
|
||||
Tag::compound([("id", string(id)), ("base", Tag::Double(v.to_bits()))])
|
||||
})
|
||||
.collect(),
|
||||
};
|
||||
map.insert(
|
||||
"attributes".into(),
|
||||
Tag::List(vec![
|
||||
attr("minecraft:max_health", f64::from(m.attrs.max_health)),
|
||||
attr("minecraft:movement_speed", m.attrs.speed),
|
||||
attr("minecraft:jump_strength", m.extra.vector[0]),
|
||||
]),
|
||||
);
|
||||
map.insert("attributes".into(), Tag::List(attributes));
|
||||
}
|
||||
"minecraft:pig" | "minecraft:strider" => {
|
||||
let mut equipment = match map.get(&"equipment".into()) {
|
||||
@@ -387,7 +407,9 @@ pub fn mob_to_nbt(m: &Mob, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
|
||||
}
|
||||
// Skeletons are spawned holding a bow (finalizeSpawn); a mob loaded
|
||||
// from NBT keeps only its saved equipment.
|
||||
"minecraft:skeleton" | "minecraft:stray" | "minecraft:bogged" if raw.is_none() => {
|
||||
"minecraft:skeleton" | "minecraft:stray" | "minecraft:bogged"
|
||||
if raw.is_none() && m.extra.main_hand.as_deref() == Some("minecraft:bow") =>
|
||||
{
|
||||
map.entry("equipment".into()).or_insert_with(|| {
|
||||
Tag::compound([(
|
||||
"mainhand",
|
||||
@@ -926,7 +948,8 @@ pub fn falling_block_to_nbt(
|
||||
state: Tag,
|
||||
position: Vec3,
|
||||
motion: Vec3,
|
||||
time: u32,
|
||||
time: i32,
|
||||
drop_item: bool,
|
||||
uuid: [i32; 4],
|
||||
) -> Tag {
|
||||
let base = Base {
|
||||
@@ -941,8 +964,8 @@ pub fn falling_block_to_nbt(
|
||||
let mut tag = base.tag(None);
|
||||
for (key, value) in [
|
||||
("BlockState", state),
|
||||
("Time", Tag::Int(time as i32)),
|
||||
("DropItem", Tag::Byte(1)),
|
||||
("Time", Tag::Int(time)),
|
||||
("DropItem", Tag::Byte(i8::from(drop_item))),
|
||||
("HurtEntities", Tag::Byte(0)),
|
||||
("CancelDrop", Tag::Byte(0)),
|
||||
("FallHurtAmount", Tag::Float(0)),
|
||||
@@ -953,15 +976,23 @@ pub fn falling_block_to_nbt(
|
||||
tag
|
||||
}
|
||||
/// (block state tag, position, motion, time)
|
||||
pub fn falling_block_from_nbt(tag: &Tag) -> Option<(Tag, Vec3, Vec3, u32)> {
|
||||
pub fn falling_block_from_nbt(tag: &Tag) -> Option<(Tag, Vec3, Vec3, i32)> {
|
||||
Some((
|
||||
tag.get("BlockState")?.clone(),
|
||||
position(tag)?,
|
||||
motion(tag),
|
||||
number(tag.get("Time")).unwrap_or(0).max(0) as u32,
|
||||
number(tag.get("Time")).unwrap_or(0) as i32,
|
||||
))
|
||||
}
|
||||
|
||||
/// `NoGravity` and `DropItem` (true when absent) of a saved falling block.
|
||||
pub fn falling_block_flags(tag: &Tag) -> (bool, bool) {
|
||||
(
|
||||
flag(tag, "NoGravity"),
|
||||
number(tag.get("DropItem")).is_none_or(|v| v != 0),
|
||||
)
|
||||
}
|
||||
|
||||
/// Painting.addAdditionalSaveData: `facing`, `variant` and `block_pos`.
|
||||
pub fn painting_to_nbt(p: &Painting, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
|
||||
let (lo, hi) = p.bounds();
|
||||
@@ -1107,22 +1138,19 @@ pub fn armor_stand_from_nbt(tag: &Tag, id: u32) -> Option<ArmorStand> {
|
||||
a.on_ground = flag(tag, "OnGround");
|
||||
Some(a)
|
||||
}
|
||||
/// A boat entity (`minecraft:<wood>_boat`, `minecraft:bamboo_raft`).
|
||||
/// A boat entity (`minecraft:<wood>_boat`, `minecraft:bamboo_chest_raft`...):
|
||||
/// chest boats keep their slots in `Items` (`ContainerHelper.saveAllItems`).
|
||||
pub fn boat_to_nbt(
|
||||
wood: &str,
|
||||
id: &str,
|
||||
position: Vec3,
|
||||
motion: Vec3,
|
||||
yaw: f32,
|
||||
uuid: [i32; 4],
|
||||
raw: Option<&Tag>,
|
||||
items: Option<&[Option<crate::items::ItemStack>]>,
|
||||
) -> Tag {
|
||||
let id = if wood == "bamboo" {
|
||||
"minecraft:bamboo_raft".to_owned()
|
||||
} else {
|
||||
format!("minecraft:{wood}_boat")
|
||||
};
|
||||
Base {
|
||||
id: &id,
|
||||
let mut tag = Base {
|
||||
id,
|
||||
uuid,
|
||||
position,
|
||||
motion,
|
||||
@@ -1130,7 +1158,11 @@ pub fn boat_to_nbt(
|
||||
on_ground: false,
|
||||
fall_distance: 0.0,
|
||||
}
|
||||
.tag(raw)
|
||||
.tag(raw);
|
||||
if let (Some(items), Tag::Compound(map)) = (items, &mut tag) {
|
||||
map.insert("Items".into(), crate::save::encode_items(items));
|
||||
}
|
||||
tag
|
||||
}
|
||||
/// A vehicle's base entity tag (boats and minecarts).
|
||||
pub fn vehicle_to_nbt(
|
||||
|
||||
@@ -451,6 +451,72 @@ impl Font {
|
||||
.sum::<f32>()
|
||||
.ceil()
|
||||
}
|
||||
/// `StringSplitter.splitLines`: each line and whether it continues a
|
||||
/// wrapped one. A line breaks at a `\n`, or once a character overflows
|
||||
/// `max_width` (never the first visible one) at the last space so far, or
|
||||
/// before that character; the `\n` or space at the break is dropped.
|
||||
pub fn split_lines(&self, t: &Text, max_width: f32) -> Vec<(Text, bool)> {
|
||||
let max_width = max_width.max(1.0);
|
||||
let chars: Vec<(char, &Style)> = t.chars().collect();
|
||||
let n = chars.len();
|
||||
let mut out = Vec::new();
|
||||
let (mut start, mut wrapped, mut force_new_line) = (0, false, false);
|
||||
loop {
|
||||
// LineBreakFinder over what is left.
|
||||
let (mut width, mut had_visible, mut last_space) = (0.0f32, false, None);
|
||||
let mut split = None;
|
||||
for (i, &(c, style)) in chars.iter().enumerate().skip(start) {
|
||||
if c == '\n' {
|
||||
split = Some(i);
|
||||
break;
|
||||
}
|
||||
if c == ' ' {
|
||||
last_space = Some(i);
|
||||
}
|
||||
let advance = self.advance(c, style);
|
||||
width += advance;
|
||||
if !had_visible || width <= max_width {
|
||||
had_visible |= advance != 0.0;
|
||||
} else {
|
||||
split = Some(last_space.unwrap_or(i));
|
||||
break;
|
||||
}
|
||||
}
|
||||
let Some(at) = split else { break };
|
||||
let new_line = chars[at].0 == '\n';
|
||||
let skip = usize::from(new_line || chars[at].0 == ' ');
|
||||
force_new_line = new_line;
|
||||
out.push((t.slice(start, at), wrapped));
|
||||
wrapped = !new_line;
|
||||
start = at + skip;
|
||||
}
|
||||
if start < n {
|
||||
out.push((t.slice(start, n), wrapped));
|
||||
} else if force_new_line {
|
||||
out.push((Text::empty(), false));
|
||||
}
|
||||
out
|
||||
}
|
||||
/// `ComponentRenderUtils.wrapComponents`: the split lines, wrapped ones
|
||||
/// indented by an unstyled space; one empty line for empty text.
|
||||
pub fn wrap_components(&self, t: &Text, max_width: i32) -> Vec<Text> {
|
||||
let lines: Vec<Text> = self
|
||||
.split_lines(t, max_width as f32)
|
||||
.into_iter()
|
||||
.map(|(line, wrapped)| {
|
||||
if wrapped {
|
||||
Text::literal(" ").append(line)
|
||||
} else {
|
||||
line
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
if lines.is_empty() {
|
||||
vec![Text::empty()]
|
||||
} else {
|
||||
lines
|
||||
}
|
||||
}
|
||||
/// StringSplitter.splitLines: wrap at the last space that fits (or
|
||||
/// mid-word when none does); `\n` always breaks. Styles are kept.
|
||||
pub fn split(&self, t: &Text, max_width: f32) -> Vec<Text> {
|
||||
|
||||
+378
-844
File diff suppressed because it is too large.
Load diff
@@ -415,6 +415,8 @@ impl Game<'_> {
|
||||
pose[5] = deg(rot[2]);
|
||||
}
|
||||
}
|
||||
// Item layers pose from the hidden arms too (`visible` only skips drawing).
|
||||
let posed = poses.clone();
|
||||
let mut hide = |name: &str| {
|
||||
if poses.has(name) {
|
||||
let pose = poses.get(name);
|
||||
@@ -446,6 +448,7 @@ impl Game<'_> {
|
||||
0,
|
||||
out,
|
||||
);
|
||||
self.armor_stand_items(a, model, base, &posed, packed, out);
|
||||
// HumanoidArmorLayer: head, chest, legs, feet.
|
||||
for (slot, layer) in [
|
||||
(5usize, "helmet"),
|
||||
@@ -562,3 +565,120 @@ impl Game<'_> {
|
||||
true
|
||||
}
|
||||
}
|
||||
impl Game<'_> {
|
||||
/// `ItemInHandLayer` and `CustomHeadLayer` of an armour stand: the hand
|
||||
/// items in the third-person contexts, and a non-armour head item on the
|
||||
/// head (`translateToHead`: down a quarter, turned, 0.625 scale).
|
||||
fn armor_stand_items(
|
||||
&self,
|
||||
a: &ArmorStand,
|
||||
model: &Part,
|
||||
base: glam::Mat4,
|
||||
poses: &Poses,
|
||||
light: u32,
|
||||
out: &mut Vec<Vertex>,
|
||||
) {
|
||||
use horizon_assets::item_model::DisplayContext;
|
||||
use horizon_client::mobs::render::part_matrix;
|
||||
let owner = super::item_draw::owner_at(
|
||||
[a.position.x, a.position.y, a.position.z],
|
||||
a.yaw,
|
||||
"minecraft:armor_stand",
|
||||
);
|
||||
// `useBabyOffset`: small stands use the baby hand offsets.
|
||||
let (ox, oy, oz) = if a.small {
|
||||
(0.0, 1.0, -4.5)
|
||||
} else {
|
||||
(1.0, 2.0, -10.0)
|
||||
};
|
||||
for (slot, left, context) in [
|
||||
(0usize, false, DisplayContext::ThirdPersonRightHand),
|
||||
(1, true, DisplayContext::ThirdPersonLeftHand),
|
||||
] {
|
||||
let Some(stack) = &a.equipment[slot] else {
|
||||
continue;
|
||||
};
|
||||
let arm = if left { "left_arm" } else { "right_arm" };
|
||||
let Some(hand) = part_matrix(model, base, poses, arm, 0.0) else {
|
||||
continue;
|
||||
};
|
||||
let pose = hand
|
||||
* glam::Mat4::from_rotation_x((-90.0f32).to_radians())
|
||||
* glam::Mat4::from_rotation_y(std::f32::consts::PI)
|
||||
* glam::Mat4::from_translation(glam::Vec3::new(
|
||||
if left { -ox } else { ox } / 16.0,
|
||||
oy / 16.0,
|
||||
oz / 16.0,
|
||||
));
|
||||
let seed = a.id as i32 + if left { 1 } else { 2 };
|
||||
let state = self.resolve_item(stack, context, Some(&owner), seed);
|
||||
self.submit_item_world(&state, pose, light, out);
|
||||
}
|
||||
// `CustomHeadLayer`: a worn skull draws the skull model.
|
||||
if let Some(stack) = &a.equipment[5]
|
||||
&& let Some(kind) = skull_kind(&stack.item)
|
||||
&& let Some(head) = part_matrix(model, base, poses, "head", 0.0)
|
||||
{
|
||||
let pose = head * glam::Mat4::from_scale(glam::Vec3::splat(1.1875));
|
||||
let special = horizon_assets::item_model::SpecialModel {
|
||||
kind: if kind == "player" {
|
||||
horizon_assets::item_model::SpecialKind::PlayerHead
|
||||
} else {
|
||||
horizon_assets::item_model::SpecialKind::Head {
|
||||
kind: kind.to_owned(),
|
||||
texture: None,
|
||||
animation: 0.0,
|
||||
}
|
||||
},
|
||||
};
|
||||
let mut sink = horizon_client::item_render::WorldSink {
|
||||
out,
|
||||
light,
|
||||
lights: Some(horizon_client::block_entity_render::diffuse_lights(
|
||||
self.dim == super::Dim::Nether,
|
||||
)),
|
||||
};
|
||||
self.res.item_renderer.submit_special(
|
||||
&special,
|
||||
&serde_json::Value::Null,
|
||||
false,
|
||||
pose,
|
||||
&self.res.atlas.sprites,
|
||||
&self.item_host(),
|
||||
&mut sink,
|
||||
);
|
||||
return;
|
||||
}
|
||||
// A head item that is not worn armour.
|
||||
if let Some(stack) = &a.equipment[5]
|
||||
&& self
|
||||
.res
|
||||
.items
|
||||
.get(&stack.item)
|
||||
.is_none_or(|i| i.equip_asset.is_none())
|
||||
&& let Some(head) = part_matrix(model, base, poses, "head", 0.0)
|
||||
{
|
||||
let pose = head
|
||||
* glam::Mat4::from_translation(glam::Vec3::new(0.0, -0.25, 0.0))
|
||||
* glam::Mat4::from_rotation_y(std::f32::consts::PI)
|
||||
* glam::Mat4::from_scale(glam::Vec3::new(0.625, -0.625, -0.625));
|
||||
// `updateForLiving`: seeded by the entity id plus HEAD's ordinal.
|
||||
let state =
|
||||
self.resolve_item(stack, DisplayContext::Head, Some(&owner), a.id as i32 + 5);
|
||||
self.submit_item_world(&state, pose, light, out);
|
||||
}
|
||||
}
|
||||
}
|
||||
/// `AbstractSkullBlock.getType` of a skull item.
|
||||
fn skull_kind(item: &str) -> Option<&'static str> {
|
||||
Some(match item {
|
||||
"minecraft:skeleton_skull" => "skeleton",
|
||||
"minecraft:wither_skeleton_skull" => "wither_skeleton",
|
||||
"minecraft:zombie_head" => "zombie",
|
||||
"minecraft:creeper_head" => "creeper",
|
||||
"minecraft:dragon_head" => "dragon",
|
||||
"minecraft:piglin_head" => "piglin",
|
||||
"minecraft:player_head" => "player",
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
@@ -2,14 +2,10 @@
|
||||
//! banner item to the block when placed and back to the dropped item when
|
||||
//! broken (`copy_components` in the banner loot tables).
|
||||
use super::Game;
|
||||
use glam::{EulerRot, Mat4, Quat, Vec3};
|
||||
use horizon_client::{
|
||||
banner::{self, BannerLayer},
|
||||
block_entity_render::{Paint, draw_part},
|
||||
containers::{Banner, BlockEntity},
|
||||
hud::{HudBatch, rgba},
|
||||
items::ItemStack,
|
||||
mesher::Vertex,
|
||||
};
|
||||
|
||||
impl Game<'_> {
|
||||
@@ -34,161 +30,6 @@ impl Game<'_> {
|
||||
b.name = stack.custom_name().map(str::to_owned);
|
||||
}
|
||||
}
|
||||
/// A banner in a slot: `item/template_banner`'s `gui` transform on the
|
||||
/// banner model (pole, bar and flag with every layer), flat-lit and
|
||||
/// depth-sorted. False when the stack is not a banner.
|
||||
pub(super) fn draw_banner_icon(
|
||||
&self,
|
||||
batch: &mut HudBatch,
|
||||
stack: &ItemStack,
|
||||
x: f32,
|
||||
y: f32,
|
||||
) -> bool {
|
||||
let Some(base) = banner::banner_color(&stack.item) else {
|
||||
return false;
|
||||
};
|
||||
let Some((main, flag)) = self.res.block_entity_render.banner_models() else {
|
||||
return true;
|
||||
};
|
||||
let banners = &self.res.stations.banners;
|
||||
let sprites = &self.res.atlas.sprites;
|
||||
// display.gui, then the item's special transformation (a banner is
|
||||
// modelled upside down and 2/3 size around the block centre).
|
||||
let m = Mat4::from_translation(Vec3::new(0.0, -3.25 / 16.0, 0.0))
|
||||
* Mat4::from_quat(Quat::from_euler(
|
||||
EulerRot::XYZ,
|
||||
30f32.to_radians(),
|
||||
20f32.to_radians(),
|
||||
0.0,
|
||||
))
|
||||
* Mat4::from_scale(Vec3::splat(0.5325))
|
||||
* Mat4::from_translation(Vec3::new(0.0, -0.5, 0.0))
|
||||
* Mat4::from_scale(Vec3::new(2.0 / 3.0, -2.0 / 3.0, -2.0 / 3.0));
|
||||
let wave = |name: &str, p: &mut [f32; 9]| {
|
||||
if name == "flag" {
|
||||
p[3] = (-0.0125 + 0.01) * std::f32::consts::PI;
|
||||
}
|
||||
};
|
||||
let none = |_: &str, _: &mut [f32; 9]| {};
|
||||
let mut passes: Vec<(&str, u32, bool, String)> = vec![
|
||||
("entity/banner/banner_base", 0xFFFFFF, true, String::new()),
|
||||
(
|
||||
"entity/banner/base",
|
||||
banner::dye_rgb(base),
|
||||
false,
|
||||
String::new(),
|
||||
),
|
||||
];
|
||||
for layer in banner::layers(stack).iter().take(16) {
|
||||
passes.push((
|
||||
"",
|
||||
banner::dye_rgb(&layer.color),
|
||||
false,
|
||||
banners.banner_texture(&layer.pattern),
|
||||
));
|
||||
}
|
||||
// (depth, pass, screen corners, uv, colour)
|
||||
let mut faces: Vec<(f32, usize, [[f32; 2]; 4], [[f32; 2]; 4], u32)> = Vec::new();
|
||||
for (index, (fixed, tint, with_pole, dynamic)) in passes.iter().enumerate() {
|
||||
let name = if fixed.is_empty() {
|
||||
dynamic.as_str()
|
||||
} else {
|
||||
fixed
|
||||
};
|
||||
let Some(sprite) = sprites.get(name).copied() else {
|
||||
continue;
|
||||
};
|
||||
let paint = Paint {
|
||||
sprite,
|
||||
tint: 0xFFFFFF,
|
||||
light: 0,
|
||||
lights: (Vec3::ZERO, Vec3::ZERO),
|
||||
};
|
||||
let mut verts: Vec<Vertex> = Vec::new();
|
||||
if *with_pole {
|
||||
draw_part(main, "root", m, &none, paint, &mut verts);
|
||||
}
|
||||
draw_part(flag, "root", m, &wave, paint, &mut verts);
|
||||
for quad in verts.as_chunks::<4>().0 {
|
||||
let p = |i: usize| Vec3::from(quad[i].pos);
|
||||
let normal = (p(1) - p(0)).cross(p(2) - p(0)).normalize_or_zero();
|
||||
// Faces turned away from the viewer are culled.
|
||||
if normal.z <= 0.0 {
|
||||
continue;
|
||||
}
|
||||
let depth = (0..4).map(|i| p(i).z).sum::<f32>() / 4.0;
|
||||
let shade = (0.6 + 0.4 * normal.z).min(1.0);
|
||||
let c = |s: u32| (((tint >> s) & 255) as f32 * shade) as u8;
|
||||
faces.push((
|
||||
depth,
|
||||
index,
|
||||
std::array::from_fn(|i| {
|
||||
[
|
||||
x + 8.0 + quad[i].pos[0] * 16.0,
|
||||
y + 8.0 - quad[i].pos[1] * 16.0,
|
||||
]
|
||||
}),
|
||||
std::array::from_fn(|i| quad[i].uv),
|
||||
rgba(c(16), c(8), c(0), 255),
|
||||
));
|
||||
}
|
||||
}
|
||||
faces.sort_by(|a, b| a.0.total_cmp(&b.0).then(a.1.cmp(&b.1)));
|
||||
for (_, _, corners, uv, color) in faces {
|
||||
batch.quad(corners, uv, color);
|
||||
}
|
||||
true
|
||||
}
|
||||
/// A banner in the first-person hand: the standing banner model under the
|
||||
/// item's `firstperson_righthand` transform (`m`) and its special
|
||||
/// transformation. False when the stack is not a banner.
|
||||
pub(super) fn held_banner_geometry(
|
||||
&self,
|
||||
m: Mat4,
|
||||
stack: &ItemStack,
|
||||
packed: u32,
|
||||
out: &mut Vec<Vertex>,
|
||||
) -> bool {
|
||||
let Some(base) = banner::banner_color(&stack.item) else {
|
||||
return false;
|
||||
};
|
||||
let Some((main, flag)) = self.res.block_entity_render.banner_models() else {
|
||||
return true;
|
||||
};
|
||||
let m = m
|
||||
* Mat4::from_translation(Vec3::new(0.5, 0.0, 0.5))
|
||||
* Mat4::from_scale(Vec3::new(2.0 / 3.0, -2.0 / 3.0, -2.0 / 3.0));
|
||||
let wave = |name: &str, p: &mut [f32; 9]| {
|
||||
if name == "flag" {
|
||||
p[3] = (-0.0125 + 0.01) * std::f32::consts::PI;
|
||||
}
|
||||
};
|
||||
let none = |_: &str, _: &mut [f32; 9]| {};
|
||||
let lights = horizon_client::block_entity_render::diffuse_lights(false);
|
||||
let sprites = &self.res.atlas.sprites;
|
||||
let paint = |name: &str, tint: u32| {
|
||||
sprites.get(name).map(|&sprite| Paint {
|
||||
sprite,
|
||||
tint,
|
||||
light: packed,
|
||||
lights,
|
||||
})
|
||||
};
|
||||
if let Some(p) = paint("entity/banner/banner_base", 0xFFFFFF) {
|
||||
draw_part(main, "root", m, &none, p, out);
|
||||
draw_part(flag, "root", m, &wave, p, out);
|
||||
}
|
||||
if let Some(p) = paint("entity/banner/base", banner::dye_rgb(base)) {
|
||||
draw_part(flag, "root", m, &wave, p, out);
|
||||
}
|
||||
for layer in banner::layers(stack).iter().take(16) {
|
||||
let texture = self.res.stations.banners.banner_texture(&layer.pattern);
|
||||
if let Some(p) = paint(&texture, banner::dye_rgb(&layer.color)) {
|
||||
draw_part(flag, "root", m, &wave, p, out);
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
/// The layers a banner dropped at `pos` carries.
|
||||
pub(super) fn attach_banner_layers(&mut self, pos: [i32; 3], stack: &mut ItemStack) {
|
||||
if !stack.item.ends_with("_banner") {
|
||||
|
||||
@@ -522,9 +522,6 @@ impl Game<'_> {
|
||||
else {
|
||||
return;
|
||||
};
|
||||
// The sprite's v range rides in the light bytes (entity shader overlay 6).
|
||||
let q = |v: f32| (v * 65535.0).round().clamp(0.0, 65535.0) as u32;
|
||||
let wrap = q(s.v0) | q(s.v1 - s.v0) << 16;
|
||||
let time = (self.world.game_time.rem_euclid(40)) as f32 + alpha as f32;
|
||||
let scoping = self.scoping();
|
||||
for (k, e) in &self.world.block_entities {
|
||||
@@ -539,75 +536,17 @@ impl Game<'_> {
|
||||
} else {
|
||||
(distance / 96.0).max(1.0)
|
||||
};
|
||||
let o = (base - camera).as_vec3();
|
||||
let mut start = 0i32;
|
||||
for (i, &(color, height)) in b.sections.iter().enumerate() {
|
||||
let height = if i == b.sections.len() - 1 {
|
||||
2048
|
||||
} else {
|
||||
height
|
||||
};
|
||||
let end = start + height;
|
||||
let scroll = -time;
|
||||
let v_off = (scroll * 0.2 - (scroll * 0.1).floor()).rem_euclid(1.0);
|
||||
let rgb = |a: u32| {
|
||||
a << 24 | (color & 255) << 16 | (color >> 8 & 255) << 8 | (color >> 16 & 255)
|
||||
};
|
||||
// Solid beam: radius 0.2, rotated with time.
|
||||
let r = 0.2 * scale;
|
||||
let rot = glam::Mat3::from_rotation_y((time * 2.25 - 45.0).to_radians());
|
||||
let corners = [
|
||||
rot * glam::Vec3::new(0.0, 0.0, r),
|
||||
rot * glam::Vec3::new(r, 0.0, 0.0),
|
||||
rot * glam::Vec3::new(-r, 0.0, 0.0),
|
||||
rot * glam::Vec3::new(0.0, 0.0, -r),
|
||||
];
|
||||
let v2 = -1.0 + v_off;
|
||||
let v1 = height as f32 * (0.5 / r) + v2;
|
||||
beam_part(out, o, start, end, corners, [v1, v2], rgb(255), s, wrap);
|
||||
// Glow: radius 0.25, translucent.
|
||||
let g = 0.25 * scale;
|
||||
let corners = [
|
||||
glam::Vec3::new(-g, 0.0, -g),
|
||||
glam::Vec3::new(g, 0.0, -g),
|
||||
glam::Vec3::new(-g, 0.0, g),
|
||||
glam::Vec3::new(g, 0.0, g),
|
||||
];
|
||||
let v1 = height as f32 + v2;
|
||||
beam_part(out, o, start, end, corners, [v1, v2], rgb(32), s, wrap);
|
||||
start = end;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
/// `renderPart`: four quads between the corners (wn, en, ws, es).
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn beam_part(
|
||||
out: &mut Vec<Vertex>,
|
||||
o: glam::Vec3,
|
||||
start: i32,
|
||||
end: i32,
|
||||
[wn, en, ws, es]: [glam::Vec3; 4],
|
||||
[v1, v2]: [f32; 2],
|
||||
color: u32,
|
||||
s: horizon_client::hud::Rect,
|
||||
wrap: u32,
|
||||
) {
|
||||
for (a, b) in [(wn, en), (es, ws), (en, es), (ws, wn)] {
|
||||
let at = |c: glam::Vec3, y: i32| o + c + glam::Vec3::new(0.0, y as f32, 0.0);
|
||||
for (p, uv) in [
|
||||
(at(a, end), [s.u1, v1]),
|
||||
(at(a, start), [s.u1, v2]),
|
||||
(at(b, start), [s.u0, v2]),
|
||||
(at(b, end), [s.u0, v1]),
|
||||
] {
|
||||
out.push(Vertex {
|
||||
pos: p.to_array(),
|
||||
uv,
|
||||
layer: 6,
|
||||
color,
|
||||
light: wrap,
|
||||
});
|
||||
let origin = (glam::DVec3::new(f64::from(k.0), f64::from(k.1), f64::from(k.2))
|
||||
- camera)
|
||||
.as_vec3();
|
||||
horizon_client::block_entity_render::beacon_beam(
|
||||
origin,
|
||||
&b.sections,
|
||||
time,
|
||||
scale,
|
||||
s,
|
||||
out,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,291 @@
|
||||
//! The client side of animated block entities (`horizon_client::block_entity_anim`):
|
||||
//! their client tickers (`Level.tickBlockEntities` on the client level) and
|
||||
//! the block events that drive them (`ClientboundBlockEventPacket` reaching
|
||||
//! `BlockEntity.triggerEvent`). The renderer reads the resulting state through
|
||||
//! [`Game::block_anim`] and [`Game::chest_lid`].
|
||||
use super::Game;
|
||||
use horizon_client::{
|
||||
block_entity_anim::{BellEntity, Shulker, ShulkerStatus, Ticker},
|
||||
block_entity_render::BlockAnim,
|
||||
collisions::{SceneCollisions, no_collision_empty},
|
||||
particles::{ParticleOptions, ParticleType},
|
||||
};
|
||||
use horizon_core::geometry::{Aabb, Vec3};
|
||||
use std::collections::HashMap;
|
||||
|
||||
/// The handle of the local player among a bell's gathered entities.
|
||||
const PLAYER: u64 = u64::MAX;
|
||||
|
||||
impl Game<'_> {
|
||||
/// `BlockEntity.triggerEvent(a, b)` on the client copy of the block entity at `pos`.
|
||||
pub(super) fn block_entity_event(&mut self, pos: [i32; 3], a: i32, b: i32) {
|
||||
let state = self.world.block(pos[0], pos[1], pos[2]).0;
|
||||
let time = self.world.game_time;
|
||||
match self.be_anims.ticker(state) {
|
||||
Some(Ticker::Lid) => {
|
||||
self.be_anims
|
||||
.chests
|
||||
.entry(pos)
|
||||
.or_default()
|
||||
.trigger_event(a, b);
|
||||
}
|
||||
Some(Ticker::Shulker { .. }) => {
|
||||
self.be_anims
|
||||
.shulkers
|
||||
.entry(pos)
|
||||
.or_default()
|
||||
.trigger_event(a, b);
|
||||
}
|
||||
Some(Ticker::Bell) => {
|
||||
let gathered = self.bell_gather(pos);
|
||||
self.be_anims
|
||||
.bells
|
||||
.entry(pos)
|
||||
.or_default()
|
||||
.trigger_event(a, b, time, || gathered);
|
||||
}
|
||||
Some(Ticker::Pot) => {
|
||||
self.be_anims
|
||||
.pots
|
||||
.entry(pos)
|
||||
.or_default()
|
||||
.trigger_event(a, b, time);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
/// A player opened (`open`) or closed the container at `key`: the opener
|
||||
/// count block event (`ContainerOpenersCounter` / `ShulkerBoxBlockEntity`
|
||||
/// `startOpen`/`stopOpen`) for chests, ender chests and shulker boxes,
|
||||
/// on both halves of a double chest (`CompoundContainer`).
|
||||
pub(super) fn container_opener_event(&mut self, key: (i32, i32, i32), open: bool) {
|
||||
// Spectators never count as openers.
|
||||
if self.spectator() {
|
||||
return;
|
||||
}
|
||||
let pos = [key.0, key.1, key.2];
|
||||
let state = self.world.block(pos[0], pos[1], pos[2]).0;
|
||||
match self.be_anims.ticker(state) {
|
||||
Some(Ticker::Lid) => {
|
||||
let count = i32::from(open);
|
||||
self.block_entity_event(pos, 1, count);
|
||||
if let Some(other) = self.chest_other_half(key) {
|
||||
self.block_entity_event([other.0, other.1, other.2], 1, count);
|
||||
}
|
||||
}
|
||||
Some(Ticker::Shulker { .. }) => {
|
||||
let shulker = self.be_anims.shulkers.entry(pos).or_default();
|
||||
let (count, _) = if open {
|
||||
shulker.start_open()
|
||||
} else {
|
||||
shulker.stop_open()
|
||||
};
|
||||
self.block_entity_event(pos, 1, count);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
/// `BellBlockEntity.updateEntities`: the living entities whose bounding
|
||||
/// box meets the bell's block box inflated by 48.
|
||||
fn bell_gather(&self, pos: [i32; 3]) -> Vec<u64> {
|
||||
let area = Aabb::new(
|
||||
Vec3::new(
|
||||
f64::from(pos[0]) - 48.0,
|
||||
f64::from(pos[1]) - 48.0,
|
||||
f64::from(pos[2]) - 48.0,
|
||||
),
|
||||
Vec3::new(
|
||||
f64::from(pos[0]) + 49.0,
|
||||
f64::from(pos[1]) + 49.0,
|
||||
f64::from(pos[2]) + 49.0,
|
||||
),
|
||||
);
|
||||
let mut out = Vec::new();
|
||||
if self
|
||||
.player_view()
|
||||
.is_some_and(|p| p.bounds.intersects(area))
|
||||
{
|
||||
out.push(PLAYER);
|
||||
}
|
||||
for m in &self.mobs {
|
||||
if !m.removed && m.bounds().intersects(area) {
|
||||
out.push(u64::from(m.id));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
/// `ShulkerBoxBlock.canOpen`: a closed box opens only when its lid has
|
||||
/// room to rise (`level.noCollision` of the half-open lid box).
|
||||
pub(super) fn shulker_can_open(&self, pos: [i32; 3]) -> bool {
|
||||
let state = self.world.block(pos[0], pos[1], pos[2]).0;
|
||||
let Some(Ticker::Shulker { facing }) = self.be_anims.ticker(state) else {
|
||||
return true;
|
||||
};
|
||||
let closed = self
|
||||
.be_anims
|
||||
.shulkers
|
||||
.get(&pos)
|
||||
.is_none_or(|s| s.status == ShulkerStatus::Closed);
|
||||
if !closed {
|
||||
return true;
|
||||
}
|
||||
let b = Shulker::lid_box(pos, facing);
|
||||
let mut scene = SceneCollisions {
|
||||
world: &self.world,
|
||||
binding: self.collision,
|
||||
};
|
||||
no_collision_empty(
|
||||
&mut scene,
|
||||
Aabb::new(Vec3::new(b[0], b[1], b[2]), Vec3::new(b[3], b[4], b[5])),
|
||||
)
|
||||
}
|
||||
/// The client tickers of the loaded animated block entities, once per
|
||||
/// client tick (`ClientLevel.tickBlockEntities`).
|
||||
pub(super) fn tick_block_entity_anims(&mut self) {
|
||||
let Some(r) = &self.renderer else {
|
||||
return;
|
||||
};
|
||||
let present: HashMap<[i32; 3], Ticker> = r
|
||||
.block_entities()
|
||||
.filter_map(|(p, s)| Some((*p, self.be_anims.ticker(*s)?)))
|
||||
.collect();
|
||||
self.be_anims.retain_present(&present);
|
||||
let players: Vec<[f64; 3]> = match &self.player {
|
||||
Some(p) if !self.spectator() => vec![[p.position.x, p.position.y, p.position.z]],
|
||||
_ => Vec::new(),
|
||||
};
|
||||
let raiders = self
|
||||
.res
|
||||
.stations
|
||||
.enchantments
|
||||
.entity_tags
|
||||
.get("minecraft:raiders")
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
let frames = self
|
||||
.res
|
||||
.items
|
||||
.block_tags
|
||||
.get("minecraft:conduit_effect_block")
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
let mut positions: Vec<([i32; 3], Ticker)> = present.into_iter().collect();
|
||||
positions.sort_unstable_by_key(|(p, _)| *p);
|
||||
for (pos, ticker) in positions {
|
||||
match ticker {
|
||||
Ticker::Lid => self.be_anims.chests.entry(pos).or_default().tick(),
|
||||
Ticker::Book => {
|
||||
let anims = &mut self.be_anims;
|
||||
anims
|
||||
.books
|
||||
.entry(pos)
|
||||
.or_default()
|
||||
.tick(pos, &players, &mut anims.book_random);
|
||||
}
|
||||
Ticker::Shulker { facing } => {
|
||||
// The lid's push of entities in its way (moveCollidedEntities)
|
||||
// and the neighbour updates are left to the level.
|
||||
self.be_anims
|
||||
.shulkers
|
||||
.entry(pos)
|
||||
.or_default()
|
||||
.tick(pos, facing);
|
||||
}
|
||||
Ticker::Skull { powered } => {
|
||||
self.be_anims.skulls.entry(pos).or_default().tick(powered);
|
||||
}
|
||||
Ticker::Bell => {
|
||||
let player = self.player_view().map(|p| BellEntity {
|
||||
position: [p.position.x, p.position.y, p.position.z],
|
||||
alive: p.alive,
|
||||
raider: false,
|
||||
});
|
||||
let mobs = &self.mobs;
|
||||
let entity = |h: u64| {
|
||||
if h == PLAYER {
|
||||
return player;
|
||||
}
|
||||
let m = mobs.iter().find(|m| u64::from(m.id) == h)?;
|
||||
Some(BellEntity {
|
||||
position: [m.position.x, m.position.y, m.position.z],
|
||||
alive: m.alive() && !m.removed,
|
||||
raider: raiders.contains(&m.kind.id),
|
||||
})
|
||||
};
|
||||
let tick = self
|
||||
.be_anims
|
||||
.bells
|
||||
.entry(pos)
|
||||
.or_default()
|
||||
.tick(pos, entity);
|
||||
let centre = [
|
||||
f64::from(pos[0]) + 0.5,
|
||||
f64::from(pos[1]) + 0.5,
|
||||
f64::from(pos[2]) + 0.5,
|
||||
];
|
||||
if tick.resonate {
|
||||
// The server's copy plays the resonance for everyone.
|
||||
self.audio
|
||||
.play("minecraft:block.bell.resonate", Some(centre), 1.0, 1.0);
|
||||
}
|
||||
if let Some((particles, glow)) = tick.ended {
|
||||
for (p, color) in particles {
|
||||
// ColorParticleOption.create(ENTITY_EFFECT, color): ARGB.
|
||||
let c = color as u32;
|
||||
let channel = |shift: u32| ((c >> shift) & 255) as f32 / 255.0;
|
||||
let options = ParticleOptions {
|
||||
color: [channel(16), channel(8), channel(0), channel(24)],
|
||||
..ParticleOptions::simple(ParticleType::EntityEffect)
|
||||
};
|
||||
self.particles.add(&options, p, [0.0; 3]);
|
||||
}
|
||||
// makeRaidersGlow.
|
||||
for m in self.mobs.iter_mut() {
|
||||
if glow.contains(&u64::from(m.id)) {
|
||||
m.add_effect("minecraft:glowing", 60, 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ticker::Conduit => {
|
||||
let world = &self.world;
|
||||
let facts = &self.res.facts;
|
||||
let registry = self.res.assets.registry;
|
||||
let is_water = |p: [i32; 3]| {
|
||||
facts.get(world.block(p[0], p[1], p[2])).fluid
|
||||
== horizon_client::blocks::Fluid::Water
|
||||
};
|
||||
let is_frame = |p: [i32; 3]| {
|
||||
registry
|
||||
.state(world.block(p[0], p[1], p[2]))
|
||||
.is_some_and(|s| frames.contains(&s.name))
|
||||
};
|
||||
let time = world.game_time;
|
||||
let anims = &mut self.be_anims;
|
||||
let particles = anims.conduits.entry(pos).or_default().client_tick(
|
||||
pos,
|
||||
time,
|
||||
is_water,
|
||||
is_frame,
|
||||
None,
|
||||
self.particles.random(),
|
||||
);
|
||||
let nautilus = ParticleOptions::simple(ParticleType::Nautilus);
|
||||
for (p, v) in particles {
|
||||
self.particles.add(&nautilus, p, v);
|
||||
}
|
||||
}
|
||||
Ticker::Pot => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
/// The renderer's view of the block entity at `pos`.
|
||||
pub(super) fn block_anim(&self, pos: [i32; 3], partial: f32) -> BlockAnim {
|
||||
self.be_anims
|
||||
.block_anim(pos, self.world.game_time, partial, Default::default())
|
||||
}
|
||||
/// `ChestLidController.getOpenness(partial)` at `pos`.
|
||||
pub(super) fn chest_lid(&self, pos: [i32; 3], partial: f32) -> f32 {
|
||||
self.be_anims.lid(pos, partial)
|
||||
}
|
||||
}
|
||||
@@ -48,7 +48,12 @@ pub(super) struct Boat {
|
||||
/// left, right, up, down
|
||||
pub input: [bool; 4],
|
||||
pub on_ground: bool,
|
||||
/// `ChestBoat`: a chest with 27 slots in the back seat.
|
||||
pub chest: bool,
|
||||
pub items: Vec<Option<ItemStack>>,
|
||||
}
|
||||
/// `ChestBoat.CONTAINER_SIZE`.
|
||||
pub(super) const CHEST_SLOTS: usize = 27;
|
||||
const WIDTH: f64 = 1.375;
|
||||
const HEIGHT: f64 = 0.5625;
|
||||
/// Player.DEFAULT_VEHICLE_ATTACHMENT minus the boat's passenger attachment.
|
||||
@@ -76,8 +81,16 @@ impl Boat {
|
||||
paddling: [false; 2],
|
||||
input: [false; 4],
|
||||
on_ground: false,
|
||||
chest: false,
|
||||
items: Vec::new(),
|
||||
}
|
||||
}
|
||||
/// A chest boat or chest raft.
|
||||
pub fn with_chest(mut self) -> Self {
|
||||
self.chest = true;
|
||||
self.items = vec![None; CHEST_SLOTS];
|
||||
self
|
||||
}
|
||||
pub fn bounds(&self) -> Aabb {
|
||||
let (w, p) = (WIDTH / 2.0, self.position);
|
||||
Aabb::new(
|
||||
@@ -95,6 +108,30 @@ impl Boat {
|
||||
}
|
||||
}
|
||||
/// Item id -> (boat entity id, wood).
|
||||
impl Boat {
|
||||
/// The vanilla entity type id (`oak_boat`, `bamboo_chest_raft`), also
|
||||
/// the item it drops.
|
||||
pub(super) fn entity_type(&self) -> String {
|
||||
let chest = if self.chest { "chest_" } else { "" };
|
||||
if &*self.wood == "bamboo" {
|
||||
format!("minecraft:bamboo_{chest}raft")
|
||||
} else {
|
||||
format!("minecraft:{}_{chest}boat", self.wood)
|
||||
}
|
||||
}
|
||||
}
|
||||
/// The wood of a boat or chest boat item or entity type, and whether it
|
||||
/// carries a chest.
|
||||
pub(super) fn any_boat_wood(item: &str) -> Option<(&str, bool)> {
|
||||
let name = item.strip_prefix("minecraft:")?;
|
||||
if let Some(wood) = name
|
||||
.strip_suffix("_chest_boat")
|
||||
.or_else(|| name.strip_suffix("_chest_raft"))
|
||||
{
|
||||
return Some((wood, true));
|
||||
}
|
||||
boat_wood(item).map(|w| (w, false))
|
||||
}
|
||||
pub(super) fn boat_wood(item: &str) -> Option<&str> {
|
||||
let name = item.strip_prefix("minecraft:")?;
|
||||
name.strip_suffix("_boat")
|
||||
@@ -103,10 +140,20 @@ pub(super) fn boat_wood(item: &str) -> Option<&str> {
|
||||
}
|
||||
pub(super) struct BoatModels {
|
||||
pub models: HashMap<String, Part>,
|
||||
pub chest_models: HashMap<String, Part>,
|
||||
}
|
||||
impl BoatModels {
|
||||
/// Whether a boat of this wood (with or without a chest) can be drawn.
|
||||
pub fn has(&self, wood: &str, chest: bool) -> bool {
|
||||
if chest {
|
||||
self.chest_models.contains_key(wood)
|
||||
} else {
|
||||
self.models.contains_key(wood)
|
||||
}
|
||||
}
|
||||
pub fn load(root: &std::path::Path) -> Self {
|
||||
let mut models = HashMap::new();
|
||||
let mut chest_models = HashMap::new();
|
||||
if let Ok(bytes) = std::fs::read(root.join("entity-models.json"))
|
||||
&& let Ok(serde_json::Value::Object(all)) =
|
||||
serde_json::from_slice::<serde_json::Value>(&bytes)
|
||||
@@ -119,9 +166,19 @@ impl BoatModels {
|
||||
{
|
||||
models.insert(wood.to_owned(), part);
|
||||
}
|
||||
if let Some(wood) = key
|
||||
.strip_prefix("minecraft:chest_boat/")
|
||||
.and_then(|k| k.strip_suffix("#main"))
|
||||
&& let Some(part) = horizon_client::mobs::load_model(root, key)
|
||||
{
|
||||
chest_models.insert(wood.to_owned(), part);
|
||||
}
|
||||
}
|
||||
}
|
||||
Self { models }
|
||||
Self {
|
||||
models,
|
||||
chest_models,
|
||||
}
|
||||
}
|
||||
}
|
||||
impl Game<'_> {
|
||||
@@ -457,10 +514,10 @@ impl Game<'_> {
|
||||
}
|
||||
/// `BoatItem.use`: aim at a block or the water surface.
|
||||
pub(super) fn place_boat(&mut self, stack: &ItemStack) -> bool {
|
||||
let Some(wood) = boat_wood(&stack.item) else {
|
||||
let Some((wood, chest)) = any_boat_wood(&stack.item) else {
|
||||
return false;
|
||||
};
|
||||
if !self.boat_models.models.contains_key(wood) {
|
||||
if !self.boat_models.has(wood, chest) {
|
||||
return false;
|
||||
}
|
||||
let Some(eye) = self.eye_position() else {
|
||||
@@ -507,12 +564,15 @@ impl Game<'_> {
|
||||
(None, Some(w)) => w.0,
|
||||
(None, None) => return false,
|
||||
};
|
||||
let boat = Boat::new(
|
||||
let mut boat = Boat::new(
|
||||
self.next_entity + 1,
|
||||
Arc::from(wood),
|
||||
Vec3::new(hit[0], hit[1], hit[2]),
|
||||
self.yaw,
|
||||
);
|
||||
if chest {
|
||||
boat = boat.with_chest();
|
||||
}
|
||||
let bb = boat.bounds();
|
||||
let (lo, hi) = (bb.min_position(), bb.max_position());
|
||||
let e = 1.0e-7;
|
||||
@@ -575,11 +635,24 @@ impl Game<'_> {
|
||||
return false;
|
||||
};
|
||||
let sneaking = self.keys.contains(&winit::keyboard::KeyCode::ShiftLeft);
|
||||
if !self.before_block(d)
|
||||
|| sneaking
|
||||
|| self.boats[i].out_of_control >= 60.0
|
||||
|| self.riding.is_some()
|
||||
{
|
||||
if !self.before_block(d) {
|
||||
return false;
|
||||
}
|
||||
// ChestBoat.interact: sneaking (or a full seat) opens the chest.
|
||||
if self.boats[i].chest && (sneaking || self.riding.is_some()) {
|
||||
let b = &self.boats[i];
|
||||
let key = (i32::MAX - 15, super::minecarts::CART_KEY_Y, b.id as i32);
|
||||
self.world.block_entities.insert(
|
||||
key,
|
||||
horizon_client::containers::BlockEntity::Chest(b.items.clone()),
|
||||
);
|
||||
self.container = Some(key);
|
||||
self.screen = super::Screen::Inventory;
|
||||
self.capture(false);
|
||||
self.right = false;
|
||||
return true;
|
||||
}
|
||||
if sneaking || self.boats[i].out_of_control >= 60.0 || self.riding.is_some() {
|
||||
return false;
|
||||
}
|
||||
self.riding = Some(self.boats[i].id);
|
||||
@@ -601,13 +674,13 @@ impl Game<'_> {
|
||||
let destroyed = self.creative || b.damage > 40.0;
|
||||
if destroyed {
|
||||
let b = self.boats.remove(i);
|
||||
let at = [b.position.x, b.position.y, b.position.z];
|
||||
if !self.creative && self.ui.rules.bool("entity_drops") {
|
||||
let item = if &*b.wood == "bamboo" {
|
||||
"minecraft:bamboo_raft".to_owned()
|
||||
} else {
|
||||
format!("minecraft:{}_boat", b.wood)
|
||||
};
|
||||
let at = [b.position.x, b.position.y, b.position.z];
|
||||
// ChestBoat.destroy: chestVehicleDestroyed spills the contents.
|
||||
for stack in b.items.iter().flatten() {
|
||||
self.spawn_item(stack.clone(), at, super::dispenser::new_item_motion(), 10);
|
||||
}
|
||||
let item = b.entity_type();
|
||||
self.spawn_item(
|
||||
ItemStack {
|
||||
item: Arc::from(item.as_str()),
|
||||
@@ -628,14 +701,20 @@ impl Game<'_> {
|
||||
pub(super) fn boat_geometry(&self, camera: glam::DVec3, alpha: f64, out: &mut Vec<Vertex>) {
|
||||
let a = alpha as f32;
|
||||
for b in &self.boats {
|
||||
let Some(model) = self.boat_models.models.get(&*b.wood) else {
|
||||
let models = if b.chest {
|
||||
&self.boat_models.chest_models
|
||||
} else {
|
||||
&self.boat_models.models
|
||||
};
|
||||
let Some(model) = models.get(&*b.wood) else {
|
||||
continue;
|
||||
};
|
||||
let folder = if b.chest { "chest_boat" } else { "boat" };
|
||||
let Some(uv) = self
|
||||
.res
|
||||
.atlas
|
||||
.sprites
|
||||
.get(&format!("entity/boat/{}", b.wood))
|
||||
.get(&format!("entity/{folder}/{}", b.wood))
|
||||
.map(|r| [r.u0, r.v0, r.u1, r.v1])
|
||||
else {
|
||||
continue;
|
||||
|
||||
@@ -4,10 +4,9 @@ use super::{
|
||||
commands::{R, err},
|
||||
};
|
||||
use horizon_client::{
|
||||
bossbar::BossBars,
|
||||
bossbar::{self, BossBars},
|
||||
command::{Arg, Context},
|
||||
hud::{HudBatch, rgba},
|
||||
save,
|
||||
hud_overlays, save,
|
||||
scoreboard::{self as sb, Comp, comp_text},
|
||||
scoreboard_cmd::format_list,
|
||||
text::Text,
|
||||
@@ -334,26 +333,10 @@ impl Game<'_> {
|
||||
Ok(self.resolve_component(value))
|
||||
}
|
||||
|
||||
/// How many boss bars the dragon and withers already show.
|
||||
pub(super) fn builtin_bar_count(&self) -> usize {
|
||||
let dragon = usize::from(self.dragon.is_some() && self.dim == super::Dim::End);
|
||||
let withers = self.player.as_ref().map_or(0, |p| {
|
||||
self.mobs
|
||||
.iter()
|
||||
.filter(|m| {
|
||||
m.species() == horizon_client::mobs::Species::Wither
|
||||
&& m.alive()
|
||||
&& m.position.distance_squared(p.position) <= 64.0 * 64.0
|
||||
})
|
||||
.count()
|
||||
});
|
||||
dragon + withers
|
||||
}
|
||||
// ---- drawing ------------------------------------------------------------------
|
||||
|
||||
/// `BossHealthOverlay`: the visible custom bars under the built-in ones.
|
||||
pub(super) fn draw_custom_bossbars(&self, batch: &mut HudBatch, mut y: f32) {
|
||||
let atlas = &self.res.atlas;
|
||||
/// The visible custom bars (`/bossbar`), after the built-in ones.
|
||||
pub(super) fn custom_bossbars(&self, bars: &mut Vec<hud_overlays::BossBar>) {
|
||||
for b in self
|
||||
.bosses
|
||||
.bars
|
||||
@@ -361,44 +344,18 @@ impl Game<'_> {
|
||||
.iter()
|
||||
.filter(|b| b.visible && b.player)
|
||||
{
|
||||
if y >= batch.height / 3.0 {
|
||||
break;
|
||||
}
|
||||
let x = (batch.width / 2.0 - 91.0).floor();
|
||||
let sprite = |kind: &str| format!("gui/sprites/boss_bar/{kind}");
|
||||
batch.sprite(atlas, &sprite(&format!("{}_background", b.color)), x, y);
|
||||
if b.progress() > 0.0 {
|
||||
batch.sprite_part(
|
||||
atlas,
|
||||
&sprite(&format!("{}_progress", b.color)),
|
||||
x,
|
||||
y,
|
||||
b.progress(),
|
||||
);
|
||||
}
|
||||
if b.style != "progress" {
|
||||
batch.sprite(atlas, &sprite(&format!("{}_background", b.style)), x, y);
|
||||
if b.progress() > 0.0 {
|
||||
batch.sprite_part(
|
||||
atlas,
|
||||
&sprite(&format!("{}_progress", b.style)),
|
||||
x,
|
||||
y,
|
||||
b.progress(),
|
||||
);
|
||||
}
|
||||
}
|
||||
let name = comp_text(&b.name);
|
||||
let w = atlas.font.width_text(&name);
|
||||
batch.text_styled(
|
||||
atlas,
|
||||
&name,
|
||||
(batch.width / 2.0 - w / 2.0).floor(),
|
||||
y - 9.0,
|
||||
rgba(255, 255, 255, 255),
|
||||
true,
|
||||
);
|
||||
y += 19.0;
|
||||
bars.push(hud_overlays::BossBar {
|
||||
name: comp_text(&b.name),
|
||||
progress: b.progress(),
|
||||
color: bossbar::COLORS
|
||||
.iter()
|
||||
.position(|c| *c == b.color)
|
||||
.unwrap_or(0),
|
||||
overlay: bossbar::STYLES
|
||||
.iter()
|
||||
.position(|s| *s == b.style)
|
||||
.unwrap_or(0),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -511,7 +511,7 @@ impl Game<'_> {
|
||||
return;
|
||||
};
|
||||
screen.resize(batch.width, batch.height);
|
||||
let bg = self.ui.chat.options.background_opacity;
|
||||
let bg = self.ui.chat.options.background_opacity as f32;
|
||||
screen.draw(
|
||||
batch,
|
||||
&self.res.atlas,
|
||||
|
||||
@@ -266,13 +266,11 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
Target::Painting(_) => "minecraft:painting".into(),
|
||||
Target::Boat(id) => self.boats.iter().find(|b| b.id == id).map_or_else(
|
||||
|| "minecraft:oak_boat".into(),
|
||||
|b| {
|
||||
let kind = if &*b.wood == "bamboo" { "raft" } else { "boat" };
|
||||
format!("minecraft:{}_{kind}", b.wood)
|
||||
},
|
||||
),
|
||||
Target::Boat(id) => self
|
||||
.boats
|
||||
.iter()
|
||||
.find(|b| b.id == id)
|
||||
.map_or_else(|| "minecraft:oak_boat".into(), |b| b.entity_type()),
|
||||
Target::Cart(id) => self
|
||||
.carts
|
||||
.iter()
|
||||
@@ -687,7 +685,7 @@ impl Game<'_> {
|
||||
.append("]")
|
||||
.with_color(color)
|
||||
}
|
||||
fn item_rarity(&self, item: &str) -> String {
|
||||
pub(super) fn item_rarity(&self, item: &str) -> String {
|
||||
static RARITY: std::sync::OnceLock<std::collections::HashMap<String, String>> =
|
||||
std::sync::OnceLock::new();
|
||||
let map = RARITY.get_or_init(|| {
|
||||
|
||||
@@ -73,12 +73,13 @@ impl Game<'_> {
|
||||
let uuid = self.uuid_for(id);
|
||||
let b = self.boats.iter().find(|b| b.id == id)?;
|
||||
entity_nbt::boat_to_nbt(
|
||||
&b.wood,
|
||||
&b.entity_type(),
|
||||
b.position,
|
||||
b.velocity,
|
||||
b.yaw,
|
||||
uuid,
|
||||
self.persist.raw.get(&id),
|
||||
b.chest.then_some(&b.items[..]),
|
||||
)
|
||||
}
|
||||
Target::Cart(id) => {
|
||||
|
||||
@@ -67,10 +67,10 @@ impl Game<'_> {
|
||||
} else {
|
||||
let text = self.component_arg(ctx, "title")?;
|
||||
if ctx.has("subtitle") {
|
||||
self.titles.subtitle = Some(text);
|
||||
self.titles.set_subtitle(text);
|
||||
"show.subtitle"
|
||||
} else if ctx.has("actionbar") {
|
||||
self.titles.set_overlay(text);
|
||||
self.titles.set_overlay(text, false);
|
||||
"show.actionbar"
|
||||
} else {
|
||||
self.titles.set_title(text);
|
||||
|
||||
@@ -509,7 +509,7 @@ impl Game<'_> {
|
||||
"frames": self.frames,
|
||||
"ticks": self.ticks,
|
||||
"day_time": self.world.day_time,
|
||||
"music": self.music.current(),
|
||||
"music": self.music.current(&self.audio.engine),
|
||||
"fishing": self.hook_status(),
|
||||
"tridents": self.tridents.len(),
|
||||
"rockets": self.rockets.len(),
|
||||
|
||||
@@ -37,17 +37,6 @@ pub(super) struct Creatures {
|
||||
/// The wandering trader spawner's clock.
|
||||
pub trader: super::wandering_trader::Spawner,
|
||||
}
|
||||
/// MobCategory caps (maxInstancesPerChunk with the 17x17 spawnable chunk area).
|
||||
const CATEGORIES: [(&str, usize); 7] = [
|
||||
("monster", 70),
|
||||
("creature", 10),
|
||||
("ambient", 15),
|
||||
("axolotls", 5),
|
||||
("underground_water_creature", 5),
|
||||
("water_creature", 5),
|
||||
("water_ambient", 20),
|
||||
];
|
||||
|
||||
struct ProjWorld<'a, 'w> {
|
||||
w: &'a MobWorld<'w>,
|
||||
targets: Vec<(Who, Aabb)>,
|
||||
@@ -138,6 +127,14 @@ fn seen(m: &Mob, player: Option<[i32; 4]>) -> Seen {
|
||||
}
|
||||
_ => 0,
|
||||
},
|
||||
pushable: m.pushable(),
|
||||
category: m.category(),
|
||||
potential_job: horizon_client::mobs::villager_brain::potential_job_site(m),
|
||||
meeting: (m.species() == Species::Villager)
|
||||
.then_some(m.extra.meeting)
|
||||
.flatten(),
|
||||
profession: m.extra.variant2,
|
||||
xp: horizon_client::mobs::villager::xp(m),
|
||||
}
|
||||
}
|
||||
impl Game<'_> {
|
||||
@@ -355,99 +352,6 @@ impl Game<'_> {
|
||||
}
|
||||
horizon_client::chunk::MIN_Y
|
||||
}
|
||||
fn weighted_entry(
|
||||
&mut self,
|
||||
entries: &[horizon_client::biome::SpawnEntry],
|
||||
) -> Option<horizon_client::biome::SpawnEntry> {
|
||||
let total: u32 = entries.iter().map(|e| e.weight).sum();
|
||||
if total == 0 {
|
||||
return None;
|
||||
}
|
||||
let mut r = self.world.random.next_int_bound(total as i32).ok()? as u32;
|
||||
for e in entries {
|
||||
if r < e.weight {
|
||||
return Some(e.clone());
|
||||
}
|
||||
r -= e.weight;
|
||||
}
|
||||
None
|
||||
}
|
||||
/// NaturalSpawner.spawnMobsForChunkGeneration (creature packs at generation).
|
||||
pub(super) fn spawn_generation_creatures(&mut self, cx: i32, cz: i32) {
|
||||
let Some(chunk) = self.world.chunks.get(&(cx, cz)).cloned() else {
|
||||
return;
|
||||
};
|
||||
let biome = self.res.biomes.get(chunk.biome(8, 64, 8)).clone();
|
||||
let Some(entries) = biome.spawns.get("creature").cloned() else {
|
||||
return;
|
||||
};
|
||||
let (xo, zo) = (cx * 16, cz * 16);
|
||||
while self.world.random.next_float() < biome.creature_probability {
|
||||
let Some(entry) = self.weighted_entry(&entries) else {
|
||||
return;
|
||||
};
|
||||
let count = entry.min
|
||||
+ self
|
||||
.world
|
||||
.random
|
||||
.next_int_bound((entry.max - entry.min + 1) as i32)
|
||||
.unwrap_or(0) as u32;
|
||||
let mut x = xo + self.world.random.next_int_bound(16).unwrap_or(0);
|
||||
let mut z = zo + self.world.random.next_int_bound(16).unwrap_or(0);
|
||||
let (sx, sz) = (x, z);
|
||||
let Some(kind) = self.res.mob_types.get(&entry.entity).cloned() else {
|
||||
continue;
|
||||
};
|
||||
for _ in 0..count {
|
||||
let mut success = false;
|
||||
let mut attempts = 0;
|
||||
while !success && attempts < 4 {
|
||||
attempts += 1;
|
||||
let mut y = self.motion_top(x, z);
|
||||
let placement = self.res.mob_data.placement(&entry.entity);
|
||||
if placement == horizon_client::mobs::data::Placement::OnGround {
|
||||
let below = self.world.block(x, y - 1, z);
|
||||
if self.res.mob_data.state(below).flags & horizon_client::mobs::data::LAND
|
||||
!= 0
|
||||
{
|
||||
y -= 1;
|
||||
}
|
||||
}
|
||||
let ok = {
|
||||
let w = self.mob_world_base(&[]);
|
||||
spawning::placement_ok(kind.species, &w, x, y, z) && {
|
||||
let mut r = self.world.random.clone();
|
||||
|
||||
spawning::spawn_rules_ok(kind.species, &w, &mut r, x, y, z)
|
||||
}
|
||||
};
|
||||
if ok {
|
||||
let width = kind.width;
|
||||
let fx =
|
||||
f64::from(x).clamp(f64::from(xo) + width, f64::from(xo) + 16.0 - width);
|
||||
let fz =
|
||||
f64::from(z).clamp(f64::from(zo) + width, f64::from(zo) + 16.0 - width);
|
||||
if self
|
||||
.spawn_mob(&entry.entity, [fx, f64::from(y), fz], true)
|
||||
.is_some()
|
||||
{
|
||||
success = true;
|
||||
}
|
||||
}
|
||||
x += self.world.random.next_int_bound(5).unwrap_or(0)
|
||||
- self.world.random.next_int_bound(5).unwrap_or(0);
|
||||
z += self.world.random.next_int_bound(5).unwrap_or(0)
|
||||
- self.world.random.next_int_bound(5).unwrap_or(0);
|
||||
while x < xo || x >= xo + 16 || z < zo || z >= zo + 16 {
|
||||
x = sx + self.world.random.next_int_bound(5).unwrap_or(0)
|
||||
- self.world.random.next_int_bound(5).unwrap_or(0);
|
||||
z = sz + self.world.random.next_int_bound(5).unwrap_or(0)
|
||||
- self.world.random.next_int_bound(5).unwrap_or(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
pub(super) fn mob_world_base<'s>(&'s self, others: &'s [Seen]) -> MobWorld<'s> {
|
||||
let (rain, thunder) = self.world.weather.levels(1.0);
|
||||
MobWorld {
|
||||
@@ -467,6 +371,9 @@ impl Game<'_> {
|
||||
block_tags: &self.res.items.block_tags,
|
||||
biomes: &self.res.biomes,
|
||||
mob_griefing: self.ui.rules.bool("mob_griefing"),
|
||||
end: self.dim == Dim::End,
|
||||
world_seed: self.level.seed,
|
||||
sea_level: if self.dim == Dim::Nether { 32 } else { 63 },
|
||||
difficulty: self.local_difficulty(
|
||||
self.player
|
||||
.as_ref()
|
||||
@@ -514,162 +421,6 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
}
|
||||
/// NaturalSpawner.spawnForChunk over the chunks within 8 of the player.
|
||||
fn spawn_natural(&mut self) {
|
||||
let Some(p) = self.player.as_ref().map(|p| p.position) else {
|
||||
return;
|
||||
};
|
||||
let (pcx, pcz) = (p.x.floor() as i32 >> 4, p.z.floor() as i32 >> 4);
|
||||
let mut counts = [0usize; 7];
|
||||
for m in &self.mobs {
|
||||
if let Some(i) = CATEGORIES.iter().position(|c| c.0 == m.kind.category) {
|
||||
counts[i] += 1;
|
||||
}
|
||||
}
|
||||
let creature_tick = self.world.game_time % 400 == 0;
|
||||
let mut chunks: Vec<(i32, i32)> = (-8..=8)
|
||||
.flat_map(|dx| (-8..=8).map(move |dz| (pcx + dx, pcz + dz)))
|
||||
.filter(|c| self.world.chunks.contains_key(c))
|
||||
.collect();
|
||||
// ChunkMap: shuffled each tick.
|
||||
for i in (1..chunks.len()).rev() {
|
||||
let j = self.world.random.next_int_bound(i as i32 + 1).unwrap_or(0) as usize;
|
||||
chunks.swap(i, j);
|
||||
}
|
||||
for (cx, cz) in chunks {
|
||||
for (ci, (category, cap)) in CATEGORIES.iter().enumerate() {
|
||||
if counts[ci] >= *cap || *category == "creature" && !creature_tick {
|
||||
continue;
|
||||
}
|
||||
counts[ci] += self.spawn_category_for_chunk(category, cx, cz, p);
|
||||
}
|
||||
}
|
||||
}
|
||||
/// NaturalSpawner.spawnCategoryForPosition at a random point in the chunk.
|
||||
fn spawn_category_for_chunk(
|
||||
&mut self,
|
||||
category: &str,
|
||||
cx: i32,
|
||||
cz: i32,
|
||||
player: Vec3,
|
||||
) -> usize {
|
||||
let x0 = cx * 16 + self.world.random.next_int_bound(16).unwrap_or(0);
|
||||
let z0 = cz * 16 + self.world.random.next_int_bound(16).unwrap_or(0);
|
||||
let top = self.motion_top(x0, z0) + 1;
|
||||
let min = horizon_client::chunk::MIN_Y;
|
||||
let y = min + self.world.random.next_int_bound(top - min + 1).unwrap_or(0);
|
||||
if y < min + 1 {
|
||||
return 0;
|
||||
}
|
||||
let start = self.res.facts.get(self.world.block(x0, y, z0));
|
||||
if start.full_collision {
|
||||
return 0;
|
||||
}
|
||||
let mut spawned = 0;
|
||||
for _ in 0..3 {
|
||||
let (mut x, mut z) = (x0, z0);
|
||||
let mut entry: Option<horizon_client::biome::SpawnEntry> = None;
|
||||
let mut group = (self.world.random.next_float() * 4.0).ceil() as u32;
|
||||
let mut cluster = 0;
|
||||
let mut i = 0;
|
||||
while i < group {
|
||||
i += 1;
|
||||
x += self.world.random.next_int_bound(6).unwrap_or(0)
|
||||
- self.world.random.next_int_bound(6).unwrap_or(0);
|
||||
z += self.world.random.next_int_bound(6).unwrap_or(0)
|
||||
- self.world.random.next_int_bound(6).unwrap_or(0);
|
||||
let (fx, fz) = (f64::from(x) + 0.5, f64::from(z) + 0.5);
|
||||
let d2 = (fx - player.x).powi(2)
|
||||
+ (f64::from(y) - player.y).powi(2)
|
||||
+ (fz - player.z).powi(2);
|
||||
if d2 <= 576.0 {
|
||||
continue;
|
||||
}
|
||||
if entry.is_none() {
|
||||
let Some(chunk) = self.world.chunks.get(&(x >> 4, z >> 4)) else {
|
||||
break;
|
||||
};
|
||||
let biome = self.res.biomes.get(chunk.biome(x, y, z)).clone();
|
||||
let list = match self.structure_spawns(category, [x, y, z]) {
|
||||
Some(list) => list,
|
||||
None => match biome.spawns.get(category).cloned() {
|
||||
Some(list) => list,
|
||||
None => break,
|
||||
},
|
||||
};
|
||||
let list: Vec<_> = list
|
||||
.into_iter()
|
||||
.filter(|e| self.res.mob_types.contains_key(&e.entity))
|
||||
.collect();
|
||||
let Some(e) = self.weighted_entry(&list) else {
|
||||
break;
|
||||
};
|
||||
group = e.min
|
||||
+ self
|
||||
.world
|
||||
.random
|
||||
.next_int_bound((e.max - e.min + 1) as i32)
|
||||
.unwrap_or(0) as u32;
|
||||
entry = Some(e);
|
||||
}
|
||||
let e = entry.clone().unwrap_or_else(|| unreachable!());
|
||||
let despawn = if category == "water_ambient" {
|
||||
64.0
|
||||
} else {
|
||||
128.0
|
||||
};
|
||||
if d2 > despawn * despawn {
|
||||
continue;
|
||||
}
|
||||
let Some(kind) = self.res.mob_types.get(&e.entity).cloned() else {
|
||||
break;
|
||||
};
|
||||
let ok = {
|
||||
let w = self.mob_world_base(&[]);
|
||||
let mut r = self.world.random.clone();
|
||||
let placed = spawning::placement_ok(kind.species, &w, x, y, z)
|
||||
&& spawning::spawn_rules_ok(kind.species, &w, &mut r, x, y, z);
|
||||
let half = kind.width / 2.0;
|
||||
let room = w.no_collision(Aabb::new(
|
||||
Vec3::new(fx - half, f64::from(y), fz - half),
|
||||
Vec3::new(fx + half, f64::from(y) + kind.height, fz + half),
|
||||
));
|
||||
// PathfinderMob.checkSpawnRules: walk target value >= 0 (monsters need darkness).
|
||||
let light_ok = !kind.category.eq("monster")
|
||||
|| matches!(
|
||||
kind.species,
|
||||
Species::Slime
|
||||
| Species::SulfurCube
|
||||
| Species::Ghast
|
||||
| Species::MagmaCube
|
||||
| Species::Guardian
|
||||
| Species::ElderGuardian
|
||||
)
|
||||
|| w.brightness(x, y, z) <= 0.5;
|
||||
let consumed = r;
|
||||
(placed && room && light_ok, consumed)
|
||||
};
|
||||
self.world.random = ok.1;
|
||||
if !ok.0 {
|
||||
continue;
|
||||
}
|
||||
if d2 > 16384.0 && kind.category != "creature" {
|
||||
continue;
|
||||
}
|
||||
if self
|
||||
.spawn_mob(&e.entity, [fx, f64::from(y), fz], false)
|
||||
.is_some()
|
||||
{
|
||||
cluster += 1;
|
||||
spawned += 1;
|
||||
if spawned >= 4 || cluster >= group.max(1) {
|
||||
return spawned;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
spawned
|
||||
}
|
||||
/// Advance every mob one tick, then apply their events.
|
||||
pub(super) fn tick_creatures(&mut self) -> Result<()> {
|
||||
self.attack_ticks = self.attack_ticks.saturating_add(1);
|
||||
@@ -688,9 +439,12 @@ impl Game<'_> {
|
||||
}
|
||||
self.tick_inhabited();
|
||||
self.tick_leads();
|
||||
self.tick_wandering_trader();
|
||||
self.spawn_natural();
|
||||
self.tick_patrol_spawner();
|
||||
// ServerChunkCache.tickChunks: the custom spawners tick only with spawn_mobs.
|
||||
if self.ui.rules.bool("spawn_mobs") {
|
||||
self.tick_wandering_trader();
|
||||
self.tick_patrol_spawner();
|
||||
}
|
||||
self.tick_raids();
|
||||
let uuid = self.level.player_uuid;
|
||||
let snapshot: Vec<Seen> = self.mobs.iter().map(|m| seen(m, uuid)).collect();
|
||||
@@ -700,6 +454,7 @@ impl Game<'_> {
|
||||
{
|
||||
let w = self.mob_world_base(&snapshot);
|
||||
let mut scratch = self.world.random.clone();
|
||||
let mut pushes = Vec::new();
|
||||
mobs.retain_mut(|m| {
|
||||
if !w
|
||||
.world
|
||||
@@ -707,18 +462,22 @@ impl Game<'_> {
|
||||
{
|
||||
return !m.hostile() || m.persistent;
|
||||
}
|
||||
horizon_client::mobs::apply_mob_pushes(m, &mut pushes);
|
||||
let from = events.len();
|
||||
let keep = m.tick(&w, &mut scratch, &mut events);
|
||||
horizon_client::mobs::take_mob_pushes(&mut events, from, &mut pushes);
|
||||
if !keep && !m.alive() && !m.removed {
|
||||
dead.push(m.clone());
|
||||
}
|
||||
keep
|
||||
});
|
||||
push_apart(&mut mobs);
|
||||
for m in &mut mobs {
|
||||
horizon_client::mobs::apply_mob_pushes(m, &mut pushes);
|
||||
}
|
||||
}
|
||||
self.mobs.append(&mut mobs);
|
||||
self.sync_riders();
|
||||
self.tick_skeleton_traps();
|
||||
self.cache_mob_icons();
|
||||
self.tick_apparition();
|
||||
self.villager_reputation_tick();
|
||||
self.villager_pickups();
|
||||
@@ -734,18 +493,25 @@ impl Game<'_> {
|
||||
self.tick_player_effects();
|
||||
self.piglin_pickups();
|
||||
self.sulfur_cube_pickups();
|
||||
// Hurt and death sounds of damage dealt outside the mob ticks.
|
||||
let mut queued = Vec::new();
|
||||
for m in &mut self.mobs {
|
||||
queued.append(&mut m.outbox);
|
||||
}
|
||||
self.handle_mob_events(queued)?;
|
||||
Ok(())
|
||||
}
|
||||
/// LivingEntity.die / dropAllDeathLoot.
|
||||
fn mob_died(&mut self, m: &Mob) {
|
||||
let by_player = self.last_hurt_by_player.remove(&m.id) || m.ai.last_hurt_by_player > 0;
|
||||
let mut dying = m.clone();
|
||||
// LivingEntity.die: the death is heard, and a sculk catalyst nearby
|
||||
// takes the experience.
|
||||
// takes the experience (before the loot is dropped).
|
||||
self.sculk_mob_died(m);
|
||||
let consumed =
|
||||
self.catalyst_takes_experience([m.position.x, m.position.y, m.position.z], |game| {
|
||||
self.catalyst_takes_experience([m.position.x, m.position.y, m.position.z], |_| {
|
||||
if by_player {
|
||||
experience_reward(m, &mut game.world.random)
|
||||
horizon_client::mobs::drops::base_experience(&mut dying).max(0) as u32
|
||||
} else {
|
||||
0
|
||||
}
|
||||
@@ -760,103 +526,8 @@ impl Game<'_> {
|
||||
self.score_entity_removed(m.id);
|
||||
self.raider_died(m, by_player);
|
||||
self.villager_murdered(m, by_player);
|
||||
let held = self.held().cloned();
|
||||
let pos = [m.position.x, m.position.y + 0.5, m.position.z];
|
||||
if !m.is_baby() {
|
||||
let mut drops = self.res.items.entity_drops(
|
||||
&m.kind.id,
|
||||
by_player,
|
||||
held.as_ref(),
|
||||
&mut self.world.random,
|
||||
);
|
||||
if m.species() == Species::Sheep && !m.extra.sheared {
|
||||
let color = horizon_client::mobs::render::DYE_NAMES[m.extra.color as usize & 15];
|
||||
drops.extend(self.res.items.loot_table(
|
||||
&format!("minecraft:entities/sheep/{color}"),
|
||||
by_player,
|
||||
held.as_ref(),
|
||||
&mut self.world.random,
|
||||
));
|
||||
}
|
||||
for d in drops {
|
||||
if d.count > 0 && &*d.item != "minecraft:air" {
|
||||
self.spawn_item(d, pos, [0.0, 0.2, 0.0], 10);
|
||||
}
|
||||
}
|
||||
// Saddles, horse armour and chests always drop.
|
||||
let mut gear: Vec<&str> = Vec::new();
|
||||
if let Some(saddle) = m.saddle_item() {
|
||||
self.spawn_item(saddle, pos, [0.0, 0.2, 0.0], 10);
|
||||
}
|
||||
// WitherBoss.dropCustomDeathLoot.
|
||||
if m.species() == Species::Wither {
|
||||
gear.push("minecraft:nether_star");
|
||||
}
|
||||
if species_drops_harness(m)
|
||||
&& let Some(h) = m.extra.head.as_deref()
|
||||
{
|
||||
gear.push(h);
|
||||
}
|
||||
if m.extra.chested && horizon_client::mobs::equine::is_equine(m.species()) {
|
||||
gear.push("minecraft:chest");
|
||||
}
|
||||
if horizon_client::mobs::equine::is_equine(m.species())
|
||||
&& let Some(armor) = m.extra.head.as_deref()
|
||||
{
|
||||
gear.push(armor);
|
||||
}
|
||||
for item in gear {
|
||||
self.spawn_item(ItemStack::new(item, 1), pos, [0.0, 0.2, 0.0], 10);
|
||||
}
|
||||
// AbstractChestedHorse.dropEquipment: the chest contents.
|
||||
for stack in m.extra.items.clone().into_iter().flatten() {
|
||||
self.spawn_item(stack, pos, [0.0, 0.2, 0.0], 10);
|
||||
}
|
||||
self.raider_death_loot(m, pos);
|
||||
let worn = if horizon_client::mobs::equine::is_equine(m.species()) {
|
||||
[None, None, None, None]
|
||||
} else {
|
||||
[
|
||||
m.extra.head.clone(),
|
||||
m.extra.chest.clone(),
|
||||
m.extra.legs.clone(),
|
||||
m.extra.feet.clone(),
|
||||
]
|
||||
};
|
||||
for (index, item) in std::iter::once(m.extra.main_hand.clone())
|
||||
.chain(worn)
|
||||
.enumerate()
|
||||
{
|
||||
// Main hand, then head to feet (the enchantment slots).
|
||||
let slot = if index == 0 {
|
||||
horizon_client::mobs::gear::MAIN
|
||||
} else {
|
||||
horizon_client::mobs::gear::HEAD + index - 1
|
||||
};
|
||||
// Equipment drop chance 8.5% (+ player kill only).
|
||||
// Vexes never drop their sword.
|
||||
if let Some(item) = &item
|
||||
&& by_player
|
||||
&& &**item != horizon_client::mobs::raider::BANNER
|
||||
&& m.species() != Species::Vex
|
||||
&& self.world.random.next_float() < 0.085
|
||||
{
|
||||
let mut stack = ItemStack::new(item, 1);
|
||||
if let Some(g) = horizon_client::mobs::gear::gear(m) {
|
||||
for (e, l) in &g.slots[slot] {
|
||||
stack.enchant(e, *l);
|
||||
}
|
||||
}
|
||||
self.spawn_item(stack, pos, [0.0, 0.2, 0.0], 10);
|
||||
}
|
||||
}
|
||||
}
|
||||
if by_player && !consumed {
|
||||
let xp = experience_reward(m, &mut self.world.random);
|
||||
if xp > 0 {
|
||||
self.spawn_orbs(pos, xp);
|
||||
}
|
||||
}
|
||||
self.drop_death_loot(&mut dying, by_player, consumed, pos);
|
||||
if m.species() == Species::SulfurCube {
|
||||
self.split_sulfur_cube(m);
|
||||
}
|
||||
@@ -881,6 +552,89 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
}
|
||||
/// `dropAllDeathLoot` through `mobs::drops`: the entity loot table from the
|
||||
/// world's random sequence, equipment by drop chance, species extras and
|
||||
/// the experience orbs, spawned in order.
|
||||
fn drop_death_loot(&mut self, m: &mut Mob, by_player: bool, consumed: bool, pos: [f64; 3]) {
|
||||
use horizon_client::mobs::{DamageKind, Who, drops};
|
||||
use horizon_world::loot::{LootDamage, LootEntity};
|
||||
let Some(loot) = horizon_client::structure_blocks::loot_data(&self.options.resources)
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let player = || {
|
||||
let mut e = LootEntity::new("minecraft:player");
|
||||
if let Some(held) = self.held() {
|
||||
e.equipment.push(("mainhand".into(), to_loot(held)));
|
||||
}
|
||||
e
|
||||
};
|
||||
let damage = m.last_damage.map(|(d, _)| {
|
||||
let source = match d.attacker {
|
||||
Some(Who::Player) => Some(player()),
|
||||
Some(Who::Mob(id)) => self
|
||||
.mobs
|
||||
.iter()
|
||||
.find(|o| o.id == id)
|
||||
.map(|o| drops::loot_entity(o, None)),
|
||||
None => None,
|
||||
};
|
||||
let direct = match d.kind {
|
||||
DamageKind::Arrow => Some(LootEntity {
|
||||
kind: "minecraft:arrow".into(),
|
||||
..LootEntity::default()
|
||||
}),
|
||||
DamageKind::Fireball => Some(LootEntity {
|
||||
kind: "minecraft:fireball".into(),
|
||||
..LootEntity::default()
|
||||
}),
|
||||
_ => source.clone(),
|
||||
};
|
||||
let player_source = matches!(d.attacker, Some(Who::Player));
|
||||
LootDamage {
|
||||
kind: damage_type(d.kind, player_source).into(),
|
||||
source,
|
||||
direct,
|
||||
}
|
||||
});
|
||||
let vehicle = m
|
||||
.extra
|
||||
.mount
|
||||
.and_then(|id| self.mobs.iter().find(|o| o.id == id))
|
||||
.map(|o| LootEntity::new(&o.kind.id));
|
||||
let death = drops::Death {
|
||||
loot: &loot,
|
||||
damage,
|
||||
player: by_player.then(player),
|
||||
luck: 0.0,
|
||||
vehicle,
|
||||
mob_drops: self.ui.rules.bool("mob_drops"),
|
||||
world_seed: self.level.seed,
|
||||
};
|
||||
let (items, skulls) = drops::die(m, &death, &mut self.rand_sequences);
|
||||
if skulls
|
||||
&& let Some(Who::Mob(id)) = m.last_damage.and_then(|(d, _)| d.attacker)
|
||||
&& let Some(creeper) = self.mobs.iter_mut().find(|o| o.id == id)
|
||||
{
|
||||
// Creeper.droppedSkulls: one head per charged explosion.
|
||||
creeper.extra.charged = false;
|
||||
}
|
||||
let raider = horizon_client::mobs::raider::is_raider(m.species());
|
||||
for d in items {
|
||||
match d {
|
||||
drops::Drop::Item(stack) => {
|
||||
let item = if raider && stack.item == horizon_client::mobs::raider::BANNER {
|
||||
horizon_client::mobs::raider::ominous_banner()
|
||||
} else {
|
||||
from_loot(&stack)
|
||||
};
|
||||
self.spawn_item(item, pos, [0.0, 0.2, 0.0], 10);
|
||||
}
|
||||
drops::Drop::Orb(value) if !consumed => self.spawn_orbs(pos, value as u32),
|
||||
drops::Drop::Orb(_) => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
pub(super) fn handle_mob_events(&mut self, events: Vec<MobEvent>) -> Result<()> {
|
||||
for e in events {
|
||||
match e {
|
||||
@@ -888,6 +642,17 @@ impl Game<'_> {
|
||||
self.audio
|
||||
.play(&format!("minecraft:{name}"), Some(at), volume, pitch)
|
||||
}
|
||||
MobEvent::LevelEventSound {
|
||||
sound,
|
||||
at,
|
||||
volume,
|
||||
spread,
|
||||
} => {
|
||||
let r = &mut self.world.random;
|
||||
let pitch = (r.next_float() - r.next_float()) * spread + 1.0;
|
||||
self.audio
|
||||
.play(&format!("minecraft:{sound}"), Some(at), volume, pitch)
|
||||
}
|
||||
MobEvent::SoundId(name, at, volume, pitch) => {
|
||||
self.audio.play(&name, Some(at), volume, pitch)
|
||||
}
|
||||
@@ -950,24 +715,18 @@ impl Game<'_> {
|
||||
if let Some(t) = self.mobs.iter_mut().find(|m| m.id == target) {
|
||||
let hit = t.damage(amount, source);
|
||||
if hit {
|
||||
let (voice, center, dead) = (
|
||||
horizon_client::mobs::ai::voice_sounds(t),
|
||||
t.center(),
|
||||
!t.alive(),
|
||||
);
|
||||
killed = dead;
|
||||
let pitch = (self.world.random.next_float()
|
||||
- self.world.random.next_float())
|
||||
* 0.2
|
||||
+ 1.0;
|
||||
let s = voice[if dead { 2 } else { 1 }];
|
||||
if !s.is_empty() {
|
||||
self.audio.play(
|
||||
&format!("minecraft:{s}"),
|
||||
Some(center),
|
||||
1.0,
|
||||
pitch,
|
||||
);
|
||||
killed = !t.alive();
|
||||
// The hurt or death sound the mob raised.
|
||||
let center = t.center();
|
||||
for event in std::mem::take(&mut t.outbox) {
|
||||
if let MobEvent::Sound(s, _, volume, pitch) = event {
|
||||
self.audio.play(
|
||||
&format!("minecraft:{s}"),
|
||||
Some(center),
|
||||
volume,
|
||||
pitch,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2614,11 +2373,6 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
}
|
||||
fn species_drops_harness(m: &Mob) -> bool {
|
||||
// Body equipment set to drop when killed: a harness, nautilus armour, a
|
||||
// sulfur cube's swallowed block.
|
||||
horizon_client::mobs::species::body_slot(m.species())
|
||||
}
|
||||
fn eat_sound(s: Species) -> &'static str {
|
||||
match s {
|
||||
Species::Pig => "entity.pig.eat",
|
||||
@@ -2633,73 +2387,67 @@ fn eat_sound(s: Species) -> &'static str {
|
||||
}
|
||||
}
|
||||
/// Mob.getBaseExperienceReward
|
||||
fn experience_reward(m: &Mob, random: &mut impl RandomSource) -> u32 {
|
||||
use Species as S;
|
||||
match m.species() {
|
||||
S::Blaze | S::Ghast | S::Hoglin | S::Zoglin | S::PiglinBrute | S::Breeze => 10,
|
||||
S::Slime | S::MagmaCube => m.extra.size.max(1) as u32,
|
||||
S::Endermite => 3,
|
||||
S::Bat
|
||||
| S::Squid
|
||||
| S::GlowSquid
|
||||
| S::Cod
|
||||
| S::Salmon
|
||||
| S::TropicalFish
|
||||
| S::Pufferfish
|
||||
| S::Dolphin => 1 + random.next_int_bound(3).unwrap_or(0) as u32,
|
||||
S::IronGolem | S::SnowGolem | S::Villager | S::WanderingTrader => 0,
|
||||
// SulfurCube.getBaseExperienceReward.
|
||||
S::SulfurCube if m.is_baby() => 0,
|
||||
S::SulfurCube => 1 + random.next_int_bound(2).unwrap_or(0) as u32,
|
||||
// Animals in the monster category (Animal.getBaseExperienceReward).
|
||||
S::CamelHusk | S::ZombieNautilus => 1 + random.next_int_bound(3).unwrap_or(0) as u32,
|
||||
_ if m.hostile() => {
|
||||
5 + if m.extra.main_hand.is_some() {
|
||||
1 + random.next_int_bound(3).unwrap_or(0) as u32
|
||||
} else {
|
||||
0
|
||||
}
|
||||
}
|
||||
_ if m.is_baby() => 0,
|
||||
_ => 1 + random.next_int_bound(3).unwrap_or(0) as u32,
|
||||
/// The damage type id of a Horizon damage kind.
|
||||
fn damage_type(kind: horizon_client::mobs::DamageKind, player: bool) -> &'static str {
|
||||
use horizon_client::mobs::DamageKind as K;
|
||||
match kind {
|
||||
K::PlayerAttack => "minecraft:player_attack",
|
||||
K::MobAttack => "minecraft:mob_attack",
|
||||
K::Arrow => "minecraft:arrow",
|
||||
K::Thrown => "minecraft:thrown",
|
||||
K::Fireball => "minecraft:fireball",
|
||||
K::Explosion if player => "minecraft:player_explosion",
|
||||
K::Explosion => "minecraft:explosion",
|
||||
K::Fall => "minecraft:fall",
|
||||
K::OnFire => "minecraft:on_fire",
|
||||
K::InFire => "minecraft:in_fire",
|
||||
K::Lava => "minecraft:lava",
|
||||
K::Drown => "minecraft:drown",
|
||||
K::Magic => "minecraft:magic",
|
||||
K::Sting => "minecraft:sting",
|
||||
K::Cactus => "minecraft:cactus",
|
||||
K::Generic => "minecraft:generic",
|
||||
K::InWall => "minecraft:in_wall",
|
||||
K::Thorns => "minecraft:thorns",
|
||||
K::SonicBoom => "minecraft:sonic_boom",
|
||||
K::OutOfWorld => "minecraft:out_of_world",
|
||||
}
|
||||
}
|
||||
/// Entity.push between overlapping mobs (LivingEntity.pushEntities).
|
||||
fn push_apart(mobs: &mut [Mob]) {
|
||||
let n = mobs.len();
|
||||
for i in 0..n {
|
||||
if !mobs[i].alive() {
|
||||
continue;
|
||||
}
|
||||
let bi = mobs[i].bounds();
|
||||
for j in (i + 1)..n {
|
||||
if !mobs[j].alive() || !bi.intersects(mobs[j].bounds()) {
|
||||
continue;
|
||||
}
|
||||
// A rider and its vehicle do not push each other.
|
||||
if mobs[i].extra.mount == Some(mobs[j].id) || mobs[j].extra.mount == Some(mobs[i].id) {
|
||||
continue;
|
||||
}
|
||||
let (dx, dz) = (
|
||||
mobs[j].position.x - mobs[i].position.x,
|
||||
mobs[j].position.z - mobs[i].position.z,
|
||||
);
|
||||
let mut dd = dx.abs().max(dz.abs());
|
||||
if dd >= 0.01 {
|
||||
dd = dd.sqrt();
|
||||
let (mut xa, mut za) = (dx / dd, dz / dd);
|
||||
let inv = (1.0 / dd).min(1.0);
|
||||
xa *= inv * 0.05;
|
||||
za *= inv * 0.05;
|
||||
mobs[i].velocity.x -= xa;
|
||||
mobs[i].velocity.z -= za;
|
||||
mobs[j].velocity.x += xa;
|
||||
mobs[j].velocity.z += za;
|
||||
}
|
||||
}
|
||||
/// A Horizon item stack as a loot stack (id, count, damage, enchantments).
|
||||
fn to_loot(stack: &ItemStack) -> horizon_world::loot::LootStack {
|
||||
horizon_world::loot::LootStack {
|
||||
item: stack.item.to_string(),
|
||||
count: stack.count as i32,
|
||||
damage: stack.damage as i32,
|
||||
enchantments: stack
|
||||
.enchantments()
|
||||
.iter()
|
||||
.map(|(e, l)| (e.to_string(), *l as i32))
|
||||
.collect(),
|
||||
potion: None,
|
||||
map: false,
|
||||
ominous_amplifier: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// A generated loot stack as a Horizon item stack.
|
||||
fn from_loot(stack: &horizon_world::loot::LootStack) -> ItemStack {
|
||||
let mut item = ItemStack {
|
||||
item: Arc::from(stack.item.as_str()),
|
||||
count: stack.count.max(1) as u32,
|
||||
damage: stack.damage.max(0) as u32,
|
||||
extra: None,
|
||||
};
|
||||
for (id, level) in &stack.enchantments {
|
||||
item.enchant(id, (*level).max(0) as u32);
|
||||
}
|
||||
if let Some(potion) = &stack.potion {
|
||||
item = item.with_potion(potion);
|
||||
}
|
||||
if let Some(amplifier) = stack.ominous_amplifier.filter(|a| *a > 0) {
|
||||
item.data_mut().ominous_amplifier = Some(amplifier as u8);
|
||||
}
|
||||
item
|
||||
}
|
||||
impl Game<'_> {
|
||||
/// The rest of `NetherPortalBlock.randomTick` after a won roll: with a
|
||||
/// player close enough for spawning, a zombified piglin spawns on the block
|
||||
@@ -2707,7 +2455,10 @@ impl Game<'_> {
|
||||
pub(super) fn portal_spawn(&mut self, pos: [i32; 3]) {
|
||||
// ChunkMap.anyPlayerCloseEnoughForSpawning: a non-spectator within 128
|
||||
// blocks of the chunk centre.
|
||||
let centre = [f64::from((pos[0] >> 4) * 16 + 8), f64::from((pos[2] >> 4) * 16 + 8)];
|
||||
let centre = [
|
||||
f64::from((pos[0] >> 4) * 16 + 8),
|
||||
f64::from((pos[2] >> 4) * 16 + 8),
|
||||
];
|
||||
let spectator = self.spectator();
|
||||
let Some(close) = self.player.as_ref().filter(|_| !spectator).map(|p| {
|
||||
let (dx, dz) = (p.position.x - centre[0], p.position.z - centre[1]);
|
||||
@@ -2719,7 +2470,9 @@ impl Game<'_> {
|
||||
return;
|
||||
}
|
||||
let mut y = pos[1];
|
||||
while &*self.res.facts.get(self.world.block(pos[0], y, pos[2])).name == "minecraft:nether_portal" {
|
||||
while &*self.res.facts.get(self.world.block(pos[0], y, pos[2])).name
|
||||
== "minecraft:nether_portal"
|
||||
{
|
||||
y -= 1;
|
||||
}
|
||||
// isValidSpawn: a sturdy top face (portal frames emit no light).
|
||||
@@ -2727,7 +2480,11 @@ impl Game<'_> {
|
||||
if below.sturdy_full & (1 << 1) == 0 {
|
||||
return;
|
||||
}
|
||||
let at = [f64::from(pos[0]) + 0.5, f64::from(y + 1), f64::from(pos[2]) + 0.5];
|
||||
let at = [
|
||||
f64::from(pos[0]) + 0.5,
|
||||
f64::from(y + 1),
|
||||
f64::from(pos[2]) + 0.5,
|
||||
];
|
||||
let _ = self.spawn_mob("minecraft:zombified_piglin", at, false);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,510 @@
|
||||
//! The Rust side of tools/test_dispenser_parity.py: with
|
||||
//! `HORIZON_DISPENSE_FIXTURE=CASES,OUTPUT` the game loads its resources, runs
|
||||
//! every case (a dispenser or dropper with items, blocks and target entities
|
||||
//! around it) through `dispense_from` on a fresh headless world and prints what
|
||||
//! changed in the format of `VanillaDispenserOracle`.
|
||||
use super::{
|
||||
Game,
|
||||
dispenser::{Slot, body_in_head, mob_set_slot, mob_slot_item},
|
||||
};
|
||||
use horizon_client::{
|
||||
Result,
|
||||
chunk::Chunk,
|
||||
containers::BlockEntity,
|
||||
gamerules::Value as RuleValue,
|
||||
item_data::{canon_stack, parse_stack},
|
||||
mobs::{Mob, Species},
|
||||
projectiles::ProjectileKind,
|
||||
world::World,
|
||||
};
|
||||
use horizon_core::{
|
||||
position::BlockPos,
|
||||
random::{LegacyRandom, RandomSource},
|
||||
};
|
||||
use horizon_world::{level::Ticks, registry::BlockStateId};
|
||||
use serde_json::Value;
|
||||
use std::{cell::RefCell, collections::HashMap, fmt::Write as _, io::Write as _, sync::Arc};
|
||||
|
||||
thread_local! {
|
||||
/// Level and game events recorded while a fixture case runs.
|
||||
static LOG: RefCell<Option<Vec<String>>> = const { RefCell::new(None) };
|
||||
}
|
||||
|
||||
/// Record a line if a fixture case is running.
|
||||
pub(super) fn record(line: impl FnOnce() -> String) {
|
||||
LOG.with(|log| {
|
||||
if let Some(lines) = log.borrow_mut().as_mut() {
|
||||
lines.push(line());
|
||||
}
|
||||
});
|
||||
}
|
||||
/// `Level.levelEvent` lines for the level events the behaviour library reported.
|
||||
pub(super) fn record_events(events: &[horizon_world::level::LevelEvent]) {
|
||||
for event in events {
|
||||
if let horizon_world::level::LevelEvent::LevelEvent { id, pos, data } = event {
|
||||
record(|| format!("L {id} {},{},{} {data}", pos.x, pos.y, pos.z));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn num(v: f64) -> String {
|
||||
format!("{v:.6}")
|
||||
}
|
||||
fn floor_pos(p: [f64; 3]) -> String {
|
||||
format!(
|
||||
"{},{},{}",
|
||||
p[0].floor() as i32,
|
||||
p[1].floor() as i32,
|
||||
p[2].floor() as i32
|
||||
)
|
||||
}
|
||||
|
||||
/// The targets a case put in front of the dispenser.
|
||||
enum Target {
|
||||
Mob(u32),
|
||||
Stand(u32),
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
/// Run every case of `spec` ("CASES,OUTPUT") and write the results.
|
||||
pub(super) fn dispense_fixture(&mut self, spec: &str) -> Result<()> {
|
||||
let (input, output) = spec
|
||||
.split_once(',')
|
||||
.ok_or("HORIZON_DISPENSE_FIXTURE=CASES,OUTPUT")?;
|
||||
// Saplings and feature placers grow from the live feature registry.
|
||||
self.install_live_features();
|
||||
let text = std::fs::read_to_string(input)?;
|
||||
let mut out = std::io::BufWriter::new(std::fs::File::create(output)?);
|
||||
for (index, line) in text.lines().enumerate() {
|
||||
let case: Value = serde_json::from_str(line)?;
|
||||
writeln!(out, "case {index}")?;
|
||||
match self.dispense_case(&case) {
|
||||
Ok(lines) => {
|
||||
for l in lines {
|
||||
writeln!(out, "{l}")?;
|
||||
}
|
||||
}
|
||||
Err(e) => writeln!(out, "failed {e}")?,
|
||||
}
|
||||
}
|
||||
LOG.with(|log| *log.borrow_mut() = None);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn fixture_reset(&mut self, seed: i64) {
|
||||
self.world = World::new(self.res.facts.air, seed);
|
||||
// The oracle level is plains everywhere (bone meal's flowers, freezing).
|
||||
let plains = u32::from(self.res.biomes.id("minecraft:plains"));
|
||||
for cx in -2..=2 {
|
||||
for cz in -2..=2 {
|
||||
let mut chunk = Chunk::empty(cx, cz);
|
||||
for section in &mut chunk.sections {
|
||||
Arc::make_mut(section).biomes = horizon_core::paletted::PalettedContainer::new(
|
||||
horizon_client::chunk::BIOME_STRATEGY,
|
||||
plains,
|
||||
);
|
||||
}
|
||||
self.world.chunks.insert((cx, cz), Arc::new(chunk));
|
||||
}
|
||||
}
|
||||
self.block_ticks = Ticks::new(0);
|
||||
self.tick_chunks.clear();
|
||||
self.sync_tick_chunks();
|
||||
self.mobs.clear();
|
||||
self.dropped.clear();
|
||||
self.creatures.projectiles.clear();
|
||||
self.tnts.clear();
|
||||
self.boats.clear();
|
||||
self.carts.clear();
|
||||
self.armor_stands.clear();
|
||||
self.rockets.clear();
|
||||
self.potions.clear_thrown();
|
||||
self.level_events.clear();
|
||||
self.orbs.clear();
|
||||
self.falling.clear();
|
||||
self.ui.rules = Default::default();
|
||||
// The oracle level plays on normal difficulty.
|
||||
self.ui.difficulty = 2;
|
||||
// The oracle level has world seed 0 and fresh random sequences.
|
||||
self.level.seed = 0;
|
||||
self.rand_sequences = Default::default();
|
||||
}
|
||||
|
||||
fn dispense_case(&mut self, case: &Value) -> Result<Vec<String>> {
|
||||
let seed = case["seed"].as_i64().unwrap_or(0);
|
||||
self.fixture_reset(seed);
|
||||
// The oracle level answers one sky and one block light everywhere.
|
||||
let light = case["light"].as_array();
|
||||
let level = |i: usize, default: i64| {
|
||||
light
|
||||
.and_then(|l| l.get(i))
|
||||
.and_then(Value::as_i64)
|
||||
.unwrap_or(default) as u8
|
||||
};
|
||||
let packed = (level(0, 15) << 4) | level(1, 0);
|
||||
for chunk in self.world.chunks.values_mut() {
|
||||
for section in &mut Arc::make_mut(chunk).sections {
|
||||
Arc::make_mut(section).light = horizon_client::chunk::Light::Uniform(packed);
|
||||
}
|
||||
}
|
||||
self.world.game_time = case["time"].as_i64().unwrap_or(0);
|
||||
// The oracle level's clock stands at day time 6000.
|
||||
self.world.day_time = 6000;
|
||||
if let Some(rules) = case["rules"].as_object() {
|
||||
for (name, value) in rules {
|
||||
if let Some(b) = value.as_bool() {
|
||||
self.ui.rules.set(name, RuleValue::Bool(b));
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut before: HashMap<(i32, i32, i32), u32> = HashMap::new();
|
||||
for cell in case["cells"].as_array().into_iter().flatten() {
|
||||
let p = (
|
||||
cell[0].as_i64().unwrap_or(0) as i32,
|
||||
cell[1].as_i64().unwrap_or(0) as i32,
|
||||
cell[2].as_i64().unwrap_or(0) as i32,
|
||||
);
|
||||
let state = BlockStateId(cell[3].as_u64().unwrap_or(0) as u32);
|
||||
self.world.set(p.0, p.1, p.2, state)?;
|
||||
before.insert(p, state.0);
|
||||
if let Some(entity) = BlockEntity::for_block(&self.res.facts.get(state).name) {
|
||||
self.world.block_entities.insert(p, entity);
|
||||
}
|
||||
}
|
||||
for item in case["items"].as_array().into_iter().flatten() {
|
||||
let p = (
|
||||
item[0].as_i64().unwrap_or(0) as i32,
|
||||
item[1].as_i64().unwrap_or(0) as i32,
|
||||
item[2].as_i64().unwrap_or(0) as i32,
|
||||
);
|
||||
let slot = item[3].as_u64().unwrap_or(0) as usize;
|
||||
let stack = parse_stack(item[4].as_str().unwrap_or("-"))?;
|
||||
if let Some(cell) = self
|
||||
.world
|
||||
.block_entities
|
||||
.get_mut(&p)
|
||||
.and_then(|e| e.slots_mut().get_mut(slot))
|
||||
{
|
||||
*cell = stack;
|
||||
}
|
||||
}
|
||||
let mut targets = Vec::new();
|
||||
for e in case["entities"].as_array().into_iter().flatten() {
|
||||
targets.push(self.fixture_target(e)?);
|
||||
}
|
||||
// Leashable.setLeashedTo: a lead from a target to an earlier one.
|
||||
for (e, t) in case["entities"]
|
||||
.as_array()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.zip(&targets)
|
||||
{
|
||||
let (Some(holder), Target::Mob(id)) = (e["leash"].as_u64(), t) else {
|
||||
continue;
|
||||
};
|
||||
let holder = match targets.get(holder as usize) {
|
||||
Some(Target::Mob(h)) => horizon_client::mobs::leash::Holder::Mob(*h),
|
||||
_ => continue,
|
||||
};
|
||||
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == *id) {
|
||||
m.extra.leash = Some(holder);
|
||||
}
|
||||
}
|
||||
// Creating the targets must not move the stream the dispenser draws from.
|
||||
self.world.random = LegacyRandom::new(seed);
|
||||
self.world.block_log = Some(Vec::new());
|
||||
self.audio.capture = Some(Vec::new());
|
||||
LOG.with(|log| *log.borrow_mut() = Some(Vec::new()));
|
||||
let old_mobs: Vec<u32> = self.mobs.iter().map(|m| m.id).collect();
|
||||
let old_stands: Vec<u32> = self.armor_stands.iter().map(|s| s.id).collect();
|
||||
|
||||
let pos = &case["pos"];
|
||||
let origin = BlockPos::new(
|
||||
pos[0].as_i64().unwrap_or(0) as i32,
|
||||
pos[1].as_i64().unwrap_or(0) as i32,
|
||||
pos[2].as_i64().unwrap_or(0) as i32,
|
||||
);
|
||||
let state = self.world.block(origin.x, origin.y, origin.z);
|
||||
self.dispense_from(origin, state)?;
|
||||
|
||||
let mut out = Vec::new();
|
||||
let mut changed: Vec<(i32, i32, i32)> = self.world.block_log.take().unwrap_or_default();
|
||||
changed.sort_unstable();
|
||||
changed.dedup();
|
||||
for p in changed {
|
||||
let now = self.world.block(p.0, p.1, p.2).0;
|
||||
if before.get(&p).copied().unwrap_or(self.res.facts.air.0) != now {
|
||||
out.push(format!("set {},{},{} {now}", p.0, p.1, p.2));
|
||||
}
|
||||
}
|
||||
let mut containers: Vec<_> = self.world.block_entities.keys().copied().collect();
|
||||
containers.sort_unstable();
|
||||
for p in containers {
|
||||
let entity = &self.world.block_entities[&p];
|
||||
for (i, slot) in entity.slots().iter().enumerate() {
|
||||
if let Some(stack) = slot {
|
||||
out.push(format!(
|
||||
"inv {},{},{} {i} {}",
|
||||
p.0,
|
||||
p.1,
|
||||
p.2,
|
||||
canon_stack(Some(stack))
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
self.fixture_entities(&old_mobs, &old_stands, &mut out);
|
||||
for (i, t) in targets.iter().enumerate() {
|
||||
out.push(self.fixture_describe(i, t));
|
||||
}
|
||||
let log = LOG.with(|log| log.borrow_mut().take()).unwrap_or_default();
|
||||
out.extend(log);
|
||||
for (sound, at, _, _) in self.audio.capture.take().unwrap_or_default() {
|
||||
out.push(format!("S {} {sound}", floor_pos(at)));
|
||||
}
|
||||
out.push(format!("rng {}", self.world.random.next_long()));
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// `EntityType.create` and `snapTo` for one target: no spawn finalization.
|
||||
fn fixture_target(&mut self, e: &Value) -> Result<Target> {
|
||||
let kind = e["type"].as_str().unwrap_or("minecraft:pig");
|
||||
let p = &e["pos"];
|
||||
let at = horizon_core::geometry::Vec3::new(
|
||||
p[0].as_f64().unwrap_or(0.0),
|
||||
p[1].as_f64().unwrap_or(0.0),
|
||||
p[2].as_f64().unwrap_or(0.0),
|
||||
);
|
||||
let yaw = e["yaw"].as_f64().unwrap_or(0.0) as f32;
|
||||
self.next_entity += 1;
|
||||
let id = self.next_entity;
|
||||
if kind == "minecraft:armor_stand" {
|
||||
self.armor_stands
|
||||
.push(horizon_client::entities::ArmorStand::new(id, at, yaw));
|
||||
return Ok(Target::Stand(id));
|
||||
}
|
||||
let t = self
|
||||
.res
|
||||
.mob_types
|
||||
.get(kind)
|
||||
.cloned()
|
||||
.ok_or_else(|| format!("no mob type {kind}"))?;
|
||||
let mut mob = Mob::new(id, t, at, yaw);
|
||||
// EntityType.create equips nothing (that is finalizeSpawn's job).
|
||||
for slot in [
|
||||
&mut mob.extra.main_hand,
|
||||
&mut mob.extra.off_hand,
|
||||
&mut mob.extra.head,
|
||||
&mut mob.extra.chest,
|
||||
&mut mob.extra.legs,
|
||||
&mut mob.extra.feet,
|
||||
] {
|
||||
*slot = None;
|
||||
}
|
||||
let s = mob.species();
|
||||
let flag = |name: &str| e[name].as_bool().unwrap_or(false);
|
||||
let ageable = !matches!(
|
||||
s,
|
||||
Species::Zombie | Species::Skeleton | Species::Bogged | Species::SnowGolem
|
||||
);
|
||||
if flag("baby") && ageable {
|
||||
mob.extra.age = -24000;
|
||||
}
|
||||
let tamable =
|
||||
body_in_head(s) || matches!(s, Species::Cat | Species::Parrot | Species::Ocelot);
|
||||
if flag("tame") && tamable {
|
||||
mob.extra.tame = true;
|
||||
}
|
||||
if flag("sheared") && matches!(s, Species::Sheep | Species::Bogged | Species::SnowGolem) {
|
||||
mob.extra.sheared = true;
|
||||
}
|
||||
if flag("chest")
|
||||
&& matches!(
|
||||
s,
|
||||
Species::Donkey | Species::Mule | Species::Llama | Species::TraderLlama
|
||||
)
|
||||
{
|
||||
mob.extra.chested = true;
|
||||
}
|
||||
if flag("loot") {
|
||||
mob.extra.pick_up_loot = true;
|
||||
}
|
||||
if flag("brown") && s == Species::Mooshroom {
|
||||
mob.extra.variant = 1;
|
||||
}
|
||||
for (slot, snbt) in e["equip"].as_object().into_iter().flatten() {
|
||||
if let (Some(slot), Some(stack)) = (
|
||||
Slot::from_name(slot),
|
||||
parse_stack(snbt.as_str().unwrap_or("-"))?,
|
||||
) {
|
||||
mob_set_slot(&mut mob, slot, stack);
|
||||
}
|
||||
}
|
||||
self.mobs.push(mob);
|
||||
Ok(Target::Mob(id))
|
||||
}
|
||||
|
||||
/// `T` lines: removal, equipment by `EquipmentSlot`, shearing readiness, chests.
|
||||
fn fixture_describe(&self, index: usize, target: &Target) -> String {
|
||||
let mut line = format!("T {index} ");
|
||||
match *target {
|
||||
Target::Stand(id) => {
|
||||
line.push_str("minecraft:armor_stand");
|
||||
let Some(stand) = self.armor_stands.iter().find(|s| s.id == id) else {
|
||||
line.push_str(" removed");
|
||||
return line;
|
||||
};
|
||||
for (slot, name) in [
|
||||
(0, "mainhand"),
|
||||
(1, "offhand"),
|
||||
(2, "feet"),
|
||||
(3, "legs"),
|
||||
(4, "chest"),
|
||||
(5, "head"),
|
||||
] {
|
||||
if let Some(s) = &stand.equipment[slot] {
|
||||
let _ = write!(line, " {name}={}x{}", s.item, s.count);
|
||||
}
|
||||
}
|
||||
}
|
||||
Target::Mob(id) => {
|
||||
let Some(m) = self.mobs.iter().find(|m| m.id == id) else {
|
||||
return line + "removed";
|
||||
};
|
||||
line.push_str(&m.kind.id);
|
||||
if m.removed || !m.alive() {
|
||||
line.push_str(" removed");
|
||||
}
|
||||
let s = m.species();
|
||||
let x = &m.extra;
|
||||
for (slot, name) in [
|
||||
(Slot::MainHand, "mainhand"),
|
||||
(Slot::OffHand, "offhand"),
|
||||
(Slot::Feet, "feet"),
|
||||
(Slot::Legs, "legs"),
|
||||
(Slot::Chest, "chest"),
|
||||
(Slot::Head, "head"),
|
||||
(Slot::Body, "body"),
|
||||
(Slot::Saddle, "saddle"),
|
||||
] {
|
||||
if let Some(item) = mob_slot_item(m, slot) {
|
||||
let _ = write!(line, " {name}={item}x1");
|
||||
}
|
||||
}
|
||||
let ready = match s {
|
||||
Species::Sheep => Some(!x.sheared && !m.is_baby()),
|
||||
Species::Mooshroom => Some(!m.is_baby()),
|
||||
Species::Bogged => Some(!x.sheared),
|
||||
Species::SnowGolem => Some(!x.sheared),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(r) = ready {
|
||||
line.push_str(if r { " ready" } else { " unready" });
|
||||
}
|
||||
if x.chested
|
||||
&& matches!(
|
||||
s,
|
||||
Species::Donkey | Species::Mule | Species::Llama | Species::TraderLlama
|
||||
)
|
||||
{
|
||||
line.push_str(" chest");
|
||||
}
|
||||
if x.leash.is_some() {
|
||||
line.push_str(" leashed");
|
||||
}
|
||||
}
|
||||
}
|
||||
line
|
||||
}
|
||||
|
||||
/// `N` lines for every entity the dispenser added.
|
||||
fn fixture_entities(&self, old_mobs: &[u32], old_stands: &[u32], out: &mut Vec<String>) {
|
||||
let line = |kind: &str, p: [f64; 3], v: [f64; 3], extra: &str| {
|
||||
let mut l = format!(
|
||||
"N {kind} {} {} {} {} {} {}",
|
||||
num(p[0]),
|
||||
num(p[1]),
|
||||
num(p[2]),
|
||||
num(v[0]),
|
||||
num(v[1]),
|
||||
num(v[2])
|
||||
);
|
||||
if !extra.is_empty() {
|
||||
l.push(' ');
|
||||
l.push_str(extra);
|
||||
}
|
||||
l
|
||||
};
|
||||
for m in self.mobs.iter().filter(|m| !old_mobs.contains(&m.id)) {
|
||||
let (p, v) = (m.position, m.velocity);
|
||||
out.push(line(&m.kind.id, [p.x, p.y, p.z], [v.x, v.y, v.z], ""));
|
||||
}
|
||||
for s in self
|
||||
.armor_stands
|
||||
.iter()
|
||||
.filter(|s| !old_stands.contains(&s.id))
|
||||
{
|
||||
let (p, v) = (s.position, s.velocity);
|
||||
out.push(line(
|
||||
"minecraft:armor_stand",
|
||||
[p.x, p.y, p.z],
|
||||
[v.x, v.y, v.z],
|
||||
"",
|
||||
));
|
||||
}
|
||||
for d in &self.dropped {
|
||||
let (p, v) = (d.position, d.velocity);
|
||||
out.push(line(
|
||||
"minecraft:item",
|
||||
[p.x, p.y, p.z],
|
||||
[v.x, v.y, v.z],
|
||||
&canon_stack(Some(&d.stack)),
|
||||
));
|
||||
}
|
||||
for shot in &self.creatures.projectiles {
|
||||
let kind = match &shot.kind {
|
||||
ProjectileKind::Arrow {
|
||||
effect: Some(("minecraft:glowing", 200)),
|
||||
..
|
||||
} => "minecraft:spectral_arrow",
|
||||
ProjectileKind::Arrow { .. } => "minecraft:arrow",
|
||||
ProjectileKind::Snowball => "minecraft:snowball",
|
||||
ProjectileKind::Egg => "minecraft:egg",
|
||||
ProjectileKind::SmallFireball => "minecraft:small_fireball",
|
||||
ProjectileKind::WindCharge => "minecraft:wind_charge",
|
||||
_ => "minecraft:unknown_projectile",
|
||||
};
|
||||
let (p, v) = (shot.position, shot.velocity);
|
||||
out.push(line(kind, [p.x, p.y, p.z], [v.x, v.y, v.z], ""));
|
||||
}
|
||||
for (kind, p, v) in self.potions.thrown_entities() {
|
||||
out.push(line(kind, [p.x, p.y, p.z], [v.x, v.y, v.z], ""));
|
||||
}
|
||||
for (p, v) in self.rockets.iter().map(|r| r.motion()) {
|
||||
out.push(line(
|
||||
"minecraft:firework_rocket",
|
||||
[p.x, p.y, p.z],
|
||||
[v.x, v.y, v.z],
|
||||
"",
|
||||
));
|
||||
}
|
||||
for t in &self.tnts {
|
||||
let (p, v) = (t.position, t.velocity);
|
||||
out.push(line(
|
||||
"minecraft:tnt",
|
||||
[p.x, p.y, p.z],
|
||||
[v.x, v.y, v.z],
|
||||
&format!("fuse={}", t.fuse),
|
||||
));
|
||||
}
|
||||
for b in &self.boats {
|
||||
let kind = b.entity_type();
|
||||
let (p, v) = (b.position, b.velocity);
|
||||
out.push(line(&kind, [p.x, p.y, p.z], [v.x, v.y, v.z], ""));
|
||||
}
|
||||
for c in &self.carts {
|
||||
let (p, v) = (c.position, c.velocity);
|
||||
out.push(line(c.kind.id(), [p.x, p.y, p.z], [v.x, v.y, v.z], ""));
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -8,9 +8,11 @@
|
||||
//! exit portal, the egg and the next gateway; and its rendering and boss bar.
|
||||
use super::{Dim, Game};
|
||||
use horizon_client::{
|
||||
hud::{HudBatch, rgba},
|
||||
hud::HudBatch,
|
||||
hud_layout, hud_overlays,
|
||||
mesher::Vertex,
|
||||
particles::{ParticleOptions, ParticleType},
|
||||
text::Text,
|
||||
};
|
||||
use horizon_core::{
|
||||
geometry::{Aabb, Vec3},
|
||||
@@ -1839,30 +1841,30 @@ impl Game<'_> {
|
||||
});
|
||||
self.breath_clouds.append(&mut clouds);
|
||||
}
|
||||
/// The boss bar (`BossHealthOverlay`): name and the pink progress bar.
|
||||
pub(super) fn draw_boss_bar(&self, batch: &mut HudBatch) {
|
||||
let dragon = self.dragon.as_ref().filter(|_| self.dim == Dim::End);
|
||||
let y = self.draw_wither_bars(batch, if dragon.is_some() { 31.0 } else { 12.0 });
|
||||
self.draw_raid_bar(batch, y);
|
||||
let Some(d) = dragon else {
|
||||
return;
|
||||
};
|
||||
let atlas = &self.res.atlas;
|
||||
let x = (batch.width / 2.0 - 91.0).floor();
|
||||
let y = 12.0;
|
||||
batch.sprite(atlas, "gui/sprites/boss_bar/pink_background", x, y);
|
||||
let progress = (d.health / MAX_HEALTH).clamp(0.0, 1.0);
|
||||
batch.sprite_part(atlas, "gui/sprites/boss_bar/pink_progress", x, y, progress);
|
||||
let name = self.translate("entity.minecraft.ender_dragon", "Ender Dragon");
|
||||
let w = HudBatch::text_width(atlas, &name);
|
||||
batch.text(
|
||||
atlas,
|
||||
&name,
|
||||
(batch.width / 2.0 - w / 2.0).floor(),
|
||||
y - 9.0,
|
||||
rgba(255, 255, 255, 255),
|
||||
true,
|
||||
/// `BossHealthOverlay`: the dragon's pink bar, then the withers', the
|
||||
/// raids' and the custom bars, in that order.
|
||||
pub(super) fn draw_boss_bars(&self, batch: &mut HudBatch) {
|
||||
let mut bars = Vec::new();
|
||||
if let Some(d) = self.dragon.as_ref().filter(|_| self.dim == Dim::End) {
|
||||
bars.push(hud_overlays::BossBar {
|
||||
name: Text::literal(
|
||||
self.translate("entity.minecraft.ender_dragon", "Ender Dragon"),
|
||||
),
|
||||
progress: (d.health / MAX_HEALTH).clamp(0.0, 1.0),
|
||||
color: 0,
|
||||
overlay: 0,
|
||||
});
|
||||
}
|
||||
self.wither_bars(&mut bars);
|
||||
self.raid_bars(&mut bars);
|
||||
self.custom_bossbars(&mut bars);
|
||||
let draws = hud_overlays::boss_overlay(
|
||||
batch.width as i32,
|
||||
batch.height as i32,
|
||||
&bars,
|
||||
&self.res.atlas.font,
|
||||
);
|
||||
hud_layout::paint(batch, &self.res.atlas, &draws);
|
||||
}
|
||||
/// `music.dragon` while the boss bar plays music.
|
||||
pub(super) fn boss_music(&self) -> bool {
|
||||
|
||||
@@ -37,10 +37,6 @@ pub(super) struct EndState {
|
||||
job: Option<GatewayJob>,
|
||||
/// Ticks until the player can use a gateway again (TheEndGatewayBlockEntity cooldown).
|
||||
gateway_cooldown: u32,
|
||||
/// End portal and gateway blocks per loaded chunk (true = gateway), for rendering.
|
||||
portals: HashMap<(i32, i32), Vec<([i32; 3], bool)>>,
|
||||
/// Chunks left to scan for portal blocks in the current round.
|
||||
scan: Vec<(i32, i32)>,
|
||||
/// The Overworld generator, for stronghold positions (ServerLevel.findNearestMapStructure).
|
||||
locator: std::sync::Mutex<Option<Arc<VanillaTerrain>>>,
|
||||
/// Thrown eyes of ender in flight.
|
||||
@@ -132,11 +128,6 @@ impl EyeOfEnder {
|
||||
}
|
||||
}
|
||||
|
||||
/// Chunks scanned for portal blocks per tick.
|
||||
const SCAN_PER_TICK: usize = 6;
|
||||
/// Radius (chunks) around the player in which portal blocks are rendered.
|
||||
const PORTAL_RADIUS: i32 = 10;
|
||||
|
||||
impl Game<'_> {
|
||||
/// The environment attributes of the current dimension.
|
||||
pub(super) fn dim_environment(&self) -> &Environment {
|
||||
@@ -295,7 +286,6 @@ impl Game<'_> {
|
||||
)?;
|
||||
}
|
||||
}
|
||||
self.rescan_portals_around(c, 1);
|
||||
// Global LevelEvent 1038: END_PORTAL_SPAWN.
|
||||
self.audio
|
||||
.play("minecraft:block.end_portal.spawn", None, 1.0, 1.0);
|
||||
@@ -350,7 +340,6 @@ impl Game<'_> {
|
||||
self.tick_fight();
|
||||
self.tick_dragon_fireballs();
|
||||
}
|
||||
self.scan_portals();
|
||||
self.tick_eyes();
|
||||
if self.dim == Dim::End {
|
||||
self.end.flash.tick(self.world.game_time);
|
||||
@@ -645,7 +634,6 @@ impl Game<'_> {
|
||||
let id = registry.resolve(&state)?;
|
||||
self.level_set([origin[0] + d[0], origin[1] + 2, origin[2] + d[1]], id)?;
|
||||
}
|
||||
self.rescan_portals_around(origin, 4);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -669,7 +657,6 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
}
|
||||
self.rescan_portals_around(origin, 0);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -887,214 +874,10 @@ impl Game<'_> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Scan a few loaded chunks per tick for End portal and gateway blocks.
|
||||
fn scan_portals(&mut self) {
|
||||
let Some(p) = self.player.as_ref() else {
|
||||
return;
|
||||
};
|
||||
let (pcx, pcz) = (
|
||||
(p.position.x.floor() as i32) >> 4,
|
||||
(p.position.z.floor() as i32) >> 4,
|
||||
);
|
||||
let chunks = &self.world.chunks;
|
||||
self.end.portals.retain(|k, _| chunks.contains_key(k));
|
||||
if self.end.scan.is_empty() {
|
||||
let mut queue: Vec<(i32, i32)> = chunks
|
||||
.keys()
|
||||
.copied()
|
||||
.filter(|&(x, z)| {
|
||||
(x - pcx).abs() <= PORTAL_RADIUS && (z - pcz).abs() <= PORTAL_RADIUS
|
||||
})
|
||||
.collect();
|
||||
// Nearest last: they are popped first.
|
||||
queue.sort_by_key(|&(x, z)| std::cmp::Reverse((x - pcx).pow(2) + (z - pcz).pow(2)));
|
||||
self.end.scan = queue;
|
||||
}
|
||||
for _ in 0..SCAN_PER_TICK {
|
||||
let Some((cx, cz)) = self.end.scan.pop() else {
|
||||
break;
|
||||
};
|
||||
self.scan_portal_chunk(cx, cz);
|
||||
}
|
||||
}
|
||||
|
||||
fn scan_portal_chunk(&mut self, cx: i32, cz: i32) {
|
||||
let registry = self.res.assets.registry;
|
||||
let (Some(portal), Some(gateway)) = (
|
||||
registry.default_state("minecraft:end_portal"),
|
||||
registry.default_state("minecraft:end_gateway"),
|
||||
) else {
|
||||
return;
|
||||
};
|
||||
let (portal, gateway) = (portal.0 as u16, gateway.0 as u16);
|
||||
let Some(chunk) = self.world.chunks.get(&(cx, cz)) else {
|
||||
self.end.portals.remove(&(cx, cz));
|
||||
return;
|
||||
};
|
||||
let mut found = Vec::new();
|
||||
for (s, section) in chunk.sections.iter().enumerate() {
|
||||
if !section
|
||||
.blocks
|
||||
.maybe_has(|v| v == u32::from(portal) || v == u32::from(gateway))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let mut blocks = Box::new([0u16; 4096]);
|
||||
section.unpack(&mut blocks);
|
||||
for (i, &b) in blocks.iter().enumerate() {
|
||||
if b == portal || b == gateway {
|
||||
let y = horizon_client::chunk::MIN_Y + s as i32 * 16 + (i >> 8) as i32;
|
||||
found.push((
|
||||
[
|
||||
cx * 16 + (i & 15) as i32,
|
||||
y,
|
||||
cz * 16 + ((i >> 4) & 15) as i32,
|
||||
],
|
||||
b == gateway,
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
if found.is_empty() {
|
||||
self.end.portals.remove(&(cx, cz));
|
||||
} else {
|
||||
self.end.portals.insert((cx, cz), found);
|
||||
}
|
||||
}
|
||||
|
||||
fn rescan_portals_around(&mut self, p: [i32; 3], r: i32) {
|
||||
for cz in (p[2] - r) >> 4..=(p[2] + r) >> 4 {
|
||||
for cx in (p[0] - r) >> 4..=(p[0] + r) >> 4 {
|
||||
self.scan_portal_chunk(cx, cz);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// End portal and gateway surfaces (TheEndPortalRenderer: the starfield
|
||||
/// approximated by end_portal.png in world space, unanimated) and the End
|
||||
/// crystals on the spikes, as camera-relative entity geometry.
|
||||
/// The End crystals on the spikes and thrown eyes of ender, as
|
||||
/// camera-relative entity geometry (End portals and gateways are block
|
||||
/// entities).
|
||||
pub(super) fn end_geometry(&self, camera: glam::DVec3, out: &mut Vec<Vertex>) {
|
||||
let atlas = &self.res.atlas;
|
||||
if let Some(r) = atlas.sprites.get("entity/end_portal/end_portal").copied() {
|
||||
let full = (15 * 16) | ((15 * 16) << 8);
|
||||
// Each block shows a quarter of the texture so neighbours join up.
|
||||
let quarter = |a: i32, b: i32| {
|
||||
let (fa, fb) = (a.rem_euclid(4) as f32 / 4.0, b.rem_euclid(4) as f32 / 4.0);
|
||||
let (du, dv) = (r.u1 - r.u0, r.v1 - r.v0);
|
||||
(
|
||||
r.u0 + du * fa,
|
||||
r.u0 + du * (fa + 0.25),
|
||||
r.v0 + dv * fb,
|
||||
r.v0 + dv * (fb + 0.25),
|
||||
)
|
||||
};
|
||||
for blocks in self.end.portals.values() {
|
||||
for &(p, gateway) in blocks {
|
||||
let base = glam::DVec3::new(f64::from(p[0]), f64::from(p[1]), f64::from(p[2]))
|
||||
- camera;
|
||||
if base.length_squared() > 160.0 * 160.0 {
|
||||
continue;
|
||||
}
|
||||
let b = base.as_vec3();
|
||||
// Portal: top face at 12/16 and bottom at 6/16; gateway: the whole cube.
|
||||
let (y0, y1) = if gateway { (0.0, 1.0) } else { (0.375, 0.75) };
|
||||
let color = if gateway { 0xFFE0_E0A0 } else { 0xFFD0_E070 };
|
||||
let mut quad =
|
||||
|corners: [glam::Vec3; 4], (u0, u1, v0, v1): (f32, f32, f32, f32)| {
|
||||
let uv = [[u0, v0], [u1, v0], [u1, v1], [u0, v1]];
|
||||
for (c, uv) in corners.into_iter().zip(uv) {
|
||||
out.push(Vertex {
|
||||
pos: (b + c).to_array(),
|
||||
uv,
|
||||
layer: 0,
|
||||
color,
|
||||
light: full,
|
||||
});
|
||||
}
|
||||
};
|
||||
let v = glam::Vec3::new;
|
||||
let open = |d: [i32; 3]| {
|
||||
let n = [p[0] + d[0], p[1] + d[1], p[2] + d[2]];
|
||||
!self
|
||||
.res
|
||||
.facts
|
||||
.get(self.world.block(n[0], n[1], n[2]))
|
||||
.solid_render
|
||||
};
|
||||
let uv_xz = quarter(p[0], p[2]);
|
||||
if !gateway || open([0, 1, 0]) {
|
||||
quad(
|
||||
[
|
||||
v(0.0, y1, 0.0),
|
||||
v(1.0, y1, 0.0),
|
||||
v(1.0, y1, 1.0),
|
||||
v(0.0, y1, 1.0),
|
||||
],
|
||||
uv_xz,
|
||||
);
|
||||
}
|
||||
if !gateway || open([0, -1, 0]) {
|
||||
quad(
|
||||
[
|
||||
v(0.0, y0, 0.0),
|
||||
v(1.0, y0, 0.0),
|
||||
v(1.0, y0, 1.0),
|
||||
v(0.0, y0, 1.0),
|
||||
],
|
||||
uv_xz,
|
||||
);
|
||||
}
|
||||
if gateway {
|
||||
let uv_x = quarter(p[2], p[1]);
|
||||
let uv_z = quarter(p[0], p[1]);
|
||||
if open([-1, 0, 0]) {
|
||||
quad(
|
||||
[
|
||||
v(0.0, 0.0, 0.0),
|
||||
v(0.0, 0.0, 1.0),
|
||||
v(0.0, 1.0, 1.0),
|
||||
v(0.0, 1.0, 0.0),
|
||||
],
|
||||
uv_x,
|
||||
);
|
||||
}
|
||||
if open([1, 0, 0]) {
|
||||
quad(
|
||||
[
|
||||
v(1.0, 0.0, 0.0),
|
||||
v(1.0, 0.0, 1.0),
|
||||
v(1.0, 1.0, 1.0),
|
||||
v(1.0, 1.0, 0.0),
|
||||
],
|
||||
uv_x,
|
||||
);
|
||||
}
|
||||
if open([0, 0, -1]) {
|
||||
quad(
|
||||
[
|
||||
v(0.0, 0.0, 0.0),
|
||||
v(1.0, 0.0, 0.0),
|
||||
v(1.0, 1.0, 0.0),
|
||||
v(0.0, 1.0, 0.0),
|
||||
],
|
||||
uv_z,
|
||||
);
|
||||
}
|
||||
if open([0, 0, 1]) {
|
||||
quad(
|
||||
[
|
||||
v(0.0, 0.0, 1.0),
|
||||
v(1.0, 0.0, 1.0),
|
||||
v(1.0, 1.0, 1.0),
|
||||
v(0.0, 1.0, 1.0),
|
||||
],
|
||||
uv_z,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if self.dim == Dim::End {
|
||||
self.end_crystal_geometry(camera, out);
|
||||
}
|
||||
|
||||
@@ -29,6 +29,12 @@ pub(super) struct Rocket {
|
||||
angled: bool,
|
||||
icon: Option<String>,
|
||||
}
|
||||
impl Rocket {
|
||||
/// Position and motion (the dispenser parity fixture).
|
||||
pub(super) fn motion(&self) -> (Vec3, Vec3) {
|
||||
(self.position, self.velocity)
|
||||
}
|
||||
}
|
||||
fn fireworks_of(stack: &ItemStack) -> (u8, Vec<FireworkExplosion>) {
|
||||
// The item's default component: flight 1, no explosions.
|
||||
stack
|
||||
@@ -39,10 +45,9 @@ fn fireworks_of(stack: &ItemStack) -> (u8, Vec<FireworkExplosion>) {
|
||||
impl Game<'_> {
|
||||
pub(super) fn new_rocket(&mut self, at: Vec3, stack: &ItemStack, attached: bool) -> Rocket {
|
||||
let (flight, explosions) = fireworks_of(stack);
|
||||
let r = &mut self.world.random;
|
||||
let tri = |r: &mut horizon_core::random::LegacyRandom| {
|
||||
0.002297 * (f64::from(r.next_float()) - f64::from(r.next_float()))
|
||||
};
|
||||
// FireworkRocketEntity: motion and lifetime come from the rocket's own random.
|
||||
let r = &mut super::dispenser::entity_random();
|
||||
let tri = |r: &mut horizon_core::random::LegacyRandom| r.triangle(0.0, 0.002297);
|
||||
let velocity = Vec3::new(tri(r), 0.05, tri(r));
|
||||
let lifetime = 10 * (1 + i32::from(flight))
|
||||
+ r.next_int_bound(6).unwrap_or(0)
|
||||
|
||||
@@ -1,16 +1,19 @@
|
||||
//! Field of view (`Camera.tickFov`, `calculateFov`,
|
||||
//! `AbstractClientPlayer.getFieldOfViewModifier`) and the spyglass
|
||||
//! (`SpyglassItem`, `Hud.extractSpyglassOverlay`).
|
||||
//! `AbstractClientPlayer.getFieldOfViewModifier`), view and hurt bobbing
|
||||
//! (`GameRenderer.bobHurt`, `bobView`) and the spyglass (`SpyglassItem`,
|
||||
//! `Hud.extractSpyglassOverlay`). The arithmetic is `horizon_client::camera`.
|
||||
use super::Game;
|
||||
use horizon_client::{
|
||||
camera::{
|
||||
BobState, CameraFluid, FovPlayer, HurtBob, UseItem, bob_matrix, field_of_view_modifier,
|
||||
},
|
||||
fog::{FogType, fog_type},
|
||||
hud::{HudBatch, rgba},
|
||||
hud::HudBatch,
|
||||
hud_layout, hud_overlays,
|
||||
};
|
||||
|
||||
/// `Abilities.walkingSpeed`.
|
||||
const WALKING_SPEED: f32 = 0.1;
|
||||
/// `options.fovEffectScale` (vanilla default 1).
|
||||
const FOV_EFFECT_SCALE: f32 = 1.0;
|
||||
|
||||
impl Game<'_> {
|
||||
/// `Player.isScoping`: using a spyglass.
|
||||
@@ -19,54 +22,115 @@ impl Game<'_> {
|
||||
.and_then(|(slot, _)| self.stack_in(slot))
|
||||
.is_some_and(|s| &*s.item == "minecraft:spyglass")
|
||||
}
|
||||
/// `AbstractClientPlayer.getFieldOfViewModifier` (first person).
|
||||
fn fov_target(&self) -> f32 {
|
||||
let Some(p) = &self.player else { return 1.0 };
|
||||
let mut modifier = 1.0f32;
|
||||
// Movement speed (sprinting, speed and slowness) relative to walking.
|
||||
modifier *= (p.speed / WALKING_SPEED + 1.0) / 2.0;
|
||||
if let Some((slot, ticks)) = self.using
|
||||
&& let Some(stack) = self.stack_in(slot)
|
||||
{
|
||||
match &*stack.item {
|
||||
"minecraft:bow" => {
|
||||
let scale = (ticks as f32 / 20.0).min(1.0);
|
||||
modifier *= 1.0 - scale * scale * 0.15;
|
||||
}
|
||||
"minecraft:spyglass" => return 0.1,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
1.0 + (modifier - 1.0) * FOV_EFFECT_SCALE
|
||||
/// `options.fovEffectScale`.
|
||||
pub(super) fn fov_effect_scale(&self) -> f32 {
|
||||
self.settings.file.double("fovEffectScale") as f32
|
||||
}
|
||||
/// `Camera.tickFov`.
|
||||
/// What `AbstractClientPlayer.getFieldOfViewModifier` reads.
|
||||
fn fov_player(&self) -> Option<FovPlayer> {
|
||||
let p = self.player.as_ref()?;
|
||||
let using = self.using.and_then(|(slot, ticks)| {
|
||||
let stack = self.stack_in(slot)?;
|
||||
let item = match &*stack.item {
|
||||
"minecraft:bow" => UseItem::Bow,
|
||||
"minecraft:spyglass" => UseItem::Spyglass,
|
||||
_ => UseItem::Other,
|
||||
};
|
||||
Some((item, ticks as i32))
|
||||
});
|
||||
Some(FovPlayer {
|
||||
flying: p.flying,
|
||||
walking_speed: WALKING_SPEED,
|
||||
// Player.aiStep keeps `speed` as the float movement speed attribute.
|
||||
movement_speed: f64::from(p.speed),
|
||||
using,
|
||||
})
|
||||
}
|
||||
/// `Camera.tick`: the eye height and the field-of-view modifier ease
|
||||
/// halfway toward the player's each tick.
|
||||
pub(super) fn tick_fov(&mut self) {
|
||||
let target = self.fov_target();
|
||||
let [_, current] = self.fov_modifier;
|
||||
let next = (current + (target - current) * 0.5).clamp(0.1, 1.5);
|
||||
self.fov_modifier = [current, next];
|
||||
let first_person = self.camera_type.first_person();
|
||||
let effect = self.fov_effect_scale();
|
||||
let target = self
|
||||
.fov_player()
|
||||
.map_or(1.0, |p| field_of_view_modifier(&p, first_person, effect));
|
||||
let eye = self.player.as_ref().map(|p| p.dimensions.eye_height);
|
||||
self.view.tick(eye, target);
|
||||
}
|
||||
/// The player's `deathTime` while dead or dying.
|
||||
fn death_time(&self) -> Option<i32> {
|
||||
let p = self.player.as_ref()?;
|
||||
p.health.dead_or_dying().then(|| {
|
||||
self.ui
|
||||
.died_at
|
||||
.map_or(0, |t| (t.elapsed().as_secs_f32() * 20.0) as i32)
|
||||
})
|
||||
}
|
||||
/// The fluid at the camera, as `Camera.getFluidInCamera` reports it.
|
||||
fn camera_fluid(&self, camera: [f64; 3]) -> CameraFluid {
|
||||
match fog_type(&self.world, &self.res.facts, camera, self.forward()) {
|
||||
FogType::Water => CameraFluid::Water,
|
||||
FogType::Lava => CameraFluid::Lava,
|
||||
FogType::PowderSnow => CameraFluid::PowderSnow,
|
||||
FogType::Atmospheric => CameraFluid::None,
|
||||
}
|
||||
}
|
||||
/// `Camera.calculateFov` with `modifyFovBasedOnDeathOrFluid`.
|
||||
pub(super) fn fov_degrees(&self, partial: f32, camera: [f64; 3]) -> f32 {
|
||||
let [old, new] = self.fov_modifier;
|
||||
let mut fov = self.settings.fov * (old + (new - old) * partial);
|
||||
if let Some(p) = &self.player
|
||||
&& p.health.dead_or_dying()
|
||||
{
|
||||
let ticks = self
|
||||
.ui
|
||||
.died_at
|
||||
.map_or(0.0, |t| t.elapsed().as_secs_f32() * 20.0);
|
||||
let duration = (ticks + partial).min(20.0);
|
||||
fov /= (1.0 - 500.0 / (duration + 500.0)) * 2.0 + 1.0;
|
||||
self.view.calculate_fov(
|
||||
partial,
|
||||
self.settings.fov as i32,
|
||||
self.fov_effect_scale(),
|
||||
self.death_time(),
|
||||
self.camera_fluid(camera),
|
||||
false,
|
||||
)
|
||||
}
|
||||
/// The pose `GameRenderer.renderLevel` multiplies into the projection:
|
||||
/// `bobHurt`, then `bobView` with the View Bobbing option.
|
||||
pub(super) fn bob_matrix(&self, partial: f32) -> glam::Mat4 {
|
||||
let Some(p) = self.player.as_ref() else {
|
||||
return glam::Mat4::IDENTITY;
|
||||
};
|
||||
let hurt = &p.health.hurt;
|
||||
let state = BobState {
|
||||
living: Some(HurtBob {
|
||||
hurt_time: hurt.hurt_time as f32 - partial,
|
||||
hurt_duration: hurt.hurt_duration,
|
||||
hurt_dir: 0.0,
|
||||
death_time: self.death_time().map(|t| t as f32 + partial),
|
||||
}),
|
||||
player: Some((
|
||||
self.avatar.backwards_interpolated_walk_distance(partial),
|
||||
self.avatar.interpolated_bob(partial),
|
||||
)),
|
||||
};
|
||||
let tilt = self.settings.file.double("damageTiltStrength");
|
||||
glam::Mat4::from_cols_array(&bob_matrix(&state, tilt, self.settings.view_bobbing).m)
|
||||
}
|
||||
/// `AbstractClientPlayer.tick` and `aiStep` (`ClientAvatarState.tick`,
|
||||
/// `updateBob`), `rideTick`'s `resetBob` and `LocalPlayer.move`'s walked
|
||||
/// distance, after the player's movement this tick. `before` is the
|
||||
/// ground state, velocity and position at the start of the tick.
|
||||
pub(super) fn tick_avatar(&mut self, before: (bool, [f64; 3], [f64; 3]), riding: bool) {
|
||||
let Some(p) = self.player.as_ref() else {
|
||||
return;
|
||||
};
|
||||
let (ground, velocity, start) = before;
|
||||
let swimming = p.pose == horizon_client::pose::Pose::Swimming;
|
||||
let alive = !p.health.dead_or_dying();
|
||||
let (dx, dz) = (
|
||||
(p.position.x - start[0]) as f32,
|
||||
(p.position.z - start[2]) as f32,
|
||||
);
|
||||
self.avatar.tick();
|
||||
self.avatar
|
||||
.update_bob(ground && alive && !swimming, velocity);
|
||||
if riding {
|
||||
self.avatar.reset_bob();
|
||||
} else {
|
||||
self.avatar.add_walked(dx, dz);
|
||||
}
|
||||
if matches!(
|
||||
fog_type(&self.world, &self.res.facts, camera, self.forward()),
|
||||
FogType::Water | FogType::Lava
|
||||
) {
|
||||
fov *= 1.0 + (0.857_142_87 - 1.0) * FOV_EFFECT_SCALE;
|
||||
}
|
||||
fov
|
||||
}
|
||||
/// `SpyglassItem.use`: start scoping.
|
||||
pub(super) fn use_spyglass(&mut self) {
|
||||
@@ -82,26 +146,20 @@ impl Game<'_> {
|
||||
self.audio
|
||||
.play("minecraft:item.spyglass.stop_using", at, 1.0, 1.0);
|
||||
}
|
||||
/// `Hud.extractCameraOverlays`: the scope, its size easing from 0.5 to
|
||||
/// 1.125 at half a step per tick of frame time.
|
||||
/// `Hud.extractCameraOverlays`: the spyglass scope, its size easing
|
||||
/// towards 1.125 by half the frame's game ticks and reset to 0.5 while
|
||||
/// not scoping in first person.
|
||||
pub(super) fn draw_spyglass(&mut self, batch: &mut HudBatch) {
|
||||
self.scope_scale += 0.5 * self.frame_ticks * (1.125 - self.scope_scale);
|
||||
if !self.camera_type.first_person() {
|
||||
return;
|
||||
}
|
||||
if !self.scoping() {
|
||||
self.scope_scale = 0.5;
|
||||
return;
|
||||
}
|
||||
self.scope_scale += (1.125 - self.scope_scale) * (0.5 * self.frame_ticks).min(1.0);
|
||||
let atlas = &self.res.atlas;
|
||||
let (w, h) = (batch.width, batch.height);
|
||||
let src = w.min(h);
|
||||
let ratio = (w / src).min(h / src) * self.scope_scale;
|
||||
let (sw, sh) = ((src * ratio).floor(), (src * ratio).floor());
|
||||
let (left, top) = (((w - sw) / 2.0).floor(), ((h - sh) / 2.0).floor());
|
||||
let (right, bottom) = (left + sw, top + sh);
|
||||
batch.sprite_scaled(atlas, "misc/spyglass_scope", left, top, sw, sh, u32::MAX);
|
||||
let black = rgba(0, 0, 0, 255);
|
||||
batch.fill(atlas, 0.0, bottom, w, h - bottom, black);
|
||||
batch.fill(atlas, 0.0, 0.0, w, top, black);
|
||||
batch.fill(atlas, 0.0, top, left, bottom - top, black);
|
||||
batch.fill(atlas, right, top, w - right, bottom - top, black);
|
||||
let draws =
|
||||
hud_overlays::spyglass(batch.width as i32, batch.height as i32, self.scope_scale);
|
||||
hud_layout::paint(batch, &self.res.atlas, &draws);
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
//! `ItemFrame.survives`, `interact` (put an item in, then rotate it),
|
||||
//! `hurtServer` (take the item out, then break the frame) and
|
||||
//! `ItemFrameRenderer` (the frame model and the item at a 45 degree step).
|
||||
use super::{Game, Hit, Icon, view_vector};
|
||||
use super::{Game, Hit, view_vector};
|
||||
use horizon_assets::{bake::bake_model, model::CuboidModel, resolve::ModelLibrary};
|
||||
use horizon_client::{entities::ItemFrame, items::ItemStack, mesher::Vertex};
|
||||
use horizon_core::{
|
||||
@@ -300,17 +300,10 @@ impl Game<'_> {
|
||||
}
|
||||
/// ItemFrameRenderer: the frame model, then the item half size on its face.
|
||||
pub(super) fn frame_geometry(&mut self, camera: glam::DVec3, out: &mut Vec<Vertex>) {
|
||||
let icons: Vec<Option<Icon>> = (0..self.item_frames.len())
|
||||
.map(|i| {
|
||||
self.item_frames[i]
|
||||
.item
|
||||
.clone()
|
||||
.and_then(|s| self.icon(&s.item))
|
||||
})
|
||||
.collect();
|
||||
let sprites = &self.res.atlas.sprites;
|
||||
let mut maps: Vec<(i32, glam::Mat4, u32)> = Vec::new();
|
||||
for (f, icon) in self.item_frames.iter().zip(icons) {
|
||||
let mut items: Vec<(ItemStack, u32, glam::Mat4, u8)> = Vec::new();
|
||||
for f in &self.item_frames {
|
||||
let map = f.item.as_ref().and_then(super::maps::map_id);
|
||||
let s = f.step();
|
||||
// Pose: to the block centre, then X/Y rotation by facing.
|
||||
@@ -391,89 +384,33 @@ impl Game<'_> {
|
||||
maps.push((id, m.as_mat4(), item_light));
|
||||
continue;
|
||||
}
|
||||
let Some(icon) = icon else { continue };
|
||||
// GlowItemFrame items render full bright.
|
||||
let item_light = if f.glow { 240 | 240 << 8 } else { packed };
|
||||
let Some(stack) = f.item.clone() else {
|
||||
continue;
|
||||
};
|
||||
// `GlowItemFrame` items render full bright (15728880).
|
||||
let item_light = if f.glow { 0xFF } else { light };
|
||||
let depth = if f.invisible { 0.5 } else { 0.4375 };
|
||||
let m = pose
|
||||
* glam::DMat4::from_translation(glam::DVec3::new(0.0, 0.0, depth))
|
||||
* glam::DMat4::from_rotation_z((f64::from(f.rotation) * 45.0).to_radians())
|
||||
* glam::DMat4::from_scale(glam::DVec3::splat(0.5));
|
||||
let full = |r: &horizon_client::hud::Rect| {
|
||||
[[r.u0, r.v1], [r.u1, r.v1], [r.u1, r.v0], [r.u0, r.v0]]
|
||||
};
|
||||
match icon {
|
||||
// A flat item faces out of the frame (towards -z here).
|
||||
Icon::Flat(sprite) => {
|
||||
if let Some(r) = sprites.get(&sprite) {
|
||||
let z = -0.03125;
|
||||
quad(
|
||||
&m,
|
||||
[
|
||||
[0.5, -0.5, z],
|
||||
[-0.5, -0.5, z],
|
||||
[-0.5, 0.5, z],
|
||||
[0.5, 0.5, z],
|
||||
],
|
||||
full(r),
|
||||
item_light,
|
||||
);
|
||||
}
|
||||
}
|
||||
Icon::Cube {
|
||||
top, left, right, ..
|
||||
} => {
|
||||
let h = 0.25;
|
||||
let face = |name: &str| sprites.get(name).copied();
|
||||
if let (Some(t), Some(l), Some(r)) = (face(&top), face(&left), face(&right)) {
|
||||
let c = |x: f64, y: f64, z: f64| [x * h, y * h, z * h - h];
|
||||
quad(
|
||||
&m,
|
||||
[
|
||||
c(-1., 1., -1.),
|
||||
c(1., 1., -1.),
|
||||
c(1., 1., 1.),
|
||||
c(-1., 1., 1.),
|
||||
],
|
||||
full(&t),
|
||||
item_light,
|
||||
);
|
||||
quad(
|
||||
&m,
|
||||
[
|
||||
c(1., -1., -1.),
|
||||
c(-1., -1., -1.),
|
||||
c(-1., 1., -1.),
|
||||
c(1., 1., -1.),
|
||||
],
|
||||
full(&l),
|
||||
item_light,
|
||||
);
|
||||
quad(
|
||||
&m,
|
||||
[
|
||||
c(-1., -1., -1.),
|
||||
c(-1., -1., 1.),
|
||||
c(-1., 1., 1.),
|
||||
c(-1., 1., -1.),
|
||||
],
|
||||
full(&r),
|
||||
item_light,
|
||||
);
|
||||
quad(
|
||||
&m,
|
||||
[
|
||||
c(1., -1., 1.),
|
||||
c(1., -1., -1.),
|
||||
c(1., 1., -1.),
|
||||
c(1., 1., 1.),
|
||||
],
|
||||
full(&r),
|
||||
item_light,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
items.push((stack, f.id, m.as_mat4(), item_light));
|
||||
}
|
||||
// `extractRenderState`: FIXED, seeded by the frame entity.
|
||||
for (stack, id, m, light) in items {
|
||||
let owner = super::item_draw::owner_at([0.0; 3], 0.0, "minecraft:item_frame");
|
||||
let state = self.resolve_item(
|
||||
&stack,
|
||||
horizon_assets::item_model::DisplayContext::Fixed,
|
||||
Some(&owner),
|
||||
id as i32,
|
||||
);
|
||||
self.submit_item_world(
|
||||
&state,
|
||||
m,
|
||||
horizon_client::block_entity_render::vertex_light(light),
|
||||
out,
|
||||
);
|
||||
}
|
||||
for (id, m, light) in maps {
|
||||
self.map_vertices(id, m, light, out);
|
||||
|
||||
@@ -24,6 +24,7 @@ impl Game<'_> {
|
||||
if self.golem_block(below(1)) == "minecraft:snow_block"
|
||||
&& self.golem_block(below(2)) == "minecraft:snow_block"
|
||||
{
|
||||
// The pattern ("^", "#", "#") is one column.
|
||||
return Some((
|
||||
"minecraft:snow_golem",
|
||||
vec![head, below(1), below(2)],
|
||||
@@ -38,9 +39,11 @@ impl Game<'_> {
|
||||
&& self.golem_block(side(0, 2)) == "minecraft:iron_block"
|
||||
&& [-1, 1].iter().all(|&s| air(side(s, 0)) && air(side(s, 2)))
|
||||
{
|
||||
let mut blocks = vec![head];
|
||||
blocks.extend((-1..=1).map(|s| side(s, 1)));
|
||||
blocks.push(side(0, 2));
|
||||
// Every cell of ("~^~", "###", "~#~"), the air ones included.
|
||||
let blocks = (-1..=1)
|
||||
.flat_map(|s| (0..3).map(move |row| (s, row)))
|
||||
.map(|(s, row)| side(s, row))
|
||||
.collect();
|
||||
return Some(("minecraft:iron_golem", blocks, side(0, 2)));
|
||||
}
|
||||
}
|
||||
@@ -58,27 +61,68 @@ impl Game<'_> {
|
||||
let Some((kind, blocks, base)) = self.find_golem(head) else {
|
||||
return self.try_spawn_copper_golem(head);
|
||||
};
|
||||
for p in blocks {
|
||||
let state = self.world.block(p[0], p[1], p[2]);
|
||||
self.destroy_particles(p, state);
|
||||
self.level_set(p, self.world.air)?;
|
||||
}
|
||||
// CarvedPumpkinBlock.spawnGolemInWorld
|
||||
self.clear_pattern_blocks(&blocks)?;
|
||||
let at = [
|
||||
f64::from(base[0]) + 0.5,
|
||||
f64::from(base[1]) + 0.05,
|
||||
f64::from(base[2]) + 0.5,
|
||||
];
|
||||
if let Some(id) = self.spawn_mob(kind, at, false)
|
||||
&& let Some(m) = self.mobs.iter_mut().find(|m| m.id == id)
|
||||
{
|
||||
m.yaw = 0.0;
|
||||
m.body_yaw = 0.0;
|
||||
m.head_yaw = 0.0;
|
||||
m.previous_yaw = 0.0;
|
||||
m.previous_body_yaw = 0.0;
|
||||
m.previous_head_yaw = 0.0;
|
||||
self.create_triggered(kind, at, 0.0);
|
||||
self.update_pattern_blocks(&blocks)
|
||||
}
|
||||
/// Whether placing a golem head at `head` completes a snow or iron golem.
|
||||
pub(super) fn completes_golem(&self, head: [i32; 3]) -> bool {
|
||||
self.find_golem(head).is_some()
|
||||
}
|
||||
/// `EntityType.create(level, TRIGGERED)` and `snapTo`: a built golem or
|
||||
/// wither has no spawn finalization and draws nothing from the level.
|
||||
pub(super) fn create_triggered(&mut self, kind: &str, at: [f64; 3], yaw: f32) -> Option<u32> {
|
||||
let t = self.res.mob_types.get(kind).cloned()?;
|
||||
self.next_entity += 1;
|
||||
let mut mob = horizon_client::mobs::Mob::new(
|
||||
self.next_entity,
|
||||
t,
|
||||
horizon_core::geometry::Vec3::new(at[0], at[1], at[2]),
|
||||
yaw,
|
||||
);
|
||||
mob.head_yaw = yaw;
|
||||
mob.body_yaw = yaw;
|
||||
mob.previous_yaw = yaw;
|
||||
mob.previous_body_yaw = yaw;
|
||||
mob.previous_head_yaw = yaw;
|
||||
let id = mob.id;
|
||||
self.mobs.push(mob);
|
||||
Some(id)
|
||||
}
|
||||
/// `CarvedPumpkinBlock.clearPatternBlocks`: every cell of the match
|
||||
/// becomes air (flags 2) with its destroy event (2001).
|
||||
pub(super) fn clear_pattern_blocks(&mut self, cells: &[[i32; 3]]) -> Result<()> {
|
||||
let air = self.world.air;
|
||||
for &p in cells {
|
||||
let state = self.world.block(p[0], p[1], p[2]);
|
||||
let pos = horizon_core::position::BlockPos::new(p[0], p[1], p[2]);
|
||||
self.with_level(|b, level, ticks| b.set_block(level, ticks, pos, air, 2));
|
||||
self.level_events
|
||||
.push(horizon_world::level::LevelEvent::LevelEvent {
|
||||
id: 2001,
|
||||
pos,
|
||||
data: state.0 as i32,
|
||||
});
|
||||
}
|
||||
Ok(())
|
||||
self.process_level_events()
|
||||
}
|
||||
/// `CarvedPumpkinBlock.updatePatternBlocks`: neighbours of every cell
|
||||
/// hear that air arrived.
|
||||
pub(super) fn update_pattern_blocks(&mut self, cells: &[[i32; 3]]) -> Result<()> {
|
||||
let air = self.world.air;
|
||||
for &p in cells {
|
||||
let pos = horizon_core::position::BlockPos::new(p[0], p[1], p[2]);
|
||||
self.with_level(|b, level, ticks| {
|
||||
b.update_neighbors_at(level, ticks, pos, b.block(air))
|
||||
});
|
||||
}
|
||||
self.process_level_events()
|
||||
}
|
||||
/// A pumpkin on a copper block: the block turns into the matching copper
|
||||
/// chest (facing the pumpkin's way) and a copper golem appears where the
|
||||
|
||||
@@ -1,226 +0,0 @@
|
||||
//! `Hud.extractPlayerHealth`: armour, hearts, food. The layout is computed as
|
||||
//! a list of sprites so it can be tested without drawing.
|
||||
use std::time::Instant;
|
||||
|
||||
/// `java.util.Random`, for the HUD's per-tick jitter.
|
||||
pub(super) struct JavaRandom(i64);
|
||||
impl JavaRandom {
|
||||
pub fn new(seed: i64) -> Self {
|
||||
Self((seed ^ 0x5_DEEC_E66D) & ((1 << 48) - 1))
|
||||
}
|
||||
fn next(&mut self, bits: u32) -> i32 {
|
||||
self.0 = (self.0.wrapping_mul(0x5_DEEC_E66D).wrapping_add(0xB)) & ((1 << 48) - 1);
|
||||
(self.0 >> (48 - bits)) as i32
|
||||
}
|
||||
pub fn next_int(&mut self, bound: i32) -> i32 {
|
||||
let mut r = self.next(31);
|
||||
let m = bound - 1;
|
||||
if bound & m == 0 {
|
||||
return ((i64::from(bound) * i64::from(r)) >> 31) as i32;
|
||||
}
|
||||
let mut u = r;
|
||||
loop {
|
||||
r = u % bound;
|
||||
if u.wrapping_sub(r).wrapping_add(m) >= 0 {
|
||||
return r;
|
||||
}
|
||||
u = self.next(31);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
||||
pub(super) enum HeartKind {
|
||||
Normal,
|
||||
Poisoned,
|
||||
Withered,
|
||||
Absorbing,
|
||||
}
|
||||
impl HeartKind {
|
||||
fn prefix(self) -> &'static str {
|
||||
match self {
|
||||
Self::Normal => "",
|
||||
Self::Poisoned => "poisoned_",
|
||||
Self::Withered => "withered_",
|
||||
Self::Absorbing => "absorbing_",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// State kept between frames (`Hud.lastHealth`, `displayHealth`, ...).
|
||||
pub(super) struct HealthHud {
|
||||
last_health: i32,
|
||||
display_health: i32,
|
||||
last_health_time: Instant,
|
||||
health_blink_time: i64,
|
||||
}
|
||||
impl Default for HealthHud {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
last_health: 0,
|
||||
display_health: 0,
|
||||
last_health_time: Instant::now(),
|
||||
health_blink_time: 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) struct HealthInputs {
|
||||
pub tick: u64,
|
||||
pub health: f32,
|
||||
pub max_health: f32,
|
||||
pub absorption: f32,
|
||||
pub armor: i32,
|
||||
pub food: i32,
|
||||
pub saturation: f32,
|
||||
pub kind: HeartKind,
|
||||
pub regeneration: bool,
|
||||
pub hunger: bool,
|
||||
pub hardcore: bool,
|
||||
/// `damageCooldownTime > 0`.
|
||||
pub damage_cooldown: bool,
|
||||
/// A mount with its own hearts replaces the food bar.
|
||||
pub show_food: bool,
|
||||
}
|
||||
|
||||
fn heart_sprite(kind: HeartKind, hardcore: bool, half: bool, blink: bool) -> String {
|
||||
let (hc, h) = (
|
||||
if hardcore { "hardcore_" } else { "" },
|
||||
if half { "half" } else { "full" },
|
||||
);
|
||||
let b = if blink { "_blinking" } else { "" };
|
||||
format!("gui/sprites/hud/heart/{}{hc}{h}{b}", kind.prefix())
|
||||
}
|
||||
|
||||
impl HealthHud {
|
||||
/// Sprites (name, x, y) in draw order for a screen of `w` x `h` GUI pixels.
|
||||
pub fn layout(
|
||||
&mut self,
|
||||
i: &HealthInputs,
|
||||
w: i32,
|
||||
h: i32,
|
||||
now: Instant,
|
||||
) -> Vec<(String, i32, i32)> {
|
||||
let mut out = Vec::new();
|
||||
let tick = i64::try_from(i.tick).unwrap_or(i64::MAX);
|
||||
let current = i.health.ceil() as i32;
|
||||
let blink = self.health_blink_time > tick && (self.health_blink_time - tick) / 3 % 2 == 1;
|
||||
if current < self.last_health && i.damage_cooldown {
|
||||
self.last_health_time = now;
|
||||
self.health_blink_time = tick + 20;
|
||||
} else if current > self.last_health && i.damage_cooldown {
|
||||
self.last_health_time = now;
|
||||
self.health_blink_time = tick + 10;
|
||||
}
|
||||
if now.duration_since(self.last_health_time).as_millis() > 1000 {
|
||||
self.display_health = current;
|
||||
self.last_health_time = now;
|
||||
}
|
||||
self.last_health = current;
|
||||
let old = self.display_health;
|
||||
let mut random = JavaRandom::new(i64::from((i.tick as i32).wrapping_mul(312_871)));
|
||||
let (x_left, x_right, y_base) = (w / 2 - 91, w / 2 + 91, h - 39);
|
||||
let max_health = i.max_health.max(old.max(current) as f32);
|
||||
let absorption = i.absorption.ceil() as i32;
|
||||
let rows = ((max_health + absorption as f32) / 2.0 / 10.0).ceil() as i32;
|
||||
let row_height = (10 - (rows - 2)).max(3);
|
||||
let offset_index = if i.regeneration {
|
||||
(i.tick as i32).rem_euclid((max_health + 5.0).ceil() as i32)
|
||||
} else {
|
||||
-1
|
||||
};
|
||||
if i.armor > 0 {
|
||||
let y = y_base - (rows - 1) * row_height - 10;
|
||||
for n in 0..10 {
|
||||
let name = match (n * 2 + 1).cmp(&i.armor) {
|
||||
std::cmp::Ordering::Less => "armor_full",
|
||||
std::cmp::Ordering::Equal => "armor_half",
|
||||
std::cmp::Ordering::Greater => "armor_empty",
|
||||
};
|
||||
out.push((format!("gui/sprites/hud/{name}"), x_left + n * 8, y));
|
||||
}
|
||||
}
|
||||
let containers = (max_health / 2.0).ceil() as i32;
|
||||
let absorb_containers = (absorption as f32 / 2.0).ceil() as i32;
|
||||
let container = |hardcore: bool, blink: bool| {
|
||||
format!(
|
||||
"gui/sprites/hud/heart/container{}{}",
|
||||
if hardcore { "_hardcore" } else { "" },
|
||||
if blink { "_blinking" } else { "" }
|
||||
)
|
||||
};
|
||||
for c in (0..containers + absorb_containers).rev() {
|
||||
let (row, column) = (c / 10, c % 10);
|
||||
let x = x_left + column * 8;
|
||||
let mut y = y_base - row * row_height;
|
||||
if current + absorption <= 4 {
|
||||
y += random.next_int(2);
|
||||
}
|
||||
if c < containers && c == offset_index {
|
||||
y -= 2;
|
||||
}
|
||||
out.push((container(i.hardcore, blink), x, y));
|
||||
let halves = c * 2;
|
||||
if c >= containers {
|
||||
let ah = halves - containers * 2;
|
||||
if ah < absorption {
|
||||
let kind = if i.kind == HeartKind::Withered {
|
||||
i.kind
|
||||
} else {
|
||||
HeartKind::Absorbing
|
||||
};
|
||||
out.push((
|
||||
heart_sprite(kind, i.hardcore, ah + 1 == absorption, false),
|
||||
x,
|
||||
y,
|
||||
));
|
||||
}
|
||||
}
|
||||
if blink && halves < old {
|
||||
out.push((
|
||||
heart_sprite(i.kind, i.hardcore, halves + 1 == old, true),
|
||||
x,
|
||||
y,
|
||||
));
|
||||
}
|
||||
if halves < current {
|
||||
out.push((
|
||||
heart_sprite(i.kind, i.hardcore, halves + 1 == current, false),
|
||||
x,
|
||||
y,
|
||||
));
|
||||
}
|
||||
}
|
||||
if i.show_food {
|
||||
let suffix = if i.hunger { "_hunger" } else { "" };
|
||||
for n in 0..10 {
|
||||
let mut y = y_base;
|
||||
if i.saturation <= 0.0 && (i.tick as i32).rem_euclid(i.food * 3 + 1) == 0 {
|
||||
y += random.next_int(3) - 1;
|
||||
}
|
||||
let x = x_right - n * 8 - 9;
|
||||
out.push((format!("gui/sprites/hud/food_empty{suffix}"), x, y));
|
||||
if n * 2 + 1 < i.food {
|
||||
out.push((format!("gui/sprites/hud/food_full{suffix}"), x, y));
|
||||
}
|
||||
if n * 2 + 1 == i.food {
|
||||
out.push((format!("gui/sprites/hud/food_half{suffix}"), x, y));
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn java_random_matches_reference() {
|
||||
// new Random(42).nextInt(10) sequence: 0, 3, 8, 4, 0.
|
||||
let mut r = JavaRandom::new(42);
|
||||
let v: Vec<i32> = (0..5).map(|_| r.next_int(10)).collect();
|
||||
assert_eq!(v, [0, 3, 8, 4, 0]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,373 @@
|
||||
//! Items drawn through the resolved item model pipeline
|
||||
//! (`horizon_client::item_render`): GUI icons (`GuiGraphics.renderItem`),
|
||||
//! dropped items (`ItemEntityRenderer`) and items posed in the world (hands,
|
||||
//! frames, armour stands). This binds the resolver's host services
|
||||
//! (`ItemEnv`) and the special renderers' registries to the game.
|
||||
use super::{Dim, Game};
|
||||
use horizon_assets::item_model::{
|
||||
Components, DisplayContext, GlobalPos, ItemEnv, LevelState, OwnerState, StackView,
|
||||
};
|
||||
use horizon_client::{
|
||||
block_entity_render::{diffuse_lights, vertex_light},
|
||||
hud::HudBatch,
|
||||
item_render::{self, ItemRenderState, SpecialHost, WorldSink},
|
||||
items::ItemStack,
|
||||
mesher::Vertex,
|
||||
};
|
||||
use serde_json::Value;
|
||||
use winit::keyboard::KeyCode;
|
||||
|
||||
/// The game's answers to what the resolver cannot read from the stack.
|
||||
pub(super) struct Env<'a, 'g> {
|
||||
game: &'a Game<'g>,
|
||||
}
|
||||
impl ItemEnv for Env<'_, '_> {
|
||||
fn potion_effect_color(&self, contents: &Value) -> Option<i32> {
|
||||
// `PotionContents.getColorOr`: the effect mix when any effect is visible.
|
||||
let id = contents["potion"].as_str();
|
||||
let effects = id
|
||||
.and_then(horizon_client::brewing::potion)
|
||||
.is_some_and(|p| !p.effects.is_empty());
|
||||
effects.then(|| (0xFF00_0000 | horizon_client::brewing::potion_color(id)) as i32)
|
||||
}
|
||||
fn grass_color(&self, temperature: f64, downfall: f64) -> i32 {
|
||||
self.game
|
||||
.res
|
||||
.item_renderer
|
||||
.grass_color(temperature, downfall)
|
||||
}
|
||||
fn bundle_fullness(&self, _stack: &StackView, components: &Components) -> f32 {
|
||||
// `BundleContents.weight`: count / max stack size, summed.
|
||||
let Some(items) = components
|
||||
.get("minecraft:bundle_contents")
|
||||
.and_then(Value::as_array)
|
||||
else {
|
||||
return 0.0;
|
||||
};
|
||||
let prototypes = &self.game.res.item_renderer.models.prototypes;
|
||||
let mut total = 0.0f64;
|
||||
for item in items {
|
||||
let id = item["id"].as_str().unwrap_or("");
|
||||
let count = item["count"].as_i64().unwrap_or(1) as f64;
|
||||
let max = prototypes
|
||||
.get(id)
|
||||
.and_then(|c| c.get("minecraft:max_stack_size"))
|
||||
.and_then(Value::as_i64)
|
||||
.unwrap_or(1) as f64;
|
||||
total += count / max;
|
||||
}
|
||||
total as f32
|
||||
}
|
||||
fn has_shift_down(&self) -> bool {
|
||||
self.game.keys.contains(&KeyCode::ShiftLeft)
|
||||
|| self.game.keys.contains(&KeyCode::ShiftRight)
|
||||
}
|
||||
fn local_time(&self, pattern: &str, _locale: &str, _time_zone: Option<&str>) -> String {
|
||||
local_time(pattern)
|
||||
}
|
||||
fn random_float(&self) -> f32 {
|
||||
let nanos = self.game.started.elapsed().subsec_nanos();
|
||||
(nanos % 1_000_000) as f32 / 1_000_000.0
|
||||
}
|
||||
}
|
||||
/// The days since 1970-01-01 as a civil (year, month, day) date.
|
||||
fn civil(days: i64) -> (i64, u32, u32) {
|
||||
let z = days + 719_468;
|
||||
let era = z.div_euclid(146_097);
|
||||
let doe = z.rem_euclid(146_097);
|
||||
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
|
||||
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
|
||||
let mp = (5 * doy + 2) / 153;
|
||||
let d = (doy - (153 * mp + 2) / 5 + 1) as u32;
|
||||
let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32;
|
||||
(yoe + era * 400 + i64::from(m <= 2), m, d)
|
||||
}
|
||||
/// `DateTimeFormatter.ofPattern(pattern).format(now)` for the month, day,
|
||||
/// year and time letters item models use (UTC: no time zone database).
|
||||
pub(super) fn local_time(pattern: &str) -> String {
|
||||
let secs = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map_or(0, |d| d.as_secs() as i64);
|
||||
let (y, mo, d) = civil(secs.div_euclid(86_400));
|
||||
let s = secs.rem_euclid(86_400);
|
||||
let (h, mi, se) = (s / 3600, s / 60 % 60, s % 60);
|
||||
let mut out = String::new();
|
||||
let chars: Vec<char> = pattern.chars().collect();
|
||||
let mut i = 0;
|
||||
while i < chars.len() {
|
||||
let c = chars[i];
|
||||
let mut n = 1;
|
||||
while i + n < chars.len() && chars[i + n] == c {
|
||||
n += 1;
|
||||
}
|
||||
let num = |v: i64| format!("{v:0n$}");
|
||||
match c {
|
||||
'M' => out += &num(i64::from(mo)),
|
||||
'd' => out += &num(i64::from(d)),
|
||||
'y' | 'u' => out += &if n == 2 { num(y % 100) } else { num(y) },
|
||||
'H' => out += &num(h),
|
||||
'm' => out += &num(mi),
|
||||
's' => out += &num(se),
|
||||
'\'' => {
|
||||
// Quoted literal text.
|
||||
let end = chars[i + 1..].iter().position(|&x| x == '\'');
|
||||
if let Some(e) = end {
|
||||
out.extend(&chars[i + 1..i + 1 + e]);
|
||||
i += e + 2;
|
||||
continue;
|
||||
}
|
||||
n = 1;
|
||||
}
|
||||
other => out.extend(std::iter::repeat_n(other, n)),
|
||||
}
|
||||
i += n;
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Banner and shield pattern textures from the banner pattern registry.
|
||||
pub(super) struct Host<'a> {
|
||||
banners: &'a horizon_client::banner::Banners,
|
||||
}
|
||||
impl SpecialHost for Host<'_> {
|
||||
fn banner_texture(&self, pattern: &str) -> Option<String> {
|
||||
self.banners
|
||||
.pattern(pattern)
|
||||
.map(|_| self.banners.banner_texture(pattern))
|
||||
}
|
||||
fn shield_texture(&self, pattern: &str) -> Option<String> {
|
||||
self.banners
|
||||
.pattern(pattern)
|
||||
.map(|_| self.banners.shield_texture(pattern))
|
||||
}
|
||||
}
|
||||
|
||||
/// `ItemOwner` of an entity holding or showing an item.
|
||||
pub(super) fn owner_at(position: [f64; 3], yaw: f32, entity_type: &str) -> OwnerState {
|
||||
OwnerState {
|
||||
position,
|
||||
visual_rotation_y_degrees: yaw,
|
||||
entity_type: entity_type.to_owned(),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
/// `UseDuration` of a stack: 72000 for drawn items, the consume time for food.
|
||||
fn use_duration(components: &Components) -> i32 {
|
||||
if let Some(c) = components.get("minecraft:consumable") {
|
||||
let seconds = c["consume_seconds"].as_f64().unwrap_or(1.6);
|
||||
return (seconds * 20.0) as i32;
|
||||
}
|
||||
let item_model = components
|
||||
.get("minecraft:item_model")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("");
|
||||
match item_model {
|
||||
"minecraft:bow"
|
||||
| "minecraft:crossbow"
|
||||
| "minecraft:trident"
|
||||
| "minecraft:spyglass"
|
||||
| "minecraft:goat_horn"
|
||||
| "minecraft:brush" => 72_000,
|
||||
_ => 0,
|
||||
}
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
pub(super) fn item_env(&self) -> Env<'_, '_> {
|
||||
Env { game: self }
|
||||
}
|
||||
pub(super) fn item_host(&self) -> Host<'_> {
|
||||
Host {
|
||||
banners: &self.res.stations.banners,
|
||||
}
|
||||
}
|
||||
/// The client level as item models read it.
|
||||
pub(super) fn item_level(&self) -> LevelState {
|
||||
let dimension = match self.dim {
|
||||
Dim::Overworld => "minecraft:overworld",
|
||||
Dim::Nether => "minecraft:the_nether",
|
||||
Dim::End => "minecraft:the_end",
|
||||
};
|
||||
let sky = self
|
||||
.dim_environment()
|
||||
.sample(self.world.day_time, None, None);
|
||||
LevelState {
|
||||
dimension: dimension.to_owned(),
|
||||
game_time: self.world.game_time,
|
||||
sun_angle: sky.sun_angle,
|
||||
moon_phase: (self.world.day_time.div_euclid(24_000)).rem_euclid(8) as i32,
|
||||
respawn: self.spawn.map(|s| GlobalPos {
|
||||
dimension: "minecraft:overworld".to_owned(),
|
||||
pos: [s.x.floor() as i32, s.y.floor() as i32, s.z.floor() as i32],
|
||||
}),
|
||||
runs_normally: true,
|
||||
}
|
||||
}
|
||||
/// The local player as `ItemOwner`; `stack` is the stack drawn, to tell
|
||||
/// whether it is the one in use.
|
||||
pub(super) fn player_owner(&self, stack: Option<&ItemStack>) -> OwnerState {
|
||||
let position = self
|
||||
.player
|
||||
.as_ref()
|
||||
.map_or([0.0; 3], |p| [p.position.x, p.position.y, p.position.z]);
|
||||
let mut owner = owner_at(position, self.yaw, "minecraft:player");
|
||||
owner.main_arm_left = self.settings.main_hand_left;
|
||||
owner.is_local_player = true;
|
||||
owner.is_view_entity = true;
|
||||
if let (Some((slot, ticks)), Some(stack)) = (self.using, stack)
|
||||
&& self.inventory.get(slot).and_then(Option::as_ref) == Some(stack)
|
||||
{
|
||||
let view = item_render::stack_view(stack);
|
||||
let duration = use_duration(&self.res.item_renderer.models.components(&view));
|
||||
owner.using_this_item = true;
|
||||
owner.use_duration = duration;
|
||||
owner.use_item_remaining_ticks = duration - ticks as i32;
|
||||
owner.crossbow_charge_duration = 25;
|
||||
}
|
||||
owner.is_selected =
|
||||
stack.is_some() && self.inventory.get(self.selected).and_then(Option::as_ref) == stack;
|
||||
owner.is_carried = owner.is_selected;
|
||||
owner
|
||||
}
|
||||
/// `ItemModelResolver.updateForTopItem`.
|
||||
pub(super) fn resolve_item(
|
||||
&self,
|
||||
stack: &ItemStack,
|
||||
display: DisplayContext,
|
||||
owner: Option<&OwnerState>,
|
||||
seed: i32,
|
||||
) -> ItemRenderState {
|
||||
let level = self.item_level();
|
||||
self.res.item_renderer.resolve(
|
||||
&item_render::stack_view(stack),
|
||||
display,
|
||||
owner,
|
||||
Some(&level),
|
||||
seed,
|
||||
&self.item_env(),
|
||||
)
|
||||
}
|
||||
/// `GuiGraphics.renderItem(player, level, stack, x, y, 0)`.
|
||||
pub(super) fn draw_item_model(&self, batch: &mut HudBatch, stack: &ItemStack, x: f32, y: f32) {
|
||||
let owner = self.player_owner(Some(stack));
|
||||
let state = self.resolve_item(stack, DisplayContext::Gui, Some(&owner), 0);
|
||||
item_render::gui::draw(
|
||||
&state,
|
||||
&self.res.item_renderer,
|
||||
&self.res.atlas.sprites,
|
||||
&self.item_host(),
|
||||
batch,
|
||||
x,
|
||||
y,
|
||||
self.started.elapsed().as_secs_f32(),
|
||||
);
|
||||
}
|
||||
/// Submit a resolved item under `pose` (camera-relative) at `light`
|
||||
/// (packed lightmap coordinates).
|
||||
pub(super) fn submit_item_world(
|
||||
&self,
|
||||
state: &ItemRenderState,
|
||||
pose: glam::Mat4,
|
||||
light: u32,
|
||||
out: &mut Vec<Vertex>,
|
||||
) {
|
||||
let mut sink = WorldSink {
|
||||
out,
|
||||
light,
|
||||
lights: Some(diffuse_lights(self.dim == Dim::Nether)),
|
||||
};
|
||||
state.submit(
|
||||
&self.res.item_renderer,
|
||||
&self.res.atlas.sprites,
|
||||
&self.item_host(),
|
||||
pose,
|
||||
&mut sink,
|
||||
);
|
||||
}
|
||||
/// [`Self::submit_item_world`] without the level's diffuse shading.
|
||||
pub(super) fn submit_item_unlit(
|
||||
&self,
|
||||
state: &ItemRenderState,
|
||||
pose: glam::Mat4,
|
||||
light: u32,
|
||||
out: &mut Vec<Vertex>,
|
||||
) {
|
||||
let mut sink = WorldSink {
|
||||
out,
|
||||
light,
|
||||
lights: None,
|
||||
};
|
||||
state.submit(
|
||||
&self.res.item_renderer,
|
||||
&self.res.atlas.sprites,
|
||||
&self.item_host(),
|
||||
pose,
|
||||
&mut sink,
|
||||
);
|
||||
}
|
||||
/// `ItemEntityRenderer.submit` for every dropped item.
|
||||
pub(super) fn dropped_item_geometry(
|
||||
&self,
|
||||
camera: glam::DVec3,
|
||||
alpha: f64,
|
||||
out: &mut Vec<Vertex>,
|
||||
) {
|
||||
for item in &self.dropped {
|
||||
let (p, q) = (item.previous, item.position);
|
||||
let pos = glam::DVec3::new(
|
||||
p.x + (q.x - p.x) * alpha,
|
||||
p.y + (q.y - p.y) * alpha,
|
||||
p.z + (q.z - p.z) * alpha,
|
||||
) - camera;
|
||||
if pos.length_squared() > 64.0 * 64.0 {
|
||||
continue;
|
||||
}
|
||||
// `extractItemGroupRenderState`: GROUND, seeded by the entity id.
|
||||
let owner = owner_at([q.x, q.y, q.z], 0.0, "minecraft:item");
|
||||
let state = self.resolve_item(
|
||||
&item.stack,
|
||||
DisplayContext::Ground,
|
||||
Some(&owner),
|
||||
item.id as i32,
|
||||
);
|
||||
if state.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let age = item.age as f32 + alpha as f32;
|
||||
let (lo, _) = state.bounding_box();
|
||||
let min_offset_y = -lo.y + 0.0625;
|
||||
let bob = (age / 10.0 + item.bob_offset).sin() * 0.1 + 0.1;
|
||||
let spin = item_render::item_spin(age, item.bob_offset);
|
||||
let pose = glam::Mat4::from_translation(pos.as_vec3())
|
||||
* glam::Mat4::from_translation(glam::Vec3::new(0.0, bob + min_offset_y, 0.0))
|
||||
* glam::Mat4::from_rotation_y(spin);
|
||||
let packed = self.world.light(
|
||||
q.x.floor() as i32,
|
||||
(q.y + 0.1).floor() as i32,
|
||||
q.z.floor() as i32,
|
||||
);
|
||||
let seed = i64::from(self.res.item_renderer.item_id(&item.stack.item))
|
||||
+ i64::from(item.stack.damage);
|
||||
for copy in item_render::cluster_poses(
|
||||
&state,
|
||||
pose,
|
||||
item_render::rendered_amount(item.stack.count),
|
||||
seed,
|
||||
) {
|
||||
self.submit_item_world(&state, copy, vertex_light(packed), out);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn civil_dates_follow_the_gregorian_calendar() {
|
||||
assert_eq!(civil(0), (1970, 1, 1));
|
||||
assert_eq!(civil(19_716), (2023, 12, 25));
|
||||
assert_eq!(civil(11_016), (2000, 2, 29));
|
||||
}
|
||||
}
|
||||
@@ -12,9 +12,6 @@ use horizon_client::{
|
||||
use horizon_core::{position::BlockPos, random::RandomSource};
|
||||
use horizon_world::level::flags as level_flags;
|
||||
|
||||
/// Linear attenuation range of a song (`SimpleSoundInstance.forJukeboxSong`: volume 4).
|
||||
const RANGE: f64 = 64.0;
|
||||
|
||||
impl Game<'_> {
|
||||
fn jukebox(&self, pos: [i32; 3]) -> Option<&Jukebox> {
|
||||
match self.world.block_entities.get(&(pos[0], pos[1], pos[2])) {
|
||||
@@ -101,19 +98,18 @@ impl Game<'_> {
|
||||
let Some(s) = self.res.items.songs.get(song).cloned() else {
|
||||
return;
|
||||
};
|
||||
if let Some(id) = self.audio.play_tracked(&s.sound, 0.0, false) {
|
||||
self.jukebox_sounds.insert((pos[0], pos[1], pos[2]), id);
|
||||
}
|
||||
// LevelRenderer.playJukeboxSong: SimpleSoundInstance.forJukeboxSong.
|
||||
let id = self.audio.play_jukebox_song(&s.sound, pos);
|
||||
self.jukebox_sounds.insert((pos[0], pos[1], pos[2]), id);
|
||||
let description =
|
||||
horizon_client::component::to_text(&s.description, &mut |k| Text::literal(k));
|
||||
self.titles
|
||||
.set_overlay(Text::translate("record.nowPlaying", &[description]));
|
||||
self.titles.overlay_animated = true;
|
||||
.set_overlay(Text::translate("record.nowPlaying", &[description]), true);
|
||||
self.update_song_volumes();
|
||||
}
|
||||
fn stop_song(&mut self, pos: [i32; 3]) {
|
||||
if let Some(id) = self.jukebox_sounds.remove(&(pos[0], pos[1], pos[2])) {
|
||||
self.audio.stop_tracked(id);
|
||||
self.audio.stop_and_release(id);
|
||||
}
|
||||
}
|
||||
/// `popOutTheItem`: the disc above the jukebox, and the song stops.
|
||||
@@ -202,30 +198,20 @@ impl Game<'_> {
|
||||
}
|
||||
self.update_song_volumes();
|
||||
}
|
||||
/// Linear distance attenuation of each song at the listener.
|
||||
/// Let go of songs that ended or whose jukebox stopped.
|
||||
fn update_song_volumes(&mut self) {
|
||||
let Some(eye) = self.eye_position() else {
|
||||
return;
|
||||
};
|
||||
let sounds: Vec<_> = self
|
||||
.jukebox_sounds
|
||||
.iter()
|
||||
.map(|(k, id)| (*k, *id))
|
||||
.collect();
|
||||
for (k, id) in sounds {
|
||||
if !self.audio.tracked_active(id)
|
||||
if !self.audio.engine.is_active(id)
|
||||
|| !matches!(self.world.block_entities.get(&k), Some(BlockEntity::Jukebox(j)) if j.playing)
|
||||
{
|
||||
self.audio.stop_tracked(id);
|
||||
self.audio.stop_and_release(id);
|
||||
self.jukebox_sounds.remove(&k);
|
||||
continue;
|
||||
}
|
||||
let d = ((f64::from(k.0) + 0.5 - eye[0]).powi(2)
|
||||
+ (f64::from(k.1) + 0.5 - eye[1]).powi(2)
|
||||
+ (f64::from(k.2) + 0.5 - eye[2]).powi(2))
|
||||
.sqrt();
|
||||
self.audio
|
||||
.set_tracked_volume(id, (1.0 - d / RANGE).clamp(0.0, 1.0) as f32);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -186,7 +186,11 @@ fn mob_meta(m: &Mob, hub: &Hub) -> Vec<(u8, i32, Value)> {
|
||||
if e.chested {
|
||||
meta.push((19, serializer::BOOLEAN, Value::Bool(true)));
|
||||
}
|
||||
meta.push((20, serializer::INT, Value::Int(i32::from(e.variant2.max(1)))));
|
||||
meta.push((
|
||||
20,
|
||||
serializer::INT,
|
||||
Value::Int(i32::from(e.variant2.max(1))),
|
||||
));
|
||||
if e.variant != 0 {
|
||||
meta.push((21, serializer::INT, Value::Int(i32::from(e.variant))));
|
||||
}
|
||||
@@ -205,7 +209,11 @@ fn mob_meta(m: &Mob, hub: &Hub) -> Vec<(u8, i32, Value)> {
|
||||
) {
|
||||
let index = if kind == "minecraft:villager" { 19 } else { 20 };
|
||||
let level = i32::from(e.variant3.max(1));
|
||||
meta.push((index, serializer::VILLAGER_DATA, Value::Ids(vec![t, p, level])));
|
||||
meta.push((
|
||||
index,
|
||||
serializer::VILLAGER_DATA,
|
||||
Value::Ids(vec![t, p, level]),
|
||||
));
|
||||
}
|
||||
}
|
||||
// AbstractCubeMob.ID_SIZE.
|
||||
|
||||
@@ -8,7 +8,6 @@ use horizon_client::{
|
||||
mesher::Vertex,
|
||||
mobs::leash::{self, Holder, Pending},
|
||||
};
|
||||
use horizon_core::random::RandomSource;
|
||||
use horizon_world::nbt::Tag;
|
||||
|
||||
/// `Vec3.yRot(angle)`.
|
||||
@@ -66,13 +65,10 @@ impl Game<'_> {
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
/// `Entity.spawnAtLocation(level, Items.LEAD)`: the item entity draws its
|
||||
/// motion from its own random, not the level's.
|
||||
fn drop_lead_item(&mut self, at: [f64; 3]) {
|
||||
let r = &mut self.world.random;
|
||||
let v = [
|
||||
f64::from(r.next_float()) * 0.2 - 0.1,
|
||||
0.2,
|
||||
f64::from(r.next_float()) * 0.2 - 0.1,
|
||||
];
|
||||
let v = super::dispenser::new_item_motion();
|
||||
self.spawn_item(ItemStack::new("minecraft:lead", 1), at, v, 10);
|
||||
}
|
||||
/// `Leashable.dropLeash` / `removeLeash`: the lead falls as an item.
|
||||
|
||||
@@ -605,7 +605,12 @@ impl Game<'_> {
|
||||
let Some(slot) = slot else {
|
||||
self.dispenser_event(1001, key, 0);
|
||||
if !dropper {
|
||||
self.post_block_game_event("minecraft:block_activate", [pos.x, pos.y, pos.z], None, Some(state));
|
||||
self.post_block_game_event(
|
||||
"minecraft:block_activate",
|
||||
[pos.x, pos.y, pos.z],
|
||||
None,
|
||||
Some(state),
|
||||
);
|
||||
}
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
@@ -72,7 +72,12 @@ impl Game<'_> {
|
||||
let triggered = self.containers().prop(state, "triggered") == Some("true");
|
||||
let mut finished = false;
|
||||
if let Some(BlockEntity::Crafter(c)) = self.world.block_entities.get_mut(&pos) {
|
||||
c.triggered = triggered;
|
||||
// CrafterBlock.neighborChanged sets the flag with the state; a
|
||||
// loaded crafter keeps its saved flag until then.
|
||||
if c.state_triggered.is_some_and(|t| t != triggered) {
|
||||
c.triggered = triggered;
|
||||
}
|
||||
c.state_triggered = Some(triggered);
|
||||
let remaining = c.crafting_ticks - 1;
|
||||
if remaining >= 0 {
|
||||
c.crafting_ticks = remaining;
|
||||
|
||||
@@ -28,6 +28,9 @@ pub(super) enum CartKind {
|
||||
Hopper,
|
||||
Furnace,
|
||||
Tnt,
|
||||
/// `MinecartCommandBlock`; its command block holds no command, so an
|
||||
/// activator rail runs nothing.
|
||||
CommandBlock,
|
||||
}
|
||||
impl CartKind {
|
||||
pub fn from_id(id: &str) -> Option<Self> {
|
||||
@@ -37,6 +40,7 @@ impl CartKind {
|
||||
"hopper_minecart" => Self::Hopper,
|
||||
"furnace_minecart" => Self::Furnace,
|
||||
"tnt_minecart" => Self::Tnt,
|
||||
"command_block_minecart" => Self::CommandBlock,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
@@ -47,6 +51,7 @@ impl CartKind {
|
||||
Self::Hopper => "minecraft:hopper_minecart",
|
||||
Self::Furnace => "minecraft:furnace_minecart",
|
||||
Self::Tnt => "minecraft:tnt_minecart",
|
||||
Self::CommandBlock => "minecraft:command_block_minecart",
|
||||
}
|
||||
}
|
||||
fn slots(self) -> usize {
|
||||
@@ -192,6 +197,7 @@ impl CartModels {
|
||||
CartKind::Hopper,
|
||||
CartKind::Furnace,
|
||||
CartKind::Tnt,
|
||||
CartKind::CommandBlock,
|
||||
];
|
||||
let models = kinds
|
||||
.into_iter()
|
||||
@@ -981,6 +987,8 @@ impl Game<'_> {
|
||||
self.prime_cart(&mut c);
|
||||
self.carts[i] = c;
|
||||
}
|
||||
// BaseCommandBlock.usedBy: only an operator in creative opens it.
|
||||
CartKind::CommandBlock => return false,
|
||||
}
|
||||
self.swing_arm();
|
||||
true
|
||||
@@ -990,9 +998,10 @@ impl Game<'_> {
|
||||
let Some(key) = self.container.filter(|k| k.1 == CART_KEY_Y) else {
|
||||
return;
|
||||
};
|
||||
if let Some(BlockEntity::Chest(items)) = self.world.block_entities.remove(&key)
|
||||
&& let Some(c) = self.carts.iter_mut().find(|c| c.id as i32 == key.2)
|
||||
{
|
||||
let Some(BlockEntity::Chest(items)) = self.world.block_entities.remove(&key) else {
|
||||
return;
|
||||
};
|
||||
if let Some(c) = self.carts.iter_mut().find(|c| c.id as i32 == key.2) {
|
||||
c.items = items;
|
||||
let at = [c.position.x, c.position.y, c.position.z];
|
||||
if c.kind == CartKind::Chest {
|
||||
@@ -1000,15 +1009,24 @@ impl Game<'_> {
|
||||
self.audio
|
||||
.play("minecraft:block.chest.close", Some(at), 0.5, pitch);
|
||||
}
|
||||
} else if let Some(b) = self.boats.iter_mut().find(|b| b.id as i32 == key.2) {
|
||||
b.items = items;
|
||||
}
|
||||
}
|
||||
/// The open cart's kind when a cart container is shown.
|
||||
pub(super) fn open_cart_kind(&self) -> Option<CartKind> {
|
||||
let key = self.container.filter(|k| k.1 == CART_KEY_Y)?;
|
||||
// A chest boat's chest shows like a chest minecart's.
|
||||
self.carts
|
||||
.iter()
|
||||
.find(|c| c.id as i32 == key.2)
|
||||
.map(|c| c.kind)
|
||||
.or_else(|| {
|
||||
self.boats
|
||||
.iter()
|
||||
.any(|b| b.chest && b.id as i32 == key.2)
|
||||
.then_some(CartKind::Chest)
|
||||
})
|
||||
}
|
||||
/// `VehicleEntity.hurtServer` from the player's attack.
|
||||
pub(super) fn attack_cart(&mut self, damage: f32) -> bool {
|
||||
@@ -1121,6 +1139,7 @@ impl Game<'_> {
|
||||
],
|
||||
),
|
||||
CartKind::Tnt => registry.default_state("minecraft:tnt"),
|
||||
CartKind::CommandBlock => registry.default_state("minecraft:command_block"),
|
||||
}
|
||||
}
|
||||
/// `AbstractMinecartRenderer` (old render): on the rail between the
|
||||
@@ -1231,15 +1250,19 @@ impl Game<'_> {
|
||||
let lid = |_: [i32; 3]| 0.0;
|
||||
let scene = horizon_client::block_entity_render::Scene {
|
||||
camera: glam::DVec3::ZERO,
|
||||
orientation: glam::Quat::IDENTITY,
|
||||
anim: &|_| horizon_client::block_entity_render::BlockAnim::default(),
|
||||
texts: None,
|
||||
partial: 0.0,
|
||||
game_time: self.world.game_time,
|
||||
max_y: self.dim.max_y(),
|
||||
world: &self.world,
|
||||
sprites: &self.res.atlas.sprites,
|
||||
nether: self.dim == super::Dim::Nether,
|
||||
lid: &lid,
|
||||
sign: &|_| None,
|
||||
banner: &|_| Vec::new(),
|
||||
glyph: &|c| self.res.atlas.glyph(c),
|
||||
glyph: &|c| self.res.atlas.glyph_cell(c),
|
||||
};
|
||||
self.res
|
||||
.block_entity_render
|
||||
@@ -1280,6 +1303,13 @@ impl Minecart {
|
||||
put("Fuel", Tag::Short(self.fuel as i16));
|
||||
}
|
||||
CartKind::Tnt => put("fuse", Tag::Int(self.fuse)),
|
||||
// BaseCommandBlock.save of an empty command block.
|
||||
CartKind::CommandBlock => {
|
||||
put("Command", Tag::String(JavaString::from("")));
|
||||
put("SuccessCount", Tag::Int(0));
|
||||
put("TrackOutput", Tag::Byte(1));
|
||||
put("UpdateLastExecution", Tag::Byte(1));
|
||||
}
|
||||
CartKind::Rideable => {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
//! Blocks mobs carry: block items in a hand (`ItemInHandLayer`) and the
|
||||
//! block an enderman holds (`CarriedBlockLayer`), drawn as shaded cubes like
|
||||
//! dropped block items.
|
||||
use super::{Game, Icon};
|
||||
use super::Game;
|
||||
use horizon_client::{
|
||||
items::ItemStack,
|
||||
mesher::Vertex,
|
||||
mobs::{Mob, Species, render},
|
||||
};
|
||||
@@ -176,22 +177,6 @@ impl Game<'_> {
|
||||
render::draw(flag, m, &flag_poses, r, color, light, 0, out);
|
||||
}
|
||||
}
|
||||
/// Resolve the icons of the items mobs hold, so drawing can look them up.
|
||||
pub(super) fn cache_mob_icons(&mut self) {
|
||||
let items: Vec<String> = self
|
||||
.mobs
|
||||
.iter()
|
||||
.filter(|m| m.extra.main_hand.is_some() || m.extra.off_hand.is_some())
|
||||
.flat_map(|m| [m.extra.main_hand.clone(), m.extra.off_hand.clone()])
|
||||
.flatten()
|
||||
.map(|i| i.to_string())
|
||||
.collect();
|
||||
for item in items {
|
||||
if !self.res.icons.contains_key(&item) {
|
||||
let _ = self.icon(&item);
|
||||
}
|
||||
}
|
||||
}
|
||||
fn push_cube(
|
||||
&self,
|
||||
t: glam::Mat4,
|
||||
@@ -232,40 +217,7 @@ impl Game<'_> {
|
||||
) {
|
||||
let overlay = u32::from(mob.hurt_time > 0);
|
||||
self.captain_banner_geometry(mob, camera, alpha, light, out);
|
||||
for (item, left) in [(&mob.extra.main_hand, false), (&mob.extra.off_hand, true)] {
|
||||
let Some(item) = item else { continue };
|
||||
let Some(Icon::Cube {
|
||||
top,
|
||||
left: z,
|
||||
right: x,
|
||||
tint,
|
||||
}) = self.res.icons.get(&**item).cloned().flatten()
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let Some(t) =
|
||||
render::held_block_transform(mob, alpha as f32, camera, &self.res.mob_data, left)
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let slot = if left {
|
||||
horizon_client::mobs::gear::OFF
|
||||
} else {
|
||||
horizon_client::mobs::gear::MAIN
|
||||
};
|
||||
let glint = horizon_client::mobs::gear::enchanted(mob, slot);
|
||||
let overlay = if glint { 7 } else { overlay };
|
||||
self.push_cube(
|
||||
t,
|
||||
&Cube {
|
||||
sprites: [&top, &z, &x],
|
||||
tint,
|
||||
},
|
||||
light,
|
||||
overlay,
|
||||
out,
|
||||
);
|
||||
}
|
||||
self.held_item_geometry(mob, camera, alpha, light, true, out);
|
||||
if mob.species() == Species::Enderman
|
||||
&& let Some(state) = mob.extra.carried
|
||||
&& let Some((sprite, tint)) = self.particle_sprite(state)
|
||||
@@ -292,4 +244,71 @@ impl Game<'_> {
|
||||
);
|
||||
}
|
||||
}
|
||||
/// `ItemInHandLayer`: both hands through the item model pipeline; `lit`
|
||||
/// applies the level's diffuse lighting (off for GUI previews, which
|
||||
/// light the projected quads themselves).
|
||||
pub(super) fn held_item_geometry(
|
||||
&self,
|
||||
mob: &Mob,
|
||||
camera: glam::DVec3,
|
||||
alpha: f64,
|
||||
light: u32,
|
||||
lit: bool,
|
||||
out: &mut Vec<Vertex>,
|
||||
) {
|
||||
let overlay = u32::from(mob.hurt_time > 0);
|
||||
for (item, left) in [(&mob.extra.main_hand, false), (&mob.extra.off_hand, true)] {
|
||||
let Some(item) = item else { continue };
|
||||
let Some(t) =
|
||||
render::held_item_transform(mob, alpha as f32, camera, &self.res.mob_data, left)
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let slot = if left {
|
||||
horizon_client::mobs::gear::OFF
|
||||
} else {
|
||||
horizon_client::mobs::gear::MAIN
|
||||
};
|
||||
let mut stack = ItemStack::new(item, 1);
|
||||
if horizon_client::mobs::gear::enchanted(mob, slot) {
|
||||
// The gear's enchantments are not tracked, only that it has some.
|
||||
stack.enchant("minecraft:unbreaking", 1);
|
||||
}
|
||||
if &**item == "minecraft:crossbow" && mob.extra.flag2 {
|
||||
stack.data_mut().charged = vec![ItemStack::new("minecraft:arrow", 1)];
|
||||
}
|
||||
let mut owner = super::item_draw::owner_at(
|
||||
[mob.position.x, mob.position.y, mob.position.z],
|
||||
mob.body_yaw,
|
||||
&mob.kind.id,
|
||||
);
|
||||
if !left && mob.extra.using_item > 0 {
|
||||
owner.using_this_item = true;
|
||||
owner.use_duration = 72_000;
|
||||
owner.use_item_remaining_ticks = 72_000 - mob.extra.using_item;
|
||||
owner.crossbow_charge_duration = 25;
|
||||
}
|
||||
let context = if left {
|
||||
horizon_assets::item_model::DisplayContext::ThirdPersonLeftHand
|
||||
} else {
|
||||
horizon_assets::item_model::DisplayContext::ThirdPersonRightHand
|
||||
};
|
||||
// `updateForLiving`: seeded by the entity id plus the context ordinal.
|
||||
let seed = mob.id as i32 + if left { 1 } else { 2 };
|
||||
let state = self.resolve_item(&stack, context, Some(&owner), seed);
|
||||
let start = out.len();
|
||||
if lit {
|
||||
self.submit_item_world(&state, t, light, out);
|
||||
} else {
|
||||
self.submit_item_unlit(&state, t, light, out);
|
||||
}
|
||||
if overlay != 0 {
|
||||
for v in &mut out[start..] {
|
||||
if v.layer == 0 {
|
||||
v.layer = overlay;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -453,54 +453,25 @@ impl Game<'_> {
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
/// `Gui.renderVehicleHealth` and the jump bar (`JumpableVehicleBarRenderer`).
|
||||
pub(super) fn draw_mount_hud(&self, batch: &mut HudBatch, w: f32, h: f32) -> bool {
|
||||
/// `Hud.extractVehicleHealth` and the jump bar that replaces the
|
||||
/// experience bar on a saddled jumper. Returns the mount's heart count
|
||||
/// (`getVehicleMaxHearts`) and whether the jump bar shows.
|
||||
pub(super) fn draw_mount_hud(&self, batch: &mut HudBatch, w: i32, h: i32) -> (i32, bool) {
|
||||
let Some(i) = self.mount_index() else {
|
||||
return false;
|
||||
return (0, false);
|
||||
};
|
||||
let m = &self.mobs[i];
|
||||
let atlas = &self.res.atlas;
|
||||
// Hearts in rows of ten, right-aligned where the food bar sits.
|
||||
let max = (m.attrs.max_health + 0.5) as i32 / 2;
|
||||
let health = m.health.ceil() as i32;
|
||||
let right = w / 2.0 + 91.0;
|
||||
let mut y = h - 39.0;
|
||||
let mut drawn = 0;
|
||||
while drawn < max {
|
||||
let row = (max - drawn).min(10);
|
||||
for k in 0..row {
|
||||
let x = right - k as f32 * 8.0 - 9.0;
|
||||
batch.sprite(atlas, "gui/sprites/hud/heart/vehicle_container", x, y);
|
||||
let v = health - (drawn + k) * 2;
|
||||
if v >= 2 {
|
||||
batch.sprite(atlas, "gui/sprites/hud/heart/vehicle_full", x, y);
|
||||
} else if v == 1 {
|
||||
batch.sprite(atlas, "gui/sprites/hud/heart/vehicle_half", x, y);
|
||||
}
|
||||
}
|
||||
drawn += row;
|
||||
y -= 10.0;
|
||||
}
|
||||
// The jump bar replaces the experience bar on a saddled jumper.
|
||||
let hearts = horizon_client::hud_layout::vehicle_max_hearts(m.attrs.max_health);
|
||||
let draws = horizon_client::hud_layout::vehicle_health(w, h, hearts, m.health);
|
||||
horizon_client::hud_layout::paint(batch, atlas, &draws);
|
||||
if m.extra.saddled {
|
||||
let x = w / 2.0 - 91.0;
|
||||
let y = h - 29.0;
|
||||
batch.sprite(atlas, "gui/sprites/hud/jump_bar_background", x, y);
|
||||
if self.mount.jump_ticks < 0 {
|
||||
batch.sprite(atlas, "gui/sprites/hud/jump_bar_cooldown", x, y);
|
||||
} else {
|
||||
let filled = (self.mount.jump_scale * 183.0).floor();
|
||||
if filled > 0.0 {
|
||||
batch.sprite_part(
|
||||
atlas,
|
||||
"gui/sprites/hud/jump_bar_progress",
|
||||
x,
|
||||
y,
|
||||
filled / 182.0,
|
||||
);
|
||||
}
|
||||
}
|
||||
// JumpableVehicleBar: the cooldown shows while the jump recharges.
|
||||
let cooldown = i32::from(self.mount.jump_ticks < 0);
|
||||
let draws =
|
||||
horizon_client::hud_overlays::jump_bar(w, h, cooldown, self.mount.jump_scale);
|
||||
horizon_client::hud_layout::paint(batch, atlas, &draws);
|
||||
}
|
||||
true
|
||||
(hearts, m.extra.saddled)
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,11 @@
|
||||
//! `Minecraft.getSituationalMusic` and `getMusicVolume` for the music manager.
|
||||
//! `Minecraft.getSituationalMusic` and `getMusicVolume` for the music manager,
|
||||
//! the local player's ambient sound handlers and the sound engine tick.
|
||||
use super::Game;
|
||||
use horizon_client::{blocks::Fluid, music::Music};
|
||||
use horizon_client::{
|
||||
blocks::Fluid,
|
||||
music::{BackgroundMusic, Music, Situation},
|
||||
sound_engine::TickContext,
|
||||
};
|
||||
|
||||
impl Game<'_> {
|
||||
/// `Minecraft.tick` -> `MusicManager.tick`, once per client tick.
|
||||
@@ -11,41 +16,86 @@ impl Game<'_> {
|
||||
self.music
|
||||
.tick(music.as_ref(), volume, loading, &mut self.audio);
|
||||
}
|
||||
fn situational_music(&self) -> (Option<Music>, f32) {
|
||||
// Title screen: no player, Musics.MENU at full volume.
|
||||
if self.title.is_some() {
|
||||
return (Some(Music::menu()), 1.0);
|
||||
/// `Minecraft.tick` -> `SoundManager.tick(pause)`, after the music, with
|
||||
/// `pauseAllExcept(MUSIC, UI)` when the game pauses and `resume` when it
|
||||
/// carries on.
|
||||
pub(super) fn tick_sound_engine(&mut self) {
|
||||
if self.pause != self.sound_paused {
|
||||
if self.pause {
|
||||
self.audio.pause();
|
||||
} else {
|
||||
self.audio.resume();
|
||||
}
|
||||
self.sound_paused = self.pause;
|
||||
}
|
||||
let Some(eye) = self.eye_position() else {
|
||||
return (Some(Music::menu()), 1.0);
|
||||
let ctx = TickContext {
|
||||
player_underwater: self.ambience.was_underwater(),
|
||||
player_removed: self.player.is_none(),
|
||||
};
|
||||
self.audio.tick(self.pause, &ctx);
|
||||
}
|
||||
/// `getSituationalMusic` and `getMusicVolume`.
|
||||
fn situational_music(&self) -> (Option<Music>, f32) {
|
||||
let empty = BackgroundMusic::default();
|
||||
// The title screen has no player (and no level for the camera probe).
|
||||
let eye = if self.title.is_some() {
|
||||
None
|
||||
} else {
|
||||
self.eye_position()
|
||||
};
|
||||
let (background, probe_volume) = match eye {
|
||||
Some(eye) => {
|
||||
let (dimension, biome) = (self.dim.id(), self.biome_at(eye));
|
||||
let tables = &self.res.music;
|
||||
(
|
||||
tables.background(dimension, biome),
|
||||
tables.volume(dimension, biome),
|
||||
)
|
||||
}
|
||||
None => (empty, 1.0),
|
||||
};
|
||||
let situation = Situation {
|
||||
screen_music: None,
|
||||
in_world: eye.is_some(),
|
||||
in_end: self.dim == super::Dim::End,
|
||||
boss_music: self.boss_music(),
|
||||
background: &background,
|
||||
probe_volume,
|
||||
creative: self.creative,
|
||||
underwater: self.ambience.was_underwater(),
|
||||
};
|
||||
(situation.music(), situation.volume())
|
||||
}
|
||||
/// `BiomeManager.getNoiseBiomeAtPosition`: the quart cell holding a position.
|
||||
fn biome_at(&self, p: [f64; 3]) -> &str {
|
||||
let (x, y, z) = (
|
||||
eye[0].floor() as i32,
|
||||
eye[1].floor() as i32,
|
||||
eye[2].floor() as i32,
|
||||
p[0].floor() as i32,
|
||||
p[1].floor() as i32,
|
||||
p[2].floor() as i32,
|
||||
);
|
||||
let biome = self
|
||||
.world
|
||||
self.world
|
||||
.chunks
|
||||
.get(&(x >> 4, z >> 4))
|
||||
.map_or("minecraft:plains", |c| {
|
||||
self.res.biomes.list[c.biome(x, y, z) as usize]
|
||||
.name
|
||||
.as_str()
|
||||
});
|
||||
// Minecraft.getSituationalMusic: the boss bar's music in the End.
|
||||
if self.boss_music() {
|
||||
return (Some(Music::end_boss()), 1.0);
|
||||
})
|
||||
}
|
||||
/// `isEyeInFluid(WATER)`: the eye inside the water of its block column.
|
||||
fn eye_in_water(&self, x: f64, eye_y: f64, z: f64) -> bool {
|
||||
let (bx, by, bz) = (x.floor() as i32, eye_y.floor() as i32, z.floor() as i32);
|
||||
let f = self.res.facts.get(self.world.block(bx, by, bz));
|
||||
if f.fluid != Fluid::Water {
|
||||
return false;
|
||||
}
|
||||
let dimension = self.dim.id();
|
||||
let tables = &self.res.music;
|
||||
// Player.isUnderWater: eyes in water.
|
||||
let underwater = self.res.facts.get(self.world.block(x, y, z)).fluid == Fluid::Water;
|
||||
let music = tables
|
||||
.background(dimension, biome)
|
||||
.select(self.creative, underwater)
|
||||
.cloned();
|
||||
(music, tables.volume(dimension, biome))
|
||||
let above = self.res.facts.get(self.world.block(bx, by + 1, bz));
|
||||
let height = if above.fluid == Fluid::Water {
|
||||
1.0
|
||||
} else {
|
||||
f64::from(f.fluid_level) / 9.0
|
||||
};
|
||||
eye_y <= f64::from(by) + height
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,33 +107,46 @@ impl Game<'_> {
|
||||
};
|
||||
let feet = [p.position.x, p.position.y, p.position.z];
|
||||
let eye_y = feet[1] + f64::from(p.dimensions.eye_height);
|
||||
let (x, z) = (feet[0].floor() as i32, feet[2].floor() as i32);
|
||||
let sounds = self.res.ambient.get(self.dim.id(), self.biome_at(feet));
|
||||
let underwater = self.eye_in_water(feet[0], eye_y, feet[2]);
|
||||
let world = &self.world;
|
||||
let biome = world
|
||||
.chunks
|
||||
.get(&(x >> 4, z >> 4))
|
||||
.map_or("minecraft:plains", |c| {
|
||||
self.res.biomes.list[c.biome(x, feet[1].floor() as i32, z) as usize]
|
||||
.name
|
||||
.as_str()
|
||||
});
|
||||
let sounds = self.res.ambient.get(self.dim.id(), biome);
|
||||
let underwater = self
|
||||
.res
|
||||
.facts
|
||||
.get(world.block(x, eye_y.floor() as i32, z))
|
||||
.fluid
|
||||
== Fluid::Water;
|
||||
let light = |b: [i32; 3]| {
|
||||
let l = world.light(b[0], b[1], b[2]);
|
||||
(l >> 4, l & 15)
|
||||
};
|
||||
let (half, height) = (
|
||||
f64::from(p.dimensions.width / 2.0),
|
||||
f64::from(p.dimensions.height),
|
||||
);
|
||||
let registry = &self.res.assets.registry;
|
||||
let facts = &self.res.facts;
|
||||
let bubble_column = horizon_client::ambience::bubble_column_in(
|
||||
[
|
||||
feet[0] - half,
|
||||
feet[1],
|
||||
feet[2] - half,
|
||||
feet[0] + half,
|
||||
feet[1] + height,
|
||||
feet[2] + half,
|
||||
],
|
||||
|b| {
|
||||
let state = world.block(b[0], b[1], b[2]);
|
||||
(&*facts.get(state).name == "minecraft:bubble_column").then(|| {
|
||||
registry
|
||||
.state(state)
|
||||
.and_then(|s| s.properties.get("drag"))
|
||||
.is_some_and(|d| d == "true")
|
||||
})
|
||||
},
|
||||
);
|
||||
let ctx = horizon_client::ambience::AmbientContext {
|
||||
sounds: &sounds,
|
||||
position: feet,
|
||||
eye_y,
|
||||
underwater,
|
||||
spectator: self.spectator(),
|
||||
light: &light,
|
||||
bubble_column,
|
||||
};
|
||||
self.ambience.tick(&ctx, &mut self.audio);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,355 @@
|
||||
//! The game's side of `NaturalSpawner` (`mobs::spawn_selection`): the level
|
||||
//! it reads, the spawning chunks and caps of `ServerChunkCache.tickChunks`,
|
||||
//! and the mobs it adds (finalized with the game's `finalizeSpawn`).
|
||||
use super::{Dim, Game};
|
||||
use horizon_client::{
|
||||
biome::{BiomeInfo, SpawnEntry},
|
||||
blocks::BlockFacts,
|
||||
collisions::{SceneCollisions, no_collision_empty},
|
||||
mobs::{
|
||||
Mob, MobWorld, Species,
|
||||
data::{self, MobData},
|
||||
spawn_rules::{Dimension, SpawnLevel},
|
||||
spawn_selection::{
|
||||
self, Category, Counted, Heightmap, NaturalLevel, SpawnCost, SpawnPlayer, SpawnRequest,
|
||||
SpawnState, Spawned, StructureAt, StructureSpawns,
|
||||
},
|
||||
},
|
||||
};
|
||||
use horizon_core::{
|
||||
geometry::{Aabb, Vec3},
|
||||
random::{LegacyRandom, RandomSource, WorldgenRandom},
|
||||
};
|
||||
use horizon_world::registry::BlockStateId;
|
||||
use std::collections::HashMap;
|
||||
|
||||
/// The game level as natural spawning reads it.
|
||||
struct GameLevel<'g, 'a> {
|
||||
game: &'g Game<'a>,
|
||||
w: MobWorld<'g>,
|
||||
/// Boxes of the mobs this pass created, not yet in the game's list.
|
||||
pending: std::cell::RefCell<Vec<Aabb>>,
|
||||
}
|
||||
impl GameLevel<'_, '_> {
|
||||
fn biome(&self, x: i32, y: i32, z: i32) -> Option<&BiomeInfo> {
|
||||
let chunk = self.game.world.chunks.get(&(x >> 4, z >> 4))?;
|
||||
Some(self.game.res.biomes.get(chunk.biome(x, y, z)))
|
||||
}
|
||||
}
|
||||
impl SpawnLevel for GameLevel<'_, '_> {
|
||||
fn state(&self, x: i32, y: i32, z: i32) -> BlockStateId {
|
||||
self.w.state(x, y, z)
|
||||
}
|
||||
fn facts(&self) -> &BlockFacts {
|
||||
self.w.facts
|
||||
}
|
||||
fn mob_data(&self) -> &MobData {
|
||||
self.w.data
|
||||
}
|
||||
fn block_tag(&self, tag: &str, block: &str) -> bool {
|
||||
self.w.block_tag(tag, block)
|
||||
}
|
||||
fn biome_tag(&self, tag: &str, x: i32, y: i32, z: i32) -> bool {
|
||||
self.w.biome_tag(tag, x, y, z)
|
||||
}
|
||||
fn sky_light(&self, x: i32, y: i32, z: i32) -> i32 {
|
||||
self.w.sky_light(x, y, z)
|
||||
}
|
||||
fn block_light(&self, x: i32, y: i32, z: i32) -> i32 {
|
||||
self.w.block_light(x, y, z)
|
||||
}
|
||||
fn sky_darken(&self) -> i32 {
|
||||
SpawnLevel::sky_darken(&self.w)
|
||||
}
|
||||
fn thundering(&self) -> bool {
|
||||
self.w.thundering
|
||||
}
|
||||
fn sea_level(&self) -> i32 {
|
||||
self.w.sea_level()
|
||||
}
|
||||
fn world_surface(&self, x: i32, z: i32) -> i32 {
|
||||
self.w.world_surface(x, z)
|
||||
}
|
||||
fn peaceful(&self) -> bool {
|
||||
self.w.peaceful()
|
||||
}
|
||||
fn dimension(&self) -> Dimension {
|
||||
self.w.dimension()
|
||||
}
|
||||
fn surface_slime_chance(&self, x: i32, y: i32, z: i32) -> f32 {
|
||||
self.w.surface_slime_chance(x, y, z)
|
||||
}
|
||||
fn world_seed(&self) -> i64 {
|
||||
self.w.world_seed
|
||||
}
|
||||
fn player_near(&self, x: f64, y: f64, z: f64, distance: f64) -> bool {
|
||||
self.w.player_near(x, y, z, distance)
|
||||
}
|
||||
}
|
||||
impl NaturalLevel for GameLevel<'_, '_> {
|
||||
fn min_y(&self) -> i32 {
|
||||
horizon_client::chunk::MIN_Y
|
||||
}
|
||||
fn max_y(&self) -> i32 {
|
||||
horizon_client::chunk::MIN_Y + horizon_client::chunk::HEIGHT - 1
|
||||
}
|
||||
fn ambient_light(&self) -> f32 {
|
||||
match self.game.dim {
|
||||
Dim::Nether => 0.1,
|
||||
Dim::End => 0.25,
|
||||
_ => 0.0,
|
||||
}
|
||||
}
|
||||
fn has_ceiling(&self) -> bool {
|
||||
self.game.dim == Dim::Nether
|
||||
}
|
||||
fn mob_type(&self, entity: &str) -> Option<(Species, f32, f32, Category)> {
|
||||
let t = self.game.res.mob_types.get(entity)?;
|
||||
Some((
|
||||
t.species,
|
||||
t.width as f32,
|
||||
t.height as f32,
|
||||
Category::from_name(&t.category),
|
||||
))
|
||||
}
|
||||
fn biome_spawns(&self, x: i32, y: i32, z: i32) -> Option<&HashMap<String, Vec<SpawnEntry>>> {
|
||||
self.biome(x, y, z).map(|b| &b.spawns)
|
||||
}
|
||||
fn spawn_cost(&self, entity: &str, x: i32, y: i32, z: i32) -> Option<SpawnCost> {
|
||||
self.biome(x, y, z)?.spawn_costs.get(entity).copied()
|
||||
}
|
||||
fn creature_probability(&self, x: i32, y: i32, z: i32) -> f32 {
|
||||
self.biome(x, y, z).map_or(0.1, |b| b.creature_probability)
|
||||
}
|
||||
fn structures_at(&self, x: i32, y: i32, z: i32) -> Vec<StructureAt> {
|
||||
self.game
|
||||
.structures_reaching([x, y, z])
|
||||
.into_iter()
|
||||
.map(|(id, in_piece)| StructureAt {
|
||||
id,
|
||||
in_piece,
|
||||
in_bounds: true,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
fn structure_spawns(&self) -> &StructureSpawns {
|
||||
StructureSpawns::shared(&self.game.options.resources)
|
||||
}
|
||||
fn nearest_player_distance_sqr(&self, x: f64, y: f64, z: f64) -> Option<f64> {
|
||||
let p = self.game.player.as_ref()?;
|
||||
if self.game.spectator() {
|
||||
return None;
|
||||
}
|
||||
let d = [p.position.x - x, p.position.y - y, p.position.z - z];
|
||||
Some(d[0] * d[0] + d[1] * d[1] + d[2] * d[2])
|
||||
}
|
||||
fn respawn(&self) -> Option<[i32; 3]> {
|
||||
// The world spawn point lives in the overworld.
|
||||
if self.game.dim != Dim::Overworld {
|
||||
return None;
|
||||
}
|
||||
self.game.level.spawn.map(|s| {
|
||||
[
|
||||
s[0].floor() as i32,
|
||||
s[1].floor() as i32,
|
||||
s[2].floor() as i32,
|
||||
]
|
||||
})
|
||||
}
|
||||
fn can_spawn_in_chunk(&self, cx: i32, cz: i32) -> bool {
|
||||
self.game.world.chunks.contains_key(&(cx, cz))
|
||||
}
|
||||
fn no_collision(&self, bounds: Aabb) -> bool {
|
||||
let mut scene = SceneCollisions {
|
||||
world: &self.game.world,
|
||||
binding: self.w.collision,
|
||||
};
|
||||
no_collision_empty(&mut scene, bounds)
|
||||
}
|
||||
fn unobstructed(&self, bounds: Aabb) -> bool {
|
||||
let player = self
|
||||
.game
|
||||
.player_view()
|
||||
.is_some_and(|p| p.alive && !p.spectator && p.bounds.intersects(bounds));
|
||||
!player
|
||||
&& !self.pending.borrow().iter().any(|b| b.intersects(bounds))
|
||||
&& !self
|
||||
.game
|
||||
.mobs
|
||||
.iter()
|
||||
.any(|m| m.alive() && !m.removed && m.bounds().intersects(bounds))
|
||||
}
|
||||
fn spawn_mobs(&self) -> bool {
|
||||
self.game.ui.rules.bool("spawn_mobs")
|
||||
}
|
||||
fn heightmap(&self, kind: Heightmap, x: i32, z: i32) -> i32 {
|
||||
let top = horizon_client::chunk::MIN_Y + horizon_client::chunk::HEIGHT;
|
||||
(horizon_client::chunk::MIN_Y..top)
|
||||
.rev()
|
||||
.find(|&y| spawn_selection::heightmap_blocks(self, kind, x, y, z))
|
||||
.map_or(horizon_client::chunk::MIN_Y, |y| y + 1)
|
||||
}
|
||||
fn pathfindable_land(&self, x: i32, y: i32, z: i32) -> bool {
|
||||
self.w.data.state(self.state(x, y, z)).flags & data::LAND != 0
|
||||
}
|
||||
}
|
||||
impl<'a> Game<'a> {
|
||||
fn natural_level(&self) -> GameLevel<'_, 'a> {
|
||||
GameLevel {
|
||||
game: self,
|
||||
w: self.mob_world_base(&[]),
|
||||
pending: Default::default(),
|
||||
}
|
||||
}
|
||||
/// Creates a mob the spawner accepted, finalized the game's way; the
|
||||
/// caller adds it with [`Game::add_spawned`].
|
||||
fn finalize_spawned(&self, r: &SpawnRequest, pending: &mut Vec<Mob>) -> Option<Spawned> {
|
||||
let kind = self.res.mob_types.get(r.entity)?.clone();
|
||||
let [x, y, z] = r.position;
|
||||
let mut mob = Mob::new(0, kind, Vec3::new(x, y, z), r.yaw);
|
||||
let facts = self.biome_facts(x.floor() as i32, y.floor() as i32, z.floor() as i32);
|
||||
let difficulty = self.local_difficulty(r.position);
|
||||
horizon_client::mobs::spawning::finalize(&mut mob, &facts, &difficulty);
|
||||
horizon_client::mobs::raider::finalize(
|
||||
&mut mob,
|
||||
horizon_client::mobs::raider::Reason::Natural,
|
||||
);
|
||||
let bounds = mob.bounds();
|
||||
pending.push(mob);
|
||||
Some(Spawned {
|
||||
bounds,
|
||||
school: true,
|
||||
})
|
||||
}
|
||||
/// Adds the mobs a spawn pass created: ids, enchanted gear and jockeys.
|
||||
fn add_spawned(&mut self, pending: Vec<Mob>, persistent: bool) {
|
||||
for mut mob in pending {
|
||||
self.next_entity += 1;
|
||||
mob.id = self.next_entity;
|
||||
mob.persistent = persistent;
|
||||
let id = mob.id;
|
||||
self.mobs.push(mob);
|
||||
self.enchant_spawn_equipment(id);
|
||||
if !persistent {
|
||||
self.spawn_jockeys(id, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
/// `ServerChunkCache.tickChunks`: the spawn state over the loaded mobs,
|
||||
/// the categories under their global caps (creatures every 400 ticks),
|
||||
/// then `spawnForChunk` in the shuffled chunks within 128 blocks of the
|
||||
/// player.
|
||||
pub(super) fn spawn_natural(&mut self) {
|
||||
let Some(p) = self.player.as_ref().map(|p| p.position) else {
|
||||
return;
|
||||
};
|
||||
let (pcx, pcz) = (p.x.floor() as i32 >> 4, p.z.floor() as i32 >> 4);
|
||||
// ChunkMap.collectSpawningChunks: tracked chunks whose centre is
|
||||
// within 128 blocks of a non-spectator.
|
||||
let spectator = self.spectator();
|
||||
let mut chunks: Vec<(i32, i32)> = (-8..=8)
|
||||
.flat_map(|dx| (-8..=8).map(move |dz| (pcx + dx, pcz + dz)))
|
||||
.filter(|&(cx, cz)| {
|
||||
let (x, z) = (f64::from(cx * 16 + 8) - p.x, f64::from(cz * 16 + 8) - p.z);
|
||||
!spectator && x * x + z * z < 16384.0 && self.world.chunks.contains_key(&(cx, cz))
|
||||
})
|
||||
.collect();
|
||||
let spawn_mobs = self.ui.rules.bool("spawn_mobs");
|
||||
let spawn_enemies = self.ui.rules.bool("spawn_monsters");
|
||||
let mut random = std::mem::replace(&mut self.world.random, LegacyRandom::new(0));
|
||||
let mut pending = Vec::new();
|
||||
{
|
||||
let level = self.natural_level();
|
||||
let counted: Vec<Counted> = self
|
||||
.mobs
|
||||
.iter()
|
||||
.map(|m| Counted {
|
||||
entity: &m.kind.id,
|
||||
category: Category::from_name(&m.kind.category),
|
||||
block: [
|
||||
m.position.x.floor() as i32,
|
||||
m.position.y.floor() as i32,
|
||||
m.position.z.floor() as i32,
|
||||
],
|
||||
is_mob: true,
|
||||
persistent: m.persistent,
|
||||
})
|
||||
.collect();
|
||||
let players = vec![SpawnPlayer {
|
||||
position: [p.x, p.y, p.z],
|
||||
spectator,
|
||||
}];
|
||||
// A single player's natural spawn chunk tracker covers 17 x 17 chunks.
|
||||
let mut state = SpawnState::new(&level, 289, players, &counted, |cx, cz| {
|
||||
self.world.chunks.contains_key(&(cx, cz))
|
||||
});
|
||||
let categories = if spawn_mobs {
|
||||
state.filtered_categories(spawn_enemies, self.world.game_time % 400 == 0)
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
// Util.shuffle.
|
||||
for i in (1..chunks.len()).rev() {
|
||||
let j = random.next_int_bound(i as i32 + 1).unwrap_or(0) as usize;
|
||||
chunks.swap(i, j);
|
||||
}
|
||||
if !categories.is_empty() {
|
||||
for chunk in chunks {
|
||||
let mut spawn = |l: &GameLevel, r: &SpawnRequest| {
|
||||
let spawned = l.game.finalize_spawned(r, &mut pending);
|
||||
if let Some(s) = &spawned {
|
||||
l.pending.borrow_mut().push(s.bounds);
|
||||
}
|
||||
spawned
|
||||
};
|
||||
spawn_selection::spawn_for_chunk(
|
||||
&level,
|
||||
&mut random,
|
||||
&mut state,
|
||||
&categories,
|
||||
chunk,
|
||||
&mut spawn,
|
||||
&mut |_| {},
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
self.world.random = random;
|
||||
self.add_spawned(pending, false);
|
||||
}
|
||||
/// `NoiseBasedChunkGenerator.spawnOriginalMobs`: creature packs for a
|
||||
/// freshly generated chunk, from the biome at its corner column's top,
|
||||
/// with the chunk's decoration-seeded random (mobs are not generated in
|
||||
/// the End).
|
||||
pub(super) fn spawn_generation_creatures(&mut self, cx: i32, cz: i32) {
|
||||
if !self.world.chunks.contains_key(&(cx, cz)) || self.dim == Dim::End {
|
||||
return;
|
||||
}
|
||||
let mut random = WorldgenRandom::new(LegacyRandom::new(0));
|
||||
random.set_decoration_seed(self.level.seed, cx * 16, cz * 16);
|
||||
// The region's own random: no creature's spawn predicate draws from it.
|
||||
let mut rules = LegacyRandom::new(self.level.seed ^ (i64::from(cx) << 32 | i64::from(cz)));
|
||||
let mut pending = Vec::new();
|
||||
{
|
||||
let level = self.natural_level();
|
||||
let source = [cx * 16, level.max_y(), cz * 16];
|
||||
let mut spawn = |l: &GameLevel, r: &SpawnRequest| {
|
||||
let spawned = l.game.finalize_spawned(r, &mut pending);
|
||||
if let Some(s) = &spawned {
|
||||
l.pending.borrow_mut().push(s.bounds);
|
||||
}
|
||||
spawned
|
||||
};
|
||||
spawn_selection::spawn_mobs_for_chunk_generation(
|
||||
&level,
|
||||
source,
|
||||
(cx, cz),
|
||||
&mut random,
|
||||
&mut rules,
|
||||
&mut spawn,
|
||||
&mut |_| {},
|
||||
);
|
||||
}
|
||||
self.add_spawned(pending, true);
|
||||
}
|
||||
}
|
||||
@@ -176,13 +176,9 @@ impl Game<'_> {
|
||||
self.settings.save(&Self::options_path());
|
||||
self.apply_ui_settings();
|
||||
}
|
||||
/// Frame limiter for the Max Framerate option: when the next frame is due.
|
||||
pub(super) fn next_frame_due(&self) -> Option<std::time::Instant> {
|
||||
if self.options.frames.is_some() {
|
||||
return None;
|
||||
}
|
||||
// FramerateLimitTracker.getFramerateLimit: the option, throttled when
|
||||
// minimised, idle, or in a menu without a world.
|
||||
/// `FramerateLimitTracker.getFramerateLimit`: the Max Framerate option,
|
||||
/// throttled when minimised, idle, or in a menu without a world.
|
||||
pub(super) fn framerate_limit(&self) -> i32 {
|
||||
let context = FrameContext {
|
||||
inactivity: if self.settings.inactivity_fps_limit == "minimized" {
|
||||
InactivityFpsLimit::Minimized
|
||||
@@ -201,11 +197,16 @@ impl Game<'_> {
|
||||
has_screen: self.title.is_some() || self.screen != super::Screen::Playing,
|
||||
has_overlay: false,
|
||||
};
|
||||
let fps = framerate_limit(self.settings.max_fps as i32, context);
|
||||
if fps >= 260 {
|
||||
return None;
|
||||
framerate_limit(self.settings.max_fps as i32, context)
|
||||
}
|
||||
/// `Minecraft.runTick`'s frame limiter: `FramerateLimiter.limitDisplayFPS`
|
||||
/// below 260 frames per second, sleeping then spin-waiting until the
|
||||
/// frame's slot ends (not in scripted frame-count runs).
|
||||
pub(super) fn limit_frame(&mut self) {
|
||||
if self.options.frames.is_some() {
|
||||
return;
|
||||
}
|
||||
let due = self.previous + std::time::Duration::from_secs_f64(1.0 / f64::from(fps));
|
||||
(due > std::time::Instant::now()).then_some(due)
|
||||
let limit = self.framerate_limit();
|
||||
horizon_client::window::end_frame(&mut self.limiter, limit, &mut self.frame_clock);
|
||||
}
|
||||
}
|
||||
@@ -123,8 +123,10 @@ impl Game<'_> {
|
||||
);
|
||||
}
|
||||
self.res.atlas = atlas;
|
||||
self.res.icons.clear();
|
||||
self.res.displays.clear();
|
||||
match horizon_client::item_render::ItemRenderer::load(&root) {
|
||||
Ok(r) => self.res.item_renderer = std::sync::Arc::new(r),
|
||||
Err(e) => eprintln!("Horizon: item model reload failed: {e}"),
|
||||
}
|
||||
} else {
|
||||
eprintln!(
|
||||
"Horizon: resource pack atlas size changed; restart to apply GUI textures"
|
||||
|
||||
@@ -48,7 +48,16 @@ fn with_raw(modelled: Tag, raw: Option<&Tag>) -> Tag {
|
||||
// Defaults Horizon invents for fields it does not simulate give way to the save.
|
||||
let invented = matches!(
|
||||
k.to_string_lossy().as_str(),
|
||||
"Fire" | "Air" | "Invulnerable" | "PortalCooldown" | "HasBeenShot"
|
||||
"Fire"
|
||||
| "Air"
|
||||
| "Invulnerable"
|
||||
| "PortalCooldown"
|
||||
| "HasBeenShot"
|
||||
// FallingBlockEntity fields Horizon does not simulate.
|
||||
| "HurtEntities"
|
||||
| "CancelDrop"
|
||||
| "FallHurtAmount"
|
||||
| "FallHurtMax"
|
||||
);
|
||||
if !(invented && out.contains_key(k)) {
|
||||
out.insert(k.clone(), v.clone());
|
||||
@@ -176,27 +185,40 @@ impl Game<'_> {
|
||||
entity_nbt::falling_block_from_nbt(&tag)
|
||||
&& let Some(state) = self.state_from_nbt(&state)
|
||||
{
|
||||
let (no_gravity, drop_item) = entity_nbt::falling_block_flags(&tag);
|
||||
self.falling.push(FallingBlock {
|
||||
state,
|
||||
position,
|
||||
previous: position,
|
||||
velocity,
|
||||
age,
|
||||
no_gravity,
|
||||
drop_item,
|
||||
saved: uuid.map(|u| Box::new((u, tag.clone()))),
|
||||
});
|
||||
}
|
||||
}
|
||||
k if super::boats::boat_wood(k).is_some()
|
||||
&& self
|
||||
.boat_models
|
||||
.models
|
||||
.contains_key(super::boats::boat_wood(k).unwrap_or("")) =>
|
||||
k if super::boats::any_boat_wood(k)
|
||||
.is_some_and(|(wood, chest)| self.boat_models.has(wood, chest)) =>
|
||||
{
|
||||
if let Some((position, velocity, yaw)) = entity_nbt::boat_from_nbt(&tag) {
|
||||
self.next_entity += 1;
|
||||
let id = self.next_entity;
|
||||
let wood =
|
||||
std::sync::Arc::from(super::boats::boat_wood(k).unwrap_or("oak"));
|
||||
let mut b = super::boats::Boat::new(id, wood, position, yaw);
|
||||
let (wood, chest) =
|
||||
super::boats::any_boat_wood(k).unwrap_or(("oak", false));
|
||||
let mut b = super::boats::Boat::new(
|
||||
id,
|
||||
std::sync::Arc::from(wood),
|
||||
position,
|
||||
yaw,
|
||||
);
|
||||
if chest {
|
||||
b = b.with_chest();
|
||||
b.items = horizon_client::save::decode_items(
|
||||
tag.get("Items"),
|
||||
super::boats::CHEST_SLOTS,
|
||||
);
|
||||
}
|
||||
b.velocity = velocity;
|
||||
self.remember(id, uuid, tag);
|
||||
self.boats.push(b);
|
||||
@@ -497,12 +519,16 @@ impl Game<'_> {
|
||||
}
|
||||
for f in &self.falling {
|
||||
if keys.contains(&chunk_of(f.position)) {
|
||||
let (uuid, raw) = anon(&self.persist, "minecraft:falling_block", f.position);
|
||||
let (uuid, raw) = match &f.saved {
|
||||
Some(saved) => (saved.0, Some(saved.1.clone())),
|
||||
None => anon(&self.persist, "minecraft:falling_block", f.position),
|
||||
};
|
||||
let tag = entity_nbt::falling_block_to_nbt(
|
||||
self.state_to_nbt(f.state),
|
||||
f.position,
|
||||
f.velocity,
|
||||
f.age,
|
||||
f.drop_item,
|
||||
uuid,
|
||||
);
|
||||
out.entry(chunk_of(f.position))
|
||||
@@ -519,12 +545,13 @@ impl Game<'_> {
|
||||
.copied()
|
||||
.unwrap_or_else(random_uuid);
|
||||
let tag = entity_nbt::boat_to_nbt(
|
||||
&b.wood,
|
||||
&b.entity_type(),
|
||||
b.position,
|
||||
b.velocity,
|
||||
b.yaw,
|
||||
uuid,
|
||||
self.persist.raw.get(&b.id),
|
||||
b.chest.then_some(&b.items[..]),
|
||||
);
|
||||
out.entry(chunk_of(b.position)).or_default().push(tag);
|
||||
}
|
||||
|
||||
@@ -3,13 +3,105 @@
|
||||
//! `getMaxZoom`), and the local player drawn as a model: in third person
|
||||
//! and in the inventory (`InventoryScreen.extractEntityInInventoryFollowsMouse`).
|
||||
use super::{Game, Screen};
|
||||
use horizon_client::{
|
||||
blocks::BlockFacts,
|
||||
camera::{CameraEntity, LivingCamera},
|
||||
collisions::SceneCollisions,
|
||||
};
|
||||
use horizon_client::{
|
||||
hud::HudBatch,
|
||||
mobs::{Anim, Brain, Mob, MobType},
|
||||
pose::Pose,
|
||||
};
|
||||
use horizon_core::geometry::Vec3;
|
||||
use std::{path::Path, sync::Arc};
|
||||
use horizon_core::{
|
||||
geometry::{Aabb, ShapeOp, Vec3, VoxelShape},
|
||||
position::BlockPos,
|
||||
};
|
||||
use horizon_world::{
|
||||
collision_context::{CollisionContext, CollisionEntity, CollisionItem},
|
||||
entity_obstruction::EntityIdentity,
|
||||
registry::BlockStateId,
|
||||
world_ray::{RayBlockMode, RayFluidMode, RayWorld, clip_world},
|
||||
};
|
||||
use std::{io, path::Path, sync::Arc};
|
||||
|
||||
/// `Level.clip` with `ClipContext.Block.VISUAL` and no fluids:
|
||||
/// `BlockBehaviour.getVisualShape` is the collision shape except for the
|
||||
/// blocks that override it.
|
||||
struct VisualRays<'a, 'r> {
|
||||
scene: SceneCollisions<'a, 'r>,
|
||||
facts: &'a BlockFacts,
|
||||
context: CollisionContext<'static>,
|
||||
}
|
||||
impl RayWorld for VisualRays<'_, '_> {
|
||||
type Error = io::Error;
|
||||
type Block = BlockStateId;
|
||||
type Fluid = ();
|
||||
fn block_state(&mut self, p: BlockPos) -> io::Result<BlockStateId> {
|
||||
Ok(self.scene.world.block(p.x, p.y, p.z))
|
||||
}
|
||||
fn fluid_state(&mut self, _: BlockPos) -> io::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
fn block_shape(
|
||||
&mut self,
|
||||
state: BlockStateId,
|
||||
p: BlockPos,
|
||||
_: RayBlockMode,
|
||||
) -> io::Result<VoxelShape> {
|
||||
let facts = self.facts.get(state);
|
||||
let name = &*facts.name;
|
||||
// TransparentBlock (glass, stained and tinted glass, copper grates),
|
||||
// IronBarsBlock (bars and panes) and PowderSnowBlock: nothing;
|
||||
// SoulSandBlock and MudBlock: a full block; SnowLayerBlock and
|
||||
// FenceBlock: their outline.
|
||||
if name.ends_with("glass")
|
||||
|| name.ends_with("glass_pane")
|
||||
|| name.ends_with("_bars")
|
||||
|| name.ends_with("copper_grate")
|
||||
|| name == "minecraft:powder_snow"
|
||||
{
|
||||
return Ok(VoxelShape::empty());
|
||||
}
|
||||
if name == "minecraft:soul_sand" || name == "minecraft:mud" {
|
||||
return Ok(VoxelShape::block());
|
||||
}
|
||||
if name == "minecraft:snow" || name.ends_with("_fence") {
|
||||
let mut shape = VoxelShape::empty();
|
||||
for b in facts.outline.iter() {
|
||||
let b = b.map(f64::from);
|
||||
let part = VoxelShape::from_box(Aabb::new(
|
||||
Vec3::new(b[0], b[1], b[2]),
|
||||
Vec3::new(b[3], b[4], b[5]),
|
||||
))
|
||||
.map_err(|e| io::Error::other(format!("{e:?}")))?;
|
||||
shape = shape
|
||||
.join(&part, ShapeOp::OR)
|
||||
.map_err(|e| io::Error::other(format!("{e:?}")))?;
|
||||
}
|
||||
return Ok(shape);
|
||||
}
|
||||
let binding = self.scene.binding;
|
||||
binding
|
||||
.collision_shape_with_context(&mut self.scene, state, p, &self.context)
|
||||
.map_err(|e| io::Error::other(format!("{e:?}")))
|
||||
}
|
||||
fn interaction_shape(&mut self, _: BlockStateId, _: BlockPos) -> io::Result<VoxelShape> {
|
||||
Ok(VoxelShape::empty())
|
||||
}
|
||||
fn fluid_is_source(&mut self, _: ()) -> io::Result<bool> {
|
||||
Ok(false)
|
||||
}
|
||||
fn fluid_is_empty(&mut self, _: ()) -> io::Result<bool> {
|
||||
Ok(true)
|
||||
}
|
||||
fn fluid_is_water(&mut self, _: ()) -> io::Result<bool> {
|
||||
Ok(false)
|
||||
}
|
||||
fn fluid_shape(&mut self, _: (), _: BlockPos) -> io::Result<VoxelShape> {
|
||||
Ok(VoxelShape::empty())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub(super) enum CameraType {
|
||||
@@ -95,34 +187,99 @@ impl Game<'_> {
|
||||
pub(super) fn cycle_camera(&mut self) {
|
||||
self.camera_type = self.camera_type.cycle();
|
||||
}
|
||||
/// `Camera.setup`: the eye, or a point up to four blocks behind (or in
|
||||
/// front of) it, pulled in where blocks are in the way. Returns the
|
||||
/// `Camera.update` (`alignWithEntity` and `getMaxZoom`): the
|
||||
/// interpolated eye, or a point up to the camera distance behind (or in
|
||||
/// front of) it, pulled in where visual shapes are in the way. Returns the
|
||||
/// position, yaw and pitch to render from.
|
||||
pub(super) fn camera_view(&self, eye: glam::DVec3) -> (glam::DVec3, f32, f32) {
|
||||
let (mut yaw, mut pitch) = (self.yaw, self.pitch);
|
||||
if self.camera_type.first_person() || self.player.is_none() || self.sleeping.is_some() {
|
||||
return (eye, yaw, pitch);
|
||||
}
|
||||
if self.camera_type == CameraType::ThirdPersonFront {
|
||||
yaw += 180.0;
|
||||
pitch = -pitch;
|
||||
}
|
||||
let f = super::view_vector(yaw, pitch);
|
||||
let mut dist = 4.0f64;
|
||||
for i in 0..8 {
|
||||
let o = [
|
||||
f64::from((i & 1) * 2 - 1) * 0.1,
|
||||
f64::from((i >> 1 & 1) * 2 - 1) * 0.1,
|
||||
f64::from((i >> 2 & 1) * 2 - 1) * 0.1,
|
||||
];
|
||||
let from = [eye.x + o[0], eye.y + o[1], eye.z + o[2]];
|
||||
let back = [-f[0], -f[1], -f[2]];
|
||||
if let Some(hit) = self.clip_ray(from, back, dist) {
|
||||
let d = glam::DVec3::from_array(hit.point) - eye;
|
||||
dist = dist.min(d.length());
|
||||
}
|
||||
}
|
||||
(eye - glam::DVec3::from_array(f) * dist, yaw, pitch)
|
||||
pub(super) fn camera_view(&mut self, partial: f32) -> Option<(glam::DVec3, f32, f32)> {
|
||||
let (scale, distance) = {
|
||||
let p = self.player.as_mut()?;
|
||||
(
|
||||
p.attribute("minecraft:scale").unwrap_or(1.0) as f32,
|
||||
p.attribute("minecraft:camera_distance").unwrap_or(4.0),
|
||||
)
|
||||
};
|
||||
let p = self.player.as_ref()?;
|
||||
let sleeping = self.sleeping.map(|(head, _)| {
|
||||
let state = self.world.block(head[0], head[1], head[2]);
|
||||
let props = self.res.assets.registry.state(state).map(|s| &s.properties);
|
||||
// BedBlock.getBedOrientation as Direction.toYRot.
|
||||
props.and_then(|props| match props.get("facing").map(String::as_str) {
|
||||
Some("south") => Some(0.0),
|
||||
Some("west") => Some(90.0),
|
||||
Some("north") => Some(180.0),
|
||||
Some("east") => Some(270.0),
|
||||
_ => None,
|
||||
})
|
||||
});
|
||||
// A living mount at the default scale and camera distance.
|
||||
let mount = self
|
||||
.riding
|
||||
.and_then(|id| self.mobs.iter().find(|m| m.id == id))
|
||||
.map(|_| (1.0, 4.0));
|
||||
let entity = CameraEntity {
|
||||
position: [p.position.x, p.position.y, p.position.z],
|
||||
previous: [
|
||||
self.previous_position.x,
|
||||
self.previous_position.y,
|
||||
self.previous_position.z,
|
||||
],
|
||||
// Entity.turn moves the old rotation with the new: the mouse
|
||||
// turns the view every frame without interpolation.
|
||||
x_rot: self.pitch,
|
||||
y_rot: self.yaw,
|
||||
x_rot_o: self.pitch,
|
||||
y_rot_o: self.yaw,
|
||||
eye_height: p.dimensions.eye_height,
|
||||
living: Some(LivingCamera {
|
||||
scale,
|
||||
camera_distance: distance,
|
||||
sleeping,
|
||||
dead: None,
|
||||
}),
|
||||
mount,
|
||||
};
|
||||
let mut view = self.view;
|
||||
let kind = match self.camera_type {
|
||||
CameraType::FirstPerson => horizon_client::camera::CameraType::FirstPerson,
|
||||
CameraType::ThirdPersonBack => horizon_client::camera::CameraType::ThirdPersonBack,
|
||||
CameraType::ThirdPersonFront => horizon_client::camera::CameraType::ThirdPersonFront,
|
||||
};
|
||||
let context = CollisionContext::for_entity(
|
||||
CollisionEntity {
|
||||
identity: EntityIdentity(0),
|
||||
y: p.position.y,
|
||||
descending: p.shift_key_down,
|
||||
main_hand: Some(CollisionItem::EMPTY),
|
||||
},
|
||||
false,
|
||||
);
|
||||
let mut rays = VisualRays {
|
||||
scene: SceneCollisions {
|
||||
world: &self.world,
|
||||
binding: self.collision,
|
||||
},
|
||||
facts: &self.res.facts,
|
||||
context,
|
||||
};
|
||||
view.align_with_entity(&entity, partial, kind, |from, to| {
|
||||
let hit = clip_world(
|
||||
&mut rays,
|
||||
Vec3::new(from[0], from[1], from[2]),
|
||||
Vec3::new(to[0], to[1], to[2]),
|
||||
RayBlockMode::Visual,
|
||||
RayFluidMode::None,
|
||||
4096,
|
||||
)
|
||||
.ok()?;
|
||||
(!hit.miss).then_some([hit.hit.location.x, hit.hit.location.y, hit.hit.location.z])
|
||||
});
|
||||
self.view = view;
|
||||
Some((
|
||||
glam::DVec3::from_array(view.position),
|
||||
view.y_rot,
|
||||
view.x_rot,
|
||||
))
|
||||
}
|
||||
/// Keep the player's model in step with the player (`LivingEntity.tick`
|
||||
/// body turning, `WalkAnimationState`, the swing and the pose).
|
||||
@@ -242,6 +399,7 @@ impl Game<'_> {
|
||||
&self.player_armor_layers(),
|
||||
out,
|
||||
);
|
||||
self.held_item_geometry(m, camera, alpha, packed, true, out);
|
||||
}
|
||||
/// `extractEntityInInventoryFollowsMouse`: the player in the box
|
||||
/// `[x0, x1) x [y0, y1)`, `size` pixels per block, turned toward the
|
||||
@@ -295,6 +453,7 @@ impl Game<'_> {
|
||||
&self.player_armor_layers(),
|
||||
&mut verts,
|
||||
);
|
||||
self.held_item_geometry(&m, camera, 1.0, 240 | 240 << 8, false, &mut verts);
|
||||
let height = if m.extra.crouching { 1.5 } else { 1.8 };
|
||||
// PictureInPictureRenderer: the box centre, (size, size, -size);
|
||||
// then the translation and Rz(pi) * Rx(y_angle * 20 degrees).
|
||||
|
||||
@@ -6,7 +6,8 @@ use super::{Game, Screen};
|
||||
use horizon_client::{
|
||||
brewing,
|
||||
effects::{EffectAction, EffectInstance, PlayerEffects},
|
||||
hud::{HudBatch, rgba},
|
||||
hud::HudBatch,
|
||||
hud_layout, hud_overlays,
|
||||
items::ItemStack,
|
||||
};
|
||||
use horizon_core::{geometry::Vec3, random::RandomSource};
|
||||
@@ -65,6 +66,27 @@ pub(super) struct PotionState {
|
||||
std::collections::HashMap<String, horizon_core::random::XoroshiroRandom>,
|
||||
}
|
||||
|
||||
impl PotionState {
|
||||
/// Thrown potions in flight as (entity type, position, motion), for the
|
||||
/// dispenser parity fixture.
|
||||
pub(super) fn thrown_entities(&self) -> Vec<(&'static str, Vec3, Vec3)> {
|
||||
self.thrown
|
||||
.iter()
|
||||
.map(|t| {
|
||||
let kind = match &*t.stack.item {
|
||||
"minecraft:lingering_potion" => "minecraft:lingering_potion",
|
||||
"minecraft:experience_bottle" => "minecraft:experience_bottle",
|
||||
_ => "minecraft:splash_potion",
|
||||
};
|
||||
(kind, t.position, t.velocity)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
pub(super) fn clear_thrown(&mut self) {
|
||||
self.thrown.clear();
|
||||
}
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
/// Right-click with a potion, splash/lingering potion or glass bottle.
|
||||
pub(super) fn use_potion_item(&mut self, stack: &ItemStack) -> horizon_client::Result<bool> {
|
||||
@@ -542,52 +564,26 @@ impl Game<'_> {
|
||||
if self.screen != Screen::Playing || self.potions.effects.list.is_empty() {
|
||||
return;
|
||||
}
|
||||
let mut list: Vec<&EffectInstance> = self.potions.effects.list.iter().collect();
|
||||
let color = |e: &EffectInstance| brewing::effect(e.id).map_or(0, |d| d.color);
|
||||
list.sort_by(|a, b| {
|
||||
(a.ambient, a.duration == -1, a.duration, color(a))
|
||||
.cmp(&(b.ambient, b.duration == -1, b.duration, color(b)))
|
||||
.reverse()
|
||||
});
|
||||
let (mut good, mut bad) = (0, 0);
|
||||
let atlas = &self.res.atlas;
|
||||
for e in list {
|
||||
let beneficial =
|
||||
brewing::effect(e.id).is_some_and(|d| d.category == brewing::Category::Beneficial);
|
||||
let mut x = batch.width;
|
||||
let mut y = 1.0;
|
||||
if beneficial {
|
||||
good += 1;
|
||||
x -= 25.0 * good as f32;
|
||||
} else {
|
||||
bad += 1;
|
||||
x -= 25.0 * bad as f32;
|
||||
y += 26.0;
|
||||
}
|
||||
let mut alpha = 1.0f32;
|
||||
if e.ambient {
|
||||
batch.sprite(atlas, "gui/sprites/hud/effect_background_ambient", x, y);
|
||||
} else {
|
||||
batch.sprite(atlas, "gui/sprites/hud/effect_background", x, y);
|
||||
if e.duration != -1 && e.duration <= 200 {
|
||||
let d = e.duration as f32;
|
||||
let used = 10.0 - (e.duration / 20) as f32;
|
||||
alpha = ((d / 10.0 / 5.0 * 0.5).clamp(0.0, 0.5)
|
||||
+ (d * std::f32::consts::PI / 5.0).cos()
|
||||
* (used / 10.0 * 0.25).clamp(0.0, 0.25))
|
||||
.clamp(0.0, 1.0);
|
||||
let icons: Vec<hud_overlays::EffectIcon> = self
|
||||
.potions
|
||||
.effects
|
||||
.list
|
||||
.iter()
|
||||
.map(|e| {
|
||||
let def = brewing::effect(e.id);
|
||||
hud_overlays::EffectIcon {
|
||||
id: e.id.to_owned(),
|
||||
beneficial: def.is_some_and(|d| d.category == brewing::Category::Beneficial),
|
||||
color: def.map_or(0, |d| d.color as i32),
|
||||
ambient: e.ambient,
|
||||
duration: e.duration,
|
||||
// Horizon keeps no showIcon flag: every effect shows its icon.
|
||||
show_icon: true,
|
||||
}
|
||||
}
|
||||
batch.sprite_scaled(
|
||||
atlas,
|
||||
&format!("mob_effect/{}", e.id),
|
||||
x + 3.0,
|
||||
y + 3.0,
|
||||
18.0,
|
||||
18.0,
|
||||
rgba(255, 255, 255, (alpha * 255.0) as u8),
|
||||
);
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let draws = hud_overlays::effects(batch.width as i32, false, &icons);
|
||||
hud_layout::paint(batch, &self.res.atlas, &draws);
|
||||
}
|
||||
/// Effects saved in level.dat (`Player.active_effects`).
|
||||
pub(super) fn restore_effects(&mut self) {
|
||||
|
||||
@@ -4,9 +4,10 @@ use super::{Dim, Game};
|
||||
use horizon_client::mobs::villager as mobs_villager;
|
||||
use horizon_client::{
|
||||
effects::EffectInstance,
|
||||
hud::{HudBatch, rgba},
|
||||
hud_overlays,
|
||||
mobs::{Mob, Species, data, raider},
|
||||
raid::{self, Action, BarName, RaidLevel, RaiderInfo, RaiderType, Raids, Spawn},
|
||||
text::Text,
|
||||
};
|
||||
use horizon_core::{geometry::Vec3, random::RandomSource};
|
||||
use std::collections::{HashMap, HashSet};
|
||||
@@ -746,31 +747,12 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// The raid boss bars (`BossHealthOverlay`): a red notched bar with the
|
||||
/// raid's title. Returns the next free row.
|
||||
pub(super) fn draw_raid_bar(&self, batch: &mut HudBatch, mut y: f32) -> f32 {
|
||||
let atlas = &self.res.atlas;
|
||||
/// The raid boss bars: red and notched in tens, with the raid's title.
|
||||
pub(super) fn raid_bars(&self, bars: &mut Vec<hud_overlays::BossBar>) {
|
||||
for raid in self.raiding.raids.map.values() {
|
||||
if !raid.bar.visible || !raid.bar.player || raid.is_stopped() {
|
||||
continue;
|
||||
}
|
||||
let x = (batch.width / 2.0 - 91.0).floor();
|
||||
batch.sprite(atlas, "gui/sprites/boss_bar/red_background", x, y);
|
||||
batch.sprite(atlas, "gui/sprites/boss_bar/notched_10_background", x, y);
|
||||
let p = raid.bar.progress;
|
||||
// Mth.lerpDiscrete(progress, 0, 182).
|
||||
let width = (p * 181.0).floor() as i32 + i32::from(p > 0.0);
|
||||
if width > 0 {
|
||||
let fraction = width as f32 / 182.0;
|
||||
batch.sprite_part(atlas, "gui/sprites/boss_bar/red_progress", x, y, fraction);
|
||||
batch.sprite_part(
|
||||
atlas,
|
||||
"gui/sprites/boss_bar/notched_10_progress",
|
||||
x,
|
||||
y,
|
||||
fraction,
|
||||
);
|
||||
}
|
||||
let title = self.translate("event.minecraft.raid", "Raid");
|
||||
let name = match raid.bar.name {
|
||||
BarName::Raid => title,
|
||||
@@ -789,18 +771,13 @@ impl Game<'_> {
|
||||
self.translate("event.minecraft.raid.defeat.full", "Raid - Defeat")
|
||||
}
|
||||
};
|
||||
let w = HudBatch::text_width(atlas, &name);
|
||||
batch.text(
|
||||
atlas,
|
||||
&name,
|
||||
(batch.width / 2.0 - w / 2.0).floor(),
|
||||
y - 9.0,
|
||||
rgba(255, 255, 255, 255),
|
||||
true,
|
||||
);
|
||||
y += 19.0;
|
||||
bars.push(hud_overlays::BossBar {
|
||||
name: Text::literal(name),
|
||||
progress: raid.bar.progress,
|
||||
color: 2,
|
||||
overlay: 2,
|
||||
});
|
||||
}
|
||||
y
|
||||
}
|
||||
/// A firework a villager launches to celebrate (`CelebrateVillagersSurvivedRaid`).
|
||||
pub(super) fn villager_firework(&mut self, at: [f64; 3], color: usize, flight: u8) {
|
||||
@@ -921,21 +898,6 @@ impl Game<'_> {
|
||||
}
|
||||
/// A raider's ominous banner (drop chance 2.0: always dropped) and a
|
||||
/// patrol captain's ominous bottle (`entities/pillager`).
|
||||
pub(super) fn raider_death_loot(&mut self, m: &horizon_client::mobs::Mob, pos: [f64; 3]) {
|
||||
if !raider::is_raider(m.species()) {
|
||||
return;
|
||||
}
|
||||
if m.extra.head.as_deref() == Some(raider::BANNER) {
|
||||
self.spawn_item(raider::ominous_banner(), pos, [0.0, 0.2, 0.0], 10);
|
||||
}
|
||||
if m.species() == Species::Pillager && raider::is_captain(m) {
|
||||
let amplifier = self.world.random.next_int_bound(5).unwrap_or(0) as u8;
|
||||
let amplifier = (amplifier > 0).then_some(amplifier);
|
||||
let mut bottle = horizon_client::items::ItemStack::new("minecraft:ominous_bottle", 1);
|
||||
bottle.data_mut().ominous_amplifier = amplifier;
|
||||
self.spawn_item(bottle, pos, [0.0, 0.2, 0.0], 10);
|
||||
}
|
||||
}
|
||||
/// `OminousBottleAmplifier.onConsume`: Bad Omen for 100 minutes at the
|
||||
/// bottle's amplifier, then the dispose sound; the bottle is used up.
|
||||
pub(super) fn drink_ominous_bottle(
|
||||
|
||||
@@ -490,7 +490,8 @@ impl Game<'_> {
|
||||
if !open {
|
||||
self.recipe_ui.overlay = None;
|
||||
}
|
||||
self.audio.play("minecraft:ui.button.click", None, 1.0, 1.0);
|
||||
self.audio
|
||||
.play("minecraft:ui.button.click", None, 0.25, 1.0);
|
||||
return true;
|
||||
}
|
||||
if !self.book_open(book_type) || self.spectator() {
|
||||
@@ -518,12 +519,14 @@ impl Game<'_> {
|
||||
if self.recipe_ui.page + 1 < pages && inside(cursor, xo + 93.0, yo + 137.0, 12.0, 17.0)
|
||||
{
|
||||
self.recipe_ui.page += 1;
|
||||
self.audio.play("minecraft:ui.button.click", None, 1.0, 1.0);
|
||||
self.audio
|
||||
.play("minecraft:ui.button.click", None, 0.25, 1.0);
|
||||
return true;
|
||||
}
|
||||
if self.recipe_ui.page > 0 && inside(cursor, xo + 38.0, yo + 137.0, 12.0, 17.0) {
|
||||
self.recipe_ui.page -= 1;
|
||||
self.audio.play("minecraft:ui.button.click", None, 1.0, 1.0);
|
||||
self.audio
|
||||
.play("minecraft:ui.button.click", None, 0.25, 1.0);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -570,7 +573,8 @@ impl Game<'_> {
|
||||
let filtering = !self.book_filtering(book_type);
|
||||
self.level.recipe_book.filtering[book_type.index()] = filtering;
|
||||
self.recipe_ui.page = 0;
|
||||
self.audio.play("minecraft:ui.button.click", None, 1.0, 1.0);
|
||||
self.audio
|
||||
.play("minecraft:ui.button.click", None, 0.25, 1.0);
|
||||
return true;
|
||||
}
|
||||
// Tabs.
|
||||
@@ -581,7 +585,8 @@ impl Game<'_> {
|
||||
self.recipe_ui.tab = Some(tab);
|
||||
self.recipe_ui.page = 0;
|
||||
}
|
||||
self.audio.play("minecraft:ui.button.click", None, 1.0, 1.0);
|
||||
self.audio
|
||||
.play("minecraft:ui.button.click", None, 0.25, 1.0);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
Loaded 100 of 275 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user