Merge remote-tracking branch 'origin/main' into feat/server-mods-world

This commit is contained in:
Ludovic Marie committed 2026-10-26 19:14:31 +01:00
commit 8ec47d6419
248 files changed
+3925 -17086

No files matched your search

-1
View File
@@ -2,7 +2,6 @@
target/
.local/
**/node_modules/
website/
benchmarks/
packs/
mods/
+1 -1
View File
@@ -2,5 +2,5 @@
# where Git for Windows' core.autocrlf would check them out with CRLF.
docs/launcher-commands.md text eol=lf
docs/horizon-ctl-commands.md text eol=lf
website/src/tokens.css text eol=lf
crates/horizon-theme/tokens.css text eol=lf
docs/api/*-reference.md text eol=lf
+36
View File
@@ -73,6 +73,42 @@ jobs:
run: |
cargo +stable test --workspace --lib --bins --tests --features horizon-client/mods,horizon-mods/pack
cargo +stable test --workspace --doc --features horizon-client/mods,horizon-mods/pack
# The launcher is its own workspace (launcher/Cargo.toml), so verify's cargo runs never build it. A pull request that
# changes it or a crate it builds is checked here, and so is every push to main: two pull requests can each pass
# alone and break it together (#2059 and the registry ratings did, 2026-10-26).
launcher:
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Does this pull request change the launcher or a crate it builds?
if: github.event_name == 'pull_request'
id: launcher
env:
BASE_SHA: ${{ github.event.pull_request.base.sha }}
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
run: python3 tools/ci/rust_changes.py --launcher "$BASE_SHA" "$HEAD_SHA" >> "$GITHUB_OUTPUT"
- name: Install Rust and the headers GPUI links against
if: steps.launcher.outputs.required != 'false'
run: |
sudo apt-get update && sudo apt-get install -y --no-install-recommends libxkbcommon-x11-dev libwayland-dev \
libx11-xcb-dev libfontconfig-dev libvulkan-dev libasound2-dev libudev-dev
rustup toolchain install stable --profile minimal --component rustfmt --component clippy
- uses: Swatinem/rust-cache@v2
if: steps.launcher.outputs.required != 'false'
with:
workspaces: launcher
shared-key: launcher
save-if: ${{ github.ref == 'refs/heads/main' }}
- name: Check the launcher's formatting, lint and tests
if: steps.launcher.outputs.required != 'false'
working-directory: launcher
run: |
cargo +stable fmt --check
cargo +stable clippy --all-targets --locked -- -D warnings
cargo +stable test --locked --lib
# Most pull requests leave the harnesses to parity-main, which runs them on main a batch of merges at a time
# (.github/workflows/parity-main.yml; releases wait for it). A pull request still runs them itself when it changes
# a harness, an oracle, a probe or the runner, or carries the run-parity label (run-parity.yml, when it is added).
+2 -1
View File
@@ -54,7 +54,8 @@ Game workspace (root `Cargo.toml`); `launcher/` and `crates/horizon-telemetry-se
Other top-level folders: `tools/` (harnesses and dev scripts), `tests/` (Python unit tests: `python3 -m
unittest discover -s tests`), `mods/horizon/src/test/java/dev/horizonlab/parity/` (Java oracles),
`website/` (playhorizon.dev; its paths are relative to `website/`), `docs/`, `.github/workflows/`.
`docs/`, `.github/workflows/`. The website (playhorizon.dev) is the private ludovic111/horizon-site: its build
reads `README.md`, `launcher/links.json`, `docs/api/`, `roadmap/roadmap.json` and `crates/horizon-theme/tokens.css` from main.
## Build and test fast
+2 -2
View File
@@ -4,8 +4,8 @@ Thanks for helping. Horizon is Minecraft: Java Edition 26.3 rewritten in Rust, a
everything is that **it plays exactly like vanilla**. This page is the short version of how we work; questions are
welcome on [Discord](https://discord.gg/HcTeMZxe5E).
This repository holds the game, the launcher and the website, plus the tools, tests and docs that build, check and
release them. Anything else (videos, Discord and other community material) lives elsewhere.
This repository holds the game and the launcher, plus the tools, tests and docs that build, check and release them.
The website (playhorizon.dev) is built in a separate repository from this one's README, `docs/api/` and roadmap. Anything else (videos, Discord and other community material) lives elsewhere.
**Where to start: [playhorizon.dev/hub](https://playhorizon.dev/hub).** It shows what is being done now and next
([ROADMAP.md](ROADMAP.md) in the repository), the work board, good first tasks, and the forms to report a bug or
+8 -4
View File
@@ -134,7 +134,7 @@ Better tools for mod makers: a test runner that needs no game window, templates
`cargo horizon-mod test` runs a mod's test scripts against a fake game snapshot ([#1816](https://github.com/ludovic111/horizon/issues/1816)) and, for server mods, scripted server events ([#1837](https://github.com/ludovic111/horizon/issues/1837)); the content template and the guides are merged ([#1859](https://github.com/ludovic111/horizon/issues/1859), [#1810](https://github.com/ludovic111/horizon/issues/1810), [docs/api/stability.md](docs/api/stability.md)). Left: templates for hud, server and machine mods, typed wrappers in the SDK, a frozen 0.1 compatibility mod that every runtime must load, a CI check that fails when a stable reference entry is removed without a new major version, and the SDK on crates.io once the API is stable.
More: [website/docs/test-a-mod.md](website/docs/test-a-mod.md)
More: [https://playhorizon.dev/docs/test-a-mod](https://playhorizon.dev/docs/test-a-mod)
</details>
@@ -310,7 +310,7 @@ Horizon should run well on small machines: integrated graphics, the Steam Deck,
Partly done: an opt-in 60 FPS cap with fewer workers on battery, Far Detail Reduced for integrated GPUs and animated textures on the GPU (both shipped). Left: measured targets and fixes per class of machine (memory use, worker counts, startup) on a Steam Deck, old Intel and Apple Silicon Macs and 8 GB computers, reported on the Benchmarks page.
More: [docs/benchmark.md](docs/benchmark.md) · [website/docs/performance.md](website/docs/performance.md)
More: [docs/benchmark.md](docs/benchmark.md) · [https://playhorizon.dev/docs/performance](https://playhorizon.dev/docs/performance)
</details>
@@ -600,7 +600,9 @@ You can port a mod to Horizon with your own AI agent on your own computer. Horiz
<details><summary>Technical</summary>
A player runs their own agent locally with `horizon-ctl` and the MCP servers ([docs/agent-control.md](docs/agent-control.md)), `cargo horizon-mod`, `horizon-port-pack` and the parity harnesses as the check. Horizon writes the guide ([website/docs/agents.md](website/docs/agents.md), [docs/modpacks.md](docs/modpacks.md)) and runs no agents, accounts or keys for players. The No AI switch turns all of it off.
A player runs their own agent locally with `horizon-ctl` and the MCP servers ([docs/agent-control.md](docs/agent-control.md)), `cargo horizon-mod`, `horizon-port-pack` and the parity harnesses as the check. Horizon writes the guide (playhorizon.dev/docs/agents, [docs/modpacks.md](docs/modpacks.md)) and runs no agents, accounts or keys for players. The No AI switch turns all of it off.
More: [https://playhorizon.dev/docs/agents](https://playhorizon.dev/docs/agents)
</details>
@@ -936,7 +938,9 @@ Run Horizon's built-in benchmark and share the report: every new computer makes
<details><summary>Technical</summary>
`horizon-launcher play --arg=--benchmark --arg=standard` writes a JSON report; `horizon-launcher data benchmark <report>` shows what would be sent and uploads it ([website/docs/performance.md](website/docs/performance.md)). Results from three installs with the same hardware appear on the Benchmarks page. Example: [#1711](https://github.com/ludovic111/horizon/issues/1711).
`horizon-launcher play --arg=--benchmark --arg=standard` writes a JSON report; `horizon-launcher data benchmark <report>` shows what would be sent and uploads it (playhorizon.dev/docs/performance). Results from three installs with the same hardware appear on the Benchmarks page. Example: [#1711](https://github.com/ludovic111/horizon/issues/1711).
More: [https://playhorizon.dev/docs/performance](https://playhorizon.dev/docs/performance)
</details>
+10 -7
View File
@@ -1031,19 +1031,22 @@ pub struct ItemModels {
}
impl ItemModels {
pub fn components(&self, stack: &StackView) -> Components {
let mut out = self
.prototypes
.get(&stack.item)
.cloned()
.unwrap_or_default();
/// The stack's components: its item's defaults with the stack's patch on top. A stack
/// with no patch (most stacks a screen draws) borrows the defaults instead of copying them.
pub fn components(&self, stack: &StackView) -> std::borrow::Cow<'_, Components> {
static NONE: Components = BTreeMap::new();
let prototype = self.prototypes.get(&stack.item).unwrap_or(&NONE);
if stack.patch.is_empty() {
return std::borrow::Cow::Borrowed(prototype);
}
let mut out = prototype.clone();
for (key, value) in &stack.patch {
match value {
Some(v) => out.insert(key.clone(), v.clone()),
None => out.remove(key),
};
}
out
std::borrow::Cow::Owned(out)
}
/// `ItemModelResolver.updateForTopItem`.
pub fn resolve(&self, stack: &StackView, ctx: &Context, env: &dyn ItemEnv) -> ResolvedItem {
@@ -0,0 +1,48 @@
use horizon_client::{
Result,
item_data::StackData,
items::{ItemStack, Items},
};
use horizon_world::nbt::Tag;
use serde_json::Value;
use std::{
fs::File,
io::{BufRead, BufReader, BufWriter, Write},
path::Path,
};
pub fn main() -> Result<()> {
let args: Vec<_> = crate::args().collect();
let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../.local/horizon-client/26.3");
let items = Items::catalog(&root)?;
let mut out = BufWriter::new(File::create(&args[2])?);
for line in BufReader::new(File::open(&args[1])?).lines() {
let row: Value = serde_json::from_str(&line?)?;
let mut stack = ItemStack::new(
row["item"].as_str().unwrap(),
row["count"].as_u64().unwrap() as u32,
);
match row["tool"].as_str().unwrap() {
"default" => {}
mode => {
let mut data = StackData::default();
let tool = if mode == "implicit" {
Tag::compound([] as [(&str, Tag); 0])
} else {
Tag::compound([(
"can_destroy_blocks_in_creative",
Tag::Byte(i8::from(mode == "allow")),
)])
};
data.set_other("minecraft:tool", Some(tool));
stack.extra = Some(Box::new(data));
}
}
writeln!(
out,
"{}",
items.can_destroy_block(Some(&stack), row["creative"].as_bool().unwrap())
)?;
}
Ok(())
}
@@ -23,6 +23,7 @@ fn stack(spec: &str) -> LootStack {
map: false,
exploration: None,
ominous_amplifier: None,
stew_effects: Vec::new(),
};
for e in parts {
let (name, level) = e.rsplit_once(':').unwrap_or((e, "1"));
@@ -27,6 +27,7 @@ mod chat_screen_fixture;
mod cloud_fixture;
mod command_parse_fixture;
mod container_screen_probe;
mod creative_mining_probe;
mod creative_search_fixture;
mod cushion_placement_probe;
mod emitter_particles_probe;
@@ -84,6 +85,7 @@ mod sound_engine_fixture;
mod sound_event_fixture;
mod spawn_rules_fixture;
mod sprint_probe;
mod squid_client_probe;
mod subtitle_fixture;
mod tab_list_fixture;
mod team_rules_fixture;
@@ -108,6 +110,8 @@ probes! {
block_transform_fixture,
breaking_fixture,
camera_probe,
squid_client_probe,
creative_mining_probe,
cushion_placement_probe,
cauldron_fixture,
chat_screen_fixture,
@@ -0,0 +1,51 @@
use horizon_client::{
Result,
mobs::{Mob, MobTypes, aquatic},
};
use horizon_core::geometry::Vec3;
use serde_json::Value;
use std::{
fs::File,
io::{BufRead, BufReader, BufWriter, Write},
path::Path,
sync::Arc,
};
pub fn main() -> Result<()> {
let args: Vec<_> = crate::args().collect();
let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../.local/horizon-client/26.3");
let types = MobTypes::load(&root)?;
let mut out = BufWriter::new(File::create(&args[2])?);
for line in BufReader::new(File::open(&args[1])?).lines() {
let row: Value = serde_json::from_str(&line?)?;
let kind = Arc::new(
types.types[&format!("minecraft:{}", row["species"].as_str().unwrap())].clone(),
);
let mut mob = Mob::new(1, kind, Vec3::ZERO, 0.0);
mob.fluids.in_water = row["water"].as_bool().unwrap();
mob.velocity = Vec3::new(
row["x"].as_f64().unwrap(),
row["y"].as_f64().unwrap(),
row["z"].as_f64().unwrap(),
);
mob.body_yaw = row["body"].as_f64().unwrap() as f32;
for (i, value) in row["v"].as_array().unwrap().iter().enumerate() {
mob.anim.v[i] = value.as_f64().unwrap() as f32;
}
if row["reset"].as_bool().unwrap() {
aquatic::squid_cycle_event(&mut mob);
}
let velocity = mob.velocity;
aquatic::squid_client_step(&mut mob);
assert_eq!(
velocity, mob.velocity,
"a remote animation must preserve packet velocity"
);
write!(out, "{:08x}", mob.body_yaw.to_bits())?;
for value in &mob.anim.v[..6] {
write!(out, " {:08x}", value.to_bits())?;
}
writeln!(out)?;
}
Ok(())
}
+94 -6
View File
@@ -58,7 +58,10 @@ enum Animate {
BubbleColumn {
drag: bool,
},
Fire,
/// `BaseFireBlock` (`SoulFireBlock.canBurn` is always true).
Fire {
soul: bool,
},
/// An active sculk sensor.
SculkSensor,
/// Potent sulfur that is not dry.
@@ -140,7 +143,8 @@ fn classify(name: &str, prop: &dyn Fn(&str) -> Option<String>) -> Animate {
"bubble_column" => Animate::BubbleColumn {
drag: prop("drag").as_deref() == Some("true"),
},
"fire" | "soul_fire" => Animate::Fire,
"fire" => Animate::Fire { soul: false },
"soul_fire" => Animate::Fire { soul: true },
"dried_ghast" => Animate::DriedGhast {
waterlogged: prop("waterlogged").as_deref() == Some("true"),
},
@@ -190,6 +194,8 @@ pub struct AnimateTables {
power_transmitter: Vec<bool>,
/// `#triggers_ambient_dried_ghast_block_sounds` (soul sand, soul soil).
dried_ghast_sounds: Vec<bool>,
/// `FireBlock.canBurn`: states with ignite odds above zero (none waterlogged).
burnable: Vec<bool>,
/// Biome id -> ambient particles and probabilities (`minecraft:visual/ambient_particles`).
ambient: Vec<Vec<(T, f32)>>,
}
@@ -220,6 +226,21 @@ impl AnimateTables {
})
.collect()
};
let behaviour: serde_json::Value =
serde_json::from_slice(&std::fs::read(root.join("block-behaviours.json"))?)?;
let ignite_odds = behaviour["fire"]["igniteOdds"].as_object();
let burnable = (0..registry.len() as u32)
.map(|id| {
// FireBlock.getIgniteOdds: a waterlogged state never burns.
registry.state(BlockStateId(id)).is_some_and(|s| {
s.properties.get("waterlogged").map(String::as_str) != Some("true")
&& ignite_odds
.and_then(|m| m.get(s.name.as_str()))
.and_then(serde_json::Value::as_i64)
.is_some_and(|odds| odds > 0)
})
})
.collect();
let mut ambient = vec![Vec::new(); biomes.list.len()];
for (name, &id) in &biomes.index {
let path = root.join(format!(
@@ -248,6 +269,7 @@ impl AnimateTables {
power_provider: by_state("minecraft:enchantment_power_provider"),
power_transmitter: by_state("minecraft:enchantment_power_transmitter"),
dried_ghast_sounds: by_state("minecraft:triggers_ambient_dried_ghast_block_sounds"),
burnable,
ambient,
})
}
@@ -1103,7 +1125,7 @@ fn block_animate_tick(
}
}
}
Animate::Fire => {
Animate::Fire { soul } => {
let r = engine.random();
if next_int(r, 24) == 0 {
let (volume, pitch) = (1.0 + r.next_float(), r.next_float() * 0.7 + 0.3);
@@ -1115,6 +1137,55 @@ fn block_animate_tick(
pitch,
);
}
// BaseFireBlock.animateTick: three large smoke above the fire, or
// two at each burnable neighbour's face when nothing burnable or
// sturdy supports it (SoulFireBlock.canBurn is always true).
let can_burn = |p: [i32; 3]| {
soul || tables
.burnable
.get(ctx.state(p).0 as usize)
.copied()
.unwrap_or(false)
};
let below = [pos[0], pos[1] - 1, pos[2]];
let sturdy = ctx.facts.get(ctx.state(below)).sturdy_full & (1 << 1) != 0;
if !can_burn(below) && !sturdy {
// (neighbour, coordinate axis, face at the axis' high end): the
// west, east, north, south and above faces, in vanilla order.
for (at, axis, high) in [
([pos[0] - 1, pos[1], pos[2]], 0usize, false),
([pos[0] + 1, pos[1], pos[2]], 0, true),
([pos[0], pos[1], pos[2] - 1], 2, false),
([pos[0], pos[1], pos[2] + 1], 2, true),
([pos[0], pos[1] + 1, pos[2]], 1, true),
] {
if !can_burn(at) {
continue;
}
for _ in 0..2 {
let r = engine.random();
let d = [r.next_double(), r.next_double(), r.next_double()];
let mut p = [fx + d[0], fy + d[1], fz + d[2]];
// The face coordinate hugs the edge: `* 0.1F` (float).
p[axis] = if high {
f64::from(pos[axis]) + 1.0 - d[axis] * f64::from(0.1f32)
} else {
f64::from(pos[axis]) + d[axis] * f64::from(0.1f32)
};
add(engine, T::LargeSmoke, p, [0.0; 3]);
}
}
} else {
for _ in 0..3 {
let r = engine.random();
let p = [
fx + r.next_double(),
fy + r.next_double() * 0.5 + 0.5,
fz + r.next_double(),
];
add(engine, T::LargeSmoke, p, [0.0; 3]);
}
}
}
}
}
@@ -1157,13 +1228,19 @@ pub fn campfire_particles<'a>(
if r.next_float() < 0.11 {
let count = next_int(r, 2) + 2;
for _ in 0..count {
make_campfire_smoke(engine, pos, signal);
make_campfire_smoke(engine, pos, signal, false);
}
}
}
}
/// `CampfireBlock.makeParticles` (always visible, overriding the limiter).
pub fn make_campfire_smoke(engine: &mut ParticleEngine, pos: [i32; 3], signal: bool) {
/// `CampfireBlock.makeParticles` (always visible, overriding the limiter);
/// `smoking` adds the plain smoke puff at the logs.
pub fn make_campfire_smoke(
engine: &mut ParticleEngine,
pos: [i32; 3],
signal: bool,
smoking: bool,
) {
let r = engine.random();
let sign = |r: &mut LegacyRandom| if r.next_boolean() { 1.0 } else { -1.0 };
let dx = r.next_double() / 3.0;
@@ -1177,6 +1254,17 @@ pub fn make_campfire_smoke(engine: &mut ParticleEngine, pos: [i32; 3], signal: b
T::CampfireCosySmoke
};
engine.create(&ParticleOptions::simple(ty), [x, y, z], [0.0, 0.07, 0.0]);
if smoking {
let r = engine.random();
let dx = r.next_double() / 4.0 * sign(r);
let dz = r.next_double() / 4.0 * sign(r);
let at = [
f64::from(pos[0]) + 0.5 + dx,
f64::from(pos[1]) + 0.4,
f64::from(pos[2]) + 0.5 + dz,
];
engine.create(&ParticleOptions::simple(T::Smoke), at, [0.0, 0.005, 0.0]);
}
}
#[cfg(test)]
+6
View File
@@ -28,6 +28,9 @@ pub struct BiomeInfo {
pub spawn_costs: HashMap<String, crate::mobs::spawn_selection::SpawnCost>,
/// The `gameplay/creature_world_gen_spawn_probability` attribute.
pub creature_probability: f32,
/// The `gameplay/increased_fire_burnout` attribute (jungles, swamps, peaks,
/// mushroom fields).
pub increased_fire_burnout: bool,
pub name: String,
pub temperature: f32,
pub downfall: f32,
@@ -128,6 +131,9 @@ impl Biomes {
attributes["minecraft:gameplay/creature_world_gen_spawn_probability"]
.as_f64()
.unwrap_or(0.1) as f32,
increased_fire_burnout: attributes["minecraft:gameplay/increased_fire_burnout"]
.as_bool()
.unwrap_or(false),
temperature,
downfall,
precipitation: v["has_precipitation"].as_bool().unwrap_or(true),
+4
View File
@@ -161,6 +161,9 @@ pub struct Chunk {
/// Structure starts reaching into this chunk (`structures.References`):
/// structure id and the start's chunk.
pub structure_refs: Vec<(Arc<str>, (i32, i32))>,
/// The starts generation made in this chunk (`structures.starts`), saved
/// with the state their placement has reached by then.
pub structure_starts: Vec<Arc<horizon_world::worldgen::structure::start::StructureStart>>,
}
impl Chunk {
pub fn empty(x: i32, z: i32) -> Self {
@@ -178,6 +181,7 @@ impl Chunk {
block_entities: Vec::new(),
block_names: Vec::new(),
structure_refs: Vec::new(),
structure_starts: Vec::new(),
postprocess: Vec::new(),
saved: None,
entities: Vec::new(),
+70 -10
View File
@@ -3,7 +3,12 @@
//! (`creative-inventory.json`), a 9x5 scrolling grid and the search tab.
use crate::{Result, items::ItemStack, ui::EditBox};
use serde_json::Value;
use std::{collections::HashMap, fs, path::Path};
use std::{
collections::HashMap,
fs,
path::Path,
sync::{Arc, Mutex},
};
#[derive(Clone, Debug)]
pub struct Tab {
@@ -40,6 +45,17 @@ pub struct Creative {
/// The search tab's stacks, in its display order.
search_items: Vec<ItemStack>,
search_index: Vec<SearchEntry>,
/// ItemPickerMenu.items: what the grid shows, filled when the tab or the
/// search changes (selectTab, refreshSearchResults), not on every frame.
shown: Mutex<Option<Shown>>,
}
/// The grid's stacks for one tab, search text and language.
struct Shown {
tab: usize,
search: String,
/// A display name, so a language change refills the search results.
language: String,
items: Arc<[ItemStack]>,
}
/// Display names from the pinned en_us language file.
#[derive(Default)]
@@ -291,6 +307,7 @@ impl Creative {
},
search_items,
search_index,
shown: Mutex::new(None),
}
}
/// Where a stack comes in the creative tabs (the search tab lists them
@@ -303,6 +320,22 @@ impl Creative {
.position(|s| s.same_item_same_components(stack))
.or_else(|| items.iter().position(|s| s.item == stack.item))
}
/// The tabs without their stacks: what the screen draws of them each frame.
pub fn tab_headers(&self) -> Vec<Tab> {
self.tabs
.iter()
.map(|t| Tab {
id: t.id.clone(),
title: t.title.clone(),
icon: t.icon.clone(),
items: Vec::new(),
top: t.top,
column: t.column,
search: t.search,
inventory: t.inventory,
})
.collect()
}
pub fn tab(&self) -> &Tab {
&self.tabs[self.selected.min(self.tabs.len() - 1)]
}
@@ -384,17 +417,44 @@ impl Creative {
})
.collect()
}
/// Items shown by the selected tab (the search tab filters by `search`).
pub fn items(&self, names: &Names) -> Vec<ItemStack> {
let tab = self.tab();
if tab.search {
return self
.search_results(&self.search.value, names)
/// Items shown by the selected tab (the search tab filters by `search`),
/// worked out again only when the tab, the search or the language changed.
pub fn items(&self, names: &Names) -> Arc<[ItemStack]> {
let tab = self.selected.min(self.tabs.len() - 1);
let search = if self.tab().search {
self.search.value.as_str()
} else {
""
};
let language = names.display_get("itemGroup.search").unwrap_or_default();
let mut shown = self.shown.lock().unwrap_or_else(|e| e.into_inner());
if let Some(s) = shown.as_ref()
&& s.tab == tab
&& s.search == search
&& s.language == language
{
return s.items.clone();
}
let items: Arc<[ItemStack]> = if self.tab().search {
self.search_results(search, names)
.into_iter()
.map(|i| self.search_items[i].clone())
.collect();
}
tab.items.clone()
.collect()
} else {
self.tab().items.as_slice().into()
};
*shown = Some(Shown {
tab,
search: search.to_owned(),
language,
items: items.clone(),
});
items
}
/// Drop the shown items, so the next `items` works them out again: a
/// reload can rename items without changing the Search label.
pub fn forget_items(&self) {
*self.shown.lock().unwrap_or_else(|e| e.into_inner()) = None;
}
pub fn rows(&self, names: &Names) -> usize {
self.items(names).len().div_ceil(9)
+12 -1
View File
@@ -166,12 +166,23 @@ fn linear_fog(d: f32, start: f32, end: f32) -> f32 {
if d >= end { return 1.0; }
return (d - start) / (end - start);
}
// CRUMBLING_UV_BEGIN
fn crumbling_uv(uv: vec2<f32>, packed_origin: vec2<f32>, sprite_size: vec2<f32>, dimensions: vec2<u32>) -> vec2<f32> {
let size = vec2<f32>(dimensions);
// Undo stage_light's 16-bit quantization before adding local texels. Otherwise
// different stages can fall on opposite sides of a nearest-sampling boundary.
let origin = round(packed_origin * size);
let extent = max(round(sprite_size * size), vec2(1.0));
let local = min(floor(fract(uv) * extent), extent - vec2(1.0));
return (origin + local + vec2(0.5)) / size;
}
// CRUMBLING_UV_END
@fragment fn fs_entity(in: Out, @builtin(front_facing) front: bool) -> @location(0) vec4<f32> {
var uv = in.uv;
if in.overlay == 6u { uv.y = in.wrap.x + fract(in.uv.y) * in.wrap.y; }
if in.overlay == 11u || in.overlay == 12u {
// Each crack's stage sprite origin comes in its light bytes (breaking::stage_light).
uv = in.wrap + fract(in.uv) * globals.crumbling_sprite.zw;
uv = crumbling_uv(in.uv, in.wrap, globals.crumbling_sprite.zw, textureDimensions(atlas));
}
var color = textureSample(atlas, atlas_sampler, uv) * select(in.back_color, in.color, front);
if in.overlay == 9u || in.overlay == 10u {
+184 -56
View File
@@ -19,6 +19,7 @@ mod books;
mod bossbars;
mod breezes;
mod campfire;
mod campfire_douse;
mod cartography;
mod chat;
mod client_ambient;
@@ -556,6 +557,11 @@ struct GameLevel<'w> {
loot: Option<std::sync::Arc<horizon_world::loot::LootData>>,
registry: &'static horizon_world::registry::BlockRegistry,
rules: horizon_world::level::GameRules,
/// `Difficulty.getId()` (peaceful 0 .. hard 3).
difficulty: i32,
/// The feet of the players who keep fire spreading around them: the local
/// player and the LAN guests in this level, spectators aside.
fire_players: Vec<[f64; 3]>,
/// Block loot random sequences (`ServerLevel.getRandomSequence`).
rand_sequences: &'w mut horizon_client::random_sequences::RandomSequences,
pending: &'w mut spill::Pending,
@@ -565,6 +571,9 @@ struct GameLevel<'w> {
random_tick_sections: &'w mut horizon_client::random_tick_sections::RandomTickSections,
/// Players with each chest open (a trapped chest's signal).
lid_openers: &'w HashMap<(i32, i32, i32), i32>,
/// `Level.isClientSide`: a client's copy of the level (a guest of a LAN
/// world, a window on its server thread, a dedicated server's client).
client_side: bool,
}
struct PlayerCollision {
y: f64,
@@ -654,6 +663,9 @@ impl GameLevel<'_> {
}
}
impl LevelAccess for GameLevel<'_> {
fn is_client_side(&self) -> bool {
self.client_side
}
fn world_surface(&self, x: i32, z: i32) -> i32 {
self.world.world_surface(x, z, |s| self.facts.is_air(s))
}
@@ -719,6 +731,34 @@ impl LevelAccess for GameLevel<'_> {
fn game_rules(&self) -> horizon_world::level::GameRules {
self.rules
}
fn difficulty(&self) -> i32 {
self.difficulty
}
/// `ServerLevel.canSpreadFireAround`: a player (not a spectator) closer
/// than the `fire_spread_radius_around_player` rule to the block's corner
/// (`ChunkMap.anyPlayerCloseEnoughTo`); -1 spreads everywhere.
fn can_spread_fire_around(&self, pos: BlockPos) -> bool {
let radius = self.rules.fire_spread_radius_around_player;
radius == -1
|| self.fire_players.iter().any(|p| {
let d = [
p[0] - f64::from(pos.x),
p[1] - f64::from(pos.y),
p[2] - f64::from(pos.z),
];
d[0] * d[0] + d[1] * d[1] + d[2] * d[2] < f64::from(radius) * f64::from(radius)
})
}
fn increased_fire_burnout(&self, pos: BlockPos) -> bool {
self.world
.chunks
.get(&(pos.x >> 4, pos.z >> 4))
.is_some_and(|chunk| {
self.biomes
.get(chunk.biome(pos.x, pos.y, pos.z))
.increased_fire_burnout
})
}
fn block_state(&self, pos: BlockPos) -> BlockStateId {
self.world.block(pos.x, pos.y, pos.z)
}
@@ -857,7 +897,16 @@ impl LevelAccess for GameLevel<'_> {
fn emit(&mut self, event: LevelEvent) {
match event {
// Block.dropResources draws its loot and spots right away.
LevelEvent::DropResources { pos, state } => self.drop_resources_now(pos, state),
// `popResource` only drops server-side (`!level.isClientSide`):
// a client's moveBlocks (a piston's block event) skips it.
LevelEvent::DropResources { pos, state } if !self.client_side => {
self.drop_resources_now(pos, state)
}
LevelEvent::DropResources { .. } => {}
// `ClientLevel.playSeededSound` plays only when the excluded
// player is this client's own: the block's `playSound(null, ...)`
// (a piston's move, a note) reaches it from the server instead.
LevelEvent::Sound { .. } if self.client_side => {}
// Block.popResource of the block's own item (tall flowers, flower beds).
LevelEvent::BlockEvent {
pos,
@@ -1270,6 +1319,13 @@ struct Game<'a> {
/// Controllers (Horizon's own).
pad: pad::Pads,
frames: u64,
/// `item_level` for one frame, game time, day time and dimension.
item_level_cache: std::cell::RefCell<
Option<(
(u64, i64, i64, &'static str),
horizon_assets::item_model::LevelState,
)>,
>,
/// Frames drawn with the player in the world, for `--world-frames`.
world_frames: u64,
fps: f32,
@@ -2462,6 +2518,19 @@ impl Game<'_> {
Dim::End => LevelDimension::End,
};
let rules = self.level_rules();
let fire_players: Vec<[f64; 3]> = self
.player
.iter()
.filter(|_| !spectator)
.map(|p| [p.position.x, p.position.y, p.position.z])
.chain(self.lan.iter().flat_map(|h| {
h.hub
.guests
.iter()
.filter(|g| h.hub.here(g) && !g.spectator())
.map(|g| g.position)
}))
.collect();
let blockers = Blockers {
mobs: &self.mobs,
armor_stands: &self.armor_stands,
@@ -2492,12 +2561,15 @@ impl Game<'_> {
loot: horizon_client::structure_blocks::loot_data(&self.options.resources),
registry: self.pack.assets.registry,
rules,
difficulty: i32::from(self.ui.difficulty),
fire_players,
rand_sequences: &mut self.rand_sequences,
pending: &mut self.pending,
spill: &mut self.spill,
tickets: self.tickets.levels(),
random_tick_sections: &mut self.random_tick_sections,
lid_openers: &self.lid_openers,
client_side: self.remote.is_some(),
};
let result = f(self.res.behaviours(), &mut level, &mut self.block_ticks);
self.flush_spill();
@@ -2559,14 +2631,15 @@ impl Game<'_> {
(p.position.z.floor() as i32) >> 4,
));
}
let sources = (self.options.dedicated.is_some() || !players.is_empty()).then(|| {
horizon_client::ticket_levels::Sources {
players,
forced: self.streaming.forced.iter().copied().collect(),
view_distance: self.settings.render_distance.clamp(2, 32),
simulation_distance: self.settings.simulation_distance.max(2),
}
});
let sources =
(!self.options.execution_role.is_client() || !players.is_empty()).then(|| {
horizon_client::ticket_levels::Sources {
players,
forced: self.streaming.forced.iter().copied().collect(),
view_distance: self.settings.render_distance.clamp(2, 32),
simulation_distance: self.settings.simulation_distance.max(2),
}
});
self.tickets.update(sources);
}
/// `ServerLevel.startTickingChunk`: a loaded chunk's saved ticks are
@@ -3346,6 +3419,13 @@ impl Game<'_> {
self.with_player_actor(|game| game.break_block_as_player(pos))
}
fn break_block_as_player(&mut self, pos: [i32; 3]) -> Result<()> {
if !self
.res
.items()
.can_destroy_block(self.held(), self.creative)
{
return Ok(());
}
self.pad_rumble(horizon_client::gamepad::Rumble::BlockBroken);
// ServerPlayerGameMode.destroyBlock.
let state = self.world.block(pos[0], pos[1], pos[2]);
@@ -3554,6 +3634,10 @@ impl Game<'_> {
self.swing_arm();
return Ok(true);
}
if self.use_campfire_douse(hit.pos, held.as_ref())? {
self.swing_arm();
return Ok(true);
}
match self.use_respawn_anchor(hit.pos, held.as_ref())? {
respawn_anchor::AnchorUse::Charged => {
self.swing_arm();
@@ -3971,7 +4055,12 @@ impl Game<'_> {
return Ok(());
};
// GameType.isBlockPlacingRestricted: adventure and spectator.
if !self.may_build() {
if !self.may_build()
|| !self
.res
.items()
.can_destroy_block(self.held(), self.creative)
{
self.mining = None;
return Ok(());
}
@@ -4245,8 +4334,9 @@ impl Game<'_> {
if running {
self.world.game_time += 1;
if self.ui.rules.bool("advance_weather") {
self.world.weather.tick(&mut self.world.random);
self.world.weather.tick_timers(&mut self.world.random);
}
self.world.weather.tick_levels();
self.tick_weather_effects();
}
if self.ticks.is_multiple_of(6000) {
@@ -8054,6 +8144,7 @@ impl Game<'_> {
/// (scaled coordinates, or the End portal's fixed destination).
pub(super) fn switch_level(&mut self, target: Dim, from: Vec3) -> Result<Vec3> {
// Save the departing dimension's changed chunks.
self.save.save_forced_chunks(&self.streaming.forced)?;
self.streaming.save(&mut self.world, true);
let keys: Vec<_> = self.world.modified.drain().collect();
let chunks = self.with_entities(keys.iter().filter_map(|k| self.world.chunks.get(k)));
@@ -9651,8 +9742,9 @@ impl Game<'_> {
rain.extend(snow_vertices);
rain
}
/// Player.startSleepInBed: night or thunder, no monsters within 8 blocks;
/// the bed becomes the respawn point either way.
/// ServerPlayer.startSleepInBed: in reach, not obstructed, night or thunder,
/// and (outside creative) no monsters within 8 blocks; the bed becomes the
/// respawn point either way.
fn try_sleep(&mut self, pos: [i32; 3]) {
let Some(p) = self.player.as_ref() else {
return;
@@ -9667,22 +9759,40 @@ impl Game<'_> {
.state(state)
.map(|s| s.properties.clone())
.unwrap_or_default();
let head = if props.get("part").map(String::as_str) == Some("foot") {
let d = facing_offset(props.get("facing").map_or("north", String::as_str));
[pos[0] + d[0], pos[1], pos[2] + d[2]]
// The head and the foot: the block faces from the foot to the head.
let d = facing_offset(props.get("facing").map_or("north", String::as_str));
let (head, foot) = if props.get("part").map(String::as_str) == Some("foot") {
([pos[0] + d[0], pos[1], pos[2] + d[2]], pos)
} else {
pos
(pos, [pos[0] - d[0], pos[1], pos[2] - d[2]])
};
let dist = ((p.position.x - f64::from(head[0]) - 0.5).powi(2)
+ (p.position.z - f64::from(head[2]) - 0.5).powi(2))
.sqrt();
if dist > 3.0 {
// ServerPlayer.bedInRange / isReachableBedBlock: within 3 blocks
// sideways and 2 vertically of the head or the foot.
let reachable = |bed: [i32; 3]| {
(p.position.x - f64::from(bed[0]) - 0.5).abs() <= 3.0
&& (p.position.y - f64::from(bed[1])).abs() <= 2.0
&& (p.position.z - f64::from(bed[2]) - 0.5).abs() <= 3.0
};
if !reachable(head) && !reachable(foot) {
self.message = Some((
"You may not rest now; the bed is too far away".into(),
Instant::now(),
));
return;
}
// ServerPlayer.bedBlocked: Player.freeAt (not suffocating) for the
// blocks above the head and the foot, as `in_wall` reads suffocating.
let blocked = [head, foot].iter().any(|bed| {
let f = self
.res
.facts()
.get(self.world.block(bed[0], bed[1] + 1, bed[2]));
f.full_collision && f.solid_render
});
if blocked {
self.message = Some(("This bed is obstructed".into(), Instant::now()));
return;
}
// In reach (bedInRange), day or night: setRespawnPosition(
// RespawnConfig(the head, the player's yaw), not forced). It replaces
// an anchor or a spawnpoint; respawning checks the bed is still there
@@ -9712,13 +9822,15 @@ impl Game<'_> {
));
return;
}
let monsters = self.mobs.iter().any(|m| {
m.hostile()
&& m.alive()
&& (m.position.x - p.position.x).abs() < 8.0
&& (m.position.y - p.position.y).abs() < 5.0
&& (m.position.z - p.position.z).abs() < 8.0
});
// Creative players skip the monster check (`!this.isCreative()`).
let monsters = !self.creative
&& self.mobs.iter().any(|m| {
m.hostile()
&& m.alive()
&& (m.position.x - p.position.x).abs() < 8.0
&& (m.position.y - p.position.y).abs() < 5.0
&& (m.position.z - p.position.z).abs() < 8.0
});
if monsters {
self.message = Some((
"You may not rest now; there are monsters nearby".into(),
@@ -9768,14 +9880,20 @@ impl Game<'_> {
}
}
// ServerLevel.tick: once enough of the players have slept 100 ticks,
// skip to morning, clear the weather and wake everyone.
// skip to morning if `advance_time` is on, wake everyone, and
// resetWeatherCycle if `advance_weather` is on and it is raining.
if self.enough_slept() {
let t = self.world.day_time;
self.world.day_time = t + 24000 - t.rem_euclid(24000);
self.world.weather.raining = false;
self.world.weather.thundering = false;
self.world.weather.rain_time = 0;
self.world.weather.thunder_time = 0;
if self.ui.rules.bool("advance_time") {
let t = self.world.day_time;
self.world.day_time = t + 24000 - t.rem_euclid(24000);
}
// Level.isRaining: the rain level is above 0.2.
if self.ui.rules.bool("advance_weather") && self.world.weather.levels(1.0).0 > 0.2 {
self.world.weather.raining = false;
self.world.weather.thundering = false;
self.world.weather.rain_time = 0;
self.world.weather.thunder_time = 0;
}
if self.sleeping.is_some() {
self.wake_up();
}
@@ -9803,8 +9921,9 @@ impl Game<'_> {
fn draw_creative(&mut self, batch: &mut HudBatch) {
let (ox, oy) = Self::creative_origin(batch);
let selected = self.creative_screen.selected;
let tabs = self.creative_screen.tabs.clone();
let tab = self.creative_screen.tab().clone();
// Only what is drawn of the tabs: their stacks stay where they are.
let tabs = self.creative_screen.tab_headers();
let tab = tabs[selected.min(tabs.len() - 1)].clone();
let tab_sprite = |t: &horizon_client::creative::Tab, on: bool| {
format!(
"gui/sprites/container/creative_inventory/tab_{}_{}_{}",
@@ -10192,6 +10311,7 @@ impl Game<'_> {
}
/// Save the level in place: its changed chunks, its entities and its raids.
fn save_level(&mut self, wait: bool) -> Result<()> {
self.save.save_forced_chunks(&self.streaming.forced)?;
self.streaming.save(&mut self.world, wait);
let keys: Vec<_> = self.world.modified.drain().collect();
let chunks = self.with_entities(keys.iter().filter_map(|k| self.world.chunks.get(k)));
@@ -11582,16 +11702,17 @@ impl ApplicationHandler for Game<'_> {
}
self.extras_mouse(button, pressed);
self.mod_mouse_key(button, pressed);
match self.settings.keys.translate_mouse(button) {
Some(Err(b)) => self.play_mouse(b, pressed)?,
Some(Ok(k)) if pressed => {
self.keys.insert(k);
self.key_pressed(k)?;
for mapped in self.settings.keys.translate_mouse_all(button) {
match mapped {
Err(b) => self.play_mouse(b, pressed)?,
Ok(k) if pressed => {
self.keys.insert(k);
self.key_pressed(k)?;
}
Ok(k) => {
self.keys.remove(&k);
}
}
Some(Ok(k)) => {
self.keys.remove(&k);
}
None => {}
}
}
Screen::Inventory | Screen::Creative
@@ -11839,18 +11960,24 @@ impl ApplicationHandler for Game<'_> {
{
self.smooth_camera = !self.smooth_camera;
}
// Key binds: act as the default key of the bound mapping.
let key = match self.settings.keys.translate(key) {
Some(Ok(k)) => k,
Some(Err(button)) => {
if self.screen == Screen::Playing && !event.repeat {
self.play_mouse(button, event.state == ElementState::Pressed)?;
// Key binds: act as the default key of every mapping bound
// to it (KeyMapping.set drives them all).
for mapped in self.settings.keys.translate_all(key) {
match mapped {
Ok(k) => {
self.mapped_key(
k,
event.state == ElementState::Pressed,
event.repeat,
)?;
}
Err(button) => {
if self.screen == Screen::Playing && !event.repeat {
self.play_mouse(button, event.state == ElementState::Pressed)?;
}
}
return Ok(());
}
None => return Ok(()),
};
self.mapped_key(key, event.state == ElementState::Pressed, event.repeat)?;
}
}
WindowEvent::RedrawRequested => {
self.observe_fullscreen();
@@ -12264,6 +12391,7 @@ impl<'a> Game<'a> {
last_input: now,
pad: Default::default(),
frames: 0,
item_level_cache: Default::default(),
world_frames: 0,
fps: 0.0,
fps_frames: 0,
+1 -1
View File
@@ -55,7 +55,7 @@ pub(super) struct Boat {
/// `ChestBoat.CONTAINER_SIZE`.
pub(super) const CHEST_SLOTS: usize = 27;
pub(super) const WIDTH: f64 = 1.375;
const HEIGHT: f64 = 0.5625;
pub(super) const HEIGHT: f64 = 0.5625;
impl Boat {
pub fn new(id: u32, wood: Arc<str>, position: Vec3, yaw: f32) -> Self {
Self {
@@ -0,0 +1,53 @@
//! `CampfireBlock.useItemOn`: an item in `#minecraft:douses_campfires` (the
//! shovels) put on a lit campfire puts it out.
use super::Game;
use horizon_client::{Result, animate_tick::make_campfire_smoke, items::ItemStack};
impl Game<'_> {
/// `CampfireBlock.useItemOn` with `held` in the hand that acts, the block
/// at `pos` clicked: true when it doused a lit campfire (SUCCESS).
pub(super) fn use_campfire_douse(
&mut self,
pos: [i32; 3],
held: Option<&ItemStack>,
) -> Result<bool> {
let Some(tool) = held.filter(|s| {
self.res
.items()
.item_tags
.get("minecraft:douses_campfires")
.is_some_and(|members| members.contains(&*s.item))
}) else {
return Ok(false);
};
let registry = self.pack.assets.registry;
let Some(mut state) = registry
.state(self.world.block(pos[0], pos[1], pos[2]))
.filter(|s| matches!(&*s.name, "minecraft:campfire" | "minecraft:soul_campfire"))
.filter(|s| s.properties.get("lit").map(String::as_str) == Some("true"))
.cloned()
else {
return Ok(false);
};
// Level event 1009 (SOUND_EXTINGUISH_FIRE) goes to every player.
self.level_event_at(1009, pos, 0);
// CampfireBlock.douse: the acting client's own smoke (twenty puffs),
// then the block change game event from the player.
if self.lan_acting.is_none() {
let signal = state.properties.get("signal_fire").map(String::as_str) == Some("true");
for _ in 0..20 {
make_campfire_smoke(&mut self.particles, pos, signal, true);
}
}
self.post_block_game_event("minecraft:block_change", pos, self.sculk_actor(), None);
state.properties.insert("lit".into(), "false".into());
if let Ok(unlit) = registry.resolve(&state) {
self.level_set(pos, unlit)?;
}
let used = tool.clone();
self.damage_held(1);
// ServerPlayerGameMode.useItemOn: the action was consumed.
self.trigger_block("minecraft:item_used_on_block", pos, &used);
Ok(true)
}
}
+1
View File
@@ -782,6 +782,7 @@ impl Game<'_> {
max_snow_accumulation_height: r.int("max_snow_accumulation_height"),
spawn_mobs: r.bool("spawn_mobs"),
spawn_monsters: r.bool("spawn_monsters"),
fire_spread_radius_around_player: r.int("fire_spread_radius_around_player"),
}
}
/// Apply rules mirrored in other state (natural regeneration, difficulty).
+77 -34
View File
@@ -1885,11 +1885,12 @@ impl Game<'_> {
}
}
}
let Some((_, new)) = horizon_client::save::decode_block_entity(&tag) else {
let Some((_, mut new)) = horizon_client::save::decode_block_entity(&tag) else {
return false;
};
let rolled = self.take_loot_table(key, &tag, &mut new);
let after = horizon_client::save::encode_block_entity(key, &new, &name);
if after == before {
if after == before && !rolled {
return false;
}
self.world.block_entities.insert(key, new);
@@ -1897,6 +1898,79 @@ impl Game<'_> {
let _ = self.world.set(pos[0], pos[1], pos[2], state);
true
}
/// RandomizableContainer.tryLoadLootTable for a tag a command gives a
/// container (`setblock`, `data`): an empty container with a `LootTable`
/// is rolled now, as a loaded chunk's are (`save::roll_loaded_loot`), and
/// the roll is kept with its chunk, so that the container saves, and
/// `data get` shows, its table and seed until its items change. A tag
/// without one drops what the container had. True when it had a table.
pub(super) fn take_loot_table(
&mut self,
key: (i32, i32, i32),
tag: &horizon_world::nbt::Tag,
entity: &mut horizon_client::containers::BlockEntity,
) -> bool {
use horizon_client::{containers::BlockEntity, save, structure_blocks};
let table = save::text(tag.get("LootTable"));
let rolled = match (table, entity) {
(Some(table), BlockEntity::Chest(slots)) if slots.iter().all(Option::is_none) => {
let loot = structure_blocks::loot_data(&self.options.resources);
let at = Vec3::new(f64::from(key.0), f64::from(key.1), f64::from(key.2));
let biome = self.biome_name_at(at).unwrap_or_default();
Some(structure_blocks::roll_container(
slots,
loot.as_deref(),
table,
structure_blocks::loot_seed(tag),
key,
&biome,
))
}
_ => None,
};
let Some(chunk) = self.world.chunks.get_mut(&(key.0 >> 4, key.2 >> 4)) else {
return false;
};
let held = |c: &horizon_client::chunk::Chunk| {
c.saved
.as_ref()
.is_some_and(|s| s.rolled_loot.iter().any(|r| r.pos == key))
};
if rolled.is_none() && !held(chunk) {
return false;
}
let chunk = std::sync::Arc::make_mut(chunk);
let mut extras = chunk
.saved
.take()
.map(std::sync::Arc::unwrap_or_clone)
.unwrap_or_default();
extras.rolled_loot.retain(|r| r.pos != key);
let taken = rolled.is_some();
extras.rolled_loot.extend(rolled);
chunk.saved = Some(std::sync::Arc::new(extras));
taken
}
/// The loot table and seed a container still holding its roll saves
/// (`RolledLoot::restore`), in place of its items.
pub(super) fn restore_loot_table(
&self,
key: (i32, i32, i32),
tag: &mut horizon_world::nbt::Tag,
) {
let horizon_world::nbt::Tag::Compound(map) = tag else {
return;
};
let rolled = self
.world
.chunks
.get(&(key.0 >> 4, key.2 >> 4))
.and_then(|c| c.saved.as_ref())
.and_then(|s| s.rolled_loot.iter().find(|r| r.pos == key));
if let Some(r) = rolled {
r.restore(map);
}
}
fn cmd_fill(&mut self, ctx: &Context) -> R<i32> {
let a = self.block_pos_arg(ctx, "from")?;
let b = self.block_pos_arg(ctx, "to")?;
@@ -2593,38 +2667,7 @@ impl Game<'_> {
/// `PoiTypes`: the point of interest type of a block state.
fn poi_type(&self, state: horizon_world::registry::BlockStateId) -> Option<&'static str> {
let s = self.pack.assets.registry.state(state)?;
let name = s.name.strip_prefix("minecraft:")?;
Some(match name {
"blast_furnace" => "minecraft:armorer",
"smoker" => "minecraft:butcher",
"cartography_table" => "minecraft:cartographer",
"brewing_stand" => "minecraft:cleric",
"composter" => "minecraft:farmer",
"barrel" => "minecraft:fisherman",
"fletching_table" => "minecraft:fletcher",
"cauldron" | "lava_cauldron" | "water_cauldron" | "powder_snow_cauldron" => {
"minecraft:leatherworker"
}
"lectern" => "minecraft:librarian",
"stonecutter" => "minecraft:mason",
"loom" => "minecraft:shepherd",
"smithing_table" => "minecraft:toolsmith",
"grindstone" => "minecraft:weaponsmith",
"bell" => "minecraft:meeting",
"beehive" => "minecraft:beehive",
"bee_nest" => "minecraft:bee_nest",
"nether_portal" => "minecraft:nether_portal",
"lodestone" => "minecraft:lodestone",
"test_instance_block" => "minecraft:test_instance",
n if n.ends_with("_bed") => {
if s.properties.get("part").map(String::as_str) != Some("head") {
return None;
}
"minecraft:home"
}
n if n.ends_with("lightning_rod") => "minecraft:lightning_rod",
_ => return None,
})
horizon_client::save::poi_type(s).map(|(kind, _)| kind)
}
/// `LocateCommand.locatePoi`: `PoiManager.findClosestWithType` within
/// 256 blocks of the source, over the loaded chunks.
@@ -286,6 +286,7 @@ impl Game<'_> {
if let Tag::Compound(c) = &mut tag {
c.remove(&"keepPacked".into());
}
self.restore_loot_table(key, &mut tag);
Ok(tag)
}
DataRef::Storage(id) => Ok(self.score.storage.get(id)),
@@ -296,7 +297,8 @@ impl Game<'_> {
DataRef::Entity(t) => self.apply_entity_nbt(*t, tag),
DataRef::Block(p) => {
let key = (p[0], p[1], p[2]);
if let Some((_, new)) = save::decode_block_entity(&tag) {
if let Some((_, mut new)) = save::decode_block_entity(&tag) {
self.take_loot_table(key, &tag, &mut new);
self.world.block_entities.insert(key, new);
let state = self.world.block(p[0], p[1], p[2]);
// Redraw what shows the block entity (signs, banners).
@@ -49,26 +49,12 @@ impl Game<'_> {
let mut tracker = Tracker::default();
for &t in &targets {
let h = holder(self.target_name(t));
// ServerRecipeBook.addRecipes/removeRecipes count recipe book
// displays (a transmute recipe has one per material count).
let mut n = 0;
// ServerPlayer.awardRecipes / resetRecipes: the displays changed.
let index = &self.res.stations().recipe_index;
n += if give {
let (count, unlocked) = self.level.recipe_book.add_recipes(index, &recipes);
self.recipe_unlocked(&unlocked);
count as i32
} else {
let removed = self.level.recipe_book.remove_recipes(index, &recipes);
let count = removed.len() as i32;
// A network player's client drops them from its book.
if self.lan_acting.is_some() && !removed.is_empty() {
self.guest_packets.push(C::RecipeBookRemove(
horizon_protocol::packets::recipes::RecipeBookRemove { ids: removed },
));
}
count
};
// ServerPlayer.awardRecipes / resetRecipes on the target's own
// ServerRecipeBook: a guest's is swapped in for the call, and the
// packets go to its client.
let n = self
.for_player(t, |g| g.change_recipes(give, &recipes))
.unwrap_or(0);
tracker.track(&h, n);
}
let verb = if give { "give" } else { "take" };
@@ -92,6 +78,27 @@ impl Game<'_> {
Ok(total)
}
/// ServerRecipeBook.addRecipes/removeRecipes for the player in place: how
/// many recipe book displays changed (a transmute recipe has one per
/// material count).
fn change_recipes(&mut self, give: bool, recipes: &[String]) -> i32 {
let index = &self.res.stations().recipe_index;
if give {
let (count, unlocked) = self.level.recipe_book.add_recipes(index, recipes);
self.recipe_unlocked(&unlocked);
return count as i32;
}
let removed = self.level.recipe_book.remove_recipes(index, recipes);
let count = removed.len() as i32;
// A network player's client drops them from its book.
if self.lan_acting.is_some() && !removed.is_empty() {
self.guest_packets.push(C::RecipeBookRemove(
horizon_protocol::packets::recipes::RecipeBookRemove { ids: removed },
));
}
count
}
// ---- /damage ----------------------------------------------------------------
fn damage_kind(ty: &str) -> DamageKind {
+15 -3
View File
@@ -841,6 +841,7 @@ impl Game<'_> {
pub(super) fn handle_mob_events(&mut self, events: Vec<MobEvent>) -> Result<()> {
for e in events {
match e {
MobEvent::SquidCycle { mob, at } => self.lan_entity_event(mob as i32, 19, at),
MobEvent::RabbitHop { mob, at } => self.lan_entity_event(mob as i32, 1, at),
MobEvent::Sound(name, at, volume, pitch)
| MobEvent::LocalSound(name, at, volume, pitch) => {
@@ -1066,7 +1067,10 @@ impl Game<'_> {
}
MobEvent::BreakBlock { pos, drop } => {
let old = self.world.block(pos[0], pos[1], pos[2]);
self.destroy_particles(pos, old);
// Block.spawnDestroyParticles: fire breaks leave nothing.
if !self.res.facts().get(old).has_class("BaseFireBlock") {
self.destroy_particles(pos, old);
}
if drop {
let drops = self.res.items().block_drops_in(
self.pack.assets.registry,
@@ -1516,15 +1520,19 @@ impl Game<'_> {
if let Some(a) = attacker.or(owner) {
self.note_attacker(damage_type, a);
}
// DamageSource.scalesWithDifficulty: a mob (or its projectile's
// owner) caused the hit, not a player.
let living_cause = attacker.or(owner).is_some();
let mut outcome = horizon_client::health::BlockOutcome::default();
if let Some(player) = self.player.as_mut()
&& let Some(id) = self.motion.health.damage.id(damage_type)
&& let Ok(o) = player.hurt_blocking(
&& let Ok(o) = player.hurt_caused_by(
&self.motion.health,
self.world.health_rules,
id,
amount,
blocking,
living_cause,
)
&& {
outcome = o;
@@ -1543,7 +1551,10 @@ impl Game<'_> {
}
}
self.hurt_armor(amount);
self.thorns(from);
// The thorns hurt the entity that caused the hit, if there is one.
if let Some(a) = attacker.or(owner) {
self.thorns(a);
}
}
if raised {
self.after_block(outcome, amount, attacker, projectile);
@@ -3298,6 +3309,7 @@ fn to_loot(stack: &ItemStack) -> horizon_world::loot::LootStack {
map: false,
exploration: None,
ominous_amplifier: None,
stew_effects: Vec::new(),
}
}
/// A generated loot stack as a Horizon item stack.
@@ -725,6 +725,11 @@ impl Game<'_> {
match self.world.block_entities.get_mut(&p) {
Some(BlockEntity::Jukebox(j)) => j.record = stack,
Some(BlockEntity::Lectern(l)) => l.book = stack,
Some(BlockEntity::Campfire(c)) => {
if let Some(cell) = c.items.get_mut(slot) {
*cell = stack;
}
}
Some(e) => {
if let Some(cell) = e.slots_mut().get_mut(slot) {
*cell = stack;
+69 -13
View File
@@ -1560,6 +1560,7 @@ impl Game<'_> {
y -= 1.0;
}
self.breath_clouds.push(Breath {
id: 0,
at: [x, y.floor() + 1.0, z],
radius: 5.0,
age: 0,
@@ -1686,10 +1687,12 @@ impl Game<'_> {
.play("minecraft:entity.ender_dragon.growl", Some(at), 2.5, pitch);
}
}
// checkCrystals: heal 1 every 10 ticks from the nearest crystal within 32.
// checkCrystals: heal 1 every 10 ticks from the nearest crystal, found
// among those touching the dragon's box grown by 32.
let d = self.dragon.as_mut().expect("dragon");
if d.nearest_crystal.is_some() && d.ticks % 10 == 0 && d.health < MAX_HEALTH {
d.health += 1.0;
// LivingEntity.setHealth clamps to the maximum.
d.health = (d.health + 1.0).min(MAX_HEALTH);
}
if d.random.next_int_bound(10).unwrap_or(1) == 0 {
let p = d.position;
@@ -2022,6 +2025,7 @@ impl Game<'_> {
// assignDirectionalMovement: the direction times the acceleration power 0.1.
let velocity = Vec3::new(v.x * 0.1, v.y * 0.1, v.z * 0.1);
self.dragon_fireballs.push(DragonFireball {
id: 0,
position: at,
previous: at,
velocity,
@@ -2068,6 +2072,7 @@ impl Game<'_> {
});
if hit_block || hit_player || b.age > 600 {
self.breath_clouds.push(Breath {
id: 0,
at: [next.x, next.y, next.z],
radius: 3.0,
age: 0,
@@ -2197,15 +2202,58 @@ impl Game<'_> {
}
}
pub(super) struct DragonFireball {
position: Vec3,
previous: Vec3,
velocity: Vec3,
/// Its entity id once a server's players track it (0 until then).
pub(super) id: u32,
pub(super) position: Vec3,
pub(super) previous: Vec3,
pub(super) velocity: Vec3,
age: i32,
}
impl DragonFireball {
/// One a server sent (`handleAddEntity`).
pub(super) fn remote(id: u32, at: Vec3, velocity: Vec3) -> Self {
Self {
id,
position: at,
previous: at,
velocity,
age: 0,
}
}
/// `AbstractHurtingProjectile.tick` on a server's client: `applyInertia`
/// first (power 0.1, inertia 0.95 or 0.8 in water), then the move; only
/// the server decides what it hits.
pub(super) fn remote_step(&mut self, water: bool) {
self.previous = self.position;
self.age += 1;
let v = self.velocity;
// Vec3.normalize: a vector shorter than 1e-5 stays zero.
let l = (v.x * v.x + v.y * v.y + v.z * v.z).sqrt();
let n = if l < 1.0e-5 {
Vec3::new(0.0, 0.0, 0.0)
} else {
Vec3::new(v.x / l, v.y / l, v.z / l)
};
let inertia = f64::from(if water { 0.8f32 } else { 0.95f32 });
let v = Vec3::new(
(v.x + n.x * 0.1) * inertia,
(v.y + n.y * 0.1) * inertia,
(v.z + n.z * 0.1) * inertia,
);
self.velocity = v;
self.position = Vec3::new(
self.position.x + v.x,
self.position.y + v.y,
self.position.z + v.z,
);
}
}
pub(super) struct Breath {
at: [f64; 3],
radius: f32,
age: i32,
/// Its entity id once a server's players track it (0 until then).
pub(super) id: u32,
pub(super) at: [f64; 3],
pub(super) radius: f32,
pub(super) age: i32,
duration: i32,
from_dragon: bool,
}
@@ -3127,16 +3175,24 @@ impl Game<'_> {
let _ = self.activate_exit_portal();
self.fight.dirty = true;
}
/// `EnderDragon.checkCrystals`' search: the nearest crystal within 32
/// blocks on each axis.
/// `EnderDragon.checkCrystals`' search: the nearest crystal whose box
/// meets the dragon's box grown by 32.
fn nearest_crystal_to(&self, p: Vec3) -> Option<usize> {
// The dragon's 16 by 8 box.
let reach = Aabb::new(
Vec3::new(p.x - 8.0, p.y, p.z - 8.0),
Vec3::new(p.x + 8.0, p.y + 8.0, p.z + 8.0),
)
.inflate(Vec3::new(32.0, 32.0, 32.0));
self.crystals
.iter()
.enumerate()
.filter(|(_, c)| {
(c.position.x - p.x).abs() <= 32.0
&& (c.position.y - p.y).abs() <= 32.0
&& (c.position.z - p.z).abs() <= 32.0
// An end crystal's 2 by 2 box.
reach.intersects(Aabb::new(
Vec3::new(c.position.x - 1.0, c.position.y, c.position.z - 1.0),
Vec3::new(c.position.x + 1.0, c.position.y + 2.0, c.position.z + 1.0),
))
})
.min_by(|a, b| {
let da = (a.1.position.x - p.x).powi(2)
@@ -255,23 +255,26 @@ impl Game<'_> {
let _ = self.wind_burst(feet, radius, knockback, false, false);
}
}
/// Thorns on worn armor when a mob at `from` hits the player.
pub(super) fn thorns(&mut self, from: [f64; 3]) {
let attacker = self
.mobs
.iter()
.enumerate()
.filter(|(_, m)| m.alive())
.min_by(|a, b| {
let d = |m: &horizon_client::mobs::Mob| {
(m.position.x - from[0]).powi(2)
+ (m.position.y - from[1]).powi(2)
+ (m.position.z - from[2]).powi(2)
};
d(a.1).total_cmp(&d(b.1))
})
.map(|(i, _)| i);
let Some(attacker) = attacker else { return };
/// Thorns on worn armor when the mob `attacker` hits the player: the
/// `affected: attacker` effect of `Enchantment.doPostAttack` hurts the
/// entity that caused the hit.
pub(super) fn thorns(&mut self, attacker: u32) {
let Some(index) = self.mobs.iter().position(|m| m.id == attacker && m.alive()) else {
return;
};
for damage in self.thorns_damage() {
let m = &mut self.mobs[index];
m.hurt(damage, None, &mut self.world.random);
let center = [m.position.x, m.position.y, m.position.z];
self.audio
.play("minecraft:enchant.thorns.hit", Some(center), 1.0, 1.0);
}
}
/// The victim's side of thorns: for each worn piece whose chance comes up,
/// the damage to deal the attacker (`DamageEntity` min 1, max 5) and the
/// piece's wear (`ChangeItemDamage` 2).
pub(super) fn thorns_damage(&mut self) -> Vec<f32> {
let mut hits = Vec::new();
for slot in 0..4 {
let Some(stack) = self.armor[slot].clone() else {
continue;
@@ -299,15 +302,7 @@ impl Game<'_> {
if !ok {
continue;
}
// DamageEntity(min 1, max 5) and ChangeItemDamage(2).
let damage = 1.0 + self.world.random.next_float() * 4.0;
let center = {
let m = &mut self.mobs[attacker];
m.hurt(damage, None, &mut self.world.random);
[m.position.x, m.position.y, m.position.z]
};
self.audio
.play("minecraft:enchant.thorns.hit", Some(center), 1.0, 1.0);
hits.push(1.0 + self.world.random.next_float() * 4.0);
if let Some(s) = self.armor[slot].as_mut() {
s.damage += 2;
if self
@@ -320,6 +315,7 @@ impl Game<'_> {
}
}
}
hits
}
/// `ExperienceOrb.repairPlayerItems`: returns the experience left over.
pub(super) fn mending(&mut self, amount: u32) -> u32 {
+3 -1
View File
@@ -296,12 +296,14 @@ impl Game<'_> {
if !creative
&& !spectator
&& hurtable
&& let Ok(o) = p.hurt_blocking(
&& let Ok(o) = p.hurt_caused_by(
&self.motion.health,
self.world.health_rules,
id,
amount,
blocking,
// The exploding mob is the causing entity.
exploding.is_some(),
)
{
outcome = o;
+1 -1
View File
@@ -231,7 +231,7 @@ impl Game<'_> {
}
damage
}
fn biome_name_at(&self, p: Vec3) -> Option<String> {
pub(super) fn biome_name_at(&self, p: Vec3) -> Option<String> {
let (x, y, z) = (p.x.floor() as i32, p.y.floor() as i32, p.z.floor() as i32);
let c = self.world.chunks.get(&(x >> 4, z >> 4))?;
Some(
+15 -4
View File
@@ -355,12 +355,23 @@ impl Game<'_> {
* glam::DMat4::from_rotation_y(y_rot.to_radians());
let light = self.world.light(f.pos[0], f.pos[1], f.pos[2]);
let packed = (u32::from(light >> 4) * 16) | ((u32::from(light & 15) * 16) << 8);
// ItemFrameRenderer.getBlockLightLevel: a glow frame's model is at least 5 bright.
let frame_light = if f.glow {
(light & 0xF0) | (light & 15).max(5)
} else {
light
};
let frame_packed =
(u32::from(frame_light >> 4) * 16) | ((u32::from(frame_light & 15) * 16) << 8);
let mut quad =
|m: &glam::DMat4, corners: [[f64; 3]; 4], uv: [[f32; 2]; 4], light: u32| {
for (c, uv) in corners.iter().zip(uv) {
let p = m.transform_point3(glam::DVec3::from_array(*c));
// The frame model is drawn with the entity shader, lit by its face normal.
let at = corners.map(|c| m.transform_point3(glam::DVec3::from_array(c)));
let n = (at[1] - at[0]).cross(at[2] - at[0]).normalize_or_zero();
let normal = [0, 1, 2].map(|k| (n[k].clamp(-1.0, 1.0) * 127.0) as i8);
for (p, uv) in at.into_iter().zip(uv) {
out.push(Vertex {
normal: [0; 4],
normal: [normal[0], normal[1], normal[2], -127],
pos: p.as_vec3().to_array(),
uv,
layer: 0,
@@ -393,7 +404,7 @@ impl Game<'_> {
]
}),
map_uv(r, *uv),
packed,
frame_packed,
);
}
}
+19 -1
View File
@@ -196,13 +196,31 @@ impl Game<'_> {
skins: &self.skin_downloads,
}
}
/// The client level as item models read it.
/// The client level as item models read it: worked out once a frame (a
/// screen of items read the same level, its sky sampled once).
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 key = (
self.frames,
self.world.game_time,
self.world.day_time,
dimension,
);
let mut cached = self.item_level_cache.borrow_mut();
if let Some((at, level)) = cached.as_ref()
&& *at == key
{
return level.clone();
}
let level = self.level_state_now(dimension);
*cached = Some((key, level.clone()));
level
}
fn level_state_now(&self, dimension: &'static str) -> LevelState {
let sky = self
.dim_environment()
.sample(self.world.day_time, None, None);
+30 -11
View File
@@ -944,6 +944,13 @@ impl Game<'_> {
for m in &mut self.mobs {
m.needs_sync = false;
}
// `runBlockEvents`' broadcast precedes the tick's block changes
// (`ChunkHolder.broadcastChanges` in `tick_level` below): the
// guest re-runs a piston's `triggerEvent` while its level still
// has the blocks as they were before the move.
for (pos, b0, b1, state) in std::mem::take(&mut self.lan_block_events) {
host.hub.block_event(pos, b0, b1, state);
}
let entities = states.as_slice();
let spawn = self.spawn.map_or([0.5, 80.0, 0.5], |s| [s.x, s.y, s.z]);
let events = {
@@ -1177,17 +1184,29 @@ impl Game<'_> {
return;
}
for e in events {
if let horizon_world::level::LevelEvent::LevelEvent { id, pos, data } = e {
// The acting guest does not predict the egg's teleport
// particles, bone meal's growth, or anvil result sounds
// (these levelEvent calls name no excluded player).
let except = if matches!(*id, 2015 | 1505 | 1029 | 1030) {
None
} else {
self.lan_acting
};
self.lan_events
.push((*id, [pos.x, pos.y, pos.z], *data, except));
match e {
horizon_world::level::LevelEvent::LevelEvent { id, pos, data } => {
// The acting guest does not predict the egg's teleport
// particles, bone meal's growth, or anvil result sounds
// (these levelEvent calls name no excluded player).
let except = if matches!(*id, 2015 | 1505 | 1029 | 1030) {
None
} else {
self.lan_acting
};
self.lan_events
.push((*id, [pos.x, pos.y, pos.z], *data, except));
}
// `ServerLevel.runBlockEvents` broadcasts a handled block
// event (`ClientboundBlockEventPacket`): the guest re-runs
// the block's `triggerEvent` — a piston's `moveBlocks` is
// what animates the move there. `state` is the block the
// event named, read before it ran (the packet's `block`).
horizon_world::level::LevelEvent::BlockEntityEvent { pos, state, a, b } => {
self.lan_block_events
.push(([pos.x, pos.y, pos.z], *a, *b, state.0));
}
_ => {}
}
}
}
@@ -77,6 +77,10 @@ pub(super) struct GuestPlay {
last_inventory: u64,
/// `Entity.portalProcess` and `portalCooldown` (game/server_travel.rs).
pub(super) portal: horizon_client::nether_portal::PortalState,
/// `ServerPlayer.wonGame`: the End poem is showing, the player waits for
/// its client's PERFORM_RESPAWN (`respawn_requested`).
pub(super) won_game: bool,
pub(super) respawn_requested: bool,
/// `Player.takeXpDelay`: ticks until the next orb can be taken.
xp_delay: u32,
/// What last killed the guest outright (`/kill`): the death message's key.
@@ -1347,6 +1351,13 @@ impl Game<'_> {
self.guest_suggestions(host, conn, &payload);
}
S::PlaceRecipe(p) => self.lan_place_recipe(host, conn, &p),
// handleClientCommand: PERFORM_RESPAWN after the poem leaves the End
// (the next server tick sends the player on).
S::ClientCommand(c)
if c.action == 0 && host.play.get(&conn).is_some_and(|p| p.won_game) =>
{
host.play.entry(conn).or_default().respawn_requested = true;
}
S::ClientCommand(c) if c.action == 0 => self.guest_respawn(host, conn),
// ServerStatsCounter.sendStats.
S::ClientCommand(c) if c.action == 1 => {
@@ -289,6 +289,10 @@ impl Game<'_> {
self.bolts.iter_mut().for_each(|b| give(&mut b.id));
self.potions.thrown_ids().for_each(&mut give);
self.end.eye_ids().for_each(&mut give);
self.breath_clouds.iter_mut().for_each(|b| give(&mut b.id));
self.dragon_fireballs
.iter_mut()
.for_each(|b| give(&mut b.id));
}
/// Primed TNT, falling blocks, projectiles, tridents, firework rockets and
@@ -445,6 +449,35 @@ impl Game<'_> {
}
out.push(e);
}
// The dragon's breath clouds (AreaEffectCloud's DATA_RADIUS,
// DATA_WAITING for its 20-tick wait, and DATA_PARTICLE): the
// client draws them from these (`tick_remote_clouds`).
let breath = self.network_particle(
&horizon_client::particles::ParticleOptions::simple(
horizon_client::particles::ParticleType::DragonBreath,
),
None,
);
for b in self.dragon_fireballs.iter().filter(|b| b.id != 0) {
let p = b.position;
let mut e = state(b.id, "minecraft:dragon_fireball", at(p), 0.0, false);
e.velocity = [b.velocity.x, b.velocity.y, b.velocity.z];
out.push(e);
}
for b in self.breath_clouds.iter().filter(|b| b.id != 0) {
let mut e = state(b.id, "minecraft:area_effect_cloud", b.at, 0.0, false);
e.meta.push((8, serializer::FLOAT, Value::Float(b.radius)));
if b.age < 20 {
e.meta.push((9, serializer::BOOLEAN, Value::Bool(true)));
}
if let Some(p) = &breath {
e.meta
.push((10, serializer::PARTICLE, Value::Particle(p.clone())));
}
e.defaults
.push((9, serializer::BOOLEAN, Value::Bool(false)));
out.push(e);
}
out.extend(self.lan_dragon_state());
}
}
@@ -95,7 +95,11 @@ impl Game<'_> {
/// The options a server's particle stands for (None for a type or data
/// this client doesn't draw).
fn received_particle(&mut self, p: &Particle, at: [f64; 3]) -> Option<ParticleOptions> {
pub(super) fn received_particle(
&mut self,
p: &Particle,
at: [f64; 3],
) -> Option<ParticleOptions> {
let name = self
.remote
.as_ref()?
+19 -6
View File
@@ -245,14 +245,18 @@ impl Game<'_> {
if hurt && strength > 0.0 {
g.knock_back_player(f64::from(strength), xd, zd);
}
hurt
// The victim's thorns, dealt to the attacker below.
let thorns = if hurt { g.thorns_damage() } else { Vec::new() };
(hurt, thorns)
};
let hurt = self.ui.rules.bool("pvp")
&& harm
&& match victim {
let (hurt, thorns) = if self.ui.rules.bool("pvp") && harm {
match victim {
Victim::Host => land(self),
Victim::Guest(conn) => self.guest_struck_by(host, conn, land).unwrap_or(false),
};
Victim::Guest(conn) => self.guest_struck_by(host, conn, land).unwrap_or_default(),
}
} else {
Default::default()
};
if hurt && s.crit {
let target = match victim {
Victim::Host => self.player.as_ref().map(|p| (host.host_entity, p)),
@@ -272,6 +276,15 @@ impl Game<'_> {
);
}
}
// `Enchantment.doPostAttack`: thorns hurt the attacking player.
for damage in thorns {
match s.attacker {
None => self.hurt_player_by("minecraft:thorns", damage),
Some(conn) => {
self.guest_hit_by(host, conn, |g| g.hurt_player_by("minecraft:thorns", damage));
}
}
}
let sound = match (hurt, s.crit, s.scale > 0.9) {
(false, ..) => "minecraft:entity.player.attack.nodamage",
(true, true, _) => "minecraft:entity.player.attack.crit",
+16 -6
View File
@@ -3,8 +3,8 @@
//! The game's tick code reads one level: the one in place, held in `Game`'s
//! own fields (the world, its streaming and save, block ticks, entities).
//! A server (the dedicated server and single player's server thread) keeps
//! the other dimensions' levels here and ticks each one that has players by
//! swapping it into place (`enter_level`), the way `as_guest` swaps a player
//! the other dimensions' levels here and ticks each one with players or forced
//! chunks by swapping it into place (`enter_level`), the way `as_guest` swaps a player
//! in, so the tick code runs unchanged, one level at a time. The in-frame
//! game holds one level, which follows its player (`enter_dimension`).
//!
@@ -306,8 +306,8 @@ impl Game<'_> {
}
/// One server tick (`MinecraftServer.tickServer`): each level with
/// players in turn (`tickChildren`), its chunks streamed and loaded and
/// its world ticked. The first pass reads the network and runs the
/// players or forced chunks in turn (`tickChildren`), its chunks streamed
/// and loaded and its world ticked. The first pass reads the network and runs the
/// tick's shared parts (the clock, the weather, functions, the autosave);
/// the next ones only their level. While `paused`, only the connections
/// run.
@@ -411,7 +411,7 @@ impl Game<'_> {
/// The levels this server tick runs, in `MinecraftServer.levels` order
/// (the Overworld, the Nether, the End): every level with a player in
/// it or a `/pregen` task, or the Overworld while nobody is online.
/// it, forced chunks or a `/pregen` task, or the Overworld while idle.
pub(super) fn levels_to_tick(&self) -> Vec<Dim> {
let guests = self
.lan
@@ -420,7 +420,17 @@ impl Game<'_> {
let mut dims: Vec<Dim> = [Dim::Overworld, Dim::Nether, Dim::End]
.into_iter()
.filter(|d| {
guests.iter().any(|g| g.dimension == d.id()) || self.pregen_level() == Some(*d)
let forced = if *d == self.dim {
!self.streaming.forced.is_empty()
} else {
self.levels
.parked
.get(d)
.is_some_and(|p| !p.streaming.forced.is_empty())
};
guests.iter().any(|g| g.dimension == d.id())
|| forced
|| self.pregen_level() == Some(*d)
})
.collect();
if dims.is_empty() {
+13 -17
View File
@@ -162,11 +162,9 @@ impl Game<'_> {
converted: Vec::new(),
});
}
/// `spawnFire`: fire at the strike point and `extra` random neighbours.
/// `spawnFire`: fire at the strike point and `extra` random neighbours,
/// wherever `BaseFireBlock.getState` fire can survive.
fn bolt_fire(&mut self, at: [f64; 3], extra: i32) {
if self.ui.difficulty < 2 {
return;
}
let base = [
at[0].floor() as i32,
at[1].floor() as i32,
@@ -183,18 +181,13 @@ impl Game<'_> {
if !self.res.facts().is_air(state) {
continue;
}
// BaseFireBlock.getState: soul fire on soul soil and sand.
let below = self.res.facts().get(self.world.block(p[0], p[1] - 1, p[2]));
let name = if matches!(&*below.name, "minecraft:soul_soil" | "minecraft:soul_sand") {
"minecraft:soul_fire"
} else {
"minecraft:fire"
};
// FireBlock.canSurvive: a sturdy floor (flammable neighbours aside).
if below.sturdy_full >> 1 & 1 == 0 {
continue;
}
if let Some(fire) = self.pack.assets.registry.default_state(name) {
// BaseFireBlock.getState and FireBlock/SoulFireBlock.canSurvive.
let at = BlockPos::new(p[0], p[1], p[2]);
let fire = self.with_level(|b, level, ticks| {
let fire = b.fire_state_for(level, ticks, at);
b.can_survive(level, ticks, at, fire).then_some(fire)
});
if let Some(fire) = fire {
let _ = self.level_set(p, fire);
}
}
@@ -219,7 +212,10 @@ impl Game<'_> {
pitch,
);
if !b.visual_only {
self.bolt_fire(b.at, 4);
// Only Normal and Hard start fires with the strike itself.
if self.ui.difficulty >= 2 {
self.bolt_fire(b.at, 4);
}
self.strike_rod(b.at);
}
}
+4 -2
View File
@@ -159,8 +159,10 @@ impl Containers<'_> {
});
}
let entity = self.world.block_entities.get(&pos)?;
// A beehive keeps bees, not items: it is no `Container`.
if matches!(entity, BlockEntity::Beehive(_)) {
// A beehive keeps bees, not items, and a campfire cooks its own: neither
// is a `Container` (HopperBlockEntity.getContainerAt), so a dropper drops
// in front of them and a hopper leaves them alone.
if matches!(entity, BlockEntity::Beehive(_) | BlockEntity::Campfire(_)) {
return None;
}
let kind = match (entity, name) {
+12 -2
View File
@@ -87,9 +87,12 @@ pub(super) struct Minecart {
pub fuse: i32,
/// Hopper minecarts: an unpowered activator rail enables them.
pub enabled: bool,
/// Chest minecarts: the loot table rolled into `items` on load, saved
/// again while the items are still that roll.
pub loot: Option<horizon_client::save::RolledLoot>,
}
const WIDTH: f64 = 0.98;
const HEIGHT: f64 = 0.7;
pub(super) const WIDTH: f64 = 0.98;
pub(super) const HEIGHT: f64 = 0.7;
/// AbstractMinecart.getDefaultDisplayOffset, in sixteenths of a block.
fn display_offset(kind: CartKind) -> f64 {
if kind == CartKind::Hopper { 1.0 } else { 6.0 }
@@ -182,6 +185,7 @@ impl Minecart {
push: [0.0; 2],
fuse: -1,
enabled: true,
loot: None,
}
}
pub fn bounds(&self) -> Aabb {
@@ -1527,6 +1531,12 @@ impl Minecart {
}
CartKind::Rideable => {}
}
// A loaded loot table goes back while the items are still its roll;
// otherwise the items replace it, as vanilla's unpackLootTable does.
if !self.loot.as_ref().is_some_and(|l| l.restore(map)) {
map.remove(&JavaString::from("LootTable"));
map.remove(&JavaString::from("LootTableSeed"));
}
}
tag
}
@@ -454,11 +454,24 @@ impl Game<'_> {
for m in &self.mobs {
self.portals.before.insert(m.id, m.position);
}
for b in &self.boats {
self.portals.before.insert(b.id, b.position);
}
for c in &self.carts {
self.portals.before.insert(c.id, c.position);
}
let ids: Vec<(u32, bool)> = self
.dropped
.iter()
.map(|e| (e.id, true))
.chain(self.mobs.iter().map(|m| (m.id, self.mob_can_use_portal(m))))
.chain(
self.boats
.iter()
.map(|b| b.id)
.chain(self.carts.iter().map(|c| c.id))
.map(|id| (id, !self.vehicle_occupied(id))),
)
.collect();
let present: std::collections::HashSet<u32> = ids.iter().map(|(id, _)| *id).collect();
self.portals.entities.retain(|id, _| present.contains(id));
@@ -501,8 +514,15 @@ impl Game<'_> {
&& self.riding != Some(m.id)
&& m.species() != Species::Wither
}
/// `NetherPortalBlock.entityInside` for the items and mobs that moved this
/// tick: the portal blocks their box swept through.
/// Whether the player, a server player or a mob rides vehicle `id`.
fn vehicle_occupied(&self, id: u32) -> bool {
self.riding == Some(id)
|| self.ridden_by_guest(id)
|| self.mobs.iter().any(|m| m.extra.mount == Some(id))
}
/// `NetherPortalBlock.entityInside` for the items, mobs and empty boats
/// and minecarts that moved this tick: the portal blocks their box swept
/// through.
pub(super) fn find_entity_portals(&mut self) {
if self.remote.is_some() {
return;
@@ -519,8 +539,23 @@ impl Game<'_> {
.filter(|m| self.mob_can_use_portal(m))
.map(|m| (m.id, m.position, m.width(), m.height()))
.collect();
let vehicles: Vec<(u32, Vec3, f32, f32)> = self
.boats
.iter()
.map(|b| (b.id, b.position, super::boats::WIDTH, super::boats::HEIGHT))
.chain(self.carts.iter().map(|c| {
(
c.id,
c.position,
super::minecarts::WIDTH,
super::minecarts::HEIGHT,
)
}))
.filter(|(id, ..)| !self.vehicle_occupied(*id))
.map(|(id, at, w, h)| (id, at, w as f32, h as f32))
.collect();
let mut portal_sections = std::mem::take(&mut self.portals.sections);
for (id, to, width, height) in items.into_iter().chain(mobs) {
for (id, to, width, height) in items.into_iter().chain(mobs).chain(vehicles) {
let from = self.portals.before.get(&id).copied().unwrap_or(to);
if let Some(pos) = self.swept_portal(from, to, width, height, &mut portal_sections) {
inside.push((id, pos));
@@ -678,6 +713,44 @@ impl Game<'_> {
}
self.portals.arrivals.push(Arrival { plan, tag });
self.portals.dirty = true;
} else if let Some(i) = self.boats.iter().position(|b| b.id == id) {
let b = &self.boats[i];
let (width, height) = (super::boats::WIDTH as f32, super::boats::HEIGHT as f32);
let plan = self.portal_plan(entry, b.position, width, height, false);
if !self.may_enter(plan.dim) {
return;
}
let uuid = self.uuid_for(id);
let b = &self.boats[i];
let tag = horizon_client::entity_nbt::boat_to_nbt(
&b.entity_type(),
b.position,
b.velocity,
b.yaw,
uuid,
self.persist_raw(id),
b.chest.then_some(&b.items[..]),
);
self.boats.remove(i);
self.forget_entity(id);
self.portals.arrivals.push(Arrival { plan, tag });
self.portals.dirty = true;
} else if let Some(i) = self.carts.iter().position(|c| c.id == id) {
let c = &self.carts[i];
let (width, height) = (
super::minecarts::WIDTH as f32,
super::minecarts::HEIGHT as f32,
);
let plan = self.portal_plan(entry, c.position, width, height, false);
if !self.may_enter(plan.dim) {
return;
}
let uuid = self.uuid_for(id);
let tag = self.carts[i].to_nbt(uuid, self.persist_raw(id));
self.carts.remove(i);
self.forget_entity(id);
self.portals.arrivals.push(Arrival { plan, tag });
self.portals.dirty = true;
}
}
/// Entities waiting for the loaded dimension whose exit area is loaded:
@@ -257,6 +257,7 @@ impl Game<'_> {
lang::set(lang::Language::load(&[&root], code));
self.refresh_remote_language();
self.ui.chat.rescale(&self.gui.atlas);
self.creative_screen.forget_items();
}
/// Settings that Horizon applies beyond render distance, volume and GUI scale.
pub(super) fn apply_ui_settings(&mut self) {
+3 -1
View File
@@ -163,7 +163,7 @@ impl Game<'_> {
self.pack.context.biomes.clone(),
self.pack.context.light.clone(),
)?;
// Keep the working GPU resources, but release its exclusive window surface.
// Keep the working GPU resources, and hand its window surface over.
if let Some(renderer) = &mut self.renderer {
renderer.release_surface();
}
@@ -221,6 +221,8 @@ impl Game<'_> {
self.apply_ui_settings();
}
self.ui.chat.rescale(&self.gui.atlas);
// The creative screen survives the reload; item names may not.
self.creative_screen.forget_items();
Ok(())
}
}
+26 -1
View File
@@ -279,7 +279,8 @@ impl Game<'_> {
k if super::minecarts::CartKind::from_id(k).is_some() => {
let kind = super::minecarts::CartKind::from_id(k).unwrap();
let id = self.next_entity + 1;
if let Some(c) = super::minecarts::Minecart::from_nbt(&tag, id, kind) {
if let Some(mut c) = super::minecarts::Minecart::from_nbt(&tag, id, kind) {
self.roll_cart_loot(&mut c, &tag);
self.next_entity = id;
self.remember(id, uuid, tag);
self.carts.push(c);
@@ -534,6 +535,30 @@ impl Game<'_> {
pub(super) fn persist_raw(&self, id: u32) -> Option<&Tag> {
self.persist.raw.get(&id)
}
/// A loaded chest minecart with a loot table and no items: the table
/// rolled now, as generated containers are (Horizon does not unpack on
/// first open), and kept to save it again while unchanged.
fn roll_cart_loot(&self, cart: &mut super::minecarts::Minecart, tag: &Tag) {
let Some(table) = horizon_client::save::text(tag.get("LootTable")) else {
return;
};
if cart.items.is_empty() || cart.items.iter().any(Option::is_some) {
return;
}
let loot = horizon_client::structure_blocks::loot_data(&self.options.resources);
let p = cart.position;
let pos = (p.x.floor() as i32, p.y.floor() as i32, p.z.floor() as i32);
let biome = self.biome_name_at(p).unwrap_or_default();
let seed = horizon_client::structure_blocks::loot_seed(tag);
cart.loot = Some(horizon_client::structure_blocks::roll_container(
&mut cart.items,
loot.as_deref(),
table,
seed,
pos,
&biome,
));
}
fn remember(&mut self, id: u32, uuid: Option<[i32; 4]>, tag: Tag) {
self.restore_portal_cooldown(id, &tag);
if let Some(u) = uuid {
@@ -71,6 +71,8 @@ pub(super) struct BodyPotions {
pub(super) struct PotionState {
pub(super) effects: PlayerEffects,
thrown: Vec<ThrownPotion>,
/// The area effect clouds a server tracks (game/remote_projectiles.rs).
pub(super) clouds: Vec<super::remote_projectiles::RemoteCloud>,
applied: Vec<(&'static str, &'static str, f64, &'static str)>,
/// Armor the protection table was computed for.
protection_for: Option<Vec<Option<ItemStack>>>,
+59 -13
View File
@@ -1406,6 +1406,14 @@ impl Game<'_> {
{
return;
}
// A dragon fireball keeps Entity's NO_OP InterpolationHandler too:
// the server's position replaces its own at once.
if let Some(b) = self.dragon_fireballs.iter_mut().find(|b| b.id == id as u32) {
if path.is_some() {
b.position = Vec3::new(position[0], position[1], position[2]);
}
return;
}
if let Some(p) = r.pending_items.get_mut(&id) {
p.0 = position;
}
@@ -1689,19 +1697,30 @@ impl Game<'_> {
}
}
C::BlockChangedAck(p) => self.end_predictions(p.sequence),
// ClientLevel.blockEvent: BlockEntity.triggerEvent (chest lids,
// shulker boxes, bells) when the block is still the one it names.
// Level.blockEvent on the client level: the block's triggerEvent
// runs on the client's copy of whatever is at pos (a piston's
// moveBlocks builds the moving block entities it animates, a note
// block plays); what it doesn't handle reaches the client copy of
// the block entity (chest lids, shulker boxes).
C::BlockEvent(p) => {
let state = self.world.block(p.pos[0], p.pos[1], p.pos[2]);
let block = &self.res.facts().get(state).name;
let named = self.remote.as_ref().and_then(|r| {
r.builtin
.get("minecraft:block")?
.name(p.block)
.map(str::to_owned)
let pos = horizon_core::position::BlockPos::new(p.pos[0], p.pos[1], p.pos[2]);
let triggered = self.with_level(|b, level, ticks| {
b.client_block_event(level, ticks, pos, i32::from(p.b0), i32::from(p.b1))
});
if named.as_deref() == Some(&**block) {
self.block_entity_event(p.pos, i32::from(p.b0), i32::from(p.b1));
self.sync_moving_pistons();
self.process_level_events()?;
if !triggered {
let state = self.world.block(p.pos[0], p.pos[1], p.pos[2]);
let block = &self.res.facts().get(state).name;
let named = self.remote.as_ref().and_then(|r| {
r.builtin
.get("minecraft:block")?
.name(p.block)
.map(str::to_owned)
});
if named.as_deref() == Some(&**block) {
self.block_entity_event(p.pos, i32::from(p.b0), i32::from(p.b1));
}
}
}
// ClientLevel.levelEvent: LevelEventHandler, as this client's own
@@ -2100,6 +2119,11 @@ impl Game<'_> {
{
horizon_client::mobs::rabbit::hop_event(m);
}
if p.event == 19
&& let Some(m) = self.mobs.iter_mut().find(|m| m.id == p.id as u32)
{
horizon_client::mobs::aquatic::squid_cycle_event(m);
}
// EntityEvent.DEATH: start the death animation.
if p.event == 3
&& let Some(m) = self.mobs.iter_mut().find(|m| m.id == p.id as u32)
@@ -2974,7 +2998,10 @@ impl Game<'_> {
fn remote_motion(&mut self, id: i32, velocity: [f64; 3]) {
self.remote_dragon_motion(id, velocity);
let v = Vec3::new(velocity[0], velocity[1], velocity[2]);
if let Some(d) = self.dropped.iter_mut().find(|d| d.id == id as u32) {
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == id as u32) {
// Squid.aiStep derives its body pitch and roll from network motion.
m.velocity = v;
} else if let Some(d) = self.dropped.iter_mut().find(|d| d.id == id as u32) {
d.velocity = v;
} else if let Some(f) = self.falling.iter_mut().find(|f| f.id == id as u32) {
f.velocity = v;
@@ -2987,6 +3014,8 @@ impl Game<'_> {
.find(|p| p.id == id as u32)
{
p.velocity = v;
} else if let Some(b) = self.dragon_fireballs.iter_mut().find(|b| b.id == id as u32) {
b.velocity = v;
} else if let Some(p) = self
.remote
.as_mut()
@@ -3144,8 +3173,14 @@ impl Game<'_> {
self.border.tick();
self.player_border();
self.tick_remote_entities(running);
// ClientLevel.tickBlockEntities: chest lids, shulker boxes, bells.
// ClientLevel.tickBlockEntities: chest lids, shulker boxes, bells,
// and the moving pistons a block event's client-side moveBlocks made
// (their progress advances between the server's block updates).
self.tick_block_entity_anims();
self.with_level(|b, level, ticks| {
b.client_tick_block_entities(level, ticks);
});
self.sync_moving_pistons();
self.tick_hand();
self.tick_particles();
self.tick_client_ambient();
@@ -3536,6 +3571,8 @@ impl Game<'_> {
} else if m.alive() {
m.body_rotation_tick();
}
// Squid.aiStep follows LivingEntity's interpolation and head turn.
horizon_client::mobs::aquatic::squid_client_step(m);
// WalkAnimationState.update from the horizontal distance moved.
let (dx, dz) = (m.position.x - m.previous.x, m.position.z - m.previous.z);
let target = (((dx * dx + dz * dz).sqrt() * 4.0) as f32).min(1.0);
@@ -3749,6 +3786,15 @@ impl Game<'_> {
/// MultiPlayerGameMode.startDestroyBlock / continueDestroyBlock.
fn remote_mining(&mut self, start: bool) {
if !self
.res
.items()
.can_destroy_block(self.held(), self.creative)
{
self.stop_remote_mining();
self.mining = None;
return;
}
let Some(r) = self.remote.as_mut() else {
return;
};
@@ -3,11 +3,26 @@
//! and `handleSetEntityData`): the server simulates them, the client draws
//! them where it says and interpolates their moves.
use super::{FallStep, FallingBlock, Game, fireworks::fireworks_of, view_vector};
use horizon_client::particles::{ParticleOptions, ParticleType};
use horizon_client::projectiles::{Hit, Projectile, ProjectileKind, ProjectileWorld};
use horizon_core::{geometry::Vec3, position::BlockPos};
use horizon_core::{geometry::Vec3, position::BlockPos, random::RandomSource};
use horizon_protocol::entity_data::Value;
use horizon_world::registry::BlockStateId;
/// An `AreaEffectCloud` a server tracks: what its client tick reads.
pub(super) struct RemoteCloud {
id: u32,
at: [f64; 3],
radius: f32,
waiting: bool,
particle: ParticleOptions,
}
/// `AreaEffectCloud.DEFAULT_PARTICLE`: a white entity effect.
fn default_cloud_particle() -> ParticleOptions {
ParticleOptions::colored(ParticleType::EntityEffect, [1.0; 4])
}
/// The projectile a type id names, as Horizon models it.
fn projectile_kind(kind: &str) -> Option<ProjectileKind> {
Some(match kind {
@@ -118,6 +133,16 @@ impl Game<'_> {
| "minecraft:experience_bottle" => {
self.potions.add_remote_thrown(id, kind, at, velocity);
}
"minecraft:dragon_fireball" => self
.dragon_fireballs
.push(super::dragon::DragonFireball::remote(id, at, velocity)),
"minecraft:area_effect_cloud" => self.potions.clouds.push(RemoteCloud {
id,
at: position,
radius: 3.0,
waiting: false,
particle: default_cloud_particle(),
}),
"minecraft:eye_of_ender" => {
self.end
.add_remote_eye(id, position, [velocity.x, velocity.y, velocity.z])
@@ -151,6 +176,31 @@ impl Game<'_> {
pub(super) fn remote_projectile_data(&mut self, id: i32, values: &[(u8, i32, Value)]) {
let id = id as u32;
self.remote_hook_data(id, values);
if let Some(at) = self
.potions
.clouds
.iter()
.find(|c| c.id == id)
.map(|c| c.at)
{
// AreaEffectCloud.DATA_RADIUS, DATA_WAITING and DATA_PARTICLE.
for (index, _, value) in values {
let particle = match (index, value) {
(10, Value::Particle(p)) => self.received_particle(p, at),
_ => None,
};
let Some(c) = self.potions.clouds.iter_mut().find(|c| c.id == id) else {
return;
};
match (index, value) {
(8, Value::Float(r)) => c.radius = *r,
(9, Value::Bool(w)) => c.waiting = *w,
(10, _) => c.particle = particle.unwrap_or_else(default_cloud_particle),
_ => {}
}
}
return;
}
if self.rockets.iter().any(|r| r.id == id) {
for (index, _, value) in values {
match (index, value) {
@@ -336,6 +386,75 @@ impl Game<'_> {
self.step_remote_thrown();
self.step_remote_hooks(&mut step);
self.end.step_remote_eyes();
self.tick_remote_clouds();
// The dragon's fireballs fly on between the tracker's updates, with
// their dragon_breath trail (`AbstractHurtingProjectile.getTrailParticle`).
let trail = ParticleOptions::simple(ParticleType::DragonBreath);
for b in &mut self.dragon_fireballs {
let at = b.position;
let water = self
.res
.facts()
.get(self.world.block(
at.x.floor() as i32,
at.y.floor() as i32,
at.z.floor() as i32,
))
.fluid
== horizon_client::blocks::Fluid::Water;
b.remote_step(water);
let p = b.position;
self.particles.add(&trail, [p.x, p.y + 0.5, p.z], [0.0; 3]);
}
}
/// `AreaEffectCloud.clientTick`: while waiting, now and then two
/// particles near the middle; then about one per square block of the
/// disc, drifting (an entity effect stays still).
fn tick_remote_clouds(&mut self) {
for c in &self.potions.clouds {
let r = self.particles.random();
if c.waiting && r.next_boolean() {
continue;
}
let (count, spread) = if c.waiting {
(2, 0.2)
} else {
(
(std::f32::consts::PI * c.radius * c.radius).ceil() as i32,
c.radius,
)
};
for _ in 0..count {
let r = self.particles.random();
let angle = r.next_float() * std::f32::consts::TAU;
let distance = r.next_float().sqrt() * spread;
let at = [
c.at[0] + f64::from(angle.cos() * distance),
c.at[1],
c.at[2] + f64::from(angle.sin() * distance),
];
let (options, velocity) = if c.particle.ty == ParticleType::EntityEffect {
let default = c.waiting && r.next_boolean();
let options = if default {
default_cloud_particle()
} else {
c.particle.clone()
};
(options, [0.0; 3])
} else if c.waiting {
(c.particle.clone(), [0.0; 3])
} else {
let v = [
(0.5 - r.next_double()) * 0.15,
f64::from(0.01f32),
(0.5 - r.next_double()) * 0.15,
];
(c.particle.clone(), v)
};
self.particles.add(&options, at, velocity);
}
}
}
pub(super) fn remove_remote_projectile(&mut self, id: u32) {
@@ -346,6 +465,8 @@ impl Game<'_> {
self.rockets.retain(|r| r.id != id);
self.crystals.retain(|c| c.id != id);
self.potions.remove_remote_thrown(id);
self.potions.clouds.retain(|c| c.id != id);
self.dragon_fireballs.retain(|b| b.id != id);
self.end.remove_remote_eye(id);
self.remove_remote_hook(id);
}
@@ -361,6 +482,8 @@ impl Game<'_> {
// The dragon a server sent goes with its crystals.
self.dragon = None;
self.potions.clear_thrown();
self.potions.clouds.clear();
self.dragon_fireballs.clear();
self.end.clear_remote_eyes();
self.clear_remote_hooks();
}
+1
View File
@@ -179,6 +179,7 @@ impl Game<'_> {
if keys.is_empty() {
self.prune_entity_memory();
self.save_raids();
self.save.save_forced_chunks(&self.streaming.forced)?;
self.save_game_metadata()?;
save.stage = Stage::Writes;
}
@@ -13,6 +13,7 @@ use super::respawn_anchor::RespawnConfig;
use super::{Dim, Game, lan::LanHost};
use horizon_client::{lan::hub::TickCtx, text::Text};
use horizon_core::{geometry::Vec3, position::BlockPos};
use horizon_protocol::packets::play::{self, Clientbound as C};
/// A trip under way: the player is in the destination's level, whose
/// chunks are loading around `target`.
@@ -103,8 +104,35 @@ impl Game<'_> {
if let Some(pos) = self.nether_portal_inside(at) {
self.portals.player.set_inside(pos, PLAYER_DELAY);
}
// `ServerPlayer.showEndCredits`: the player stays out of the world while the
// poem rolls, then PERFORM_RESPAWN takes it to its respawn point.
let play = host.play.entry(conn).or_default();
if play.won_game {
if std::mem::take(&mut play.respawn_requested) {
play.won_game = false;
self.start_server_trip(host, conn, Dim::Overworld, None);
self.portals.player.cooldown = 10;
}
return;
}
let (end_portal, gateway) = self.inside_end_portals(at, WIDTH, HEIGHT);
if end_portal {
// EndPortalBlock.entityInside: the first exit from the End shows the credits.
if self.dim == Dim::End && !host.hub.guest(conn).is_some_and(|g| g.seen_credits) {
if let Some(g) = host.hub.guests.iter_mut().find(|g| g.conn == conn) {
g.seen_credits = true;
}
host.play.entry(conn).or_default().won_game = true;
// ClientboundGameEventPacket.WIN_GAME.
host.hub.send_to(
conn,
&C::GameEvent(play::GameEvent {
event: 4,
param: 0.0,
}),
);
return;
}
if self.portals.player.cooldown == 0 {
let target = if self.dim == Dim::End {
Dim::Overworld
@@ -261,6 +261,7 @@ pub(super) fn make_streaming(
res.threads(),
)
};
streaming.forced = save.load_forced_chunks();
streaming.radius = settings.render_distance;
streaming.biome_blend = settings.biome_blend;
streaming.smooth_lighting = settings.smooth_lighting;
+35 -6
View File
@@ -32,6 +32,8 @@ fn entity_shader() -> String {
crate::overlay::wgsl_constants() + include_str!("entity.wgsl")
}
#[cfg(test)]
mod crumbling_tests;
mod entity_batch;
mod weather;
use entity_batch::EntityBatch;
@@ -334,6 +336,10 @@ pub struct Renderer {
adapter: wgpu::Adapter,
/// The window's swapchain; `None` when rendering offscreen (`--offscreen`).
surface: Option<wgpu::Surface<'static>>,
/// The surface `release_surface` set aside for the renderer that replaces
/// this one on the same device: reused, not created again, as a new
/// surface for the window can come back lost (Wayland on Mesa).
spare_surface: std::sync::Mutex<Option<wgpu::Surface<'static>>>,
/// The offscreen colour target, sized like `config`.
offscreen: Option<wgpu::Texture>,
/// Offscreen submissions still on the GPU: at most two frames run ahead.
@@ -1044,7 +1050,15 @@ impl Renderer {
surface: if offscreen {
None
} else {
Some(renderer.instance.create_surface(window.clone())?)
let spare = renderer
.spare_surface
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.take();
Some(match spare {
Some(surface) => surface,
None => renderer.instance.create_surface(window.clone())?,
})
},
instance: renderer.instance.clone(),
adapter: renderer.adapter.clone(),
@@ -2320,6 +2334,7 @@ impl Renderer {
instance,
adapter,
surface,
spare_surface: std::sync::Mutex::new(None),
offscreen,
in_flight: std::collections::VecDeque::new(),
first_frame: if previous.is_none() {
@@ -2499,18 +2514,32 @@ impl Renderer {
view_formats: &config.view_formats,
})
}
/// Release the window surface before another renderer uses the same window.
/// Hand the window surface over to the renderer that replaces this one on
/// the same device (it takes `spare_surface`).
pub fn release_surface(&mut self) {
self.surface = None;
*self
.spare_surface
.get_mut()
.unwrap_or_else(std::sync::PoisonError::into_inner) = self.surface.take();
}
/// Reattach a window after surface loss or a failed renderer replacement.
pub fn recreate_surface(&mut self) -> Result<()> {
if self.offscreen.is_some() {
return Ok(());
}
// Wayland allows only one tearing-control object per window surface.
self.release_surface();
let surface = self.instance.create_surface(self.window.clone())?;
// The surface set aside for a replacement that failed, else a new one
// (Wayland allows only one tearing-control object per window surface,
// so the old one goes first).
let spare = self
.spare_surface
.get_mut()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.take();
self.surface = None;
let surface = match spare {
Some(surface) => surface,
None => self.instance.create_surface(self.window.clone())?,
};
Self::configure(
&self.device,
&surface,
@@ -0,0 +1,191 @@
use wgpu::util::DeviceExt;
/// Sample the production WGSL with the production nearest/no-mipmap atlas sampler.
#[test]
#[ignore = "requires a Vulkan/Metal/DX12 adapter"]
fn crack_samples_stay_aligned_between_stages() {
let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle());
let adapter =
pollster::block_on(instance.request_adapter(&wgpu::RequestAdapterOptions::default()))
.unwrap();
eprintln!("crack sampling adapter: {:?}", adapter.get_info());
let (device, queue) =
pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor::default())).unwrap();
#[repr(C)]
#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
struct Case {
uv: [f32; 2],
stage: u32,
extent: u32,
}
let mut cases = Vec::new();
let mut expected = Vec::<[u32; 2]>::new();
// Include exact texel boundaries, their neighbours, negative projected UVs,
// wrapping and the far sprite edge. Origins straddle the packing error's sign change.
for extent in [16, 32] {
for x in [32, 2080, 4064] {
for y in [0, 32] {
for i in -32..=32 {
for delta in [-0.0001, 0.0, 0.0001] {
let uv = [i as f32 / 16.0 + delta, 0.5 - delta];
cases.push(Case {
uv,
stage: crate::breaking::stage_light([
x as f32 / 4096.0,
y as f32 / 64.0,
]),
extent,
});
expected.push(uv.map(|v| {
(((v - v.floor()) * extent as f32).floor() as u32).min(extent - 1)
}));
}
}
}
}
}
let math = include_str!("../entity.wgsl")
.split("// CRUMBLING_UV_BEGIN")
.nth(1)
.unwrap()
.split("// CRUMBLING_UV_END")
.next()
.unwrap();
let source = format!(
"{math}\n{}",
r#"
struct Case { uv: vec2<f32>, stage: u32, extent: u32 }
@group(0) @binding(0) var<storage, read> cases: array<Case>;
@group(0) @binding(1) var<storage, read_write> results: array<vec4<u32>>;
@group(0) @binding(2) var atlas: texture_2d<f32>;
@group(0) @binding(3) var atlas_sampler: sampler;
@compute @workgroup_size(64) fn main(@builtin(global_invocation_id) id: vec3<u32>) {
if id.x >= arrayLength(&cases) { return; }
let c = cases[id.x];
let origin = vec2(f32(c.stage & 65535u), f32(c.stage >> 16u)) / 65535.0;
let dimensions = textureDimensions(atlas);
let uv = crumbling_uv(c.uv, origin, vec2(f32(c.extent)) / vec2<f32>(dimensions), dimensions);
// Keep the old addressing as a control: the fixture must reproduce its stage jitter.
let old_uv = origin + fract(c.uv) * vec2(f32(c.extent)) / vec2<f32>(dimensions);
results[id.x] = vec4<u32>(
vec2<u32>(round(textureSampleLevel(atlas, atlas_sampler, uv, 0.0).rg * 255.0)),
vec2<u32>(round(textureSampleLevel(atlas, atlas_sampler, old_uv, 0.0).rg * 255.0)));
}"#
);
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("Crack sampling regression"),
source: wgpu::ShaderSource::Wgsl(source.into()),
});
let pipeline = device.create_compute_pipeline(&wgpu::ComputePipelineDescriptor {
label: None,
layout: None,
module: &shader,
entry_point: Some("main"),
compilation_options: Default::default(),
cache: None,
});
// Every sprite repeats the same coordinate pattern, so moving a stage's
// atlas origin must leave every sampled local texel unchanged.
let pixels: Vec<u8> = (0..64)
.flat_map(|y| (0..4096).flat_map(move |x| [(x % 32) as u8, (y % 32) as u8, 0, 255]))
.collect();
let atlas = device.create_texture_with_data(
&queue,
&wgpu::TextureDescriptor {
label: None,
size: wgpu::Extent3d {
width: 4096,
height: 64,
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: wgpu::TextureFormat::Rgba8Unorm,
usage: wgpu::TextureUsages::TEXTURE_BINDING,
view_formats: &[],
},
wgpu::util::TextureDataOrder::LayerMajor,
&pixels,
);
let view = atlas.create_view(&Default::default());
let sampler = device.create_sampler(&wgpu::SamplerDescriptor::default());
let input = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: None,
contents: bytemuck::cast_slice(&cases),
usage: wgpu::BufferUsages::STORAGE,
});
let size = (expected.len() * 16) as u64;
let output = device.create_buffer(&wgpu::BufferDescriptor {
label: None,
size,
usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_SRC,
mapped_at_creation: false,
});
let readback = device.create_buffer(&wgpu::BufferDescriptor {
label: None,
size,
usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: None,
layout: &pipeline.get_bind_group_layout(0),
entries: &[
wgpu::BindGroupEntry {
binding: 0,
resource: input.as_entire_binding(),
},
wgpu::BindGroupEntry {
binding: 1,
resource: output.as_entire_binding(),
},
wgpu::BindGroupEntry {
binding: 2,
resource: wgpu::BindingResource::TextureView(&view),
},
wgpu::BindGroupEntry {
binding: 3,
resource: wgpu::BindingResource::Sampler(&sampler),
},
],
});
let mut encoder = device.create_command_encoder(&Default::default());
{
let mut pass = encoder.begin_compute_pass(&Default::default());
pass.set_pipeline(&pipeline);
pass.set_bind_group(0, &group, &[]);
pass.dispatch_workgroups((cases.len() as u32).div_ceil(64), 1, 1);
}
encoder.copy_buffer_to_buffer(&output, 0, &readback, 0, size);
queue.submit([encoder.finish()]);
let (tx, rx) = std::sync::mpsc::channel();
readback.map_async(wgpu::MapMode::Read, .., move |r| tx.send(r).unwrap());
device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
rx.recv().unwrap().unwrap();
let mapped = readback.get_mapped_range(..).unwrap();
let actual: &[[u32; 4]] = bytemuck::cast_slice(&mapped);
for (i, (actual, expected)) in actual.iter().zip(&expected).enumerate() {
assert_eq!(
&actual[..2],
expected,
"case {i}, uv={:?}, stage={}, extent={}",
cases[i].uv,
cases[i].stage,
cases[i].extent
);
}
let old_mismatches = actual
.iter()
.zip(&expected)
.filter(|(a, e)| &a[2..] != e.as_slice())
.count();
assert!(
old_mismatches > 0,
"fixture must detect the original sampling bug"
);
eprintln!(
"{} crack texture samples matched; old addressing mismatched {old_mismatches}",
cases.len()
);
}
+24 -2
View File
@@ -295,6 +295,20 @@ impl PlayerMotion {
source: DamageTypeId,
amount: f32,
blocking: Option<Blocking>,
) -> Result<BlockOutcome> {
self.hurt_caused_by(resources, rules, source, amount, blocking, false)
}
/// `hurt_blocking` from a source whose causing entity is a living
/// non-player (`living_cause`), which the `when_caused_by_living_non_player`
/// damage types scale with the difficulty.
pub fn hurt_caused_by(
&mut self,
resources: &HealthResources,
rules: HealthRules,
source: DamageTypeId,
amount: f32,
blocking: Option<Blocking>,
living_cause: bool,
) -> Result<BlockOutcome> {
if self.client_side {
return Ok(BlockOutcome::default());
@@ -315,6 +329,7 @@ impl PlayerMotion {
invulnerable,
rules,
source,
living_cause,
events: &mut self.events,
dead: &mut h.dead,
sync_velocity: &mut h.sync_velocity,
@@ -390,6 +405,8 @@ pub(crate) struct Environment<'a> {
pub(crate) invulnerable: bool,
pub(crate) rules: HealthRules,
pub(crate) source: DamageSource<'a, ()>,
/// The causing entity is a living non-player (a mob, or its projectile's owner).
pub(crate) living_cause: bool,
pub(crate) events: &'a mut Vec<MotionEvent>,
pub(crate) dead: &'a mut bool,
pub(crate) sync_velocity: &'a mut bool,
@@ -773,8 +790,13 @@ impl PlayerHurtWorld for Environment<'_> {
Ok(())
}
fn scales_with_difficulty(&mut self, _: &mut State) -> io::Result<bool> {
Ok(self.source.definition().scaling == Scaling::Always)
} // no causing entity
// DamageSource.scalesWithDifficulty.
Ok(match self.source.definition().scaling {
Scaling::Never => false,
Scaling::Always => true,
Scaling::LivingNonPlayer => self.living_cause,
})
}
fn difficulty(&mut self, _: &mut State) -> io::Result<Difficulty> {
Ok(self.rules.difficulty)
}
+1
View File
@@ -151,6 +151,7 @@ impl FoodWorld for TickWorld<'_> {
.damage
.source::<()>(id, None, None, None)
.expect("validated starvation source"),
living_cause: false,
events: self.events,
dead: self.dead,
sync_velocity: self.sync_velocity,
+4
View File
@@ -363,6 +363,9 @@ pub struct ItemRenderer {
sprites: FileSprites,
baked: Mutex<HashMap<String, Arc<BakedItemModel>>>,
extents: Mutex<HashMap<String, Arc<Vec<Vec3>>>>,
/// GUI items already drawn: the HUD vertices of a static item at a GUI position
/// (`gui::draw`), which are the same every frame it stays there.
pub(crate) gui_drawn: Mutex<HashMap<gui::DrawnKey, Arc<[crate::hud::HudVertex]>>>,
/// Entity model layers of the special renderers.
parts: HashMap<String, ModelPart>,
/// `colormap/grass.png` as ARGB.
@@ -452,6 +455,7 @@ impl ItemRenderer {
},
baked: Mutex::default(),
extents: Mutex::default(),
gui_drawn: Mutex::default(),
parts,
grass,
})
+90 -1
View File
@@ -8,7 +8,7 @@
use super::{Foil, ItemQuad, ItemRenderState, ItemRenderer, SpecialHost};
use crate::hud::{HudBatch, Rect};
use glam::{EulerRot, Mat4, Vec2, Vec3};
use std::{collections::HashMap, f32::consts::PI};
use std::{collections::HashMap, f32::consts::PI, sync::Arc};
mod depth;
@@ -148,6 +148,95 @@ pub fn draw(
if state.is_empty() {
return;
}
// A static item drawn where it was drawn before gives the same vertices:
// take them instead of submitting, sorting and clipping its quads again.
let key = DrawnKey::of(state, renderer, sprites, x, y);
if let Some(key) = &key
&& let Some(drawn) = renderer
.gui_drawn
.lock()
.ok()
.and_then(|d| d.get(key).cloned())
{
batch.vertices.extend_from_slice(&drawn);
return;
}
let start = batch.vertices.len();
draw_now(state, renderer, sprites, host, batch, x, y, time);
if let Some(key) = key
&& let Ok(mut drawn) = renderer.gui_drawn.lock()
{
// GUI positions and items are few; a long scroll through every tab stays bounded.
if drawn.len() >= DRAWN_LIMIT {
drawn.clear();
}
drawn.insert(key, batch.vertices[start..].into());
}
}
/// Entries of `ItemRenderer::gui_drawn` before it starts over.
const DRAWN_LIMIT: usize = 8192;
/// What a GUI item's vertices depend on: its layers' baked models (kept for the
/// renderer's life, so their addresses name them), tints and transforms, the
/// atlas, and the exact position. None for items whose drawing changes with time
/// or the host: a glint, or a special model renderer.
#[derive(Clone, PartialEq, Eq, Hash)]
pub(crate) struct DrawnKey {
renderer: usize,
sprites: (usize, usize),
at: [u32; 2],
oversized: bool,
layers: Vec<(usize, Vec<i32>, [u32; 16], bool)>,
}
impl DrawnKey {
fn of(
state: &ItemRenderState,
renderer: &ItemRenderer,
sprites: &HashMap<String, Rect>,
x: f32,
y: f32,
) -> Option<Self> {
if state.animated {
return None;
}
let layers = state
.layers
.iter()
.map(|layer| match &layer.geometry {
super::LayerGeometry::Quads(baked) if layer.foil == super::FoilType::None => {
Some((
Arc::as_ptr(baked) as usize,
layer.tints.clone(),
layer.matrix.to_cols_array().map(f32::to_bits),
layer.uses_block_light,
))
}
_ => None,
})
.collect::<Option<Vec<_>>>()?;
Some(Self {
renderer: std::ptr::from_ref(renderer) as usize,
sprites: (std::ptr::from_ref(sprites) as usize, sprites.len()),
at: [x.to_bits(), y.to_bits()],
oversized: state.oversized_in_gui,
layers,
})
}
}
/// [`draw`] without the cache.
#[allow(clippy::too_many_arguments)]
fn draw_now(
state: &ItemRenderState,
renderer: &ItemRenderer,
sprites: &HashMap<String, Rect>,
host: &dyn SpecialHost,
batch: &mut HudBatch,
x: f32,
y: f32,
time: f32,
) {
let pose = Mat4::from_translation(Vec3::new(x + 8.0, y + 8.0, 0.0))
* Mat4::from_scale(Vec3::new(16.0, -16.0, 16.0));
let mut quads: Vec<ItemQuad> = Vec::new();
+26
View File
@@ -71,6 +71,8 @@ pub struct ItemInfo {
/// The `tool` component's `damage_per_block`, when the item has the
/// component (a mace or trident has one without rules).
pub tool_damage_per_block: Option<u32>,
/// `tool.can_destroy_blocks_in_creative` (defaults to true).
pub can_destroy_blocks_in_creative: bool,
pub food: Option<(u32, f32)>,
pub can_always_eat: bool,
/// `consumable`: its timing, sound, particles and `on_consume_effects`.
@@ -558,6 +560,10 @@ impl Items {
tool_damage_per_block: c
.get("minecraft:tool")
.map(|t| t["damage_per_block"].as_u64().unwrap_or(1) as u32),
can_destroy_blocks_in_creative:
c["minecraft:tool"]["can_destroy_blocks_in_creative"]
.as_bool()
.unwrap_or(true),
food,
can_always_eat,
// Consumable.CODEC's defaults.
@@ -758,6 +764,26 @@ impl Items {
None => spec == block,
}
}
/// `Item.canDestroyBlock`: a tool may forbid block destruction with instabuild.
pub fn can_destroy_block(&self, held: Option<&ItemStack>, creative: bool) -> bool {
if !creative {
return true;
}
let Some(held) = held.filter(|s| s.count > 0 && &*s.item != "minecraft:air") else {
return true;
};
if let Some(tool) = held.data().and_then(|d| d.other("minecraft:tool")) {
return tool
.get("can_destroy_blocks_in_creative")
.and_then(|v| match v {
horizon_world::nbt::Tag::Byte(v) => Some(*v),
_ => None,
})
.is_none_or(|v| v != 0);
}
self.get(&held.item)
.is_none_or(|info| info.can_destroy_blocks_in_creative)
}
/// `Tool.getMiningSpeed` for the held item (1.0 without a tool component).
pub fn mining_speed(&self, held: Option<&ItemStack>, block: &str) -> f32 {
let Some(info) = held.and_then(|s| self.get(&s.item)) else {
+64 -19
View File
@@ -275,6 +275,20 @@ impl Default for KeyBinds {
}
}
}
/// Each default key once, in order: two mappings that share a default key
/// (Advancements and the debug profiler both on L) are one press of it, not
/// two (the second would close the screen the first opened). Vanilla clicks
/// both mappings, but the profiler one only acts with F3 held.
fn distinct<T: PartialEq>(keys: Vec<T>) -> Vec<T> {
let mut out = Vec::with_capacity(keys.len());
for k in keys {
if !out.contains(&k) {
out.push(k);
}
}
out
}
impl KeyBinds {
pub fn get(&self, name: &str) -> &str {
self.keys
@@ -320,49 +334,80 @@ impl KeyBinds {
}
/// The mapping a physical input triggers (first in list order).
pub fn mapping_for(&self, key: &str) -> Option<&'static str> {
MAPPINGS.iter().map(|m| m.0).find(|n| self.get(n) == key)
self.mappings_for(key).next()
}
/// Every mapping a physical input triggers, in list order: like
/// `KeyMapping.set`/`click`, one key drives all the mappings bound to it.
pub fn mappings_for<'a>(&'a self, key: &'a str) -> impl Iterator<Item = &'static str> + 'a {
MAPPINGS
.iter()
.map(|m| m.0)
.filter(move |n| self.get(n) == key)
}
/// Translate a physical key into the default key of the mapping it is
/// bound to, so game code written against vanilla defaults follows the
/// player's bindings. `None` means the key now does nothing; mouse
/// mappings bound to keys come back as `Err(button)`.
/// mappings bound to keys come back as `Err(button)`. The first of
/// [`KeyBinds::translate_all`].
pub fn translate(&self, key: KeyCode) -> Option<Result<KeyCode, MouseButton>> {
self.translate_all(key).into_iter().next()
}
/// The default keys of every mapping `key` is bound to (empty when it
/// now does nothing), so a key bound to two mappings (forward and
/// sprint) drives both, as `KeyMapping.set` does.
pub fn translate_all(&self, key: KeyCode) -> Vec<Result<KeyCode, MouseButton>> {
let Some(name) = key_name(key) else {
return Some(Ok(key));
return vec![Ok(key)];
};
if let Some(mapping) = self.mapping_for(name) {
let default = Self::default_of(mapping);
if let Some(code) = key_code(default) {
return Some(Ok(code));
}
return mouse_button(default).map(Err);
let bound: Vec<_> = self
.mappings_for(name)
.filter_map(|mapping| {
let default = Self::default_of(mapping);
match key_code(default) {
Some(code) => Some(Ok(code)),
None => mouse_button(default).map(Err),
}
})
.collect();
if !bound.is_empty() {
return distinct(bound);
}
// An unbound key whose default meaning was moved elsewhere is inert.
if MAPPINGS
.iter()
.any(|(n, d, _)| *d == name && !self.is_default(n))
{
return None;
return Vec::new();
}
Some(Ok(key))
vec![Ok(key)]
}
/// Translate a mouse button the same way (keys bound to mouse buttons).
pub fn translate_mouse(&self, button: MouseButton) -> Option<Result<KeyCode, MouseButton>> {
self.translate_mouse_all(button).into_iter().next()
}
/// [`KeyBinds::translate_all`] for a mouse button.
pub fn translate_mouse_all(&self, button: MouseButton) -> Vec<Result<KeyCode, MouseButton>> {
let name = mouse_name(button);
if let Some(mapping) = self.mapping_for(&name) {
let default = Self::default_of(mapping);
if let Some(b) = mouse_button(default) {
return Some(Err(b));
}
return key_code(default).map(Ok);
let bound: Vec<_> = self
.mappings_for(&name)
.filter_map(|mapping| {
let default = Self::default_of(mapping);
match mouse_button(default) {
Some(b) => Some(Err(b)),
None => key_code(default).map(Ok),
}
})
.collect();
if !bound.is_empty() {
return distinct(bound);
}
if MAPPINGS
.iter()
.any(|(n, d, _)| *d == name && !self.is_default(n))
{
return None;
return Vec::new();
}
Some(Err(button))
vec![Err(button)]
}
/// `AbstractContainerScreen.checkHotbarMouseClicked`: off hand, then hotbar.
pub fn container_mouse_key(&self, button: MouseButton, creative: bool) -> Option<KeyCode> {
+3
View File
@@ -237,6 +237,8 @@ pub struct Guest {
pub drag: Option<(u8, Vec<usize>)>,
/// The compound this player was loaded from, for fields Horizon does not model.
pub saved: Option<horizon_world::nbt::Tag>,
/// `ServerPlayer.seenCredits`: the end poem has been shown.
pub seen_credits: bool,
/// `ServerPlayer.recipeBook`.
pub recipe_book: crate::recipe_book::Settings,
/// `PlayerAdvancements`: the criteria obtained, whether they came from
@@ -358,6 +360,7 @@ impl Guest {
next_window: 0,
drag: None,
saved: None,
seen_credits: false,
recipe_book: Default::default(),
advancements: Default::default(),
advancements_loaded: false,
@@ -85,6 +85,7 @@ pub fn apply(guest: &mut Guest, level: &Level) {
.map(|s| [s[0] + 0.5, s[1], s[2] + 0.5]);
guest.respawn_elsewhere = level.respawn_elsewhere.clone();
guest.saved = level.player_extra.clone();
guest.seen_credits = level.seen_credits;
let extra = level.player_extra.as_ref();
let short = |key: &str, default: i64| {
extra
@@ -166,6 +167,7 @@ pub fn store(guest: &Guest, level: &mut Level) {
level.spawn = guest.spawn.map(|s| [s[0] - 0.5, s[1], s[2] - 0.5]);
level.respawn_elsewhere = guest.respawn_elsewhere.clone();
level.on_ground = guest.on_ground;
level.seen_credits = guest.seen_credits;
level.player_extra = guest.saved.clone();
// The live body's fire, air and fall distance (Entity.saveWithoutId),
// or what the file had while the player has no body yet.
+8
View File
@@ -709,6 +709,9 @@ pub struct Extra {
/// A copper golem's `openedChestPos`.
pub opened_chest: Option<[i32; 3]>,
pub point: Option<[f64; 3]>,
/// `NEAREST_REPELLENT`: the nearest repelling block a hoglin or piglin
/// sensed, looked up every 20 ticks.
pub repellent: Option<[i32; 3]>,
pub vector: [f64; 3],
/// Active status effects: (effect id, ticks left, amplifier).
pub effects: Vec<(&'static str, i32, i32)>,
@@ -868,6 +871,11 @@ pub struct Mob {
/// Events a mob tick asks the game to act on.
#[derive(Clone, Debug)]
pub enum MobEvent {
/// Squid.handleEntityEvent(19), resetting the remote tentacle cycle.
SquidCycle {
mob: u32,
at: [f64; 3],
},
/// Rabbit entity event 1, sent only when a ground jump actually happens.
RabbitHop {
mob: u32,
+14
View File
@@ -1242,6 +1242,20 @@ pub fn walk_target_value(m: &Mob, w: &MobWorld, p: [i32; 3]) -> f32 {
if m.species() == Species::Strider {
return super::strider::walk_target_value(m, w, p);
}
// Hoglin.getWalkTargetValue: nowhere near a repellent, crimson nylium best.
if m.species() == Species::Hoglin {
let near = m.extra.repellent.is_some_and(|r| {
let d = |i: usize| i64::from(r[i] - p[i]);
d(0) * d(0) + d(1) * d(1) + d(2) * d(2) < 64
});
return if near {
-1.0
} else if w.block_name(p[0], p[1] - 1, p[2]) == "minecraft:crimson_nylium" {
10.0
} else {
0.0
};
}
// Bee.getWalkTargetValue: any air block.
if m.species() == Species::Bee {
return if w.facts.is_air(w.world.block(p[0], p[1], p[2])) {
+50 -11
View File
@@ -234,12 +234,40 @@ pub fn ai_step(m: &mut Mob, w: &MobWorld, events: &mut Vec<MobEvent>) {
}
/// Squid.aiStep: tentacle cycle, propulsion and body rotation.
fn squid_ai_step(m: &mut Mob) {
squid_step(m, false);
}
/// Squid.aiStep also runs on remote entities: animate from packet velocity,
/// without running goals or replacing that velocity with the server's movement vector.
pub fn squid_client_step(m: &mut Mob) {
if matches!(m.species(), Species::Squid | Species::GlowSquid) {
m.anim.vo = m.anim.v;
squid_step(m, true);
}
}
/// Squid.handleEntityEvent(19): begin the next server-synchronised tentacle cycle.
pub fn squid_cycle_event(m: &mut Mob) {
if matches!(m.species(), Species::Squid | Species::GlowSquid) {
m.anim.v[TENTACLE_MOVEMENT] = 0.0;
}
}
fn squid_step(m: &mut Mob, client: bool) {
let v = &mut m.anim.v;
v[TENTACLE_MOVEMENT] += v[TENTACLE_SPEED];
if v[TENTACLE_MOVEMENT] > PI * 2.0 {
v[TENTACLE_MOVEMENT] -= PI * 2.0;
if m.random.next_int_bound(10).unwrap_or(1) == 0 {
m.anim.v[TENTACLE_SPEED] = 1.0 / (m.random.next_float() + 1.0) * 0.2;
if f64::from(v[TENTACLE_MOVEMENT]) > std::f64::consts::TAU {
if client {
v[TENTACLE_MOVEMENT] = PI * 2.0;
} else {
v[TENTACLE_MOVEMENT] -= PI * 2.0;
if m.random.next_int_bound(10).unwrap_or(1) == 0 {
m.anim.v[TENTACLE_SPEED] = 1.0 / (m.random.next_float() + 1.0) * 0.2;
}
m.outbox.push(MobEvent::SquidCycle {
mob: m.id,
at: [m.position.x, m.position.y, m.position.z],
});
}
}
let v = &mut m.anim.v;
@@ -248,28 +276,39 @@ fn squid_ai_step(m: &mut Mob) {
let scale = v[TENTACLE_MOVEMENT] / PI;
v[TENTACLE_ANGLE] = (scale * scale * PI).msin() * PI * 0.25;
if scale > 0.75 {
let mv = m.extra.vector;
m.velocity = Vec3::new(mv[0], mv[1], mv[2]);
if !client {
let mv = m.extra.vector;
m.velocity = Vec3::new(mv[0], mv[1], mv[2]);
}
m.anim.v[ROTATE_SPEED] = 1.0;
} else {
m.anim.v[ROTATE_SPEED] *= 0.8;
}
} else {
v[TENTACLE_ANGLE] = 0.0;
m.velocity = Vec3::new(m.velocity.x * 0.9, m.velocity.y * 0.9, m.velocity.z * 0.9);
if !client {
m.velocity = Vec3::new(m.velocity.x * 0.9, m.velocity.y * 0.9, m.velocity.z * 0.9);
}
m.anim.v[ROTATE_SPEED] *= 0.99;
}
let mv = m.velocity;
let horizontal = (mv.x * mv.x + mv.z * mv.z).sqrt();
m.body_yaw += (-(mv.x.atan2(mv.z) as f32).to_degrees() - m.body_yaw) * 0.1;
m.body_yaw += (-(horizon_core::geometry::trig::atan2(mv.x, mv.z) as f32) * 57.295776
- m.body_yaw)
* 0.1;
m.yaw = m.body_yaw;
let v = &mut m.anim.v;
v[Z_BODY_ROT] += PI * v[ROTATE_SPEED] * 1.5;
v[X_BODY_ROT] += (-(horizontal.atan2(mv.y) as f32).to_degrees() - v[X_BODY_ROT]) * 0.1;
v[X_BODY_ROT] += (-(horizon_core::geometry::trig::atan2(horizontal, mv.y) as f32)
* 57.295776
- v[X_BODY_ROT])
* 0.1;
} else {
v[TENTACLE_ANGLE] = v[TENTACLE_MOVEMENT].msin().abs() * PI * 0.25;
let yd = m.velocity.y - m.kind.gravity;
m.velocity = Vec3::new(0.0, yd * 0.98, 0.0);
if !client {
let yd = m.velocity.y - m.kind.gravity;
m.velocity = Vec3::new(0.0, yd * 0.98, 0.0);
}
let v = &mut m.anim.v;
v[X_BODY_ROT] += (-90.0 - v[X_BODY_ROT]) * 0.02;
}
+1
View File
@@ -304,6 +304,7 @@ fn loot_stack(item: &str, count: i32) -> LootStack {
map: false,
exploration: None,
ominous_amplifier: None,
stew_effects: Vec::new(),
}
}
/// `Mob.dropChances.byEquipment(slot)`: the recorded chance, or the one the
+83 -3
View File
@@ -9,7 +9,7 @@ use super::{
NearestTarget, Prey, RandomLookAround, Stroll, goal_clone,
},
};
use horizon_core::geometry::trig::Mth;
use horizon_core::geometry::{Vec3, trig::Mth};
use horizon_core::random::RandomSource;
use std::f32::consts::PI;
@@ -83,12 +83,16 @@ pub fn register_hoglin(m: &mut Mob) {
g.add(1, BabyPanic(super::goals::Panic::new(1.25)));
g.add(2, MeleeAttack::new(1.0, true));
g.add(3, super::goals::Breed::new(1.0));
g.add(3, HoglinAvoidRepellent);
g.add(4, super::goals::FollowParent::new(1.1));
g.add(5, Stroll::avoiding_water(0.4));
g.add(6, LookAtPlayer::new(8.0));
m.ai.targets.add(1, HurtByTarget::new().alert());
m.ai.targets
.add(2, NearestTarget::player(true).when(|m, _| !m.is_baby()));
// `HoglinAi.findNearestValidAttackTarget`: nothing while pacified.
m.ai.targets.add(
2,
NearestTarget::player(true).when(|m, _| !m.is_baby() && m.extra.ints[0] <= 0),
);
}
pub fn register_zoglin(m: &mut Mob) {
let g = &mut m.ai.goals;
@@ -108,6 +112,49 @@ pub fn register_zoglin(m: &mut Mob) {
.interval(10),
);
}
/// `SetWalkTargetAwayFrom.pos(NEAREST_REPELLENT, 1.0, 8, true)` of the hoglin's
/// idle activity: closer than 8 blocks to a repellent, walk away from it.
#[derive(Clone, Debug)]
struct HoglinAvoidRepellent;
impl Goal for HoglinAvoidRepellent {
goal_clone!();
fn flags(&self) -> u8 {
MOVE
}
fn can_use(&mut self, m: &mut Mob, cx: &mut Cx) -> bool {
let Some(r) = m.extra.repellent else {
return false;
};
// Vec3.atBottomCenterOf.
let avoid = [
f64::from(r[0]) + 0.5,
f64::from(r[1]),
f64::from(r[2]) + 0.5,
];
if m.distance_sqr(Vec3::new(avoid[0], avoid[1], avoid[2])) >= 64.0 {
return false;
}
for _ in 0..10 {
if let Some(p) = m.land_random_pos_away(cx.w, 16, 7, avoid) {
let at = [
p[0].floor() as i32,
p[1].floor() as i32,
p[2].floor() as i32,
];
m.ai.pending_path = m.create_path_to(cx.w, at, 0);
return m.ai.pending_path.is_some();
}
}
false
}
fn can_continue(&mut self, m: &mut Mob, _: &mut Cx) -> bool {
!m.ai.nav.is_done()
}
fn start(&mut self, m: &mut Mob, _: &mut Cx) {
let path = m.ai.pending_path.take();
m.nav_move_to_path(path, 1.0);
}
}
/// Baby hoglins flee what hurts them; adults stand their ground.
#[derive(Clone, Debug)]
struct BabyPanic(super::goals::Panic);
@@ -165,12 +212,45 @@ impl Goal for GoToWantedItem {
m.extra.point = None;
}
}
/// `HoglinSpecificSensor.findNearestRepellent`: the nearest `#hoglin_repellents`
/// block within 8 blocks sideways and 4 up or down.
fn find_hoglin_repellent(m: &Mob, w: &MobWorld) -> Option<[i32; 3]> {
let tag = w.block_tags.get("minecraft:hoglin_repellents")?;
let at = [
m.position.x.floor() as i32,
m.position.y.floor() as i32,
m.position.z.floor() as i32,
];
w.nearest_block(at, 8, 4, |_, name| tag.contains(name))
}
/// The hoglin's repellent memories: the sensor (every 20 ticks), the end of
/// `PACIFIED` (`ints[0]`), a hit ending it (`HoglinAi.wasHurtBy`, `ints[1]`
/// remembers the hit seen) and `BecomePassiveIfMemoryPresent(NEAREST_REPELLENT, 200)`.
fn hoglin_repellent(m: &mut Mob, w: &MobWorld) {
if m.extra.ints[0] > 0 {
m.extra.ints[0] -= 1;
}
if m.ai.last_hurt_by.is_some() && m.ai.last_hurt_by_time != m.extra.ints[1] {
m.extra.ints[1] = m.ai.last_hurt_by_time;
m.extra.ints[0] = 0;
}
if m.age.wrapping_add(m.id).is_multiple_of(20) {
m.extra.repellent = find_hoglin_repellent(m, w);
}
if m.extra.ints[0] <= 0 && m.extra.repellent.is_some() {
m.extra.ints[0] = 200;
m.ai.target = None;
}
}
/// Per-tick piglin and hoglin state: admiring, anger, zombification and the
/// hoglin attack animation. Returns a conversion when it happens.
pub fn ai_step(m: &mut Mob, w: &MobWorld, events: &mut Vec<MobEvent>) {
if m.extra.anim_ticks > 0 {
m.extra.anim_ticks -= 1;
}
if m.species() == Species::Hoglin && m.alive() {
hoglin_repellent(m, w);
}
if m.extra.anger > 0 {
m.extra.anger -= 1;
if m.extra.anger == 0 && m.ai.target == Some(Who::Player) && !hostile_to_player(m, w) {
+29
View File
@@ -65,6 +65,35 @@ impl MobWorld<'_> {
pub fn has_tag(&self, tag: &str, block: &str) -> bool {
self.block_tags.get(tag).is_some_and(|s| s.contains(block))
}
/// `findBlocksInBoxByManhattanDistance(at, range_xz, range_y).filterState(..).findFirst()`:
/// the first block `matches` accepts (given its position and name), going
/// out from `at` by Manhattan distance as `BlockPos.withinBoxByManhattanDistance` does.
pub fn nearest_block(
&self,
at: [i32; 3],
range_xz: i32,
range_y: i32,
matches: impl Fn([i32; 3], &str) -> bool,
) -> Option<[i32; 3]> {
// Visiting order: distance, then x, then y, then +z before -z.
let mut best: Option<((i32, i32, i32, bool), [i32; 3])> = None;
for dx in -range_xz..=range_xz {
for dy in -range_y..=range_y {
for dz in -range_xz..=range_xz {
let key = (dx.abs() + dy.abs() + dz.abs(), dx, dy, dz < 0);
if best.is_some_and(|(k, _)| k <= key) {
continue;
}
let p = [at[0] + dx, at[1] + dy, at[2] + dz];
let state = self.world.block(p[0], p[1], p[2]);
if !self.facts.is_air(state) && matches(p, &self.facts.get(state).name) {
best = Some((key, p));
}
}
}
}
best.map(|(_, p)| p)
}
/// Level.clip with COLLIDER shapes (optionally stopping at any fluid): true on a miss.
pub fn clear_between(&self, from: Vec3, to: Vec3, fluids_block: bool) -> bool {
let mut scene = SceneCollisions {
+14 -6
View File
@@ -1083,7 +1083,7 @@ impl MotionWorld<'_, '_, '_> {
/// `Entity.applyEffectsFromBlocks` for the blocks Horizon's player reacts
/// to inside its movement path: powder snow (`PowderSnowBlock.entityInside`
/// and the FREEZE / EXTINGUISH effects), cobwebs, sweet berry bushes,
/// cactus and lit campfires, and the magma block it stands on
/// cactus and lit campfires, and the magma and slime blocks it stands on
/// (`stepOn`). Pressure plates, tripwires and rails are the world tick's
/// scan.
pub fn apply_effects_from_blocks(&mut self) -> Result<()> {
@@ -1141,14 +1141,22 @@ impl MotionWorld<'_, '_, '_> {
// MagmaBlock's hot floor hurts unless it steps carefully (sneaks).
if self.player.ground.on_ground {
let on = self.legacy_on_position()?;
let magma = self
.assets
.registry
.state(self.block(on))
.is_some_and(|s| s.name == "minecraft:magma_block");
let under = self.assets.registry.state(self.block(on));
let magma = under.is_some_and(|s| s.name == "minecraft:magma_block");
let slime = under.is_some_and(|s| s.name == "minecraft:slime_block");
if magma && !self.player.shift_key_down {
self.environment_hurt("minecraft:hot_floor", 1.0)?;
}
// SlimeBlock.stepOn: a slow walk on slime drags the horizontal
// speed (the client moves its own player, so not the server's copy).
if slime && !damage_only && !self.player.shift_key_down {
let abs_delta_y = self.player.velocity.y.abs();
if abs_delta_y < 0.1 {
let scale = 0.4 + abs_delta_y * 0.2;
self.player.velocity.x *= scale;
self.player.velocity.z *= scale;
}
}
}
let mut visited = std::collections::HashSet::new();
let mut entered = Vec::new();
+661 -35
View File
@@ -36,7 +36,7 @@ use horizon_world::{
worldgen::terrain::TerrainBlockStates,
};
use std::{
collections::{BTreeMap, HashMap},
collections::{BTreeMap, HashMap, HashSet},
fs,
path::{Path, PathBuf},
sync::{
@@ -247,6 +247,44 @@ pub struct SaveTables {
block_entity: Vec<bool>,
heightmaps: crate::assets::Properties,
air: u32,
/// The POI type and its max tickets of each state (`PoiTypes`).
poi: Vec<Option<(&'static str, i32)>>,
}
/// `PoiTypes.register`: the POI type of a block state and its max tickets.
pub fn poi_type(state: &horizon_world::chunk::BlockState) -> Option<(&'static str, i32)> {
let name = state.name.strip_prefix("minecraft:")?;
Some(match name {
"blast_furnace" => ("minecraft:armorer", 1),
"smoker" => ("minecraft:butcher", 1),
"cartography_table" => ("minecraft:cartographer", 1),
"brewing_stand" => ("minecraft:cleric", 1),
"composter" => ("minecraft:farmer", 1),
"barrel" => ("minecraft:fisherman", 1),
"fletching_table" => ("minecraft:fletcher", 1),
"cauldron" | "lava_cauldron" | "water_cauldron" | "powder_snow_cauldron" => {
("minecraft:leatherworker", 1)
}
"lectern" => ("minecraft:librarian", 1),
"stonecutter" => ("minecraft:mason", 1),
"loom" => ("minecraft:shepherd", 1),
"smithing_table" => ("minecraft:toolsmith", 1),
"grindstone" => ("minecraft:weaponsmith", 1),
"bell" => ("minecraft:meeting", 32),
"beehive" => ("minecraft:beehive", 0),
"bee_nest" => ("minecraft:bee_nest", 0),
"nether_portal" => ("minecraft:nether_portal", 0),
"lodestone" => ("minecraft:lodestone", 0),
"test_instance_block" => ("minecraft:test_instance", 0),
// PoiTypes.BEDS is the sixteen colours: no straw bed.
bed if bed.ends_with("_bed") && bed != "straw_bed" => {
if state.properties.get("part").map(String::as_str) != Some("head") {
return None;
}
("minecraft:home", 1)
}
rod if rod.ends_with("lightning_rod") => ("minecraft:lightning_rod", 0),
_ => return None,
})
}
impl SaveTables {
pub fn new(assets: &crate::assets::Assets) -> Self {
@@ -275,6 +313,9 @@ impl SaveTables {
block_entity: Vec::new(),
heightmaps: assets.properties.clone(),
air: registry.default_state("minecraft:air").map_or(0, |s| s.0),
poi: (0..registry.len())
.map(|id| registry.state(BlockStateId(id as u32)).and_then(poi_type))
.collect(),
}
}
/// Tables with only the heightmaps' state facts, for tests.
@@ -287,6 +328,7 @@ impl SaveTables {
block_entity: Vec::new(),
heightmaps,
air: 0,
poi: Vec::new(),
}
}
/// Which states carry a block entity (`BlockFacts`), to drop entries for
@@ -297,6 +339,35 @@ impl SaveTables {
}
}
/// A container's loot table and what Horizon rolled from it. Vanilla keeps
/// `LootTable` and `LootTableSeed` until the container is first opened;
/// Horizon rolls when it generates or loads the chunk, and saves the table
/// again while the container still holds exactly the rolled items.
#[derive(Clone, Debug, PartialEq)]
pub struct RolledLoot {
pub pos: (i32, i32, i32),
pub table: String,
/// The saved `LootTableSeed`, written back unless 0 (trySaveLootTable).
pub seed: Option<i64>,
/// The rolled `Items`.
pub items: Tag,
}
impl RolledLoot {
/// Puts the loot table back in place of `Items` while they are still the
/// roll; false once the container holds anything else.
pub fn restore(&self, map: &mut Compound) -> bool {
if map.get(&"Items".into()) != Some(&self.items) {
return false;
}
map.remove(&"Items".into());
map.insert("LootTable".into(), string(&self.table));
if let Some(seed) = self.seed.filter(|s| *s != 0) {
map.insert("LootTableSeed".into(), Tag::Long(seed));
}
true
}
}
/// Save data loaded with a chunk that Horizon keeps but does not simulate.
#[derive(Clone, Debug, Default)]
pub struct ChunkExtras {
@@ -318,6 +389,8 @@ pub struct ChunkExtras {
pub fluid_ticks: Vec<SavedTick>,
/// Game time written as `LastUpdate`.
pub last_update: i64,
/// Containers still holding their loot table's roll.
pub rolled_loot: Vec<RolledLoot>,
}
fn tick_list(ticks: &[SavedTick], name: impl Fn(u32) -> Option<String>) -> Tag {
@@ -600,6 +673,34 @@ pub fn decode_banner(tag: &Tag) -> crate::containers::Banner {
},
}
}
/// A loaded container with a loot table and no items yet, rolled as
/// generation rolls it (Horizon does not unpack on first open).
fn roll_loaded_loot(
chunk: &Chunk,
biomes: &Biomes,
tag: &Tag,
be: &mut ((i32, i32, i32), crate::containers::BlockEntity),
) -> Option<RolledLoot> {
let crate::containers::BlockEntity::Chest(slots) = &mut be.1 else {
return None;
};
let table = text(tag.get("LootTable"))?;
if slots.iter().any(Option::is_some) {
return None;
}
let loot = crate::structure_blocks::loaded_loot_data()?;
let (x, y, z) = be.0;
let biome = &biomes.get(chunk.biome(x & 15, y, z & 15)).name;
let seed = crate::structure_blocks::loot_seed(tag);
Some(crate::structure_blocks::roll_container(
slots,
Some(&loot),
table,
seed,
be.0,
biome,
))
}
pub fn decode_block_entity(tag: &Tag) -> Option<((i32, i32, i32), crate::containers::BlockEntity)> {
use crate::containers::BlockEntity;
let int = |k: &str| number(tag.get(k)).map(|v| v as i32);
@@ -754,6 +855,103 @@ pub fn decode_block_entity(tag: &Tag) -> Option<((i32, i32, i32), crate::contain
Some((pos, entity))
}
/// The chunk's points of interest as `PoiManager` saves them: each section
/// with POI blocks, valid, with a record per block at its max tickets (a
/// freshly made world). None when the chunk has none.
pub fn encode_poi(chunk: &Chunk, tables: &SaveTables) -> Option<NamedTag> {
let kind = |v: u32| tables.poi.get(v as usize).copied().flatten();
let mut sections = Compound::new();
let mut states = [0u32; 4096];
for (si, section) in chunk.sections.iter().enumerate() {
if !section.blocks.maybe_has(|v| kind(v).is_some()) {
continue;
}
section.unpack(&mut states);
let y = (MIN_Y >> 4) + si as i32;
let records: Vec<Tag> = states
.iter()
.enumerate()
.filter_map(|(i, &v)| {
let (id, tickets) = kind(v)?;
let i = i as i32;
let pos = [
chunk.x * 16 + (i & 15),
y * 16 + (i >> 8),
chunk.z * 16 + ((i >> 4) & 15),
];
let mut record = vec![("pos", Tag::IntArray(pos.to_vec())), ("type", string(id))];
// PoiRecord.CODEC: free_tickets defaults to 0 and is then left out.
if tickets != 0 {
record.push(("free_tickets", Tag::Int(tickets)));
}
Some(Tag::compound(record))
})
.collect();
if !records.is_empty() {
sections.insert(
y.to_string().as_str().into(),
Tag::compound([("Valid", Tag::Byte(1)), ("Records", Tag::List(records))]),
);
}
}
(!sections.is_empty()).then(|| poi_root(sections))
}
/// A record's `free_tickets` from the POI file the chunk had, while the same
/// POI is still there: a bed or job site a villager claimed stays claimed.
fn keep_claims(saved: &mut NamedTag, old: &NamedTag) {
let key = |r: &Tag| match (r.get("pos"), text(r.get("type"))) {
(Some(Tag::IntArray(pos)), Some(kind)) => Some((pos.clone(), kind)),
_ => None,
};
let mut claims = HashMap::new();
if let Some(Tag::Compound(sections)) = old.tag.get("Sections") {
for section in sections.values() {
if let Some(Tag::List(records)) = section.get("Records") {
for r in records {
if let Some(k) = key(r) {
claims.insert(k, number(r.get("free_tickets")).unwrap_or(0) as i32);
}
}
}
}
}
let Tag::Compound(root) = &mut saved.tag else {
return;
};
let Some(Tag::Compound(sections)) = root.get_mut(&"Sections".into()) else {
return;
};
for section in sections.values_mut() {
let Tag::Compound(section) = section else {
continue;
};
let Some(Tag::List(records)) = section.get_mut(&"Records".into()) else {
continue;
};
for r in records {
let Some(&tickets) = key(r).and_then(|k| claims.get(&k)) else {
continue;
};
if let Tag::Compound(r) = r {
if tickets == 0 {
r.remove(&"free_tickets".into());
} else {
r.insert("free_tickets".into(), Tag::Int(tickets));
}
}
}
}
}
fn poi_root(sections: Compound) -> NamedTag {
NamedTag {
name: "".into(),
tag: Tag::compound([
("DataVersion", Tag::Int(DATA_VERSION)),
("Sections", Tag::Compound(sections)),
]),
}
}
/// What a dimension's saved chunks depend on: skylight and the build height
/// (`DimensionType.minY` and `height`). `Chunk` always holds the Overworld's
/// sections; a level saves only the ones inside its own height.
@@ -1039,7 +1237,16 @@ pub fn encode_with(
}
None => current,
};
crate::block_entity_nbt::canonical(&merged).unwrap_or(merged)
let mut saved = crate::block_entity_nbt::canonical(&merged).unwrap_or(merged);
if let Some(r) = extras.and_then(|x| x.rolled_loot.iter().find(|r| r.pos == *p))
&& let Tag::Compound(map) = &mut saved
&& !r.restore(map)
{
// A loaded source still carries the table; kept, it rolls again on load.
map.remove(&"LootTable".into());
map.remove(&"LootTableSeed".into());
}
saved
})
.collect();
if let Some(extras) = extras {
@@ -1097,6 +1304,25 @@ pub fn encode_with(
.collect();
s.insert("References".into(), Tag::Compound(refs));
}
let starts: Compound = chunk
.structure_starts
.iter()
.filter_map(|start| match start.save() {
Some(tag) => Some((start.name.as_str().into(), tag)),
None => {
log::warn!(
"Horizon: structure start {} not saved: one of its pieces cannot be",
start.name
);
None
}
})
.collect();
if !starts.is_empty()
&& let Some(Tag::Compound(s)) = map.get_mut(&"structures".into())
{
s.insert("starts".into(), Tag::Compound(starts));
}
map.entry("PostProcessing".into())
.or_insert_with(|| Tag::List(vec![Tag::List(Vec::new()); shape.section_count()]));
}
@@ -1269,10 +1495,13 @@ fn decode_column(
extras.block_entity_order.push((x, y, z));
}
match decode_block_entity(e) {
Some(be) => {
Some(mut be) => {
if let Some(name) = text(e.get("CustomName")) {
chunk.block_names.push((be.0, name));
}
if let Some(rolled) = roll_loaded_loot(&chunk, biomes, e, &mut be) {
extras.rolled_loot.push(rolled);
}
extras.block_entity_sources.push((be.0, e.clone()));
chunk.block_entities.push(be);
}
@@ -1636,6 +1865,9 @@ enum Record {
Tag(Arc<NamedTag>),
/// A chunk to encode on the writer thread.
Chunk(Arc<Chunk>, Arc<Encoding>),
/// A chunk's points of interest, made on the writer thread against the
/// saved ones (`resolve_poi`).
Poi(Arc<Chunk>, Arc<SaveTables>),
Delete,
}
/// What encoding a chunk needs, captured when it is queued.
@@ -1656,14 +1888,86 @@ impl Record {
e.tables.as_deref(),
e.shape,
))),
Record::Poi(c, t) => encode_poi(c, t).map(Arc::new),
Record::Delete => None,
}
}
}
/// A queued POI record as it is written: claims kept from the saved one, and
/// a chunk that lost its last POI gets an empty one, as vanilla trusts a
/// saved section. None when the chunk never had any.
fn resolve_poi(
root: &Path,
cache: &RegionCache,
(x, z): (i32, i32),
record: &Record,
) -> Option<Record> {
let Record::Poi(chunk, tables) = record else {
return Some(record.clone());
};
let old = read_region_record(root, cache, "poi", x, z).unwrap_or_else(|e| {
log::warn!("Horizon: could not read the poi record of chunk {x} {z}: {e}");
None
});
let tag = match (encode_poi(chunk, tables), old) {
(Some(mut tag), Some(old)) => {
keep_claims(&mut tag, &old);
tag
}
(Some(tag), None) => tag,
(None, Some(_)) => poi_root(Compound::new()),
(None, None) => return None,
};
Some(Record::Tag(Arc::new(tag)))
}
fn read_region_record(
root: &Path,
cache: &RegionCache,
folder: &str,
x: i32,
z: i32,
) -> Result<Option<NamedTag>> {
let key = (x >> 5, z >> 5);
let path = region_path(root, folder, key.0, key.1);
let Some(region) = cached_region(cache, key, &path, false)? else {
return Ok(None);
};
let record = region
.lock()
.map_err(|_| "region file poisoned")?
.read(ChunkPos::new(x, z))?;
Ok(match record {
Some(r) => Some(r.nbt(Limits::default())?),
None => None,
})
}
/// Chunk and entity records waiting for the writer thread, by (entities?,
/// x, z): reads see them before the region files do.
type PendingWrites = Mutex<HashMap<(bool, i32, i32), (u64, Record)>>;
type WriteBatch = (bool, Vec<((i32, i32), Record, u64)>);
type PendingWrites = Mutex<HashMap<(Folder, i32, i32), (u64, Record)>>;
type WriteBatch = (Folder, Vec<((i32, i32), Record, u64)>);
/// The region folders of a dimension.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
enum Folder {
Region,
Entities,
Poi,
}
impl Folder {
fn of(name: &str) -> Self {
match name {
"entities" => Self::Entities,
"poi" => Self::Poi,
_ => Self::Region,
}
}
fn name(self) -> &'static str {
match self {
Self::Region => "region",
Self::Entities => "entities",
Self::Poi => "poi",
}
}
}
/// The background thread that encodes chunks and writes them into the region files, in
/// submission order.
@@ -1685,6 +1989,7 @@ pub struct SaveDir {
pub root: PathBuf,
regions: Arc<RegionCache>,
entity_regions: Arc<RegionCache>,
poi_regions: Arc<RegionCache>,
pending: Arc<PendingWrites>,
sequence: AtomicU64,
writer: Mutex<Option<Writer>>,
@@ -1694,6 +1999,9 @@ pub struct SaveDir {
/// The dimension's skylight and height, from its folder name.
pub shape: LevelShape,
}
fn is_forced_ticket(ticket: &Tag) -> bool {
matches!(ticket.get("type"), Some(Tag::String(t)) if *t == JavaString::from("minecraft:forced"))
}
impl SaveDir {
pub fn new(root: PathBuf) -> Self {
let shape = LevelShape::for_dir(&root);
@@ -1701,6 +2009,7 @@ impl SaveDir {
root,
regions: Default::default(),
entity_regions: Default::default(),
poi_regions: Default::default(),
pending: Default::default(),
sequence: AtomicU64::new(0),
writer: Mutex::new(None),
@@ -1709,6 +2018,50 @@ impl SaveDir {
shape,
}
}
/// 26.3's TicketStorage: persistent tickets live in each dimension's
/// data/minecraft/chunk_tickets.dat, with ChunkPos.CODEC's [x, z] int array.
pub fn load_forced_chunks(&self) -> HashSet<(i32, i32)> {
let Some(data) = read_saved_data(&self.root, "chunk_tickets") else {
return HashSet::new();
};
let Some(Tag::List(tickets)) = data.get("tickets") else {
return HashSet::new();
};
tickets
.iter()
.filter(|t| is_forced_ticket(t))
.filter_map(|t| match t.get("chunk_pos")? {
Tag::IntArray(pos) if pos.len() == 2 => Some((pos[0], pos[1])),
_ => None,
})
.collect()
}
pub fn save_forced_chunks(&self, forced: &HashSet<(i32, i32)>) -> Result<()> {
if forced.is_empty() && !saved_data_path(&self.root, "chunk_tickets").exists() {
return Ok(());
}
let mut data = compound_or_empty(read_saved_data(&self.root, "chunk_tickets"));
// Preserve persistent tickets Horizon does not simulate.
let mut tickets = match data.get("tickets") {
Some(Tag::List(tickets)) => tickets
.iter()
.filter(|t| !is_forced_ticket(t))
.cloned()
.collect::<Vec<_>>(),
_ => Vec::new(),
};
let mut chunks: Vec<_> = forced.iter().copied().collect();
chunks.sort_unstable();
tickets.extend(chunks.into_iter().map(|(x, z)| {
Tag::compound([
("type", Tag::String("minecraft:forced".into())),
("level", Tag::Int(31)), // ChunkMap.FORCED_TICKET_LEVEL: ENTITY_TICKING.
("chunk_pos", Tag::IntArray(vec![x, z])),
])
}));
put(&mut data, "tickets", Tag::List(tickets));
write_saved_data(&self.root, "chunk_tickets", data)
}
fn region_path(&self, folder: &str, rx: i32, rz: i32) -> PathBuf {
region_path(&self.root, folder, rx, rz)
}
@@ -1717,21 +2070,17 @@ impl SaveDir {
.pending
.lock()
.ok()?
.get(&(folder != "region", x, z))
.get(&(Folder::of(folder), x, z))
.map(|(_, r)| r.clone());
if let Some(record) = pending {
return record.tag().map(|t| (*t).clone());
}
let key = (x >> 5, z >> 5);
let cache = if folder == "region" {
&self.regions
} else {
&self.entity_regions
let cache = match Folder::of(folder) {
Folder::Region => &self.regions,
Folder::Entities => &self.entity_regions,
Folder::Poi => &self.poi_regions,
};
let path = self.region_path(folder, key.0, key.1);
let region = cached_region(cache, key, &path, false).ok()??;
let record = region.lock().ok()?.read(ChunkPos::new(x, z)).ok()??;
record.nbt(Limits::default()).ok()
read_region_record(&self.root, cache, folder, x, z).ok()?
}
pub fn load(&self, x: i32, z: i32, registry: &BlockRegistry, biomes: &Biomes) -> Option<Chunk> {
let root = self.read_record("region", x, z)?;
@@ -1791,16 +2140,16 @@ impl SaveDir {
.into_iter()
.map(|(k, t)| (k, t.map_or(Record::Delete, |t| Record::Tag(Arc::new(t)))))
.collect();
self.queue(folder != "region", records, wait)
self.queue(Folder::of(folder), records, wait)
}
fn queue(&self, entities: bool, records: Vec<((i32, i32), Record)>, wait: bool) -> Result<()> {
fn queue(&self, folder: Folder, records: Vec<((i32, i32), Record)>, wait: bool) -> Result<()> {
if !records.is_empty() {
let mut batch = Vec::with_capacity(records.len());
{
let mut pending = self.pending.lock().map_err(|_| "pending writes poisoned")?;
for (key, record) in records {
let seq = self.sequence.fetch_add(1, Ordering::Relaxed);
pending.insert((entities, key.0, key.1), (seq, record.clone()));
pending.insert((folder, key.0, key.1), (seq, record.clone()));
batch.push((key, record, seq));
}
}
@@ -1811,7 +2160,7 @@ impl SaveDir {
.map_err(|_| "writer poisoned")? += 1;
let mut writer = self.writer.lock().map_err(|_| "writer poisoned")?;
let writer = writer.get_or_insert_with(|| self.start_writer());
if writer.send.send((entities, batch)).is_err() {
if writer.send.send((folder, batch)).is_err() {
*self
.outstanding
.error
@@ -1834,30 +2183,36 @@ impl SaveDir {
fn start_writer(&self) -> Writer {
let (send, receive) = mpsc::channel::<WriteBatch>();
let root = self.root.clone();
let (regions, entity_regions) = (self.regions.clone(), self.entity_regions.clone());
let (regions, entity_regions, poi_regions) = (
self.regions.clone(),
self.entity_regions.clone(),
self.poi_regions.clone(),
);
let pending = self.pending.clone();
let outstanding = self.outstanding.clone();
let thread = std::thread::Builder::new()
.name("horizon-region-writer".into())
.spawn(move || {
for (entities, batch) in receive {
let (folder, cache) = if entities {
("entities", &entity_regions)
} else {
("region", &regions)
for (kind, batch) in receive {
let cache = match kind {
Folder::Region => &regions,
Folder::Entities => &entity_regions,
Folder::Poi => &poi_regions,
};
let folder = kind.name();
// IOWorker.pendingWrites: a chunk queued again since is
// written once, by its newest batch.
let newest = |k: &(i32, i32), seq: u64| {
pending.lock().map_or(true, |p| {
p.get(&(entities, k.0, k.1)).is_none_or(|p| p.0 <= seq)
p.get(&(kind, k.0, k.1)).is_none_or(|p| p.0 <= seq)
})
};
let records: Vec<_> = batch
let resolved: Vec<_> = batch
.iter()
.filter(|(k, _, seq)| newest(k, *seq))
.map(|(k, r, _)| (*k, r))
.filter_map(|(k, r, _)| Some((*k, resolve_poi(&root, cache, *k, r)?)))
.collect();
let records: Vec<_> = resolved.iter().map(|(k, r)| (*k, r)).collect();
let result = write_region_records(&root, cache, folder, &records);
if let Err(e) = &result {
let message = format!("failed to write {folder} files: {e}");
@@ -1869,11 +2224,8 @@ impl SaveDir {
&& let Ok(mut pending) = pending.lock()
{
for (k, _, seq) in &batch {
if pending
.get(&(entities, k.0, k.1))
.is_some_and(|p| p.0 == *seq)
{
pending.remove(&(entities, k.0, k.1));
if pending.get(&(kind, k.0, k.1)).is_some_and(|p| p.0 == *seq) {
pending.remove(&(kind, k.0, k.1));
}
}
}
@@ -1942,8 +2294,19 @@ impl SaveDir {
})
.collect();
self.write_records("region", records, true)?;
self.save_poi(chunks, true)?;
Ok(chunks.len())
}
fn save_poi(&self, chunks: &[Arc<Chunk>], wait: bool) -> Result<()> {
let Some(tables) = self.tables.get() else {
return Ok(());
};
let records = chunks
.iter()
.map(|c| ((c.x, c.z), Record::Poi(c.clone(), tables.clone())))
.collect();
self.queue(Folder::Poi, records, wait)
}
/// `save` on the writer thread: returns before the files are written;
/// loads see the chunks meanwhile.
pub fn save_later(
@@ -1962,7 +2325,8 @@ impl SaveDir {
.iter()
.map(|c| ((c.x, c.z), Record::Chunk(c.clone(), encoding.clone())))
.collect();
self.queue(false, records, false)?;
self.queue(Folder::Region, records, false)?;
self.save_poi(chunks, false)?;
Ok(chunks.len())
}
/// Write the entity chunks (`EntityStorage`): an empty list deletes it.
@@ -3407,7 +3771,7 @@ impl SaveDir {
.pending
.lock()
.ok()?
.get(&(false, x, z))
.get(&(Folder::Region, x, z))
.map(|(_, record)| record.clone());
let root = if let Some(record) = pending {
record.tag().map(|tag| (*tag).clone())?
@@ -3517,6 +3881,212 @@ impl<G: ChunkGenerator> ChunkGenerator for Persistent<G> {
#[cfg(test)]
mod tests {
#[test]
fn points_of_interest_save_as_poi_sections() {
let Some(root) = std::env::var_os("HORIZON_TEST_RESOURCES") else {
return;
};
let assets = crate::assets::Assets::load(&PathBuf::from(root)).expect("assets");
let tables = SaveTables::new(&assets);
let state = |name: &str, props: &[(&str, &str)]| {
let mut s = assets
.registry
.state(assets.registry.default_state(name).unwrap())
.unwrap()
.clone();
for (k, v) in props {
s.properties.insert((*k).into(), (*v).into());
}
assets.registry.id(&s).unwrap().0
};
let mut chunk = Chunk::empty(2, -1);
chunk.set(1, 70, 1, state("minecraft:red_bed", &[("part", "head")]));
chunk.set(1, 70, 2, state("minecraft:red_bed", &[("part", "foot")]));
chunk.set(5, 71, 5, state("minecraft:bell", &[]));
chunk.set(6, 71, 5, state("minecraft:stone", &[]));
chunk.set(9, 70, 9, state("minecraft:straw_bed", &[("part", "head")]));
assert!(encode_poi(&Chunk::empty(0, 0), &tables).is_none());
let saved = encode_poi(&chunk, &tables).expect("records");
let Some(Tag::Compound(sections)) = saved.tag.get("Sections") else {
panic!("no sections");
};
assert_eq!(sections.len(), 1);
let section = &sections[&"4".into()];
assert_eq!(section.get("Valid"), Some(&Tag::Byte(1)));
let Some(Tag::List(records)) = section.get("Records") else {
panic!("no records");
};
let records: Vec<_> = records
.iter()
.map(|r| {
(
text(r.get("type")).unwrap(),
r.get("pos").cloned(),
number(r.get("free_tickets")),
)
})
.collect();
assert_eq!(
records,
[
(
"minecraft:home".into(),
Some(Tag::IntArray(vec![33, 70, -15])),
Some(1)
),
(
"minecraft:meeting".into(),
Some(Tag::IntArray(vec![37, 71, -11])),
Some(32)
),
]
);
}
#[test]
fn a_chunk_that_loses_its_last_poi_saves_empty_sections() {
let Some(root) = std::env::var_os("HORIZON_TEST_RESOURCES") else {
return;
};
let root = PathBuf::from(root);
let assets = crate::assets::Assets::load(&root).expect("assets");
let biomes = Arc::new(Biomes::load(&root).expect("biomes"));
let bell = assets.registry.default_state("minecraft:bell").unwrap().0;
let dir = std::env::temp_dir().join(format!("horizon-poi-save-{}", std::process::id()));
fs::create_dir_all(&dir).unwrap();
let save = SaveDir::new(dir.clone());
let _ = save.tables.set(Arc::new(SaveTables::new(&assets)));
let mut chunk = Chunk::empty(0, 0);
chunk.set(5, 71, 5, bell);
let sections = |save: &SaveDir| match save.read_record("poi", 0, 0)?.tag.get("Sections") {
Some(Tag::Compound(s)) => Some(s.len()),
_ => None,
};
save.save_later(&[Arc::new(chunk.clone())], assets.registry, &biomes)
.unwrap();
save.flush().unwrap();
assert_eq!(sections(&save), Some(1));
chunk.set(5, 71, 5, 0);
save.save_later(&[Arc::new(chunk)], assets.registry, &biomes)
.unwrap();
save.flush().unwrap();
assert_eq!(sections(&SaveDir::new(dir.clone())), Some(0));
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn saved_poi_records_keep_their_claims() {
let record = |pos: [i32; 3], kind: &str, tickets: Option<i32>| {
let mut r = vec![("pos", Tag::IntArray(pos.to_vec())), ("type", string(kind))];
if let Some(t) = tickets {
r.push(("free_tickets", Tag::Int(t)));
}
Tag::compound(r)
};
let poi = |records: Vec<Tag>| {
let mut sections = Compound::new();
sections.insert(
"4".into(),
Tag::compound([("Valid", Tag::Byte(1)), ("Records", Tag::List(records))]),
);
poi_root(sections)
};
let old = poi(vec![
record([1, 70, 1], "minecraft:home", None),
record([5, 71, 5], "minecraft:meeting", Some(30)),
]);
let mut saved = poi(vec![
record([1, 70, 1], "minecraft:home", Some(1)),
record([5, 71, 5], "minecraft:meeting", Some(32)),
record([9, 71, 9], "minecraft:home", Some(1)),
]);
keep_claims(&mut saved, &old);
let expected = poi(vec![
record([1, 70, 1], "minecraft:home", None),
record([5, 71, 5], "minecraft:meeting", Some(30)),
record([9, 71, 9], "minecraft:home", Some(1)),
]);
assert_eq!(saved.tag, expected.tag);
}
#[test]
fn unopened_rolled_containers_save_their_loot_table() {
let Some(root) = std::env::var_os("HORIZON_TEST_RESOURCES") else {
return;
};
let root = PathBuf::from(root);
let assets = crate::assets::Assets::load(&root).expect("assets");
let biomes = Biomes::load(&root).expect("biomes");
let tables = SaveTables::new(&assets);
let chest = assets.registry.default_state("minecraft:chest").unwrap();
let mut slots = vec![None; 27];
slots[3] = Some(crate::items::ItemStack::new("minecraft:bone", 2));
let mut chunk = Chunk::empty(0, 0);
chunk.set(1, 64, 2, chest.0);
chunk.block_entities.push((
(1, 64, 2),
crate::containers::BlockEntity::Chest(slots.clone()),
));
let rolled = RolledLoot {
pos: (1, 64, 2),
table: "minecraft:chests/simple_dungeon".into(),
seed: Some(42),
items: encode_items(&slots),
};
chunk.saved = Some(Arc::new(ChunkExtras {
rolled_loot: vec![rolled],
..ChunkExtras::default()
}));
let saved_chest = |chunk: &Chunk| -> Tag {
let saved = encode_with(
chunk,
assets.registry,
&biomes,
Some(&tables),
LevelShape::OVERWORLD,
);
let Some(Tag::List(list)) = saved.tag.get("block_entities") else {
panic!("no block entities");
};
list[0].clone()
};
let unopened = saved_chest(&chunk);
assert_eq!(
text(unopened.get("LootTable")).as_deref(),
Some("minecraft:chests/simple_dungeon")
);
assert_eq!(number(unopened.get("LootTableSeed")), Some(42));
assert!(unopened.get("Items").is_none());
// Taking an item makes the items the container's own.
if let crate::containers::BlockEntity::Chest(s) = &mut chunk.block_entities[0].1 {
s[3] = None;
s[4] = Some(crate::items::ItemStack::new("minecraft:bone", 1));
}
let opened = saved_chest(&chunk);
assert!(opened.get("LootTable").is_none());
assert!(opened.get("Items").is_some());
// Loaded with its table, rolled, then emptied: no table, or it refills on load.
let mut extras = ChunkExtras::clone(chunk.saved.as_ref().unwrap());
extras.block_entity_sources = vec![(
(1, 64, 2),
Tag::compound([
("id", string("minecraft:chest")),
("x", Tag::Int(1)),
("y", Tag::Int(64)),
("z", Tag::Int(2)),
("LootTable", string("minecraft:chests/simple_dungeon")),
("LootTableSeed", Tag::Long(42)),
]),
)];
chunk.saved = Some(Arc::new(extras));
if let crate::containers::BlockEntity::Chest(s) = &mut chunk.block_entities[0].1 {
s.fill(None);
}
let emptied = saved_chest(&chunk);
assert!(emptied.get("LootTable").is_none());
assert!(emptied.get("LootTableSeed").is_none());
}
#[test]
fn nether_and_end_chunks_save_with_their_own_height() {
let Some(root) = std::env::var_os("HORIZON_TEST_RESOURCES") else {
@@ -4577,4 +5147,60 @@ mod tests {
Some(&5)
);
}
#[test]
fn forced_chunks_survive_dimension_restarts_and_removals() {
let root = std::env::temp_dir().join(format!("horizon-forced-{}", std::process::id()));
let _ = fs::remove_dir_all(&root);
let dimensions = [
"minecraft:overworld",
"minecraft:the_nether",
"minecraft:the_end",
];
for (i, dim) in dimensions.iter().enumerate() {
let save = SaveDir::new(dimension_dir(&root, dim));
assert!(save.load_forced_chunks().is_empty());
let forced = HashSet::from([(i as i32, -17), (-23, i as i32), (-1, -1)]);
save.save_forced_chunks(&forced).unwrap();
}
for (i, dim) in dimensions.iter().enumerate() {
let path = dimension_dir(&root, dim);
let save = SaveDir::new(path.clone());
let mut forced = save.load_forced_chunks();
assert_eq!(
forced,
HashSet::from([(i as i32, -17), (-23, i as i32), (-1, -1)])
);
let mut data = read_saved_data(&path, "chunk_tickets").unwrap();
let other = Tag::compound([
("type", Tag::String("minecraft:portal".into())),
("level", Tag::Int(30)),
("chunk_pos", Tag::IntArray(vec![7, -8])),
("ticks_left", Tag::Long(42)),
]);
if let Some(Tag::List(tickets)) = data.get_mut("tickets") {
assert!(
tickets
.iter()
.all(|t| t.get("level") == Some(&Tag::Int(31)))
);
tickets.push(other.clone());
}
write_saved_data(&path, "chunk_tickets", data).unwrap();
forced.remove(&(-1, -1));
save.save_forced_chunks(&forced).unwrap();
drop(save);
let reopened = SaveDir::new(path.clone());
assert_eq!(reopened.load_forced_chunks(), forced);
reopened.save_forced_chunks(&HashSet::new()).unwrap();
assert!(SaveDir::new(path.clone()).load_forced_chunks().is_empty());
assert_eq!(
read_saved_data(&path, "chunk_tickets")
.unwrap()
.get("tickets"),
Some(&Tag::List(vec![other]))
);
}
fs::remove_dir_all(root).unwrap();
}
}
+94 -63
View File
@@ -63,6 +63,12 @@ pub fn loot_item_with_components(s: &horizon_world::loot::LootStack) -> ItemStac
if let Some(level) = s.ominous_amplifier.filter(|l| *l > 0) {
item.data_mut().ominous_amplifier = Some(level.clamp(0, 4) as u8);
}
// set_stew_effect's `suspicious_stew_effects`.
for (effect, duration) in &s.stew_effects {
item.data_mut()
.stew_effects
.push((Arc::from(effect.as_str()), *duration));
}
item
}
@@ -108,9 +114,10 @@ pub fn has_pending_map(item: &ItemStack) -> bool {
})
}
static LOOT: OnceLock<Option<Arc<LootData>>> = OnceLock::new();
/// Loot data of the resource root, loaded once per process.
pub fn loot_data(root: &Path) -> Option<Arc<LootData>> {
static LOOT: OnceLock<Option<Arc<LootData>>> = OnceLock::new();
LOOT.get_or_init(|| match LootData::load(root) {
Ok(data) => Some(Arc::new(data)),
Err(e) => {
@@ -121,6 +128,54 @@ pub fn loot_data(root: &Path) -> Option<Arc<LootData>> {
.clone()
}
/// The loot data `loot_data` loaded, if it ran.
pub fn loaded_loot_data() -> Option<Arc<LootData>> {
LOOT.get().cloned().flatten()
}
/// `RandomizableContainer.unpackLootTable` of a container at `pos`: `table`
/// rolled into `slots`. A missing or zero seed is the level's random in
/// vanilla; Horizon derives one from the position. Without loot data the
/// container stays empty and still saves its table.
pub fn roll_container(
slots: &mut [Option<ItemStack>],
loot: Option<&LootData>,
table: String,
saved_seed: Option<i64>,
pos: (i32, i32, i32),
biome: &str,
) -> crate::save::RolledLoot {
let seed = saved_seed.filter(|s| *s != 0).unwrap_or_else(|| {
horizon_core::random::position_seed(horizon_core::position::BlockPos::new(
pos.0, pos.1, pos.2,
))
});
if let Some(loot) = loot {
// LootTable.fill spreads the items over the container's own size.
let rolled = loot.fill(&table, seed, slots.len(), Some(biome));
for (slot, stack) in slots.iter_mut().zip(rolled) {
// With what the rolls put on them: enchantments
// (enchant_with_levels in End city chests), potions and an
// ominous bottle's level.
*slot = stack.map(|s| loot_item_with_components(&s));
}
}
crate::save::RolledLoot {
pos,
table,
seed: saved_seed,
items: crate::save::encode_items(slots),
}
}
/// A saved `LootTableSeed`, if any.
pub fn loot_seed(nbt: &Tag) -> Option<i64> {
match nbt.get("LootTableSeed") {
Some(Tag::Long(seed)) => Some(*seed),
_ => None,
}
}
fn text(nbt: &Compound, key: &str) -> Option<String> {
match nbt.get(&JavaString::from(key)) {
Some(Tag::String(s)) => Some(s.to_string_lossy()),
@@ -149,81 +204,37 @@ fn generated_block_entity(block: &str, nbt: &Compound) -> Option<BlockEntity> {
Some(entity)
}
/// Client block entities for the generated block entities of a chunk.
/// Client block entities for the generated block entities of a chunk, and the
/// loot rolled into its containers.
pub fn block_entities(
chunk: &ProtoChunk,
loot: Option<&LootData>,
) -> Vec<((i32, i32, i32), BlockEntity)> {
) -> (
Vec<((i32, i32, i32), BlockEntity)>,
Vec<crate::save::RolledLoot>,
) {
let mut out = Vec::new();
let mut rolled = Vec::new();
for (pos, nbt) in &chunk.block_entities {
let [x, y, z] = *pos;
let block = chunk.block_name(x, y, z);
let Some(mut entity) = generated_block_entity(block, nbt) else {
continue;
};
if let (BlockEntity::Chest(slots), Some(table), Some(loot)) =
(&mut entity, text(nbt, "LootTable"), loot)
{
let seed = match nbt.get(&JavaString::from("LootTableSeed")) {
Some(Tag::Long(seed)) => *seed,
// Seed 0 means "the level's random": derive one from the position.
_ => horizon_core::random::position_seed(horizon_core::position::BlockPos::new(
x, y, z,
)),
};
let biome = chunk.biome_at(x, y, z);
for (slot, stack) in slots
.iter_mut()
.zip(loot.fill(&table, seed, 27, Some(biome)))
{
// With what the rolls put on them: enchantments
// (enchant_with_levels in End city chests), potions and an
// ominous bottle's level.
*slot = stack.map(|s| loot_item_with_components(&s));
}
if let (BlockEntity::Chest(slots), Some(table)) = (&mut entity, text(nbt, "LootTable")) {
let seed = loot_seed(&Tag::Compound(nbt.clone()));
rolled.push(roll_container(
slots,
loot,
table,
seed,
(x, y, z),
chunk.biome_at(x, y, z),
));
}
out.push(((x, y, z), entity));
}
out
}
/// Chest minecarts placed with a loot table (mineshafts): the table rolled
/// into `Items` as `unpackLootTable` would on first open.
pub fn unpack_entity_loot(
chunk: &ProtoChunk,
mut nbt: horizon_world::nbt::Compound,
loot: Option<&LootData>,
) -> horizon_world::nbt::Compound {
let (Some(table), Some(loot)) = (
match nbt.get(&JavaString::from("LootTable")) {
Some(Tag::String(s)) => Some(s.to_string_lossy()),
_ => None,
},
loot,
) else {
return nbt;
};
let seed = match nbt.get(&JavaString::from("LootTableSeed")) {
Some(Tag::Long(seed)) => *seed,
_ => 0,
};
let pos = match nbt.get(&JavaString::from("Pos")) {
Some(Tag::List(p)) => std::array::from_fn(|i| match p.get(i) {
Some(Tag::Double(b)) => f64::from_bits(*b).floor() as i32,
_ => 0,
}),
_ => [0; 3],
};
let biome = chunk.biome_at(pos[0], pos[1], pos[2]);
let slots: Vec<Option<ItemStack>> = loot
.fill(&table, seed, 27, Some(biome))
.into_iter()
.map(|s| s.map(|s| loot_item_with_components(&s)))
.collect();
nbt.remove(&JavaString::from("LootTable"));
nbt.remove(&JavaString::from("LootTableSeed"));
nbt.insert("Items".into(), crate::save::encode_items(&slots));
nbt
(out, rolled)
}
#[cfg(test)]
@@ -243,6 +254,7 @@ mod tests {
map: false,
exploration: None,
ominous_amplifier: None,
stew_effects: Vec::new(),
};
// An End city chest's enchant_with_levels roll.
let boots = loot_item_with_components(&stack(
@@ -270,6 +282,24 @@ mod tests {
assert_eq!(arrow.potion(), Some("minecraft:slowness"));
}
#[test]
fn a_container_rolled_without_loot_data_saves_its_table() {
let mut slots = vec![None; 27];
let rolled = roll_container(
&mut slots,
None,
"minecraft:chests/simple_dungeon".into(),
Some(7),
(1, 2, 3),
"minecraft:plains",
);
let mut saved = horizon_world::nbt::Compound::new();
saved.insert("Items".into(), crate::save::encode_items(&slots));
assert!(rolled.restore(&mut saved));
assert!(!saved.contains_key(&"Items".into()));
assert_eq!(saved.get(&"LootTableSeed".into()), Some(&Tag::Long(7)));
}
#[test]
fn exploration_maps_wait_in_custom_data_until_searched() {
let mut stack = LootStack {
@@ -286,6 +316,7 @@ mod tests {
"zoom": 1
})))),
ominous_amplifier: None,
stew_effects: Vec::new(),
};
let mut item = loot_item(&stack);
assert!(has_pending_map(&item));
+12 -10
View File
@@ -158,6 +158,12 @@ impl VanillaTerrain {
chunk.terrain.min_y().max(MIN_Y),
(chunk.terrain.min_y() + chunk.terrain.height()).min(MIN_Y + HEIGHT),
);
out.structure_refs = chunk
.structures()
.into_iter()
.map(|(id, start, _, _)| (Arc::from(id), (start.x, start.z)))
.collect();
out.structure_starts = chunk.own_starts();
let mut values = Box::new([0u32; 4096]);
for si in 0..SECTIONS as i32 {
let base = MIN_Y + si * 16;
@@ -541,7 +547,9 @@ impl ChunkGenerator for VanillaTerrain {
VanillaProto::Failed(x, z) => return Chunk::empty(x, z),
};
let mut out = self.column(&chunk);
out.block_entities = crate::structure_blocks::block_entities(&chunk, self.loot.as_deref());
let (block_entities, rolled_loot) =
crate::structure_blocks::block_entities(&chunk, self.loot.as_deref());
out.block_entities = block_entities;
// The block entities Horizon does not model (decorated pots with their
// sherds and loot table, brushable blocks) stay as placed, like a
// loaded chunk's.
@@ -565,26 +573,20 @@ impl ChunkGenerator for VanillaTerrain {
Some(Tag::Compound(nbt))
})
.collect();
if !unmodelled.is_empty() {
if !unmodelled.is_empty() || !rolled_loot.is_empty() {
let mut extras = out
.saved
.take()
.map(Arc::unwrap_or_clone)
.unwrap_or_default();
extras.block_entities = unmodelled;
extras.rolled_loot = rolled_loot;
out.saved = Some(Arc::new(extras));
}
out.structure_refs = chunk
.structures()
.into_iter()
.map(|(id, start, _, _)| (Arc::from(id), (start.x, start.z)))
.collect();
let entities = std::mem::take(&mut chunk.entities);
// Chest minecarts keep their loot table; the game rolls it on load.
out.entities = entities
.into_iter()
.map(|nbt| {
crate::structure_blocks::unpack_entity_loot(&chunk, nbt, self.loot.as_deref())
})
.map(horizon_world::nbt::Tag::Compound)
.collect();
for s in &mut out.sections {
+7 -1
View File
@@ -109,7 +109,9 @@ pub struct Weather {
pub thunder_level: (f32, f32),
}
impl Weather {
pub fn tick(&mut self, random: &mut horizon_core::random::LegacyRandom) {
/// The timers of `advanceWeatherCycle`, which run only while
/// `advance_weather` is on.
pub fn tick_timers(&mut self, random: &mut horizon_core::random::LegacyRandom) {
use horizon_core::random::RandomSource;
let mut uniform =
|min: i32, max: i32| min + random.next_int_bound(max - min + 1).unwrap_or(0);
@@ -141,6 +143,10 @@ impl Weather {
self.rain_time = uniform(12000, 180000);
}
}
}
/// The rain and thunder level fade at the end of `advanceWeatherCycle`,
/// which runs whatever `advance_weather` says, so `/weather` still shows.
pub fn tick_levels(&mut self) {
self.thunder_level.0 = self.thunder_level.1;
self.thunder_level.1 =
(self.thunder_level.1 + if self.thundering { 0.01 } else { -0.01 }).clamp(0.0, 1.0);
+1 -1
View File
@@ -1,4 +1,4 @@
/* The Horizon server panel. Colours and spacing are horizon-theme's tokens (website/src/tokens.css). */
/* The Horizon server panel. Colours and spacing are horizon-theme's tokens (crates/horizon-theme/tokens.css). */
:root {
--page: #fafafc;
--plate-soft: #f3f3f8;
+1 -1
View File
@@ -30,7 +30,7 @@ The totals are grouped per version, OS, GPU vendor and render distance. Each gro
## Benchmarks summary
`src/summary.rs` makes it with the same rules as `website/scripts/benchmarks.mjs`, which bakes the page's fallback copy; both are tested against the same fixtures (`website/scripts/fixtures/benchmarks/`).
`src/summary.rs` makes it with the same rules as the website's `scripts/benchmarks.mjs` (ludovic111/horizon-site), which bakes the page's fallback copy; both are tested against the same fixtures (`tests/fixtures/benchmarks/` here, copied in the site's repository).
- Only reports that compare: a version 1 `horizon-benchmark-report` of the standard flight over the four scenes, VSync off, no frame cap, on screen, from Horizon or plain vanilla, naming its GPU and CPU.
- A group (GPU, CPU, render distance, game) is published only with **at least 3 different installs**. Each install counts once: the median of its runs, then the median across installs.
@@ -1,6 +1,6 @@
//! The hardware classes the public output breaks results down by. Coarse on purpose: a class
//! holds many machines, and a published cell still needs `MIN_INSTALLS` different installs.
//! `website/scripts/benchmarks.mjs` has the same functions, tested on the same fixtures.
//! The website's `scripts/benchmarks.mjs` (ludovic111/horizon-site) has the same functions, tested on the same fixtures.
//!
//! The raw values (the exact memory, the driver, the screen) stay in the database for the
//! retention period and are never published.
@@ -1,7 +1,7 @@
//! `GET /v1/benchmarks/summary`: players' benchmark uploads, grouped for the website's Benchmarks page.
//!
//! The same rules as `website/scripts/benchmarks.mjs` (which builds the page's baked copy), checked against the
//! same fixtures (`website/scripts/fixtures/benchmarks/`):
//! The same rules as the website's `scripts/benchmarks.mjs` (ludovic111/horizon-site; it builds the page's baked
//! copy), checked against the same fixtures (`tests/fixtures/benchmarks/`, which the site's test keeps a copy of):
//!
//! - Only reports that compare: `horizon-benchmark-report` version 1, kind `game`, Horizon or plain vanilla, the
//! standard flight over the four scenes, VSync off, no frame cap, drawn on a screen, with a GPU and CPU name.
@@ -198,7 +198,7 @@ struct Group {
versions: BTreeSet<String>,
}
/// Players' groups of at least `min_installs` installs, as `website/scripts/benchmarks.mjs` makes them.
/// Players' groups of at least `min_installs` installs, as the website's `scripts/benchmarks.mjs` makes them.
pub fn groups(uploads: &[Upload], min_installs: usize) -> Vec<Value> {
let mut by: BTreeMap<(String, String, i64, &str), Group> = BTreeMap::new();
for u in uploads {
@@ -1,7 +1,8 @@
# Benchmark summary fixtures
Shared by the website's aggregator test (`website/scripts/benchmarks.test.mjs`) and the telemetry server's
`GET /v1/benchmarks/summary` test: the same `benchmark` items in, the same groups out.
Used by the telemetry server's `GET /v1/benchmarks/summary` test (`tests/summary.rs`) and, as a copy, by the
website's aggregator test (`scripts/benchmarks.test.mjs` in ludovic111/horizon-site, which checks its copy against
this one on main): the same `benchmark` items in, the same groups out. Change both together.
- `items.jsonl`: one stored item per line (`install_id`, `day`, `version`, `data`: a benchmark report). Group
RTX 4080 + i7-13700KF at render distance 12 has three installs (one with two runs); the same install at 32,
@@ -1,5 +1,5 @@
//! `GET /v1/benchmarks/summary` against the website's fixtures (website/scripts/fixtures/benchmarks/): the same
//! uploads must give the groups `website/scripts/benchmarks.mjs` gives.
//! `GET /v1/benchmarks/summary` against the shared fixtures (tests/fixtures/benchmarks/): the same uploads must
//! give the groups the website's `scripts/benchmarks.mjs` (ludovic111/horizon-site) gives on its copy of them.
use std::sync::Arc;
use std::sync::atomic::AtomicU32;
@@ -15,10 +15,7 @@ use http_body_util::BodyExt;
use serde_json::Value;
use tower::ServiceExt;
const FIXTURES: &str = concat!(
env!("CARGO_MANIFEST_DIR"),
"/../../website/scripts/fixtures/benchmarks"
);
const FIXTURES: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/tests/fixtures/benchmarks");
fn items() -> Vec<Value> {
std::fs::read_to_string(format!("{FIXTURES}/items.jsonl"))
+8 -8
View File
@@ -1,6 +1,6 @@
//! Horizon's design tokens, the one source of truth for the launcher (`launcher/src/theme.rs`),
//! the game's on-screen keyboard (`horizon-client`'s `ui_theme`) and the website (`website/src/tokens.css`,
//! generated from [`css`] by `cargo run -p xtask -- tokens`). Change a value here and all three
//! the game's on-screen keyboard (`horizon-client`'s `ui_theme`) and the website (`tokens.css` in this crate,
//! generated from [`css`] by `cargo run -p xtask -- tokens`, which the site's build reads from main). Change a value here and all three
//! follow. The system is described in `docs/design-system.md`.
//!
//! Cool neutrals in two appearances and one violet accent; Geist and Geist Mono only, bundled
@@ -528,7 +528,7 @@ pub fn contrast(a: u32, b: u32) -> f32 {
(x.max(y) + 0.05) / (x.min(y) + 0.05)
}
/// `website/src/tokens.css`: every token as a CSS custom property, the light palette by
/// `tokens.css`: every token as a CSS custom property, the light palette by
/// default, the dark one when the system asks for it or the page sets `data-theme="dark"`.
pub fn css() -> String {
let mut s = String::new();
@@ -771,15 +771,15 @@ mod tests {
assert!((bezier([0., 0., 1., 1.], 0.3) - 0.3).abs() < 1e-3);
}
/// `website/src/tokens.css` is what [`css`] writes; regenerate it with
/// `cargo run -p xtask -- tokens`.
/// `tokens.css` (beside Cargo.toml, read by the website's build) is what [`css`] writes;
/// regenerate it with `cargo run -p xtask -- tokens`.
#[test]
fn website_tokens_are_up_to_date() {
let path = concat!(env!("CARGO_MANIFEST_DIR"), "/../../website/src/tokens.css");
fn tokens_css_is_up_to_date() {
let path = concat!(env!("CARGO_MANIFEST_DIR"), "/tokens.css");
let written = std::fs::read_to_string(path).unwrap_or_default();
assert!(
written == css(),
"website/src/tokens.css is out of date: run `cargo run -p xtask -- tokens`"
"crates/horizon-theme/tokens.css is out of date: run `cargo run -p xtask -- tokens`"
);
}
}
File renamed without changes.
+13
View File
@@ -181,6 +181,8 @@ pub struct GameRules {
pub max_snow_accumulation_height: i32,
pub spawn_mobs: bool,
pub spawn_monsters: bool,
/// `fire_spread_radius_around_player`: -1 spreads everywhere.
pub fire_spread_radius_around_player: i32,
}
impl Default for GameRules {
fn default() -> Self {
@@ -193,6 +195,7 @@ impl Default for GameRules {
max_snow_accumulation_height: 1,
spawn_mobs: true,
spawn_monsters: true,
fire_spread_radius_around_player: 128,
}
}
}
@@ -288,6 +291,11 @@ pub enum LevelEvent {
/// The world as seen by block behaviour. Implemented by the game and tests.
pub trait LevelAccess {
/// `Level.isClientSide`: this level is a client's copy (false on a
/// server, a host or the test harness).
fn is_client_side(&self) -> bool {
false
}
/// `getBlockState`. Unloaded or out-of-bounds positions read as air.
fn block_state(&self, pos: BlockPos) -> BlockStateId;
/// Storage write (`LevelChunkSection.setBlockState`). Returns the previous
@@ -343,6 +351,11 @@ pub trait LevelAccess {
fn can_spread_fire_around(&self, _pos: BlockPos) -> bool {
true
}
/// The `INCREASED_FIRE_BURNOUT` environment attribute at `pos` (a biome
/// attribute).
fn increased_fire_burnout(&self, _pos: BlockPos) -> bool {
false
}
/// `Level.isUnobstructed(state, pos, context)`: whether the state's
/// collision shape at `pos` would intersect an entity.
fn is_unobstructed(&self, _pos: BlockPos, _state: BlockStateId) -> bool {
+141 -16
View File
@@ -27,6 +27,9 @@ pub struct MovingPiston {
pub progress: f32,
pub progress_o: f32,
pub last_ticked: i64,
/// `PistonMovingBlockEntity.deathTicks`: on a client the entity waits
/// five ticks past progress 1.0 before placing the block itself.
pub death_ticks: i32,
/// Identity of this block entity (a replaced entity is a new ticker).
pub id: u64,
}
@@ -126,11 +129,8 @@ impl<L: LevelAccess + ?Sized> Sim<'_, L> {
self.note_trigger_event(pos, state);
true
} else if data.is(super::classes::Class::Bell) {
// BellBlockEntity.triggerEvent handles event 1; the server
// broadcasts handled events to the clients.
if a == 1 {
self.emit(LevelEvent::BlockEntityEvent { pos, state, a, b });
}
// BellBlockEntity.triggerEvent handles event 1; the caller
// reports handled events (`runBlockEvents` broadcasts them).
a == 1
} else {
false
@@ -164,7 +164,9 @@ impl<L: LevelAccess + ?Sized> Sim<'_, L> {
let Some(piston) = self.moving_piston(pos) else {
return;
};
if piston.progress_o >= 1.0 {
// finalTick: `progressO < 1.0 || level.isClientSide` — a client
// finishes its copy even at full progress.
if piston.progress_o >= 1.0 && !self.level.is_client_side() {
return;
}
self.remove_moving_piston(pos);
@@ -189,6 +191,20 @@ impl<L: LevelAccess + ?Sized> Sim<'_, L> {
piston.last_ticked = self.level.game_time();
piston.progress_o = piston.progress;
if piston.progress_o >= 1.0 {
if self.level.is_client_side() && piston.death_ticks < 5 {
// The client's copy waits for the server's state before
// placing the block itself (deathTicks < 5).
piston.death_ticks += 1;
if let Some(entry) = self
.ticks
.moving_pistons
.iter_mut()
.find(|(p, _)| *p == pos)
{
entry.1 = piston;
}
return;
}
self.remove_moving_piston(pos);
if b.is_block(self.state(pos), b.named.moving_piston) {
let mut new = self.update_from_neighbour_shapes(piston.moved_state, pos);
@@ -241,8 +257,13 @@ impl<L: LevelAccess + ?Sized> Sim<'_, L> {
direction != Direction::Down && self.has_signal(rel(up, direction), direction)
})
}
/// `PistonBaseBlock.checkIfExtend`.
/// `PistonBaseBlock.checkIfExtend`, which `neighborChanged`, `onPlace`
/// and `setPlacedBy` call only server-side: a client moves its pistons on
/// the server's block events alone.
pub(crate) fn piston_check_if_extend(&mut self, pos: BlockPos, state: BlockStateId) {
if self.level.is_client_side() {
return;
}
let b = self.b;
let direction = b.facing(state);
let extend = self.piston_neighbor_signal(pos, direction);
@@ -527,6 +548,12 @@ impl<L: LevelAccess + ?Sized> Sim<'_, L> {
for pos in to_destroy.iter().rev() {
let state = self.state(*pos);
self.drop_resources(state, *pos);
// The client's copy plays the destroy effect itself (the
// server sends no 2001 for piston moves).
let fire = b.is_block(state, b.named.fire) || b.is_block(state, b.named.soul_fire);
if !fire && self.level.is_client_side() {
self.level_event(2001, *pos, state.0 as i32);
}
self.set_block(*pos, b.air(), 18);
self.game_event(*pos, "minecraft:block_destroy");
to_update.push(state);
@@ -551,6 +578,7 @@ impl<L: LevelAccess + ?Sized> Sim<'_, L> {
progress: 0.0,
progress_o: 0.0,
last_ticked: 0,
death_ticks: 0,
id: 0,
},
);
@@ -576,6 +604,7 @@ impl<L: LevelAccess + ?Sized> Sim<'_, L> {
progress: 0.0,
progress_o: 0.0,
last_ticked: 0,
death_ticks: 0,
id: 0,
},
);
@@ -624,13 +653,17 @@ impl<L: LevelAccess + ?Sized> Sim<'_, L> {
let direction = b.facing(state);
let extended_state = b.table.set_bool(state, p.extended, true);
let sticky = self.is_sticky_piston(state);
let extend = self.piston_neighbor_signal(pos, direction);
if extend && (a == 1 || a == 2) {
self.set_block(pos, extended_state, 2);
return false;
}
if !extend && a == 0 {
return false;
// The signal re-check is server-only (`!level.isClientSide`): a
// client trusts the event as sent.
if !self.level.is_client_side() {
let extend = self.piston_neighbor_signal(pos, direction);
if extend && (a == 1 || a == 2) {
self.set_block(pos, extended_state, 2);
return false;
}
if !extend && a == 0 {
return false;
}
}
if a == 0 {
if !self.piston_move_blocks(pos, direction, true, sticky) {
@@ -664,6 +697,7 @@ impl<L: LevelAccess + ?Sized> Sim<'_, L> {
progress: 0.0,
progress_o: 0.0,
last_ticked: 0,
death_ticks: 0,
id: 0,
},
);
@@ -748,8 +782,18 @@ impl Behaviours {
}
let mut sim = self.sim(level, ticks);
let state = sim.state(event.pos);
if self.block(state) == event.block {
sim.trigger_event(state, event.pos, event.param_a, event.param_b);
if self.block(state) == event.block
&& sim.trigger_event(state, event.pos, event.param_a, event.param_b)
{
// ServerLevel.runBlockEvents broadcasts a handled event to
// the clients (`ClientboundBlockEventPacket`); their copy of
// the block re-runs `triggerEvent` (a piston's `moveBlocks`).
sim.emit(LevelEvent::BlockEntityEvent {
pos: event.pos,
state,
a: event.param_a,
b: event.param_b,
});
}
}
for event in reschedule {
@@ -779,4 +823,85 @@ impl Behaviours {
self.sim(level, ticks).moving_piston_tick(pos);
}
}
/// `ClientLevel.blockEvent`: the client re-runs the block's
/// `triggerEvent` on its own level (a piston's `moveBlocks` builds the
/// moving block entities it then animates) instead of waiting for the
/// server's states. No queue and no ticking-chunk gate: a client runs
/// the event where the packet lands.
pub fn client_block_event<L: LevelAccess + ?Sized>(
&self,
level: &mut L,
ticks: &mut Ticks,
pos: BlockPos,
a: i32,
b: i32,
) -> bool {
let triggered = {
let mut sim = self.sim(level, ticks);
let state = sim.state(pos);
let triggered = sim.trigger_event(state, pos, a, b);
if triggered {
// The client's copy of a handled event reaches the client
// copy of the block entity (a bell's ring).
sim.emit(LevelEvent::BlockEntityEvent { pos, state, a, b });
}
triggered
};
self.client_drain_block_events(level, ticks);
triggered
}
/// `ClientLevel.tickBlockEntities` for moving pistons: the client copy of
/// [`Behaviours::tick_block_entities`], without the server's
/// ticking-chunk gate (a client ticks every loaded block entity). One
/// whose block was replaced is dropped (`TickingBlockEntity.isRemoved`).
pub fn client_tick_block_entities<L: LevelAccess + ?Sized>(
&self,
level: &mut L,
ticks: &mut Ticks,
) {
let tickers: Vec<(BlockPos, u64)> = ticks
.moving_pistons
.iter()
.map(|(p, m)| (*p, m.id))
.collect();
for (pos, id) in tickers {
if !ticks
.moving_pistons
.iter()
.any(|(p, m)| *p == pos && m.id == id)
{
continue;
}
{
let mut sim = self.sim(level, ticks);
let state = sim.state(pos);
if sim.b.is_block(state, sim.b.named.moving_piston) {
sim.moving_piston_tick(pos);
} else {
sim.remove_moving_piston(pos);
}
}
self.client_drain_block_events(level, ticks);
}
}
/// Block events a client-side `triggerEvent` queued
/// (`PistonBaseBlock.checkIfExtend` in a neighbour update): a client
/// runs them as they come (`ClientLevel.blockEvent`), not in a tick pass.
fn client_drain_block_events<L: LevelAccess + ?Sized>(&self, level: &mut L, ticks: &mut Ticks) {
while let Some(event) = ticks.block_events.first().copied() {
ticks.block_events.remove(0);
let mut sim = self.sim(level, ticks);
let state = sim.state(event.pos);
if self.block(state) == event.block
&& sim.trigger_event(state, event.pos, event.param_a, event.param_b)
{
sim.emit(LevelEvent::BlockEntityEvent {
pos: event.pos,
state,
a: event.param_a,
b: event.param_b,
});
}
}
}
}
+100
View File
@@ -47,6 +47,8 @@ pub(crate) struct TestLevel {
pub day_time: i64,
pub events: Vec<LevelEvent>,
pub block_light: HashMap<BlockPos, i32>,
/// `Level.isClientSide`.
pub client_side: bool,
}
impl TestLevel {
fn new(b: &Behaviours) -> Self {
@@ -58,10 +60,14 @@ impl TestLevel {
day_time: 0,
events: Vec::new(),
block_light: HashMap::new(),
client_side: false,
}
}
}
impl LevelAccess for TestLevel {
fn is_client_side(&self) -> bool {
self.client_side
}
fn block_state(&self, pos: BlockPos) -> BlockStateId {
self.blocks.get(&pos).copied().unwrap_or(self.air)
}
@@ -919,6 +925,100 @@ fn pistons_push_and_sticky_pistons_pull() {
assert_eq!(w.get(BlockPos::new(2, 1, 0)), air);
}
/// `ServerLevel.runBlockEvents` reports a handled event for the clients
/// (`ClientboundBlockEventPacket`), carrying the block the event named.
#[test]
fn a_handled_block_event_reaches_the_clients() {
let mut w = world!();
w.floor(0, 10, "stone");
let piston = w.id("piston", &[("facing", "east")]);
let block = w.id("redstone_block", &[]);
w.set(BlockPos::new(0, 1, 0), piston);
w.set(BlockPos::new(-1, 1, 0), block);
w.level.events.clear();
w.full_tick();
assert!(w.level.events.iter().any(|e| matches!(
e,
LevelEvent::BlockEntityEvent {
pos,
state,
a: 0,
..
} if *pos == BlockPos::new(0, 1, 0) && *state == piston
)));
}
/// `ClientLevel.blockEvent`: the client re-runs `triggerEvent` on its own
/// level, which builds the moving block entities a piston's `moveBlocks`
/// makes, and ticks them to the placed block.
#[test]
fn a_client_replays_a_pistons_block_event() {
let mut w = world!();
w.floor(0, 10, "stone");
let piston = w.id("sticky_piston", &[("facing", "east")]);
let dirt = w.id("dirt", &[]);
let block = w.id("redstone_block", &[]);
w.put(BlockPos::new(0, 1, 0), piston);
w.put(BlockPos::new(1, 1, 0), dirt);
w.put(BlockPos::new(-1, 1, 0), block);
w.f.behaviours.client_block_event(
&mut w.level,
&mut w.ticks,
BlockPos::new(0, 1, 0),
0,
Direction::East as i32,
);
assert!(
w.ticks
.moving_pistons
.iter()
.any(|(p, _)| *p == BlockPos::new(2, 1, 0))
);
for _ in 0..3 {
w.level.time += 1;
w.f.behaviours
.client_tick_block_entities(&mut w.level, &mut w.ticks);
}
assert_eq!(w.get(BlockPos::new(2, 1, 0)), dirt);
assert_eq!(
w.name(BlockPos::new(1, 1, 0)),
"piston_head[facing=east,short=false,type=sticky]"
);
assert!(w.ticks.moving_pistons.is_empty());
}
/// A client's copy of a move doesn't start the pistons the moved block
/// powers (`checkIfExtend` is server-only): their own block events come from
/// the server.
#[test]
fn a_client_replay_starts_no_other_piston() {
let mut w = world!();
w.floor(0, 10, "stone");
w.level.client_side = true;
let piston = w.id("piston", &[("facing", "east")]);
let block = w.id("redstone_block", &[]);
let up = w.id("piston", &[("facing", "up")]);
w.put(BlockPos::new(0, 1, 0), piston);
w.put(BlockPos::new(1, 1, 0), block);
w.put(BlockPos::new(2, 2, 0), up);
w.f.behaviours.client_block_event(
&mut w.level,
&mut w.ticks,
BlockPos::new(0, 1, 0),
0,
Direction::East as i32,
);
for _ in 0..10 {
w.level.time += 1;
w.f.behaviours
.client_tick_block_entities(&mut w.level, &mut w.ticks);
}
assert_eq!(w.get(BlockPos::new(2, 1, 0)), block);
assert_eq!(w.get(BlockPos::new(2, 2, 0)), up);
assert!(w.ticks.moving_pistons.is_empty());
assert!(w.ticks.block_events.is_empty());
}
#[test]
fn pistons_pop_fragile_blocks_and_refuse_obsidian() {
let mut w = world!();
+2 -3
View File
@@ -565,9 +565,8 @@ impl<L: LevelAccess + ?Sized> Sim<'_, L> {
return;
}
}
// FireBlock.tick reads the INCREASED_FIRE_BURNOUT environment attribute
// (jungles, swamps, peaks, mushroom fields); Horizon doesn't yet.
let burnout = false;
// FireBlock.tick: the INCREASED_FIRE_BURNOUT environment attribute.
let burnout = self.level.increased_fire_burnout(pos);
let extra = if burnout { -50 } else { 0 };
for (direction, chance) in [
(Direction::East, 300),
+19 -1
View File
@@ -31,6 +31,9 @@ pub struct LootStack {
pub exploration: Option<Box<ExplorationMap>>,
/// Set by set_ominous_bottle_amplifier.
pub ominous_amplifier: Option<i32>,
/// Added by set_stew_effect (the `suspicious_stew_effects` component):
/// (effect id, duration in ticks).
pub stew_effects: Vec<(String, i32)>,
}
/// `ExplorationMapFunction`'s settings.
#[derive(Clone, Debug, PartialEq, Eq)]
@@ -74,6 +77,7 @@ impl LootStack {
map: false,
exploration: None,
ominous_amplifier: None,
stew_effects: Vec::new(),
}
}
pub fn is_empty(&self) -> bool {
@@ -678,6 +682,13 @@ impl LootData {
}
}
/// `MobEffect.isInstantaneous`.
fn is_instantaneous(effect: &str) -> bool {
matches!(
effect,
"minecraft:saturation" | "minecraft:instant_health" | "minecraft:instant_damage"
)
}
/// The level of one enchantment on a stack (its `enchantments` component).
fn item_level(stack: &LootStack, enchantment: &str) -> i32 {
stack
@@ -1134,7 +1145,14 @@ impl<R: RandomSource> Context<'_, R> {
.random
.next_int_bound(effects.len() as i32)
.unwrap_or(0) as usize;
self.int(&effects[pick]["duration"]);
let effect = effects[pick]["type"].as_str().unwrap_or("");
let mut duration = self.int(&effects[pick]["duration"]);
// Seconds, unless the effect is instantaneous.
if !is_instantaneous(effect) {
duration *= 20;
}
// SuspiciousStewEffects.withEffectAdded
stack.stew_effects.push((effect.to_owned(), duration));
}
}
"minecraft:set_ominous_bottle_amplifier" => {
@@ -618,6 +618,15 @@ impl ProtoChunk {
pub fn is_decorated(&self) -> bool {
self.decorated
}
/// The structure starts this chunk holds itself (`structures.starts`).
pub fn own_starts(&self) -> Vec<Arc<super::structure::start::StructureStart>> {
let pos = self.position();
self.structure_starts
.iter()
.filter(|s| s.chunk == pos)
.cloned()
.collect()
}
/// Also restores a saved proto chunk that was decorated before it was saved.
pub fn set_decorated(&mut self) {
self.decorated = true;
@@ -871,6 +871,29 @@ impl LegacyPiece for FortressPiece {
fn bounding_box(&self) -> BoundingBox {
self.frame.bounding_box
}
fn save(&self) -> Option<crate::nbt::Compound> {
use crate::nbt::Tag;
let mut tag = super::legacy::piece_tag(
self.type_name(),
self.frame.bounding_box,
self.frame.orientation,
self.depth,
);
let flag = Tag::Byte(i8::from(*self.flag.lock().unwrap()));
match self.kind {
Kind::BridgeEndFiller => {
tag.insert("Seed".into(), Tag::Int(self.seed));
}
Kind::SmallCorridorLeftTurn | Kind::SmallCorridorRightTurn => {
tag.insert("Chest".into(), flag);
}
Kind::MonsterThrone => {
tag.insert("Mob".into(), flag);
}
_ => {}
}
Some(tag)
}
fn type_name(&self) -> &'static str {
match self.kind {
// StartPiece is built through the BridgeCrossing constructor.
@@ -732,6 +732,57 @@ impl LegacyPiece for MineshaftPiece {
fn bounding_box(&self) -> BoundingBox {
self.frame.bounding_box
}
fn save(&self) -> Option<crate::nbt::Compound> {
use crate::nbt::Tag;
let flag = |b: bool| Tag::Byte(i8::from(b));
let mut tag = super::legacy::piece_tag(
self.type_name(),
self.frame.bounding_box,
self.frame.orientation,
self.depth,
);
// MineshaftStructure.Type's ordinal: NORMAL, MESA.
let mesa = self.wood.log == MESA.log;
let mut entries = vec![("MST", Tag::Int(i32::from(mesa)))];
match &self.kind {
Kind::Room(entrances) => entries.push((
"Entrances",
Tag::List(
entrances
.iter()
.map(|b| {
Tag::IntArray(vec![
b.min[0], b.min[1], b.min[2], b.max[0], b.max[1], b.max[2],
])
})
.collect(),
),
)),
Kind::Corridor {
rails,
spider,
sections,
placed_spider,
} => entries.extend([
("hr", flag(*rails)),
("sc", flag(*spider)),
("hps", flag(*placed_spider.lock().unwrap())),
("Num", Tag::Int(*sections)),
]),
Kind::Crossing {
direction,
two_floored,
} => {
let d = crate::level::data_2d(*direction) as i8;
entries.extend([("tf", flag(*two_floored)), ("D", Tag::Byte(d))]);
}
Kind::Stairs => {}
}
for (key, value) in entries {
tag.insert(key.into(), value);
}
Some(tag)
}
fn type_name(&self) -> &'static str {
match self.kind {
Kind::Room(_) => "minecraft:msroom",
@@ -463,6 +463,16 @@ impl LegacyPiece for MonumentBuilding {
fn type_name(&self) -> &'static str {
"minecraft:omb"
}
/// `MonumentBuilding`: the base fields only; vanilla builds its rooms
/// again after loading (`OceanMonumentStructure.regeneratePiecesAfterLoad`).
fn save(&self) -> Option<crate::nbt::Compound> {
Some(super::legacy::piece_tag(
"minecraft:omb",
self.frame.bounding_box,
self.frame.orientation,
0,
))
}
fn post_process(&self, c: &mut PieceContext<'_>) -> Result<()> {
let sea = c.world.sea_level()?;
let water = c.state("water");
@@ -18,11 +18,22 @@ fn empty_processors() -> Arc<ProcessorList> {
#[derive(Debug)]
pub struct BuriedTreasurePiece {
pub position: [i32; 3],
/// Where the chest went: the box vanilla saves once placed.
pub found: Mutex<Option<[i32; 3]>>,
}
impl LegacyPiece for BuriedTreasurePiece {
fn bounding_box(&self) -> BoundingBox {
BoundingBox::new(self.position, self.position)
}
fn save(&self) -> Option<crate::nbt::Compound> {
let at = self.found.lock().unwrap().unwrap_or(self.position);
Some(super::legacy::piece_tag(
"minecraft:btp",
BoundingBox::new(at, at),
None,
0,
))
}
fn type_name(&self) -> &'static str {
"minecraft:btp"
}
@@ -58,6 +69,7 @@ impl LegacyPiece for BuriedTreasurePiece {
}
}
}
*self.found.lock().unwrap() = Some(pos);
create_chest(c, pos, "minecraft:chests/buried_treasure", None)?;
return Ok(());
}
@@ -441,6 +453,21 @@ impl Scattered {
pub fn frame(&self) -> PieceFrame {
*self.frame.lock().unwrap()
}
/// `ScatteredFeaturePiece`'s tag: the box and the height it settled on.
pub fn saved(&self, id: &str) -> crate::nbt::Compound {
use crate::nbt::Tag;
let f = self.frame();
let mut tag = super::legacy::piece_tag(id, f.bounding_box, f.orientation, 0);
for (key, value) in [
("Width", self.width),
("Height", self.height),
("Depth", self.depth),
("HPos", *self.height_position.lock().unwrap()),
] {
tag.insert(key.into(), Tag::Int(value));
}
tag
}
/// updateAverageGroundHeight over the part of the box inside the chunk.
pub fn average_ground(&self, c: &mut PieceContext<'_>, offset: i32) -> Result<bool> {
if *self.height_position.lock().unwrap() >= 0 {
@@ -498,12 +525,15 @@ impl Scattered {
#[derive(Debug)]
pub struct SwampHutPiece {
pub base: Scattered,
/// spawnedWitch and spawnedCat: both spawn in the same chunk.
spawned: Mutex<bool>,
}
impl SwampHutPiece {
pub fn new(random: &mut impl RandomSource, west: i32, north: i32) -> Self {
let direction = random_horizontal(random);
Self {
base: Scattered::new(west, 64, north, 7, 7, 9, direction),
spawned: Mutex::new(false),
}
}
}
@@ -511,6 +541,13 @@ impl LegacyPiece for SwampHutPiece {
fn bounding_box(&self) -> BoundingBox {
self.base.frame().bounding_box
}
fn save(&self) -> Option<crate::nbt::Compound> {
let mut tag = self.base.saved("minecraft:tesh");
let spawned = crate::nbt::Tag::Byte(i8::from(*self.spawned.lock().unwrap()));
tag.insert("Witch".into(), spawned.clone());
tag.insert("Cat".into(), spawned);
Some(tag)
}
fn type_name(&self) -> &'static str {
"minecraft:tesh"
}
@@ -579,6 +616,7 @@ impl LegacyPiece for SwampHutPiece {
c.world
.entity(super::legacy::spawn_nbt(id, pos, true, true))?;
}
*self.spawned.lock().unwrap() = true;
}
Ok(())
}
@@ -962,6 +962,7 @@ fn try_generate(
let stub = top_of_chunk_center(context, chunk, HeightmapType::OceanFloorWg)?;
let piece = super::simple::BuriedTreasurePiece {
position: [chunk.x * 16 + 9, 90, chunk.z * 16 + 9],
found: std::sync::Mutex::new(None),
};
(stub, legacy(vec![piece]))
}
@@ -1075,6 +1075,48 @@ impl LegacyPiece for StrongholdPiece {
fn bounding_box(&self) -> BoundingBox {
self.frame.bounding_box
}
fn save(&self) -> Option<crate::nbt::Compound> {
use crate::nbt::Tag;
let flag = |b: bool| Tag::Byte(i8::from(b));
let mut tag = super::legacy::piece_tag(
self.type_name(),
self.frame.bounding_box,
self.frame.orientation,
self.depth,
);
// SmallDoorType.LEGACY_CODEC: the enum name.
let door = match self.door {
Door::Opening => "OPENING",
Door::Wood => "WOOD_DOOR",
Door::Grates => "GRATES",
Door::Iron => "IRON_DOOR",
};
let placed = *self.placed.lock().unwrap();
let [a, b, c, d] = self.flags;
let mut entries = vec![("EntryDoor", Tag::String(door.into()))];
match self.kind {
Kind::ChestCorridor => entries.push(("Chest", flag(placed))),
Kind::Filler => entries.push(("Steps", Tag::Int(self.value))),
Kind::FiveCrossing => entries.extend([
("leftLow", flag(a)),
("leftHigh", flag(b)),
("rightLow", flag(c)),
("rightHigh", flag(d)),
]),
Kind::Library => entries.push(("Tall", flag(a))),
Kind::PortalRoom => entries.push(("Mob", flag(placed))),
Kind::RoomCrossing => entries.push(("Type", Tag::Int(self.value))),
// StartPiece is a StairsDown that is the source.
Kind::StairsDown => entries.push(("Source", flag(false))),
Kind::Start => entries.push(("Source", flag(true))),
Kind::Straight => entries.extend([("Left", flag(a)), ("Right", flag(b))]),
_ => {}
}
for (key, value) in entries {
tag.insert(key.into(), value);
}
Some(tag)
}
fn type_name(&self) -> &'static str {
match self.kind {
Kind::Straight => "minecraft:shs",
@@ -82,6 +82,16 @@ impl LegacyPiece for DesertPyramidPiece {
fn bounding_box(&self) -> BoundingBox {
self.base.frame().bounding_box
}
fn save(&self) -> Option<crate::nbt::Compound> {
let mut tag = self.base.saved("minecraft:tedp");
for (i, placed) in self.placed_chest.lock().unwrap().iter().enumerate() {
tag.insert(
format!("hasPlacedChest{i}").as_str().into(),
crate::nbt::Tag::Byte(i8::from(*placed)),
);
}
Some(tag)
}
fn type_name(&self) -> &'static str {
"minecraft:tedp"
}
@@ -448,6 +458,22 @@ impl LegacyPiece for JungleTemplePiece {
fn bounding_box(&self) -> BoundingBox {
self.base.frame().bounding_box
}
fn save(&self) -> Option<crate::nbt::Compound> {
let mut tag = self.base.saved("minecraft:tejp");
let placed = *self.placed.lock().unwrap();
for (key, value) in [
"placedMainChest",
"placedHiddenChest",
"placedTrap1",
"placedTrap2",
]
.into_iter()
.zip(placed)
{
tag.insert(key.into(), crate::nbt::Tag::Byte(i8::from(value)));
}
Some(tag)
}
fn type_name(&self) -> &'static str {
"minecraft:tejp"
}
+1 -1
View File
@@ -3,7 +3,7 @@
A player's own AI agent (Claude Code, Codex, Cursor, any MCP client) or a script can do
everything a player does with the mouse and keyboard, in the launcher and in the game. This page
covers developers: the surfaces, the full inventory, and the rules every new command follows.
Players read [Control Horizon with your AI agent](../website/docs/agents.md)
Players read [Control Horizon with your AI agent](https://playhorizon.dev/docs/agents)
(playhorizon.dev/docs/agents).
The command lists are generated, so they can't drift:
+1 -1
View File
@@ -53,4 +53,4 @@ Every page has the same parts: **Overview** (what you can build), **Guide** (whe
The website shows these pages at [playhorizon.dev/docs/api](https://playhorizon.dev/docs/api), built from this
folder, and a summary for players and authors at [playhorizon.dev/docs/apis](https://playhorizon.dev/docs/apis)
(`website/docs/apis.md`).
(in the website's repository, ludovic111/horizon-site).
+4 -4
View File
@@ -8,8 +8,8 @@ Horizon's own settings are pages built from vanilla's widgets, the way mods add
The tokens live in one place, `crates/horizon-theme`. The launcher (`launcher/src/theme.rs`)
and the game (`crates/horizon-client/src/ui_theme.rs`) read them as Rust constants, and the
website reads them as CSS custom properties generated from the same crate
(`cargo run -p xtask -- tokens` writes `website/src/tokens.css`; a test fails when the file is
out of date). Change a value in the crate and all three follow.
(`cargo run -p xtask -- tokens` writes `crates/horizon-theme/tokens.css`, which the website's build
reads from main; a test fails when the file is out of date). Change a value in the crate and all three follow.
## Where it comes from
@@ -140,7 +140,7 @@ Geist (400, 500, 600) and Geist Mono (400), bundled under the SIL Open Font Lice
landscape banner with the hero, the version picker and Play; Mods holds the mod, resource pack
and modpack tabs; Versions and Patch notes are lists of panels; Settings is
grouped panels of rows. Dark by default, Light or System in Settings (Appearance).
- **Website** (`website/`): the Blocks look below. The same page structure as before (what it
- **Website** (ludovic111/horizon-site): the Blocks look below. The same page structure as before (what it
is, screenshots, parity, launcher, patch notes, community), headings in Pixelify Sans, lists as
stacks of panels, the stone texture behind the page and Horizon's pixel landscape (a dawn or a
sunset) behind the hero. Light and dark follow the system, with a toggle. A giant wordmark in
@@ -177,7 +177,7 @@ of the main action. The game's menus keep `LIGHT` and `DARK`.
(`TILE_SHADES`), never Minecraft's stone; the landscape is a banded sky, a sun cut in slices like
the mark, hills and square trees (a sunset in dark, a dawn in light). The launcher bakes them in
`launcher/src/art.rs` at the window's scale so each texel covers whole device pixels; the website
draws them in `website/scripts/art.mjs`.
draws them in its `scripts/art.mjs`.
- **Icons**: Pixelarticons (MIT), 24 unit pixel SVGs in `crates/horizon-theme/icons/pixel/`, tinted
with the text colour.
- **Contrast**: every text colour meets WCAG AA (4.5:1) on the panels, the page and the lightest
+4 -4
View File
@@ -1,10 +1,10 @@
<!doctype html>
<!-- README banner (docs/media/banner.webp), 1600x480, in the design of docs/design-system.md.
Rendered with Playwright from website/ (see docs/media/brand/README.md). -->
Rendered with Playwright (see docs/media/brand/README.md). -->
<meta charset="utf-8"><style>
@font-face{font-family:Geist;src:url(../../website/public/fonts/Geist-Regular.woff2);font-weight:400}
@font-face{font-family:Geist;src:url(../../website/public/fonts/Geist-Medium.woff2);font-weight:500}
@font-face{font-family:"Geist Mono";src:url(../../website/public/fonts/GeistMono-Regular.woff2)}
@font-face{font-family:Geist;src:url(../../crates/horizon-theme/fonts/Geist-Regular.ttf);font-weight:400}
@font-face{font-family:Geist;src:url(../../crates/horizon-theme/fonts/Geist-Medium.ttf);font-weight:500}
@font-face{font-family:"Geist Mono";src:url(../../crates/horizon-theme/fonts/GeistMono-Regular.ttf)}
html,body{margin:0;width:1600px;height:480px;overflow:hidden;background:#0d0e13;color:#ecedf3;font-family:Geist}
main{position:relative;height:100%;padding:56px 72px;box-sizing:border-box}
.top{display:flex;align-items:center;gap:12px;font-size:20px;font-weight:500}
+4 -5
View File
@@ -26,16 +26,15 @@ places it in the design system; `candidates/` keeps the two alternatives that we
| `packaging/icons/horizon.ico` | Windows: embedded in `horizon-client.exe`, its tools and `horizon-launcher.exe` as icon resource 1 (`build.rs` of each, Windows builds only) |
| `packaging/icons/horizon.icns` | macOS: `Horizon.app/Contents/Resources/horizon.icns`, packaged with the .dmg by `packaging/macos/package.sh` |
| `crates/horizon-theme/icons/horizon-64.png` | the game window's icon at run time (X11 and Windows title bars, `window::icon`) |
| `website/public/favicon.svg`, `favicon-32.png`, `apple-touch-icon.png` | the website |
| `web/favicon.svg`, `web/favicon-32.png`, `web/apple-touch-icon.png` | the website (its `npm run assets` copies them into its `public/`) |
| `icon-1024.png`, `icon-macos-1024.png` | previews, store listings |
| `social/github-avatar-500.png` | GitHub: the organisation or account avatar |
The page in `social/` is a screenshot, at their exact size, with Playwright from the
website's dev dependencies:
The page in `social/` is a screenshot, at their exact size, with Playwright, from the repository's root:
```sh
cd website && npm install
node -e 'const {chromium}=require("playwright");(async()=>{const b=await chromium.launch();for(const[f,w,h,o]of[["card",1280,640,"github-social-1280x640"]]){const p=await b.newPage({viewport:{width:w,height:h}});await p.goto("file://"+require("path").resolve("../docs/media/brand/social/"+f+".html"));await p.waitForTimeout(300);await p.screenshot({path:"../docs/media/brand/social/"+o+".png"})}await b.close()})()'
d=$(mktemp -d) && npm install --prefix "$d" playwright && npx --prefix "$d" playwright install chromium
NODE_PATH="$d/node_modules" node -e 'const {chromium}=require("playwright");(async()=>{const b=await chromium.launch();for(const[f,w,h,o]of[["card",1280,640,"github-social-1280x640"]]){const p=await b.newPage({viewport:{width:w,height:h}});await p.goto("file://"+require("path").resolve("docs/media/brand/social/"+f+".html"));await p.waitForTimeout(300);await p.screenshot({path:"docs/media/brand/social/"+o+".png"})}await b.close()})()'
```
## Drawn in code
+3 -3
View File
@@ -1,7 +1,7 @@
<!doctype html><meta charset=utf-8><style>
@font-face{font-family:Geist;src:url(/home/ludovic/.t3/worktrees/horizon/t3-1a6cb6c4/website/public/fonts/Geist-Regular.woff2);font-weight:400}
@font-face{font-family:Geist;src:url(/home/ludovic/.t3/worktrees/horizon/t3-1a6cb6c4/website/public/fonts/Geist-Medium.woff2);font-weight:500}
@font-face{font-family:"Geist Mono";src:url(/home/ludovic/.t3/worktrees/horizon/t3-1a6cb6c4/website/public/fonts/GeistMono-Regular.woff2)}
@font-face{font-family:Geist;src:url(../../../../crates/horizon-theme/fonts/Geist-Regular.ttf);font-weight:400}
@font-face{font-family:Geist;src:url(../../../../crates/horizon-theme/fonts/Geist-Medium.ttf);font-weight:500}
@font-face{font-family:"Geist Mono";src:url(../../../../crates/horizon-theme/fonts/GeistMono-Regular.ttf)}
html,body{margin:0;width:100%;height:100%;overflow:hidden;background:#0d0e13;color:#ecedf3;font-family:Geist}
.wrap{position:relative;height:100%;padding:var(--pad);box-sizing:border-box}
.top{display:flex;align-items:center;gap:12px;font-size:var(--small);font-weight:500}
Loaded 100 of 248 files, more files were not shown because too many files have changed in this diff. Show more