mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-09 17:19:15 -10:00
Merge remote-tracking branch 'origin/main' into feat/server-mods-world
This commit is contained in:
commit
8ec47d6419
248 files changed
+3925
-17086
No files matched your search
@@ -2,7 +2,6 @@
|
||||
target/
|
||||
.local/
|
||||
**/node_modules/
|
||||
website/
|
||||
benchmarks/
|
||||
packs/
|
||||
mods/
|
||||
+1
-1
@@ -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
|
||||
@@ -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).
|
||||
|
||||
@@ -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
@@ -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
@@ -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>
|
||||
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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)]
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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).
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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()?
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
}
|
||||
@@ -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>>>,
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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()
|
||||
);
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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])) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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", ®ions)
|
||||
for (kind, batch) in receive {
|
||||
let cache = match kind {
|
||||
Folder::Region => ®ions,
|
||||
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 = §ions[&"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();
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
+3
-2
@@ -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,
|
||||
File renamed without changes.
File renamed without changes.
@@ -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"))
|
||||
|
||||
@@ -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.
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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!();
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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
@@ -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).
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
Reference in new issue
Block a user