Merge remote-tracking branch 'origin/main' into ci/launcher-check
# Conflicts: # tools/ci/rust_changes.py
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -262,6 +262,9 @@ fn play(path: &Path, report: &mut Report) -> Result<(), String> {
|
||||
format!("{}: {text}", if error { "error" } else { "feedback" })
|
||||
}
|
||||
Output::Command { line } => format!("command: /{line}"),
|
||||
Output::QuietCommands { lines, .. } => {
|
||||
format!("{} quiet command(s)", lines.len())
|
||||
}
|
||||
Output::Stopped { reason, .. } => return Err(format!("it stopped: {reason}")),
|
||||
Output::OpenScreen { .. } => "opened a screen".to_string(),
|
||||
Output::CloseScreen { .. } => "closed its screen".to_string(),
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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;
|
||||
@@ -108,6 +109,7 @@ probes! {
|
||||
block_transform_fixture,
|
||||
breaking_fixture,
|
||||
camera_probe,
|
||||
creative_mining_probe,
|
||||
cushion_placement_probe,
|
||||
cauldron_fixture,
|
||||
chat_screen_fixture,
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -664,7 +664,9 @@ pub fn canonical(tag: &Tag) -> Option<Tag> {
|
||||
}
|
||||
"minecraft:trial_spawner" => {
|
||||
// `TrialSpawnerStateData.Packed` and `FullConfig`: every key is optional
|
||||
// and defaulted by vanilla; they are kept as loaded.
|
||||
// and defaulted by vanilla; they are kept as loaded. No `spawn_data` is
|
||||
// added: an absent one loads as Optional.empty and is not stored, and
|
||||
// an empty entity would keep the spawner from ever picking a mob.
|
||||
if let Tag::Compound(c) = tag {
|
||||
for (k, v) in c {
|
||||
let key = k.to_string_lossy();
|
||||
@@ -676,12 +678,6 @@ pub fn canonical(tag: &Tag) -> Option<Tag> {
|
||||
}
|
||||
}
|
||||
}
|
||||
if !f.out.contains_key(&"spawn_data".into()) {
|
||||
f.put(
|
||||
"spawn_data",
|
||||
Tag::compound([("entity", Tag::Compound(Compound::new()))]),
|
||||
);
|
||||
}
|
||||
}
|
||||
"minecraft:vault" => {
|
||||
// `VaultConfig.DEFAULT` (when the config is absent): the trial key and the
|
||||
@@ -827,6 +823,21 @@ mod tests {
|
||||
canonical(&snbt(s)).expect("known type")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_trial_spawner_without_spawn_data_saves_none() {
|
||||
let fresh = canon(
|
||||
r#"{id:"minecraft:trial_spawner",x:1,y:2,z:3,normal_config:"minecraft:trial_chamber/ranged/skeleton/normal"}"#,
|
||||
);
|
||||
assert_eq!(fresh.get("spawn_data"), None);
|
||||
let picked = canon(
|
||||
r#"{id:"minecraft:trial_spawner",x:1,y:2,z:3,spawn_data:{entity:{id:"minecraft:skeleton"}}}"#,
|
||||
);
|
||||
assert_eq!(
|
||||
picked.get("spawn_data"),
|
||||
Some(&snbt(r#"{entity:{id:"minecraft:skeleton"}}"#))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_type_has_defaults() {
|
||||
for id in TYPES.iter().chain(NEWEST.iter()) {
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -94,9 +94,66 @@ pub enum BlockEntity {
|
||||
Beehive(Box<crate::beehive::Beehive>),
|
||||
/// `CommandBlockEntity` of the three command blocks (`crate::command_block`).
|
||||
CommandBlock(Box<crate::command_block::CommandBlock>),
|
||||
/// `CampfireBlockEntity` of campfires and soul campfires.
|
||||
Campfire(Box<Campfire>),
|
||||
/// `SkullBlockEntity` of every skull and head block.
|
||||
Skull(Skull),
|
||||
}
|
||||
/// `CampfireBlockEntity`: four cooking slots, each with its
|
||||
/// `cookingProgress` and `cookingTime` (the recipe's).
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct Campfire {
|
||||
pub items: [Option<ItemStack>; 4],
|
||||
pub progress: [i32; 4],
|
||||
pub total: [i32; 4],
|
||||
}
|
||||
impl Campfire {
|
||||
/// `placeFood`'s slot: the first empty one, given the item's
|
||||
/// `campfire_cooking` time; it takes one item of `held`.
|
||||
pub fn place_food(&mut self, held: &mut ItemStack, cooking_time: i32) -> bool {
|
||||
let Some(slot) = self.items.iter().position(Option::is_none) else {
|
||||
return false;
|
||||
};
|
||||
self.total[slot] = cooking_time;
|
||||
self.progress[slot] = 0;
|
||||
self.items[slot] = Some(ItemStack {
|
||||
count: 1,
|
||||
..held.clone()
|
||||
});
|
||||
held.count = held.count.saturating_sub(1);
|
||||
true
|
||||
}
|
||||
/// `cookTick` (lit): each item's progress rises by one; the slots whose
|
||||
/// progress reached their cooking time give up their item (the caller
|
||||
/// drops its result).
|
||||
pub fn cook_tick(&mut self) -> Vec<(usize, ItemStack)> {
|
||||
let mut done = Vec::new();
|
||||
for i in 0..4 {
|
||||
if self.items[i].is_none() {
|
||||
continue;
|
||||
}
|
||||
self.progress[i] += 1;
|
||||
if self.progress[i] >= self.total[i]
|
||||
&& let Some(item) = self.items[i].take()
|
||||
{
|
||||
done.push((i, item));
|
||||
}
|
||||
}
|
||||
done
|
||||
}
|
||||
/// `cooldownTick` (unlit): the progress falls by two, no lower than 0.
|
||||
/// Whether anything changed (`setChanged`).
|
||||
pub fn cooldown_tick(&mut self) -> bool {
|
||||
let mut changed = false;
|
||||
for i in 0..4 {
|
||||
if self.progress[i] > 0 {
|
||||
changed = true;
|
||||
self.progress[i] = (self.progress[i] - 2).clamp(0, self.total[i]);
|
||||
}
|
||||
}
|
||||
changed
|
||||
}
|
||||
}
|
||||
/// `SkullBlockEntity`: the player profile the head shows (kept as loaded:
|
||||
/// a compound, or a string when it names a player only), the note block
|
||||
/// sound and `custom_name`.
|
||||
@@ -315,6 +372,9 @@ impl BlockEntity {
|
||||
if name == "minecraft:brewing_stand" {
|
||||
return Some(Self::Brewing(Box::new(crate::brewing::BrewingStand::new())));
|
||||
}
|
||||
if name == "minecraft:campfire" || name == "minecraft:soul_campfire" {
|
||||
return Some(Self::Campfire(Box::default()));
|
||||
}
|
||||
let size = match name {
|
||||
"minecraft:chest" | "minecraft:trapped_chest" | "minecraft:barrel" => 27,
|
||||
n if n.ends_with("copper_chest") => 27,
|
||||
@@ -352,6 +412,7 @@ impl BlockEntity {
|
||||
Self::Beehive(_) => "minecraft:beehive",
|
||||
Self::CommandBlock(_) => "minecraft:command_block",
|
||||
Self::Skull(_) => "minecraft:skull",
|
||||
Self::Campfire(_) => "minecraft:campfire",
|
||||
}
|
||||
}
|
||||
pub fn contents(&self) -> Vec<ItemStack> {
|
||||
@@ -373,6 +434,7 @@ impl BlockEntity {
|
||||
Self::Sculk(_) => Vec::new(),
|
||||
Self::Banner(_) => Vec::new(),
|
||||
Self::Crafter(c) => c.items.iter().flatten().cloned().collect(),
|
||||
Self::Campfire(c) => c.items.iter().flatten().cloned().collect(),
|
||||
}
|
||||
}
|
||||
pub fn slots(&self) -> &[Option<ItemStack>] {
|
||||
@@ -393,7 +455,9 @@ impl BlockEntity {
|
||||
| Self::TrialSpawner(_)
|
||||
| Self::Vault(_)
|
||||
| Self::Beehive(_)
|
||||
| Self::CommandBlock(_) => &[],
|
||||
| Self::CommandBlock(_)
|
||||
// Not a Container (only Clearable): hoppers don't reach in.
|
||||
| Self::Campfire(_) => &[],
|
||||
}
|
||||
}
|
||||
pub fn slots_mut(&mut self) -> &mut [Option<ItemStack>] {
|
||||
@@ -414,7 +478,8 @@ impl BlockEntity {
|
||||
| Self::TrialSpawner(_)
|
||||
| Self::Vault(_)
|
||||
| Self::Beehive(_)
|
||||
| Self::CommandBlock(_) => &mut [],
|
||||
| Self::CommandBlock(_)
|
||||
| Self::Campfire(_) => &mut [],
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -706,6 +771,41 @@ mod tests {
|
||||
use super::*;
|
||||
use horizon_core::random::{LegacyRandom, RandomSource};
|
||||
|
||||
#[test]
|
||||
fn campfire_slots_take_food_cook_it_and_cool_down() {
|
||||
let mut c = Campfire::default();
|
||||
let mut beef = ItemStack::new("minecraft:beef", 2);
|
||||
// placeFood: the first free slot, one item, its recipe's time.
|
||||
assert!(c.place_food(&mut beef, 600));
|
||||
assert_eq!(beef.count, 1);
|
||||
assert_eq!(c.items[0].as_ref().map(|s| s.count), Some(1));
|
||||
assert_eq!((c.progress[0], c.total[0]), (0, 600));
|
||||
let mut fish = ItemStack::new("minecraft:cod", 1);
|
||||
assert!(c.place_food(&mut fish, 3));
|
||||
assert_eq!(c.items[1].as_ref().map(|s| &*s.item), Some("minecraft:cod"));
|
||||
// cookTick: the cod is done on its third tick, the beef keeps cooking.
|
||||
assert!(c.cook_tick().is_empty());
|
||||
assert!(c.cook_tick().is_empty());
|
||||
let done = c.cook_tick();
|
||||
assert_eq!(done.len(), 1);
|
||||
assert_eq!((done[0].0, &*done[0].1.item), (1, "minecraft:cod"));
|
||||
assert!(c.items[1].is_none());
|
||||
assert_eq!(c.progress[0], 3);
|
||||
// cooldownTick: two a tick, never below zero.
|
||||
assert!(c.cooldown_tick());
|
||||
assert_eq!(c.progress[0], 1);
|
||||
assert!(c.cooldown_tick());
|
||||
assert_eq!(c.progress[0], 0);
|
||||
// Full: four slots, no fifth.
|
||||
for _ in 0..3 {
|
||||
let mut more = ItemStack::new("minecraft:potato", 1);
|
||||
assert!(c.place_food(&mut more, 600));
|
||||
}
|
||||
let mut fifth = ItemStack::new("minecraft:potato", 1);
|
||||
assert!(!c.place_food(&mut fifth, 600));
|
||||
assert_eq!(fifth.count, 1);
|
||||
}
|
||||
|
||||
/// A whole amount pays itself without a draw; a fraction is a chance of one more.
|
||||
#[test]
|
||||
fn create_experience_rounds_the_fraction_by_chance() {
|
||||
|
||||
@@ -18,6 +18,7 @@ mod boats;
|
||||
mod books;
|
||||
mod bossbars;
|
||||
mod breezes;
|
||||
mod campfire;
|
||||
mod cartography;
|
||||
mod chat;
|
||||
mod client_ambient;
|
||||
@@ -3345,6 +3346,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]);
|
||||
@@ -3970,7 +3978,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(());
|
||||
}
|
||||
@@ -5302,7 +5315,8 @@ impl Game<'_> {
|
||||
| BlockEntity::Vault(_)
|
||||
| BlockEntity::Beehive(_)
|
||||
| BlockEntity::CommandBlock(_)
|
||||
| BlockEntity::Skull(_),
|
||||
| BlockEntity::Skull(_)
|
||||
| BlockEntity::Campfire(_),
|
||||
) => {}
|
||||
None => {
|
||||
v.extend((0..self.craft_size * self.craft_size).map(|i| 100 + i));
|
||||
@@ -5572,7 +5586,8 @@ impl Game<'_> {
|
||||
| BlockEntity::Vault(_)
|
||||
| BlockEntity::Beehive(_)
|
||||
| BlockEntity::CommandBlock(_)
|
||||
| BlockEntity::Skull(_),
|
||||
| BlockEntity::Skull(_)
|
||||
| BlockEntity::Campfire(_),
|
||||
) => {}
|
||||
None if self.mount_active() => self.draw_mount_background(batch, ox, oy),
|
||||
None => {
|
||||
@@ -7037,6 +7052,9 @@ impl Game<'_> {
|
||||
// `VaultRenderer`.
|
||||
self.mark_entity(out.len(), "");
|
||||
self.vault_geometry(camera, alpha, &mut out);
|
||||
// `CampfireRenderer`.
|
||||
self.mark_entity(out.len(), "");
|
||||
self.campfire_geometry(camera, &mut out);
|
||||
self.mark_entity(out.len(), "minecraft:ominous_item_spawner");
|
||||
self.ominous_item_spawner_geometry(camera, alpha, &mut out);
|
||||
// Mobs and their projectiles (a span each).
|
||||
@@ -8740,6 +8758,13 @@ impl Game<'_> {
|
||||
{
|
||||
return Ok(UseResult::Success { swing: true });
|
||||
}
|
||||
if allow_block_actions
|
||||
&& let Some(hit) = self.pick()
|
||||
&& let Some(placed) = self.use_on_campfire(hit.pos)?
|
||||
{
|
||||
// SUCCESS_SERVER swings; a full campfire's CONSUME does not.
|
||||
return Ok(UseResult::Success { swing: placed });
|
||||
}
|
||||
// BucketItem has no useOn: a guest's UseItemOn leaves it to UseItem.
|
||||
let bucket = horizon_client::items::bucket_contents(&stack.item);
|
||||
if matches!(self.guest_use, Some(GuestUse::OnBlock(_)))
|
||||
|
||||
@@ -56,6 +56,7 @@ impl Game<'_> {
|
||||
Some(BlockEntity::Beehive(_)) => self.tick_beehive(pos),
|
||||
Some(BlockEntity::Furnace(_)) => self.tick_furnace(pos)?,
|
||||
Some(BlockEntity::Brewing(_)) => self.tick_brewing_stand(pos)?,
|
||||
Some(BlockEntity::Campfire(_)) => self.tick_campfire(pos)?,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
@@ -92,7 +93,10 @@ impl Game<'_> {
|
||||
| "minecraft:vault"
|
||||
| "minecraft:beehive"
|
||||
| "minecraft:bee_nest"
|
||||
| "minecraft:brewing_stand" => Ticking::Yes,
|
||||
| "minecraft:brewing_stand"
|
||||
// Lit, cookTick; unlit, cooldownTick.
|
||||
| "minecraft:campfire"
|
||||
| "minecraft:soul_campfire" => Ticking::Yes,
|
||||
"minecraft:jukebox" => {
|
||||
let record = registry
|
||||
.state(state)
|
||||
|
||||
@@ -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,190 @@
|
||||
//! Campfires' cooking: `CampfireBlock.useItemOn` (`CampfireBlockEntity.placeFood`)
|
||||
//! and the block entity's `cookTick` (lit) and `cooldownTick` (unlit). The
|
||||
//! items drawn on the fire are `campfire_geometry`'s; a server's clients get
|
||||
//! them through the block entity's update tag (`lan::chunks::entity_tag`).
|
||||
use super::Game;
|
||||
use horizon_assets::item_model::DisplayContext;
|
||||
use horizon_client::{
|
||||
Result, block_entities::Pos, block_entity_render::vertex_light, containers::BlockEntity,
|
||||
items::ItemStack, mesher::Vertex, mobs::Who,
|
||||
};
|
||||
use horizon_core::position::BlockPos;
|
||||
|
||||
/// The `campfire_cooking` recipe kind (`RecipeType.CAMPFIRE_COOKING`).
|
||||
const KIND: &str = "minecraft:campfire_cooking";
|
||||
|
||||
fn is_campfire(name: &str) -> bool {
|
||||
name == "minecraft:campfire" || name == "minecraft:soul_campfire"
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
/// `CampfireBlock.useItemOn` with a held item that is a campfire recipe's
|
||||
/// input (`RecipePropertySet.CAMPFIRE_INPUT`): on a server level the food
|
||||
/// goes into the first free slot (`placeFood`), and the use is consumed
|
||||
/// whether or not there was room. A client of a server leaves the placing
|
||||
/// to the server. Whether the food went in; None when this isn't a
|
||||
/// campfire use, so the item's own use runs.
|
||||
pub(super) fn use_on_campfire(&mut self, pos: [i32; 3]) -> Result<Option<bool>> {
|
||||
let state = self.world.block(pos[0], pos[1], pos[2]);
|
||||
if !is_campfire(&self.res.facts().get(state).name) {
|
||||
return Ok(None);
|
||||
}
|
||||
let Some(held) = self.held().cloned() else {
|
||||
return Ok(None);
|
||||
};
|
||||
let Some(time) = self.res.recipes().cook(KIND, &held).map(|r| r.time as i32) else {
|
||||
return Ok(None);
|
||||
};
|
||||
// A client of a server predicts the placing; the server decides.
|
||||
if self.remote.is_some() {
|
||||
return Ok(Some(true));
|
||||
}
|
||||
let key = (pos[0], pos[1], pos[2]);
|
||||
let placed = match self.world.block_entities.get_mut(&key) {
|
||||
Some(BlockEntity::Campfire(c)) => {
|
||||
let mut one = held.clone();
|
||||
c.place_food(&mut one, time)
|
||||
}
|
||||
_ => false,
|
||||
};
|
||||
if placed {
|
||||
// ItemStack.consumeAndReturn: creative keeps the item.
|
||||
self.consume_held();
|
||||
self.stats.custom("interact_with_campfire", 1);
|
||||
self.post_block_game_event(
|
||||
"minecraft:block_change",
|
||||
pos,
|
||||
Some(Who::Player),
|
||||
Some(state),
|
||||
);
|
||||
self.mark_block_entity_updated(key);
|
||||
}
|
||||
Ok(Some(placed))
|
||||
}
|
||||
|
||||
/// `CampfireBlockEntity.cookTick` of a lit campfire, `cooldownTick` of an
|
||||
/// unlit one. A finished item's result (the recipe's, or the item itself
|
||||
/// when no recipe takes it any more) drops at the block's corner
|
||||
/// (`Containers.dropItemStack` at its integer position), then the clients
|
||||
/// see the slot empty and a `block_change` goes out.
|
||||
pub(super) fn tick_campfire(&mut self, pos: Pos) -> Result<()> {
|
||||
let state = self.world.block(pos.0, pos.1, pos.2);
|
||||
let lit = self
|
||||
.pack
|
||||
.assets
|
||||
.registry
|
||||
.state(state)
|
||||
.and_then(|s| s.properties.get("lit"))
|
||||
.is_some_and(|v| v == "true");
|
||||
let Some(BlockEntity::Campfire(c)) = self.world.block_entities.get_mut(&pos) else {
|
||||
return Ok(());
|
||||
};
|
||||
// BlockEntity.setChanged: a timer that moved is saved with the
|
||||
// chunk (no update to the clients, who don't see the timers).
|
||||
let changed = if lit {
|
||||
c.items.iter().any(Option::is_some)
|
||||
} else {
|
||||
c.cooldown_tick()
|
||||
};
|
||||
if changed {
|
||||
self.world.modified.insert((pos.0 >> 4, pos.2 >> 4));
|
||||
}
|
||||
if !lit {
|
||||
return Ok(());
|
||||
}
|
||||
let Some(BlockEntity::Campfire(c)) = self.world.block_entities.get_mut(&pos) else {
|
||||
return Ok(());
|
||||
};
|
||||
let done = c.cook_tick();
|
||||
for (_, item) in done {
|
||||
let result = self
|
||||
.res
|
||||
.recipes()
|
||||
.cook(KIND, &item)
|
||||
.map_or_else(|| item.clone(), |r| r.result.clone());
|
||||
self.campfire_drop(pos, result);
|
||||
self.mark_block_entity_updated(pos);
|
||||
self.post_block_game_event(
|
||||
"minecraft:block_change",
|
||||
[pos.0, pos.1, pos.2],
|
||||
None,
|
||||
Some(state),
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `CampfireRenderer`: each cooking item lies on the fire, slot `i`
|
||||
/// turned to `Direction.from2DDataValue((i + facing) % 4)`, laid flat
|
||||
/// (90° about X), at a corner and three eighths of its size, drawn as a
|
||||
/// FIXED item seeded by the block's position plus the slot.
|
||||
pub(super) fn campfire_geometry(&self, camera: glam::DVec3, out: &mut Vec<Vertex>) {
|
||||
for (key, entity) in &self.world.block_entities {
|
||||
let BlockEntity::Campfire(c) = entity else {
|
||||
continue;
|
||||
};
|
||||
if c.items.iter().all(Option::is_none) {
|
||||
continue;
|
||||
}
|
||||
let at = glam::DVec3::new(f64::from(key.0), f64::from(key.1), f64::from(key.2));
|
||||
if at.distance_squared(camera) >= 64.0 * 64.0 {
|
||||
continue;
|
||||
}
|
||||
// Direction.get2DDataValue: south 0, west 1, north 2, east 3.
|
||||
let state = self.world.block(key.0, key.1, key.2);
|
||||
let facing = match self
|
||||
.pack
|
||||
.assets
|
||||
.registry
|
||||
.state(state)
|
||||
.and_then(|s| s.properties.get("facing"))
|
||||
.map(String::as_str)
|
||||
{
|
||||
Some("west") => 1,
|
||||
Some("north") => 2,
|
||||
Some("east") => 3,
|
||||
_ => 0,
|
||||
};
|
||||
let light = vertex_light(self.world.light(key.0, key.1, key.2));
|
||||
let packed = BlockPos::new(key.0, key.1, key.2).pack();
|
||||
for (i, stack) in c.items.iter().enumerate() {
|
||||
let Some(stack) = stack else {
|
||||
continue;
|
||||
};
|
||||
let seed = (packed as i32).wrapping_add(i as i32);
|
||||
let item = self.resolve_item(stack, DisplayContext::Fixed, None, seed);
|
||||
if item.is_empty() {
|
||||
continue;
|
||||
}
|
||||
// Direction.toYRot: 90° per 2D step from south.
|
||||
let yaw = -(((i + facing) % 4) as f32 * 90.0);
|
||||
let pose = glam::Mat4::from_translation(
|
||||
(at - camera).as_vec3() + glam::Vec3::new(0.5, 0.449_218_75, 0.5),
|
||||
) * glam::Mat4::from_rotation_y(yaw.to_radians())
|
||||
* glam::Mat4::from_rotation_x(90f32.to_radians())
|
||||
* glam::Mat4::from_translation(glam::Vec3::new(-0.3125, -0.3125, 0.0))
|
||||
* glam::Mat4::from_scale(glam::Vec3::splat(0.375));
|
||||
self.submit_item_world(&item, pose, light, out);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `Containers.dropItemStack(level, x, y, z, stack)` at the block's
|
||||
/// integer position.
|
||||
fn campfire_drop(&mut self, pos: Pos, stack: ItemStack) {
|
||||
let at = BlockPos::new(pos.0, pos.1, pos.2);
|
||||
for piece in
|
||||
horizon_world::level::removal::drop_item_stack(&mut self.world.random, at, stack.count)
|
||||
{
|
||||
self.spawn_item(
|
||||
ItemStack {
|
||||
count: piece.count,
|
||||
..stack.clone()
|
||||
},
|
||||
piece.pos,
|
||||
piece.motion,
|
||||
0,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -373,6 +373,7 @@ impl Game<'_> {
|
||||
// Drawn seated (Entity.isPassenger); set apart because the
|
||||
// json! above is at the macro recursion limit.
|
||||
state["passenger"] = m.extra.passenger.into();
|
||||
state["guardian_beam"] = serde_json::json!(horizon_client::mobs::guardian::beam(m, 0.0));
|
||||
state
|
||||
})
|
||||
.collect();
|
||||
@@ -1065,7 +1066,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,
|
||||
|
||||
@@ -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,
|
||||
@@ -2022,6 +2023,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 +2070,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 +2200,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,
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ use horizon_client::{
|
||||
hub::{HostEvent, HostState, Hub, HubConfig, TickCtx},
|
||||
playerdata,
|
||||
},
|
||||
mobs::{Mob, leash::Holder},
|
||||
mobs::{Mob, Who, leash::Holder},
|
||||
text::Text,
|
||||
};
|
||||
use horizon_core::geometry::Vec3;
|
||||
@@ -147,6 +147,7 @@ pub(super) fn baby_index(kind: &str) -> Option<u8> {
|
||||
fn mob_meta(
|
||||
m: &Mob,
|
||||
hub: &Hub,
|
||||
player_entity: Option<i32>,
|
||||
meta: &mut Vec<(u8, i32, Value)>,
|
||||
defaults: &mut Vec<(u8, i32, Value)>,
|
||||
) {
|
||||
@@ -196,6 +197,25 @@ fn mob_meta(
|
||||
let e = &m.extra;
|
||||
let id = |registry: &str, name: &str| hub.registry_id(registry, &format!("minecraft:{name}"));
|
||||
match kind {
|
||||
"minecraft:guardian" | "minecraft:elder_guardian" => {
|
||||
// Guardian.DATA_ID_MOVING and DATA_ID_ATTACK_TARGET.
|
||||
if e.flag2 {
|
||||
meta.push((16, serializer::BOOLEAN, Value::Bool(true)));
|
||||
}
|
||||
let target = if e.flag {
|
||||
m.ai.target.and_then(|who| match who {
|
||||
Who::Player => player_entity,
|
||||
Who::Mob(id) => Some(id as i32),
|
||||
})
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if let Some(target) = target {
|
||||
meta.push((17, serializer::INT, Value::Int(target)));
|
||||
}
|
||||
defaults.push((16, serializer::BOOLEAN, Value::Bool(false)));
|
||||
defaults.push((17, serializer::INT, Value::Int(0)));
|
||||
}
|
||||
// Bat.DATA_ID_FLAGS: resting in bit 0, flying by default.
|
||||
"minecraft:bat" => {
|
||||
if e.flag {
|
||||
@@ -831,12 +851,13 @@ impl Game<'_> {
|
||||
for m in self.mobs.iter().filter(|m| !m.removed) {
|
||||
// The lead's holder: the player the mob sees (a guest, else the
|
||||
// host) or another mob. A fence knot isn't an entity here.
|
||||
let player_entity = mob_players
|
||||
.get(&m.id)
|
||||
.and_then(|conn| hub.guest(*conn))
|
||||
.map(|g| g.entity_id)
|
||||
.or(host_entity);
|
||||
let leash = m.extra.leash.and_then(|h| match h {
|
||||
Holder::Player => mob_players
|
||||
.get(&m.id)
|
||||
.and_then(|conn| hub.guest(*conn))
|
||||
.map(|g| g.entity_id)
|
||||
.or(host_entity),
|
||||
Holder::Player => player_entity,
|
||||
Holder::Mob(id) => Some(id as i32),
|
||||
Holder::Knot(_) => None,
|
||||
});
|
||||
@@ -846,7 +867,7 @@ impl Game<'_> {
|
||||
e.on_ground = m.on_ground;
|
||||
e.velocity = [m.velocity.x, m.velocity.y, m.velocity.z];
|
||||
e.needs_sync = m.needs_sync;
|
||||
mob_meta(m, hub, &mut e.meta, &mut e.defaults);
|
||||
mob_meta(m, hub, player_entity, &mut e.meta, &mut e.defaults);
|
||||
mob_equipment(m, hub, &mut e.equipment);
|
||||
self.mob_attributes(m, &mut e.attributes);
|
||||
e.leash = leash;
|
||||
|
||||
@@ -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.
|
||||
@@ -875,7 +879,11 @@ impl Game<'_> {
|
||||
.filter(|g| host.hub.here(g))
|
||||
.filter(|g| {
|
||||
g.menu.as_ref().is_some_and(|m| {
|
||||
m.slots.valid(&self.world) && !m.slots.still_valid(&self.world, g, m.pos)
|
||||
(m.slots.valid(&self.world) && !m.slots.still_valid(&self.world, g, m.pos))
|
||||
|| m.pos.is_some_and(|k| {
|
||||
k.1 == super::minecarts::CART_KEY_Y
|
||||
&& !self.chest_vehicle_still_valid(k.2, g)
|
||||
})
|
||||
})
|
||||
})
|
||||
.map(|g| g.conn)
|
||||
@@ -889,6 +897,35 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// `ContainerEntity.isChestVehicleStillValid`: the chest minecart or chest
|
||||
/// boat is still there and within the guest's entity interaction range
|
||||
/// plus 4 of its box.
|
||||
fn chest_vehicle_still_valid(&self, id: i32, g: &horizon_client::lan::guest::Guest) -> bool {
|
||||
let bounds = self
|
||||
.carts
|
||||
.iter()
|
||||
.find(|c| c.id as i32 == id)
|
||||
.map(|c| c.bounds())
|
||||
.or_else(|| {
|
||||
self.boats
|
||||
.iter()
|
||||
.find(|b| b.id as i32 == id)
|
||||
.map(|b| b.bounds())
|
||||
});
|
||||
let Some(b) = bounds else {
|
||||
return false;
|
||||
};
|
||||
let eye = [g.position[0], g.position[1] + g.eye_height(), g.position[2]];
|
||||
let (lo, hi) = (b.min_position(), b.max_position());
|
||||
let d = [
|
||||
eye[0] - eye[0].clamp(lo.x, hi.x),
|
||||
eye[1] - eye[1].clamp(lo.y, hi.y),
|
||||
eye[2] - eye[2].clamp(lo.z, hi.z),
|
||||
];
|
||||
let range = if g.creative() { 5.0 } else { 3.0 } + 4.0;
|
||||
d[0] * d[0] + d[1] * d[1] + d[2] * d[2] < range * range
|
||||
}
|
||||
|
||||
/// A packet from a guest that the hub does not handle itself.
|
||||
pub(super) fn lan_guest_packet(&mut self, host: &mut LanHost, conn: u32, packet: S) {
|
||||
let Some(gi) = host.hub.guests.iter().position(|g| g.conn == conn) else {
|
||||
@@ -1314,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()?
|
||||
|
||||
@@ -144,15 +144,16 @@ impl Game<'_> {
|
||||
.offers
|
||||
.iter()
|
||||
.filter_map(|o| {
|
||||
let cost =
|
||||
|st: &ItemStack, hub: &mut horizon_client::lan::hub::Hub| play::NetCost {
|
||||
item: hub.item_id(&st.item),
|
||||
count: st.count as i32,
|
||||
};
|
||||
let cost = |st: &ItemStack, hub: &mut horizon_client::lan::hub::Hub| {
|
||||
hub.stack(&Some(st.clone())).map(play::NetCost::from)
|
||||
};
|
||||
Some(play::NetOffer {
|
||||
cost_a: cost(&o.base_cost_a.stack, &mut host.hub),
|
||||
cost_a: cost(&o.base_cost_a.stack, &mut host.hub)?,
|
||||
result: host.hub.stack(&Some(o.result.clone()))?,
|
||||
cost_b: o.cost_b.as_ref().map(|b| cost(&b.stack, &mut host.hub)),
|
||||
cost_b: match &o.cost_b {
|
||||
Some(b) => Some(cost(&b.stack, &mut host.hub)?),
|
||||
None => None,
|
||||
},
|
||||
out_of_stock: o.out_of_stock(),
|
||||
uses: o.uses,
|
||||
max_uses: o.max_uses,
|
||||
|
||||
@@ -88,8 +88,8 @@ pub(super) struct Minecart {
|
||||
/// Hopper minecarts: an unpowered activator rail enables them.
|
||||
pub enabled: bool,
|
||||
}
|
||||
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 }
|
||||
@@ -97,7 +97,7 @@ fn display_offset(kind: CartKind) -> f64 {
|
||||
/// The minecart's passenger attachment minus Player.DEFAULT_VEHICLE_ATTACHMENT.
|
||||
pub(super) const SEAT: f64 = 0.1875 - 0.6;
|
||||
/// The placeholder container key under which an open cart's items live.
|
||||
pub(super) const CART_KEY_Y: i32 = -100_000;
|
||||
pub(super) const CART_KEY_Y: i32 = horizon_client::lan::menu::VEHICLE_KEY_Y;
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
||||
pub(super) enum Shape {
|
||||
|
||||
@@ -68,6 +68,10 @@ pub(super) struct ModsState {
|
||||
/// is still being shown, and a mod answering its own lines would recurse.
|
||||
#[cfg(feature = "mods")]
|
||||
commands: Vec<String>,
|
||||
/// `command.run-quiet` batches for this window's own world, by mod id,
|
||||
/// run on the next tick as `commands` are.
|
||||
#[cfg(feature = "mods")]
|
||||
quiet: Vec<(String, Vec<String>)>,
|
||||
/// The server's command tree with the running mods' commands added.
|
||||
#[cfg(feature = "mods")]
|
||||
remote_tree: Option<Dispatcher>,
|
||||
@@ -505,6 +509,51 @@ impl Game<'_> {
|
||||
}
|
||||
None
|
||||
}
|
||||
/// A mod's `command.run-quiet` lines (vanilla's commands only, as
|
||||
/// `command.run`): to a server that runs them quietly as one payload
|
||||
/// (`horizon_protocol::quiet`), to any other as chat commands, or for this
|
||||
/// window's own world next tick.
|
||||
fn mod_quiet_commands(&mut self, mod_id: String, lines: Vec<String>) {
|
||||
let lines: Vec<String> = lines
|
||||
.into_iter()
|
||||
.filter(|l| vanilla_command(l.split(' ').next().unwrap_or_default()))
|
||||
.collect();
|
||||
match self.remote.as_ref().map(|r| r.quiet_commands) {
|
||||
Some(true) => {
|
||||
for data in horizon_protocol::quiet::encode(&mod_id, &lines) {
|
||||
self.send(horizon_protocol::packets::play::Serverbound::CustomPayload(
|
||||
horizon_protocol::packets::configuration::CustomPayload {
|
||||
channel: horizon_protocol::quiet::CHANNEL.into(),
|
||||
data,
|
||||
},
|
||||
));
|
||||
}
|
||||
}
|
||||
// The server checks the player's permissions; its feedback shows.
|
||||
Some(false) => {
|
||||
for line in lines {
|
||||
self.send_chat(&format!("/{line}"));
|
||||
}
|
||||
}
|
||||
None => self.mods.quiet.push((mod_id, lines)),
|
||||
}
|
||||
}
|
||||
/// A quiet batch in this window's own world, as a server runs one
|
||||
/// (game/lan_quiet.rs): each line as the player typing it, no chat, a
|
||||
/// chat message refused; one line in the log.
|
||||
fn run_quiet_batch(&mut self, mod_id: &str, lines: &[String]) {
|
||||
let permission = self.ui.registries.permission;
|
||||
let mut failed = 0;
|
||||
for line in lines {
|
||||
let ok = self.signable_arguments(permission, line).is_empty()
|
||||
&& self.run_command_quietly(line);
|
||||
failed += usize::from(!ok);
|
||||
}
|
||||
log::info!(
|
||||
"[mod {mod_id}] ran {} quiet command(s): {failed} failed",
|
||||
lines.len()
|
||||
);
|
||||
}
|
||||
/// A game tick.
|
||||
pub(super) fn tick_mods(&mut self) {
|
||||
// Here, at the player's level: shown inside a console call (a control
|
||||
@@ -512,6 +561,9 @@ impl Game<'_> {
|
||||
for line in std::mem::take(&mut self.mods.commands) {
|
||||
self.run_command(&line);
|
||||
}
|
||||
for (mod_id, lines) in std::mem::take(&mut self.mods.quiet) {
|
||||
self.run_quiet_batch(&mod_id, &lines);
|
||||
}
|
||||
if !self.mods.mods.is_running() {
|
||||
return;
|
||||
}
|
||||
@@ -606,6 +658,10 @@ impl Game<'_> {
|
||||
}
|
||||
continue;
|
||||
}
|
||||
Output::QuietCommands { mod_id, lines } => {
|
||||
self.mod_quiet_commands(mod_id, lines);
|
||||
continue;
|
||||
}
|
||||
Output::Stopped { name, reason } => {
|
||||
// Its screen goes with it: the entry's run is gone, so
|
||||
// `mod_ui_view` finds no widgets and the screen closes.
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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>>>,
|
||||
|
||||
@@ -109,6 +109,8 @@ enum Received {
|
||||
pub(super) struct Remote {
|
||||
pub session: Session,
|
||||
pub brand: String,
|
||||
/// The server announced it runs mods' quiet commands.
|
||||
pub quiet_commands: bool,
|
||||
pub(super) builtin: Arc<Builtin>,
|
||||
lang: Arc<Language>,
|
||||
pub(super) registries: Arc<Dynamic>,
|
||||
@@ -419,6 +421,7 @@ impl Game<'_> {
|
||||
self.remote = Some(Remote {
|
||||
session,
|
||||
brand: "unknown".into(),
|
||||
quiet_commands: false,
|
||||
builtin: Arc::new(builtin),
|
||||
lang,
|
||||
registries: Arc::new(Dynamic::default()),
|
||||
@@ -839,6 +842,11 @@ impl Game<'_> {
|
||||
remote.brand = brand;
|
||||
}
|
||||
}
|
||||
Event::QuietCommands => {
|
||||
if let Some(remote) = &mut self.remote {
|
||||
remote.quiet_commands = true;
|
||||
}
|
||||
}
|
||||
Event::Reconfigured(registries) => {
|
||||
self.boss_overlay.reset();
|
||||
self.set_registries(registries);
|
||||
@@ -1398,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;
|
||||
}
|
||||
@@ -2979,6 +2995,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()
|
||||
@@ -3462,6 +3480,13 @@ impl Game<'_> {
|
||||
if m.species() == horizon_client::mobs::Species::Creeper {
|
||||
horizon_client::mobs::hostile::creeper_client_tick(m);
|
||||
}
|
||||
if matches!(
|
||||
m.species(),
|
||||
horizon_client::mobs::Species::Guardian
|
||||
| horizon_client::mobs::Species::ElderGuardian
|
||||
) {
|
||||
horizon_client::mobs::guardian::client_tick(m);
|
||||
}
|
||||
if m.hurt_time > 0 {
|
||||
m.hurt_time -= 1;
|
||||
}
|
||||
@@ -3734,6 +3759,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;
|
||||
};
|
||||
@@ -4868,16 +4902,20 @@ impl Game<'_> {
|
||||
if self.open_menu_name(p.container) != "minecraft:merchant" {
|
||||
return;
|
||||
}
|
||||
let cost = |g: &Self, c: &play::NetCost| horizon_client::merchant::ItemCost {
|
||||
stack: ItemStack::new(&g.item_name(c.item), c.count.max(0) as u32),
|
||||
let cost = |g: &Self, c: &play::NetCost| {
|
||||
g.to_stack(&Some(c.clone().into()))
|
||||
.map(|stack| horizon_client::merchant::ItemCost { stack })
|
||||
};
|
||||
let offers: Vec<_> = p
|
||||
.offers
|
||||
.iter()
|
||||
.filter_map(|o| {
|
||||
let mut offer = horizon_client::merchant::MerchantOffer {
|
||||
base_cost_a: cost(self, &o.cost_a),
|
||||
cost_b: o.cost_b.as_ref().map(|b| cost(self, b)),
|
||||
base_cost_a: cost(self, &o.cost_a)?,
|
||||
cost_b: match &o.cost_b {
|
||||
Some(b) => Some(cost(self, b)?),
|
||||
None => None,
|
||||
},
|
||||
result: self.to_stack(&Some(o.result.clone()))?,
|
||||
uses: o.uses,
|
||||
max_uses: o.max_uses,
|
||||
|
||||
@@ -4,14 +4,14 @@
|
||||
//! own simulation, which a client of a server doesn't run.
|
||||
use super::Game;
|
||||
use horizon_client::mobs::render::{PROFESSIONS, VILLAGER_TYPES};
|
||||
use horizon_client::mobs::{Mob, Species, bat, tame};
|
||||
use horizon_client::mobs::{Mob, Species, Who, bat, tame};
|
||||
use horizon_protocol::entity_data::Value;
|
||||
use horizon_protocol::registry::Builtin;
|
||||
|
||||
/// The values `apply` takes from one SetEntityData packet, by vanilla's entry
|
||||
/// indices (Entity 0..=7, LivingEntity 8..=14, Mob 15, then the subclasses).
|
||||
/// `builtin` names the ids of built-in registries.
|
||||
fn apply(m: &mut Mob, values: &[(u8, i32, Value)], builtin: &Builtin) {
|
||||
fn apply(m: &mut Mob, values: &[(u8, i32, Value)], builtin: &Builtin, local_entity: i32) {
|
||||
let species = m.species();
|
||||
let baby = super::lan::baby_index(&m.kind.id);
|
||||
for (index, _, value) in values {
|
||||
@@ -46,6 +46,19 @@ fn apply(m: &mut Mob, values: &[(u8, i32, Value)], builtin: &Builtin) {
|
||||
_ => {}
|
||||
}
|
||||
match (species, *index, value) {
|
||||
(Species::Guardian | Species::ElderGuardian, 16, Value::Bool(moving)) => {
|
||||
m.extra.flag2 = *moving;
|
||||
}
|
||||
(Species::Guardian | Species::ElderGuardian, 17, Value::Int(target)) => {
|
||||
// Guardian.onSyncedDataUpdated resets the visual charge clock.
|
||||
m.extra.counter2 = 0;
|
||||
m.extra.flag = *target != 0;
|
||||
m.ai.target = (*target != 0).then_some(if *target == local_entity {
|
||||
Who::Player
|
||||
} else {
|
||||
Who::Mob(*target as u32)
|
||||
});
|
||||
}
|
||||
// Bat.DATA_ID_FLAGS: resting in bit 0.
|
||||
(Species::Bat, 16, Value::Byte(flags)) => bat::set_resting(m, flags & 1 != 0),
|
||||
// Sheep.DATA_WOOL_ID: the colour in the low nibble, 16 sheared.
|
||||
@@ -116,7 +129,7 @@ impl Game<'_> {
|
||||
return;
|
||||
};
|
||||
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == id as u32) {
|
||||
apply(m, values, &r.builtin);
|
||||
apply(m, values, &r.builtin, r.entity_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -153,7 +166,7 @@ mod tests {
|
||||
}
|
||||
|
||||
fn apply(m: &mut Mob, values: &[(u8, i32, Value)]) {
|
||||
super::apply(m, values, &builtin());
|
||||
super::apply(m, values, &builtin(), 99);
|
||||
}
|
||||
|
||||
fn mob(kind: &str) -> Option<Mob> {
|
||||
@@ -164,6 +177,49 @@ mod tests {
|
||||
Some(m)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn guardian_charge_follows_target_updates_and_caps_at_its_duration() {
|
||||
use horizon_client::mobs::guardian::{beam, client_tick};
|
||||
for (kind, duration) in [("guardian", 80), ("elder_guardian", 60)] {
|
||||
let Some(mut m) = mob(&format!("minecraft:{kind}")) else {
|
||||
return;
|
||||
};
|
||||
assert!(beam(&m, 0.0).is_none());
|
||||
apply(
|
||||
&mut m,
|
||||
&[
|
||||
(16, s::BOOLEAN, Value::Bool(true)),
|
||||
(17, s::INT, Value::Int(99)),
|
||||
],
|
||||
);
|
||||
assert!(m.extra.flag2);
|
||||
assert_eq!(m.ai.target, Some(Who::Player));
|
||||
assert_eq!(beam(&m, 0.0), Some((0.0, 0.0)));
|
||||
for _ in 0..duration + 10 {
|
||||
client_tick(&mut m);
|
||||
}
|
||||
assert_eq!(beam(&m, 0.0), Some((duration as f32, 1.0)));
|
||||
// Another player or a mob retains its network entity id.
|
||||
apply(&mut m, &[(17, s::INT, Value::Int(42))]);
|
||||
assert_eq!(m.ai.target, Some(Who::Mob(42)));
|
||||
assert_eq!(m.extra.counter2, 0);
|
||||
client_tick(&mut m);
|
||||
assert_eq!(m.extra.counter2, 1);
|
||||
apply(
|
||||
&mut m,
|
||||
&[
|
||||
(16, s::BOOLEAN, Value::Bool(false)),
|
||||
(17, s::INT, Value::Int(0)),
|
||||
],
|
||||
);
|
||||
client_tick(&mut m);
|
||||
assert!(!m.extra.flag2);
|
||||
assert_eq!(m.ai.target, None);
|
||||
assert_eq!(m.extra.counter2, 0);
|
||||
assert!(beam(&m, 0.0).is_none());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_sheep_takes_its_wool_and_a_calf_its_size() {
|
||||
let Some(mut m) = mob("minecraft:sheep") else {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -398,8 +398,12 @@ impl GameLevel<'_> {
|
||||
}
|
||||
}
|
||||
PreRemove::Campfire => {
|
||||
for _ in 0..4 {
|
||||
self.drop_item_stack(pos, None);
|
||||
let items = match &entity {
|
||||
Some(BlockEntity::Campfire(c)) => c.items.clone(),
|
||||
_ => Default::default(),
|
||||
};
|
||||
for stack in items {
|
||||
self.drop_item_stack(pos, stack);
|
||||
}
|
||||
}
|
||||
PreRemove::Jukebox => {
|
||||
|
||||
@@ -1703,6 +1703,8 @@ impl Game<'_> {
|
||||
};
|
||||
let name = if let Some(n) = stack.custom_name() {
|
||||
n.to_owned()
|
||||
} else if let Some(n) = stack.item_name() {
|
||||
n
|
||||
} else if stack.data().is_some_and(|d| d.ominous_banner) {
|
||||
self.translate("block.minecraft.ominous_banner", "Ominous Banner")
|
||||
} else if stack.data().and_then(|d| d.potion.as_ref()).is_some()
|
||||
@@ -1729,7 +1731,9 @@ impl Game<'_> {
|
||||
// ItemContainerContents.addToTooltip.
|
||||
let hover_name = |s: &ItemStack| {
|
||||
s.custom_name()
|
||||
.map_or_else(|| self.pack.names.display_item(&s.item), str::to_owned)
|
||||
.map(str::to_owned)
|
||||
.or_else(|| s.item_name())
|
||||
.unwrap_or_else(|| self.pack.names.display_item(&s.item))
|
||||
};
|
||||
if display.shows("minecraft:container") {
|
||||
for text in
|
||||
|
||||
@@ -204,6 +204,14 @@ impl ItemStack {
|
||||
pub fn custom_name(&self) -> Option<&str> {
|
||||
self.data().and_then(|d| d.custom_name.as_deref())
|
||||
}
|
||||
/// `minecraft:item_name` in the current language (`getItemName`'s
|
||||
/// component). It stays in the patch as loaded, so a translatable name
|
||||
/// saves and reaches clients unchanged; the ominous banner's is modelled
|
||||
/// by `ominous_banner` instead.
|
||||
pub fn item_name(&self) -> Option<String> {
|
||||
let name = self.data()?.other("minecraft:item_name")?;
|
||||
Some(crate::entity_nbt::name_text(name).plain())
|
||||
}
|
||||
pub fn potion(&self) -> Option<&str> {
|
||||
self.data().and_then(|d| d.potion.as_deref())
|
||||
}
|
||||
|
||||
@@ -238,6 +238,85 @@ mod tests {
|
||||
use super::*;
|
||||
use horizon_protocol::registry::Ids;
|
||||
|
||||
#[test]
|
||||
fn merchant_water_cost_survives_the_offer_packet() {
|
||||
use horizon_protocol::{
|
||||
buf::{Codec, Reader, Writer},
|
||||
packets::play::{NetOffer, Offers},
|
||||
};
|
||||
let mut components = vec![String::new(); component::POTION_CONTENTS as usize + 1];
|
||||
components[component::POTION_CONTENTS as usize] = "minecraft:potion_contents".into();
|
||||
let builtin = Builtin {
|
||||
registries: [
|
||||
(
|
||||
"minecraft:item".into(),
|
||||
Ids::from_names(vec!["minecraft:potion".into(), "minecraft:emerald".into()]),
|
||||
),
|
||||
(
|
||||
"minecraft:potion".into(),
|
||||
Ids::from_names(vec!["minecraft:water".into()]),
|
||||
),
|
||||
(
|
||||
"minecraft:data_component_type".into(),
|
||||
Ids::from_names(components),
|
||||
),
|
||||
]
|
||||
.into(),
|
||||
};
|
||||
let dynamic = Dynamic::default();
|
||||
let water = ItemStack::new("minecraft:potion", 1).with_potion("minecraft:water");
|
||||
let wire = crate::lan::guest::stack_to_network(
|
||||
|name| builtin.get("minecraft:item").unwrap().id(name).unwrap(),
|
||||
|registry, name| builtin.get(registry)?.id(name),
|
||||
&Some(water.clone()),
|
||||
)
|
||||
.unwrap();
|
||||
let offers = vec![NetOffer {
|
||||
cost_a: wire.into(),
|
||||
result: Stack::new(1, 1),
|
||||
cost_b: Some(Stack::new(1, 2).into()),
|
||||
out_of_stock: false,
|
||||
uses: 0,
|
||||
max_uses: 2,
|
||||
xp: 0,
|
||||
special_price: 0,
|
||||
price_multiplier: 0.05,
|
||||
demand: 0,
|
||||
}];
|
||||
let mut w = Writer::new();
|
||||
Offers::write(&offers, &mut w);
|
||||
let mut r = Reader::new(&w.data);
|
||||
let decoded = Offers::read(&mut r).unwrap();
|
||||
assert_eq!(r.remaining(), 0);
|
||||
let cost = crate::merchant::ItemCost {
|
||||
stack: from_network(&decoded[0].cost_a.clone().into(), &builtin, &dynamic),
|
||||
};
|
||||
assert_eq!(cost.stack, water);
|
||||
assert_eq!(cost.stack.potion(), Some("minecraft:water"));
|
||||
let authoritative = crate::merchant::ItemCost {
|
||||
stack: water.clone(),
|
||||
};
|
||||
for payment in [
|
||||
water,
|
||||
ItemStack::new("minecraft:potion", 1).with_potion("minecraft:swiftness"),
|
||||
ItemStack::new("minecraft:potion", 1),
|
||||
] {
|
||||
assert_eq!(cost.test(&payment), authoritative.test(&payment));
|
||||
}
|
||||
assert!(cost.test(&ItemStack::new("minecraft:potion", 1).with_potion("minecraft:water")));
|
||||
assert!(
|
||||
!cost.test(&ItemStack::new("minecraft:potion", 1).with_potion("minecraft:swiftness"))
|
||||
);
|
||||
let emerald = crate::merchant::ItemCost {
|
||||
stack: from_network(
|
||||
&decoded[0].cost_b.clone().unwrap().into(),
|
||||
&builtin,
|
||||
&dynamic,
|
||||
),
|
||||
};
|
||||
assert!(emerald.test(&ItemStack::new("minecraft:emerald", 2)));
|
||||
}
|
||||
|
||||
/// A bundle's contents cross the wire, so a server's client can draw
|
||||
/// its tooltip (BundleContents' stream codec: a list of templates).
|
||||
#[test]
|
||||
@@ -286,6 +365,7 @@ mod tests {
|
||||
for (id, name) in [
|
||||
(component::DAMAGE, "minecraft:damage"),
|
||||
(component::CUSTOM_NAME, "minecraft:custom_name"),
|
||||
(component::ITEM_NAME, "minecraft:item_name"),
|
||||
(component::ENCHANTMENTS, "minecraft:enchantments"),
|
||||
(component::POTION_CONTENTS, "minecraft:potion_contents"),
|
||||
(component::REPAIR_COST, "minecraft:repair_cost"),
|
||||
@@ -345,6 +425,10 @@ mod tests {
|
||||
.push((Arc::from("minecraft:sharpness"), 2));
|
||||
d.repair_cost = 9;
|
||||
d.custom_name = Some("Keeper".to_owned());
|
||||
// A translatable item name reaches the client as the same component.
|
||||
let item_name = Tag::compound([("translate", Tag::String("filled_map.monument".into()))]);
|
||||
d.other
|
||||
.push(("minecraft:item_name".into(), item_name.clone()));
|
||||
d.trim = Some((Arc::from("minecraft:gold"), Arc::from("minecraft:sentry")));
|
||||
let id = |registry: &str, name: &str| {
|
||||
builtin
|
||||
@@ -374,6 +458,7 @@ mod tests {
|
||||
);
|
||||
assert_eq!(d.repair_cost, 9);
|
||||
assert_eq!(d.custom_name.as_deref(), Some("Keeper"));
|
||||
assert_eq!(d.other("minecraft:item_name"), Some(&item_name));
|
||||
assert_eq!(
|
||||
d.trim,
|
||||
Some((Arc::from("minecraft:gold"), Arc::from("minecraft:sentry")))
|
||||
|
||||
@@ -188,6 +188,9 @@ pub fn name_line(stack: &ItemStack, translate: &dyn Fn(&str) -> Option<String>)
|
||||
if let Some(name) = stack.custom_name() {
|
||||
return name.to_owned();
|
||||
}
|
||||
if let Some(name) = stack.item_name() {
|
||||
return name;
|
||||
}
|
||||
if stack.data().is_some_and(|d| d.ominous_banner) {
|
||||
return translate("block.minecraft.ominous_banner")
|
||||
.unwrap_or_else(|| "Ominous Banner".to_owned());
|
||||
@@ -611,6 +614,14 @@ mod tests {
|
||||
assert_eq!(rarity(&banner, "common"), "uncommon");
|
||||
banner.data_mut().custom_name = Some("Captain's Standard".into());
|
||||
assert_eq!(name_line(&banner, &translate), "Captain's Standard");
|
||||
let mut custom_item = ItemStack::new("minecraft:diamond_sword", 1);
|
||||
custom_item.data_mut().other.push((
|
||||
"minecraft:item_name".into(),
|
||||
Tag::String("Excalibur".into()),
|
||||
));
|
||||
assert_eq!(name_line(&custom_item, &translate), "Excalibur");
|
||||
custom_item.data_mut().custom_name = Some("Custom Blade".into());
|
||||
assert_eq!(name_line(&custom_item, &translate), "Custom Blade");
|
||||
banner.enchant("minecraft:unbreaking", 1);
|
||||
assert_eq!(rarity(&banner, "common"), "rare");
|
||||
let plain = ItemStack::new("minecraft:white_banner", 1);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -952,6 +952,11 @@ pub fn entity_tag(entity: &BlockEntity) -> Option<Tag> {
|
||||
Some(Tag::compound(entries))
|
||||
}
|
||||
BlockEntity::Jukebox(_) | BlockEntity::Lectern(_) => None,
|
||||
// CampfireBlockEntity.getUpdateTag: the items alone.
|
||||
BlockEntity::Campfire(c) => Some(Tag::compound([(
|
||||
"Items",
|
||||
crate::save::encode_items(&c.items),
|
||||
)])),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
@@ -645,6 +648,13 @@ fn network_stack(
|
||||
horizon_protocol::item::Component::Text(horizon_protocol::text::literal(name)),
|
||||
));
|
||||
}
|
||||
// The component as loaded, so a translatable name stays one.
|
||||
if let Some(name) = s.data().and_then(|d| d.other("minecraft:item_name")) {
|
||||
stack.added.push((
|
||||
horizon_protocol::item::component::ITEM_NAME,
|
||||
horizon_protocol::item::Component::Text(name.clone()),
|
||||
));
|
||||
}
|
||||
Some(stack)
|
||||
}
|
||||
|
||||
|
||||
@@ -37,6 +37,10 @@ pub struct Menu {
|
||||
pub sent_data: Vec<i16>,
|
||||
}
|
||||
|
||||
/// The y of the key a chest minecart's or chest boat's items are kept under
|
||||
/// while a menu shows them.
|
||||
pub const VEHICLE_KEY_Y: i32 = -100_000;
|
||||
|
||||
/// Where the slots of a container menu live.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Slots {
|
||||
@@ -72,10 +76,12 @@ impl Slots {
|
||||
Slots::Ender => at.into_iter().collect(),
|
||||
_ => self.halves(),
|
||||
};
|
||||
// A vehicle's container is kept under a key, not a block: the game
|
||||
// checks its reach to the vehicle (`isChestVehicleStillValid`).
|
||||
self.valid(world)
|
||||
&& blocks
|
||||
.iter()
|
||||
.all(|k| guest.within_block_range([k.0, k.1, k.2], 4.0))
|
||||
.all(|k| k.1 == VEHICLE_KEY_Y || guest.within_block_range([k.0, k.1, k.2], 4.0))
|
||||
}
|
||||
/// Container slot `n`.
|
||||
pub fn get(self, world: &World, guest: &Guest, mut n: usize) -> Option<ItemStack> {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -241,9 +241,7 @@ pub fn ai_step(m: &mut Mob, w: &MobWorld, events: &mut Vec<MobEvent>) {
|
||||
} else {
|
||||
a.v[SPIKES] += (1.0 - a.v[SPIKES]) * 0.06;
|
||||
}
|
||||
if m.extra.flag && m.extra.counter2 < attack_duration(m) {
|
||||
m.extra.counter2 += 1;
|
||||
}
|
||||
client_tick(m);
|
||||
if in_water {
|
||||
m.air = 300;
|
||||
} else if m.on_ground {
|
||||
@@ -332,6 +330,13 @@ pub fn elder_server_ai_step(m: &mut Mob, cx: &mut Cx) {
|
||||
]);
|
||||
}
|
||||
}
|
||||
/// Guardian.aiStep's visual attack clock, also used by server clients.
|
||||
/// Does not run attack goals, movement, damage or random draws.
|
||||
pub fn client_tick(m: &mut Mob) {
|
||||
if m.extra.flag && m.extra.counter2 < attack_duration(m) {
|
||||
m.extra.counter2 += 1;
|
||||
}
|
||||
}
|
||||
/// The beam's clock for rendering: (attack time with the partial tick,
|
||||
/// attack scale), while a target is locked.
|
||||
pub fn beam(m: &Mob, partial: f32) -> Option<(f32, f32)> {
|
||||
|
||||
@@ -40,6 +40,8 @@ pub enum Event {
|
||||
Status(String),
|
||||
/// The server's `minecraft:brand` custom payload.
|
||||
Brand(String),
|
||||
/// The server runs mods' quiet commands (`horizon_protocol::quiet`).
|
||||
QuietCommands,
|
||||
/// Configuration finished: the play state begins with these registries.
|
||||
Joined {
|
||||
registries: Arc<Dynamic>,
|
||||
@@ -668,6 +670,8 @@ fn receive_brand(payload: &cfg::CustomPayload, send: &Sender<Event>) {
|
||||
&& let Ok(brand) = horizon_protocol::buf::Reader::new(&payload.data).string(32767)
|
||||
{
|
||||
let _ = send.send(Event::Brand(brand));
|
||||
} else if payload.channel == horizon_protocol::quiet::CHANNEL {
|
||||
let _ = send.send(Event::QuietCommands);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -526,6 +526,12 @@ pub fn encode_block_entity(
|
||||
}
|
||||
}
|
||||
BlockEntity::Sculk(s) => entries.extend(s.to_nbt()),
|
||||
// CampfireBlockEntity.saveAdditional.
|
||||
BlockEntity::Campfire(c) => {
|
||||
entries.push(("Items", encode_items(&c.items)));
|
||||
entries.push(("CookingTimes", Tag::IntArray(c.progress.to_vec())));
|
||||
entries.push(("CookingTotalTimes", Tag::IntArray(c.total.to_vec())));
|
||||
}
|
||||
BlockEntity::Crafter(c) => {
|
||||
entries.push(("Items", encode_items(&c.items)));
|
||||
entries.push((
|
||||
@@ -706,6 +712,18 @@ pub fn decode_block_entity(tag: &Tag) -> Option<((i32, i32, i32), crate::contain
|
||||
}
|
||||
BlockEntity::Sign(sign) => sign.decode(tag),
|
||||
BlockEntity::Sculk(s) => s.load(tag),
|
||||
// CampfireBlockEntity.loadAdditional: missing time arrays stay zeros.
|
||||
BlockEntity::Campfire(c) => {
|
||||
for (slot, item) in decode_items(tag.get("Items"), 4).into_iter().enumerate() {
|
||||
c.items[slot] = item;
|
||||
}
|
||||
let ints = |key: &str| match tag.get(key) {
|
||||
Some(Tag::IntArray(v)) => std::array::from_fn(|i| v.get(i).copied().unwrap_or(0)),
|
||||
_ => [0; 4],
|
||||
};
|
||||
c.progress = ints("CookingTimes");
|
||||
c.total = ints("CookingTotalTimes");
|
||||
}
|
||||
BlockEntity::Crafter(c) => {
|
||||
for (slot, item) in decode_items(tag.get("Items"), 9).into_iter().enumerate() {
|
||||
c.items[slot] = item;
|
||||
@@ -736,18 +754,67 @@ pub fn decode_block_entity(tag: &Tag) -> Option<((i32, i32, i32), crate::contain
|
||||
Some((pos, entity))
|
||||
}
|
||||
|
||||
/// 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.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct LevelShape {
|
||||
pub sky_light: bool,
|
||||
pub min_y: i32,
|
||||
pub height: i32,
|
||||
}
|
||||
impl LevelShape {
|
||||
pub const OVERWORLD: Self = Self {
|
||||
sky_light: true,
|
||||
min_y: MIN_Y,
|
||||
height: SECTIONS as i32 * 16,
|
||||
};
|
||||
pub const NETHER: Self = Self {
|
||||
sky_light: false,
|
||||
min_y: 0,
|
||||
height: 256,
|
||||
};
|
||||
pub const END: Self = Self {
|
||||
sky_light: true,
|
||||
min_y: 0,
|
||||
height: 256,
|
||||
};
|
||||
/// The shape of a dimension folder (`dimensions/minecraft/<name>`, or the
|
||||
/// old `DIM-1` and `DIM1`).
|
||||
pub fn for_dir(root: &Path) -> Self {
|
||||
if root.ends_with("the_nether") || root.ends_with("DIM-1") {
|
||||
Self::NETHER
|
||||
} else if root.ends_with("the_end") || root.ends_with("DIM1") {
|
||||
Self::END
|
||||
} else {
|
||||
Self::OVERWORLD
|
||||
}
|
||||
}
|
||||
fn min_section(self) -> i32 {
|
||||
self.min_y >> 4
|
||||
}
|
||||
fn section_count(self) -> usize {
|
||||
(self.height >> 4) as usize
|
||||
}
|
||||
fn has_section(self, y: i32) -> bool {
|
||||
(self.min_section()..self.min_section() + self.section_count() as i32).contains(&y)
|
||||
}
|
||||
}
|
||||
|
||||
/// A column's sections as `Heightmap.primeHeightmaps` reads them.
|
||||
struct HeightmapView<'a> {
|
||||
sections: &'a [Arc<Section>],
|
||||
tables: &'a SaveTables,
|
||||
min_y: i32,
|
||||
height: i32,
|
||||
}
|
||||
impl HeightmapChunk for HeightmapView<'_> {
|
||||
type State = crate::assets::StateFacts;
|
||||
fn min_y(&self) -> i32 {
|
||||
MIN_Y
|
||||
self.min_y
|
||||
}
|
||||
fn height(&self) -> i32 {
|
||||
SECTIONS as i32 * 16
|
||||
self.height
|
||||
}
|
||||
fn highest_section_y(&self) -> i32 {
|
||||
let top = self
|
||||
@@ -767,7 +834,7 @@ impl HeightmapChunk for HeightmapView<'_> {
|
||||
}
|
||||
}
|
||||
/// Heightmaps kept after world generation (`ChunkStatus.FULL.heightmapsAfter`).
|
||||
fn heightmaps(chunk: &Chunk, tables: &SaveTables) -> Tag {
|
||||
fn heightmaps(chunk: &Chunk, tables: &SaveTables, shape: LevelShape) -> Tag {
|
||||
let kinds = [
|
||||
HeightmapType::WorldSurface,
|
||||
HeightmapType::OceanFloor,
|
||||
@@ -776,11 +843,13 @@ fn heightmaps(chunk: &Chunk, tables: &SaveTables) -> Tag {
|
||||
];
|
||||
let mut maps: Vec<Heightmap> = kinds
|
||||
.iter()
|
||||
.filter_map(|k| Heightmap::new(*k, MIN_Y, SECTIONS as i32 * 16).ok())
|
||||
.filter_map(|k| Heightmap::new(*k, shape.min_y, shape.height).ok())
|
||||
.collect();
|
||||
let view = HeightmapView {
|
||||
sections: &chunk.sections,
|
||||
tables,
|
||||
min_y: shape.min_y,
|
||||
height: shape.height,
|
||||
};
|
||||
let mut refs: Vec<&mut Heightmap> = maps.iter_mut().collect();
|
||||
if Heightmap::prime(&view, &mut refs).is_err() {
|
||||
@@ -810,7 +879,12 @@ pub fn client_heightmaps_in(
|
||||
.iter()
|
||||
.filter_map(|(id, k)| Heightmap::new(*k, min_y, height).ok().map(|m| (*id, m)))
|
||||
.collect();
|
||||
let view = HeightmapView { sections, tables };
|
||||
let view = HeightmapView {
|
||||
sections,
|
||||
tables,
|
||||
min_y,
|
||||
height,
|
||||
};
|
||||
let mut refs: Vec<&mut Heightmap> = maps.iter_mut().map(|(_, m)| m).collect();
|
||||
if Heightmap::prime(&view, &mut refs).is_err() {
|
||||
return Vec::new();
|
||||
@@ -836,17 +910,18 @@ fn nibbles(light: &Light, sky: bool) -> Vec<u8> {
|
||||
}
|
||||
|
||||
pub fn encode(chunk: &Chunk, registry: &BlockRegistry, biomes: &Biomes) -> NamedTag {
|
||||
encode_with(chunk, registry, biomes, None, true)
|
||||
encode_with(chunk, registry, biomes, None, LevelShape::OVERWORLD)
|
||||
}
|
||||
/// The chunk NBT vanilla's `SerializableChunkData.write` produces for a full
|
||||
/// chunk. `sky_light` is false in dimensions without skylight.
|
||||
/// chunk in a level of `shape`.
|
||||
pub fn encode_with(
|
||||
chunk: &Chunk,
|
||||
registry: &BlockRegistry,
|
||||
biomes: &Biomes,
|
||||
tables: Option<&SaveTables>,
|
||||
sky_light: bool,
|
||||
shape: LevelShape,
|
||||
) -> NamedTag {
|
||||
let sky_light = shape.sky_light;
|
||||
let mut sections = Vec::new();
|
||||
let air = tables.map_or(0, |t| t.air);
|
||||
// Sky layers below the highest section with light other than 15 are
|
||||
@@ -858,6 +933,9 @@ pub fn encode_with(
|
||||
});
|
||||
for (si, section) in chunk.sections.iter().enumerate() {
|
||||
let y = (MIN_Y >> 4) + si as i32;
|
||||
if !shape.has_section(y) {
|
||||
continue;
|
||||
}
|
||||
// PalettedContainer.pack: distinct states in index order, indices at
|
||||
// the saved width.
|
||||
let packed = section.blocks.pack();
|
||||
@@ -987,7 +1065,7 @@ pub fn encode_with(
|
||||
("DataVersion", Tag::Int(DATA_VERSION)),
|
||||
("xPos", Tag::Int(chunk.x)),
|
||||
("zPos", Tag::Int(chunk.z)),
|
||||
("yPos", Tag::Int(MIN_Y >> 4)),
|
||||
("yPos", Tag::Int(shape.min_section())),
|
||||
("Status", string("minecraft:full")),
|
||||
("LastUpdate", Tag::Long(extras.map_or(0, |e| e.last_update))),
|
||||
("isLightOn", Tag::Byte(i8::from(chunk.lit))),
|
||||
@@ -1019,11 +1097,30 @@ 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()); SECTIONS]));
|
||||
.or_insert_with(|| Tag::List(vec![Tag::List(Vec::new()); shape.section_count()]));
|
||||
}
|
||||
if let Some(t) = tables {
|
||||
put(&mut root, "Heightmaps", heightmaps(chunk, t));
|
||||
put(&mut root, "Heightmaps", heightmaps(chunk, t, shape));
|
||||
let names = |k: u32| t.block_names.get(k as usize).cloned();
|
||||
let (blocks, fluids) = extras.map_or((&[][..], &[][..]), |e| {
|
||||
(&e.block_ticks[..], &e.fluid_ticks[..])
|
||||
@@ -1239,18 +1336,19 @@ pub fn encode_proto(
|
||||
registry: &BlockRegistry,
|
||||
biomes: &Biomes,
|
||||
tables: Option<&SaveTables>,
|
||||
sky_light: bool,
|
||||
shape: LevelShape,
|
||||
) -> NamedTag {
|
||||
let mut root = encode_with(&proto.column, registry, biomes, tables, sky_light);
|
||||
let mut root = encode_with(&proto.column, registry, biomes, tables, shape);
|
||||
let status = if proto.decorated {
|
||||
ChunkStatus::Features
|
||||
} else {
|
||||
ChunkStatus::Terrain
|
||||
};
|
||||
// ProtoChunk.postProcessing: one list per section, packed section offsets.
|
||||
let mut marks = vec![Vec::new(); SECTIONS];
|
||||
let mut marks = vec![Vec::new(); shape.section_count()];
|
||||
for &[x, y, z] in &proto.column.postprocess {
|
||||
if let Some(list) = marks.get_mut(((y - MIN_Y) >> 4) as usize) {
|
||||
let section = usize::try_from((y - shape.min_y) >> 4).ok();
|
||||
if let Some(list) = section.and_then(|s| marks.get_mut(s)) {
|
||||
list.push(Tag::Short(
|
||||
((x & 15) | (y & 15) << 4 | (z & 15) << 8) as i16,
|
||||
));
|
||||
@@ -1564,7 +1662,7 @@ struct Encoding {
|
||||
registry: &'static BlockRegistry,
|
||||
biomes: Arc<Biomes>,
|
||||
tables: Option<Arc<SaveTables>>,
|
||||
sky_light: bool,
|
||||
shape: LevelShape,
|
||||
}
|
||||
impl Record {
|
||||
fn tag(&self) -> Option<Arc<NamedTag>> {
|
||||
@@ -1575,7 +1673,7 @@ impl Record {
|
||||
e.registry,
|
||||
&e.biomes,
|
||||
e.tables.as_deref(),
|
||||
e.sky_light,
|
||||
e.shape,
|
||||
))),
|
||||
Record::Delete => None,
|
||||
}
|
||||
@@ -1612,12 +1710,12 @@ pub struct SaveDir {
|
||||
outstanding: Arc<Outstanding>,
|
||||
/// Set by `Persistent` before the first load.
|
||||
pub tables: OnceLock<Arc<SaveTables>>,
|
||||
/// False in dimensions without skylight (no SkyLight layers are written).
|
||||
pub sky_light: bool,
|
||||
/// The dimension's skylight and height, from its folder name.
|
||||
pub shape: LevelShape,
|
||||
}
|
||||
impl SaveDir {
|
||||
pub fn new(root: PathBuf) -> Self {
|
||||
let sky_light = !root.ends_with("the_nether") && !root.ends_with("DIM-1");
|
||||
let shape = LevelShape::for_dir(&root);
|
||||
Self {
|
||||
root,
|
||||
regions: Default::default(),
|
||||
@@ -1627,7 +1725,7 @@ impl SaveDir {
|
||||
writer: Mutex::new(None),
|
||||
outstanding: Default::default(),
|
||||
tables: OnceLock::new(),
|
||||
sky_light,
|
||||
shape,
|
||||
}
|
||||
}
|
||||
fn region_path(&self, folder: &str, rx: i32, rz: i32) -> PathBuf {
|
||||
@@ -1658,7 +1756,7 @@ impl SaveDir {
|
||||
let root = self.read_record("region", x, z)?;
|
||||
let tables = self.tables.get().map(|t| &**t);
|
||||
let mut chunk = decode_with(&root, registry, biomes, tables)?;
|
||||
load_light(&root.tag, &mut chunk, self.sky_light);
|
||||
load_light(&root.tag, &mut chunk, self.shape.sky_light);
|
||||
Some(chunk)
|
||||
}
|
||||
/// A proto chunk saved before it became final (`decode_proto`).
|
||||
@@ -1683,7 +1781,7 @@ impl SaveDir {
|
||||
let records = protos
|
||||
.iter()
|
||||
.map(|p| {
|
||||
let tag = encode_proto(p, registry, biomes, tables, self.sky_light);
|
||||
let tag = encode_proto(p, registry, biomes, tables, self.shape);
|
||||
((p.column.x, p.column.z), Some(tag))
|
||||
})
|
||||
.collect();
|
||||
@@ -1858,7 +1956,7 @@ impl SaveDir {
|
||||
.map(|c| {
|
||||
(
|
||||
(c.x, c.z),
|
||||
Some(encode_with(c, registry, biomes, tables, self.sky_light)),
|
||||
Some(encode_with(c, registry, biomes, tables, self.shape)),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
@@ -1877,7 +1975,7 @@ impl SaveDir {
|
||||
registry,
|
||||
biomes: biomes.clone(),
|
||||
tables: self.tables.get().cloned(),
|
||||
sky_light: self.sky_light,
|
||||
shape: self.shape,
|
||||
});
|
||||
let records = chunks
|
||||
.iter()
|
||||
@@ -3347,7 +3445,7 @@ impl SaveDir {
|
||||
{
|
||||
return None;
|
||||
}
|
||||
decode_lod_surfaces(&root, assets, biomes, self.sky_light)
|
||||
decode_lod_surfaces(&root, assets, biomes, self.shape.sky_light)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3438,6 +3536,71 @@ impl<G: ChunkGenerator> ChunkGenerator for Persistent<G> {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn nether_and_end_chunks_save_with_their_own_height() {
|
||||
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 rack = assets
|
||||
.registry
|
||||
.default_state("minecraft:netherrack")
|
||||
.unwrap();
|
||||
let mut chunk = Chunk::empty(0, 0);
|
||||
for y in 0..128 {
|
||||
chunk.set(0, y, 0, rack.0);
|
||||
}
|
||||
let saved = encode_with(
|
||||
&chunk,
|
||||
assets.registry,
|
||||
&biomes,
|
||||
Some(&tables),
|
||||
LevelShape::NETHER,
|
||||
);
|
||||
assert_eq!(number(saved.tag.get("yPos")), Some(0));
|
||||
let Some(Tag::List(sections)) = saved.tag.get("sections") else {
|
||||
panic!("no sections");
|
||||
};
|
||||
let ys: Vec<i32> = sections
|
||||
.iter()
|
||||
.filter_map(|s| number(s.get("Y")).map(|y| y as i32))
|
||||
.collect();
|
||||
assert_eq!(ys, (0..16).collect::<Vec<_>>());
|
||||
let Some(Tag::List(post)) = saved.tag.get("PostProcessing") else {
|
||||
panic!("no PostProcessing");
|
||||
};
|
||||
assert_eq!(post.len(), 16);
|
||||
// Heightmap values count from the level's minimum: the column's top
|
||||
// block at y=127 reads 128 (vanilla), not 192 (from the Overworld's -64).
|
||||
let Some(Tag::LongArray(surface)) = saved
|
||||
.tag
|
||||
.get("Heightmaps")
|
||||
.and_then(|h| h.get("WORLD_SURFACE"))
|
||||
else {
|
||||
panic!("no WORLD_SURFACE");
|
||||
};
|
||||
assert_eq!(surface[0] & 0x1ff, 128);
|
||||
let overworld = encode_with(
|
||||
&chunk,
|
||||
assets.registry,
|
||||
&biomes,
|
||||
Some(&tables),
|
||||
LevelShape::OVERWORLD,
|
||||
);
|
||||
assert_eq!(number(overworld.tag.get("yPos")), Some(-4));
|
||||
assert_eq!(
|
||||
LevelShape::for_dir(Path::new("w/dimensions/minecraft/the_end")),
|
||||
LevelShape::END
|
||||
);
|
||||
assert_eq!(
|
||||
LevelShape::for_dir(Path::new("w/DIM-1")),
|
||||
LevelShape::NETHER
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn saved_far_surface_reads_builds_and_water_without_gameplay_chunks() {
|
||||
let Some(root) = std::env::var_os("HORIZON_TEST_RESOURCES") else {
|
||||
@@ -3556,7 +3719,7 @@ mod tests {
|
||||
"unfinished proto saves must not suppress complete decoration"
|
||||
);
|
||||
let mut nether = SaveDir::new(dir.clone());
|
||||
nether.sky_light = false;
|
||||
nether.shape = LevelShape::NETHER;
|
||||
let dark = nether.lod_surfaces(-2, 3, &assets, &biomes).unwrap();
|
||||
assert!(
|
||||
dark.iter()
|
||||
@@ -3875,6 +4038,31 @@ mod tests {
|
||||
assert_eq!(back.redstone_signal(), 2);
|
||||
}
|
||||
#[test]
|
||||
fn campfire_block_entity_round_trips() {
|
||||
let mut c = crate::containers::Campfire::default();
|
||||
c.items[1] = Some(crate::items::ItemStack::new("minecraft:beef", 1));
|
||||
c.progress[1] = 120;
|
||||
c.total[1] = 600;
|
||||
let tag = encode_block_entity(
|
||||
(3, 64, -2),
|
||||
&crate::containers::BlockEntity::Campfire(Box::new(c.clone())),
|
||||
"minecraft:soul_campfire",
|
||||
);
|
||||
// One block entity type for both campfires.
|
||||
assert_eq!(text(tag.get("id")).as_deref(), Some("minecraft:campfire"));
|
||||
let (pos, back) = decode_block_entity(&tag).expect("campfire");
|
||||
assert_eq!(pos, (3, 64, -2));
|
||||
let crate::containers::BlockEntity::Campfire(back) = back else {
|
||||
panic!("not a campfire");
|
||||
};
|
||||
assert_eq!(*back, c);
|
||||
// The update tag a server's clients get: the items alone.
|
||||
let update =
|
||||
crate::lan::chunks::entity_tag(&crate::containers::BlockEntity::Campfire(Box::new(c)))
|
||||
.expect("update tag");
|
||||
assert!(update.get("Items").is_some() && update.get("CookingTimes").is_none());
|
||||
}
|
||||
#[test]
|
||||
fn spawner_block_entity_round_trips() {
|
||||
// A dungeon spawner as vanilla saves it (`id` is mob_spawner, not the block).
|
||||
let saved = horizon_world::nbt::parse_snbt(
|
||||
|
||||
@@ -175,7 +175,9 @@ pub fn increased_repair_cost(cost: u32) -> u32 {
|
||||
pub fn hover_name(names: &dyn Fn(&str) -> String, stack: &ItemStack) -> String {
|
||||
stack
|
||||
.custom_name()
|
||||
.map_or_else(|| names(&stack.item), str::to_owned)
|
||||
.map(str::to_owned)
|
||||
.or_else(|| stack.item_name())
|
||||
.unwrap_or_else(|| names(&stack.item))
|
||||
}
|
||||
/// `AnvilMenu.createResult`. `name` is the rename box text (None = untouched).
|
||||
pub fn anvil_result(
|
||||
|
||||
@@ -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;
|
||||
@@ -436,6 +442,14 @@ impl ChunkGenerator for VanillaTerrain {
|
||||
.iter()
|
||||
.map(|(pos, nbt)| {
|
||||
let mut nbt = nbt.clone();
|
||||
// Placed data can lack its type (a brushable block's loot table
|
||||
// alone); vanilla saves every pending block entity with its id,
|
||||
// and skips one without when the chunk finishes.
|
||||
if let Some(id) =
|
||||
crate::lan::chunks::block_entity_type(chunk.block_name(pos[0], pos[1], pos[2]))
|
||||
{
|
||||
nbt.insert("id".into(), Tag::String(id.into()));
|
||||
}
|
||||
for (key, v) in ["x", "y", "z"].into_iter().zip(pos) {
|
||||
nbt.insert(key.into(), Tag::Int(*v));
|
||||
}
|
||||
@@ -566,11 +580,6 @@ impl ChunkGenerator for VanillaTerrain {
|
||||
extras.block_entities = unmodelled;
|
||||
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);
|
||||
out.entities = entities
|
||||
.into_iter()
|
||||
|
||||
@@ -315,3 +315,70 @@ fn saved_proto_chunks_restore_exactly() {
|
||||
drop(save);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn saved_proto_block_entities_keep_their_id() {
|
||||
use horizon_client::{
|
||||
save::{Persistent, SaveDir},
|
||||
vanillagen::VanillaTerrain,
|
||||
};
|
||||
use horizon_world::{
|
||||
nbt::{Limits, Tag},
|
||||
region::RegionFile,
|
||||
};
|
||||
let Some(root) = resources() else {
|
||||
return;
|
||||
};
|
||||
let assets = Arc::new(Assets::load(&root).unwrap());
|
||||
let biomes = Arc::new(Biomes::load(&root).unwrap());
|
||||
let terrain = VanillaTerrain::for_dimension(
|
||||
&root.join("worldgen"),
|
||||
assets.registry,
|
||||
&biomes,
|
||||
12345,
|
||||
"minecraft:overworld",
|
||||
)
|
||||
.unwrap();
|
||||
let dir = std::env::temp_dir().join(format!("horizon-proto-ids-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
let save = Arc::new(SaveDir::new(dir.clone()));
|
||||
let generator = Persistent {
|
||||
inner: Arc::new(terrain),
|
||||
save: save.clone(),
|
||||
registry: assets.clone(),
|
||||
biomes,
|
||||
};
|
||||
let mut worker = generator.worker();
|
||||
// A cold ocean ruin of seed 12345: its suspicious gravel is placed with a
|
||||
// loot table and no type.
|
||||
let keys: Vec<(i32, i32)> = (0..9).map(|i| (43 + i % 3 - 1, 21 + i / 3 - 1)).collect();
|
||||
let mut region: [Proto; 9] =
|
||||
std::array::from_fn(|i| generator.proto(&mut worker, keys[i].0, keys[i].1));
|
||||
generator.decorate(&mut worker, &mut region);
|
||||
generator.save_protos(®ion.iter().collect::<Vec<_>>());
|
||||
save.flush().unwrap();
|
||||
let mut file = RegionFile::open(dir.join("region/r.1.0.mca")).unwrap();
|
||||
let mut suspicious = 0;
|
||||
for &(x, z) in &keys {
|
||||
let Some(record) = file
|
||||
.read(horizon_core::position::ChunkPos::new(x, z))
|
||||
.unwrap()
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let root = record.nbt(Limits::default()).unwrap();
|
||||
let Some(Tag::List(entities)) = root.tag.get("block_entities") else {
|
||||
continue;
|
||||
};
|
||||
for e in entities {
|
||||
assert!(e.get("id").is_some(), "chunk {x} {z}: {e:?}");
|
||||
if let Some(Tag::String(table)) = e.get("LootTable") {
|
||||
suspicious += usize::from(table.to_string_lossy().contains("archaeology/"));
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(suspicious > 0, "the ruin placed no suspicious blocks");
|
||||
drop(generator);
|
||||
drop(save);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
@@ -111,6 +111,14 @@ interface storage {
|
||||
interface command {
|
||||
/// `line` without its slash; its feedback goes to the chat as if typed.
|
||||
run: func(line: string);
|
||||
/// `lines` (each without its slash, at most 256 a call) as `run` does,
|
||||
/// but with nothing in the chat: no feedback and no failure. In single
|
||||
/// player and on Horizon's servers, each runs exactly as the player
|
||||
/// typing it would (the same rights), in order, 64 a tick, and the
|
||||
/// server logs one line a batch; one that carries a chat message (`say`,
|
||||
/// `msg`) is refused. On any other server they go as chat commands, and
|
||||
/// there their feedback shows: a mod running many should say so.
|
||||
run-quiet: func(lines: list<string>);
|
||||
}
|
||||
|
||||
/// Capability `ui`: widgets the game draws with its own menus' widgets, for
|
||||
|
||||
@@ -7,13 +7,13 @@
|
||||
//! version's level so the launcher can tell before it installs.
|
||||
|
||||
/// The newest level this Horizon serves.
|
||||
pub const LEVEL: u32 = 2;
|
||||
pub const LEVEL: u32 = 3;
|
||||
|
||||
/// Every function the game offers mods, as `interface.function`, with its
|
||||
/// level. Level 0 is the contract of Horizon 0.2.67 to 0.2.69. A new
|
||||
/// function takes the next level and raises [`LEVEL`] (the `api_reference`
|
||||
/// test fails until it is listed here).
|
||||
pub const FUNCTIONS: [(&str, u32); 33] = [
|
||||
pub const FUNCTIONS: [(&str, u32); 34] = [
|
||||
("log.write", 0),
|
||||
("game.get-player", 0),
|
||||
("game.get-clock", 0),
|
||||
@@ -47,6 +47,7 @@ pub const FUNCTIONS: [(&str, u32); 33] = [
|
||||
("players.tell", 1),
|
||||
("players.broadcast", 1),
|
||||
("game.raycast", 2),
|
||||
("command.run-quiet", 3),
|
||||
];
|
||||
|
||||
/// What the game's program carries so the launcher can read its level
|
||||
|
||||
@@ -89,6 +89,9 @@ const FUEL_FRAME: u64 = 10_000_000;
|
||||
const FUEL_EVENT: u64 = 100_000_000;
|
||||
/// Chat lines a mod may show per call, HUD lines and their length.
|
||||
const CHAT_PER_CALL: usize = 8;
|
||||
/// `command.run-quiet` lines one call of a mod may queue (a quiet payload's
|
||||
/// cap, `horizon_protocol::quiet::MAX_LINES`).
|
||||
const QUIET_PER_CALL: usize = 256;
|
||||
/// How far `game.raycast` sees, in blocks.
|
||||
pub const RAYCAST_MAX: f32 = 1024.0;
|
||||
const HUD_LINES: usize = 16;
|
||||
@@ -160,6 +163,9 @@ pub enum Output {
|
||||
/// `command.run`: a vanilla command line, without its slash, to run as
|
||||
/// the player.
|
||||
Command { line: String },
|
||||
/// `command.run-quiet`: vanilla command lines to run as the player with
|
||||
/// nothing in the chat (`horizon_protocol::quiet` on a server).
|
||||
QuietCommands { mod_id: String, lines: Vec<String> },
|
||||
/// The mod was turned off: it trapped, overran or failed to start.
|
||||
Stopped { name: String, reason: String },
|
||||
/// `players.tell`: a line after the mod's name, to the player with this id.
|
||||
@@ -1408,6 +1414,23 @@ impl command::Host for State {
|
||||
self.out.push(Output::Command { line: clip(line) });
|
||||
}
|
||||
}
|
||||
fn run_quiet(&mut self, lines: Vec<String>) {
|
||||
let queued: usize = self
|
||||
.out
|
||||
.iter()
|
||||
.map(|o| match o {
|
||||
Output::QuietCommands { lines, .. } => lines.len(),
|
||||
_ => 0,
|
||||
})
|
||||
.sum();
|
||||
let room = QUIET_PER_CALL.saturating_sub(queued);
|
||||
if room > 0 && !lines.is_empty() {
|
||||
self.out.push(Output::QuietCommands {
|
||||
mod_id: self.id.clone(),
|
||||
lines: lines.into_iter().take(room).map(clip).collect(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
impl hud::Host for State {
|
||||
fn set_panel(&mut self, corner: Corner, lines: Vec<Line>) {
|
||||
|
||||
@@ -26,7 +26,7 @@ id = "test_ui_guest"
|
||||
name = "Test UI Guest"
|
||||
version = "0.1.0"
|
||||
api = "0.1"
|
||||
capabilities = ["game-read", "chat", "ui", "render", "input"]
|
||||
capabilities = ["game-read", "chat", "command", "ui", "render", "input"]
|
||||
experimental = ["ui", "render", "input"]
|
||||
"#;
|
||||
|
||||
@@ -763,6 +763,32 @@ fn game_raycast_sees_the_view_hit_within_its_distance() {
|
||||
assert_eq!(said(&mut mods, "1e9"), [hit]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn quiet_commands_come_out_as_one_batch_capped() {
|
||||
let mut mods = load("horizon_mods_test_ui_guest.hmod");
|
||||
let quiet = |mods: &mut Mods, command: &str| -> Vec<(String, Vec<String>)> {
|
||||
assert!(mods.command(command));
|
||||
mods.drain()
|
||||
.into_iter()
|
||||
.filter_map(|o| match o {
|
||||
Output::QuietCommands { mod_id, lines } => Some((mod_id, lines)),
|
||||
_ => None,
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
assert_eq!(
|
||||
quiet(&mut mods, "quiet setblock 0 0 0 stone;say hi"),
|
||||
[(
|
||||
"test_ui_guest".to_string(),
|
||||
vec!["setblock 0 0 0 stone".to_string(), "say hi".to_string()]
|
||||
)]
|
||||
);
|
||||
let batches = quiet(&mut mods, "quiet_many 300");
|
||||
assert_eq!(batches.len(), 1);
|
||||
assert_eq!(batches[0].1.len(), 256);
|
||||
assert_eq!(batches[0].1[255], "setblock 255 300 0 stone");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn corner_panels_text_width_and_readings() {
|
||||
let mut mods = load("horizon_mods_test_ui_guest.hmod");
|
||||
|
||||
@@ -5,9 +5,10 @@
|
||||
//! `/input_claim`, `/input_poll`, `/input_held` and `/input_wheel` test
|
||||
//! `input`; `/zoom FACTOR SMOOTH TURN` tests `render.set-zoom`;
|
||||
//! `/corner` and `/readings` the calls the official extras mods use;
|
||||
//! `/raycast DISTANCE` says what `game.raycast` sees.
|
||||
//! `/raycast DISTANCE` says what `game.raycast` sees; `/quiet A;B` runs
|
||||
//! lines with `command.run-quiet` (`/quiet_many N`: N of them).
|
||||
use horizon_mod_sdk::{
|
||||
Mod, Registration, chat, game,
|
||||
Mod, Registration, chat, command, game,
|
||||
input::{self, Event},
|
||||
render::{self, FillShape, ItemShape, Point, QuadShape, Rect, Shape, SpriteShape, TextShape},
|
||||
ui::{self, ItemWidget, ListWidget, Screen, TextWidget, Widget},
|
||||
@@ -99,6 +100,8 @@ impl Mod for Guest {
|
||||
"zoom",
|
||||
"minimap",
|
||||
"raycast",
|
||||
"quiet",
|
||||
"quiet_many",
|
||||
"corner",
|
||||
"readings",
|
||||
]
|
||||
@@ -219,6 +222,15 @@ impl Mod for Guest {
|
||||
});
|
||||
render::set_minimap(map);
|
||||
}
|
||||
"quiet" => command::run_quiet(&args.split(';').map(str::to_owned).collect::<Vec<_>>()),
|
||||
"quiet_many" => {
|
||||
let n: usize = args.parse().unwrap_or(0);
|
||||
command::run_quiet(
|
||||
&(0..n)
|
||||
.map(|i| format!("setblock {i} 300 0 stone"))
|
||||
.collect::<Vec<_>>(),
|
||||
);
|
||||
}
|
||||
"raycast" => {
|
||||
let distance = args.parse().unwrap_or(f32::NAN);
|
||||
chat::show(&match game::raycast(distance) {
|
||||
|
||||
@@ -91,8 +91,9 @@ pub fn keep(
|
||||
pub fn parse(spec: &str) -> Option<ureq::Proxy> {
|
||||
if socks(spec) {
|
||||
say(&format!(
|
||||
"{spec} is a SOCKS proxy; downloads go without one. Write the http:// address your \
|
||||
proxy app listens on as well."
|
||||
"{} is a SOCKS proxy; downloads go without one. Write the http:// address your \
|
||||
proxy app listens on as well.",
|
||||
redacted(spec)
|
||||
));
|
||||
return None;
|
||||
}
|
||||
@@ -100,13 +101,29 @@ pub fn parse(spec: &str) -> Option<ureq::Proxy> {
|
||||
Ok(proxy) => Some(proxy),
|
||||
Err(e) => {
|
||||
say(&format!(
|
||||
"The proxy address {spec} can't be used ({e}); downloads go without one."
|
||||
"The proxy address {} can't be used ({e}); downloads go without one.",
|
||||
redacted(spec)
|
||||
));
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `spec` as it may be shown: a user name and password in it become `***`. An address that can't
|
||||
/// be used is printed to stderr and shown in the launcher, and a proxy's credentials don't belong in
|
||||
/// either. Everything up to the last `@` is the credentials, so a password with an unescaped `/` or
|
||||
/// `@` is hidden too.
|
||||
pub fn redacted(spec: &str) -> String {
|
||||
let (scheme, rest) = match spec.split_once("://") {
|
||||
Some((scheme, rest)) => (format!("{scheme}://"), rest),
|
||||
None => (String::new(), spec),
|
||||
};
|
||||
match rest.rfind('@') {
|
||||
Some(at) => format!("{scheme}***@{}", &rest[at + 1..]),
|
||||
None => spec.to_owned(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `spec` names a SOCKS proxy, which these downloads don't go through: `ureq` reads a
|
||||
/// SOCKS address happily and only fails when it connects through it.
|
||||
pub fn socks(spec: &str) -> bool {
|
||||
@@ -227,6 +244,26 @@ mod tests {
|
||||
lock(&TURN)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn credentials_are_not_shown() {
|
||||
assert_eq!(redacted("http://127.0.0.1:7890"), "http://127.0.0.1:7890");
|
||||
assert_eq!(redacted("proxy.local:3128"), "proxy.local:3128");
|
||||
assert_eq!(
|
||||
redacted("http://user:pass@proxy.local:3128"),
|
||||
"http://***@proxy.local:3128"
|
||||
);
|
||||
assert_eq!(
|
||||
redacted("socks5://u:p@10.0.0.1:1080"),
|
||||
"socks5://***@10.0.0.1:1080"
|
||||
);
|
||||
assert_eq!(
|
||||
redacted("user:pass@proxy.local:3128"),
|
||||
"***@proxy.local:3128"
|
||||
);
|
||||
assert_eq!(redacted("http://u:p/a@ss@proxy:1"), "http://***@proxy:1");
|
||||
assert_eq!(redacted("http://user:pass@"), "http://***@");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_proxy_the_launcher_sets_is_trimmed_and_kept() {
|
||||
let _turn = alone();
|
||||
|
||||
@@ -164,7 +164,7 @@ pub struct Profile {
|
||||
pub properties: Vec<(String, String, Option<String>)>,
|
||||
}
|
||||
impl Component {
|
||||
fn read(kind: i32, r: &mut Reader) -> Result<Self> {
|
||||
pub(crate) fn read(kind: i32, r: &mut Reader) -> Result<Self> {
|
||||
use component::*;
|
||||
Ok(match kind {
|
||||
MAX_STACK_SIZE | MAX_DAMAGE | DAMAGE | RARITY | REPAIR_COST | MAP_ID
|
||||
@@ -338,7 +338,7 @@ impl Component {
|
||||
}
|
||||
})
|
||||
}
|
||||
fn write(&self, kind: i32, w: &mut Writer) {
|
||||
pub(crate) fn write(&self, kind: i32, w: &mut Writer) {
|
||||
match self {
|
||||
Self::Raw(bytes) => w.bytes(bytes),
|
||||
Self::Trim { material, pattern } => {
|
||||
|
||||
@@ -8,7 +8,7 @@ use crate::{
|
||||
},
|
||||
chunk::{ChunkData, LightData},
|
||||
entity_data::{EntityData, Particle, Value},
|
||||
item::{Hashed, HashedSlot, Slot, Stack},
|
||||
item::{Component, Hashed, HashedSlot, Slot, Stack},
|
||||
packet, packet_enum,
|
||||
packets::{
|
||||
Empty,
|
||||
@@ -88,11 +88,31 @@ packet!(ContainerSetContent {
|
||||
carried: Option<Stack> => Slot,
|
||||
});
|
||||
/// ItemCost.STREAM_CODEC: item, count, then the exact component predicate
|
||||
/// (Horizon sends and reads it as a component patch without removals).
|
||||
/// (a list of typed component values, with no patch removal count).
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct NetCost {
|
||||
pub item: i32,
|
||||
pub count: i32,
|
||||
pub components: Vec<(i32, Component)>,
|
||||
}
|
||||
impl From<Stack> for NetCost {
|
||||
fn from(stack: Stack) -> Self {
|
||||
Self {
|
||||
item: stack.item,
|
||||
count: stack.count,
|
||||
components: stack.added,
|
||||
}
|
||||
}
|
||||
}
|
||||
impl From<NetCost> for Stack {
|
||||
fn from(cost: NetCost) -> Self {
|
||||
Self {
|
||||
item: cost.item,
|
||||
count: cost.count,
|
||||
added: cost.components,
|
||||
removed: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
/// MerchantOffer.STREAM_CODEC.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
@@ -114,10 +134,17 @@ impl Codec<Vec<NetOffer>> for Offers {
|
||||
fn cost(r: &mut Reader) -> Result<NetCost> {
|
||||
let item = r.varint()?;
|
||||
let count = r.varint()?;
|
||||
if r.varint()? != 0 {
|
||||
return Err(Error::new("item cost components are not supported"));
|
||||
let n = r.count(r.remaining())?;
|
||||
let mut components = Vec::with_capacity(n.min(64));
|
||||
for _ in 0..n {
|
||||
let kind = r.varint()?;
|
||||
components.push((kind, Component::read(kind, r)?));
|
||||
}
|
||||
Ok(NetCost { item, count })
|
||||
Ok(NetCost {
|
||||
item,
|
||||
count,
|
||||
components,
|
||||
})
|
||||
}
|
||||
let n = r.count(r.remaining())?;
|
||||
let mut out = Vec::with_capacity(n.min(64));
|
||||
@@ -144,7 +171,11 @@ impl Codec<Vec<NetOffer>> for Offers {
|
||||
fn cost(c: &NetCost, w: &mut Writer) {
|
||||
w.varint(c.item);
|
||||
w.varint(c.count);
|
||||
w.varint(0);
|
||||
w.varint(c.components.len() as i32);
|
||||
for (kind, value) in &c.components {
|
||||
w.varint(*kind);
|
||||
value.write(*kind, w);
|
||||
}
|
||||
}
|
||||
w.varint(v.len() as i32);
|
||||
for o in v {
|
||||
@@ -2300,6 +2331,39 @@ mod tests {
|
||||
use super::*;
|
||||
use crate::{chunk::BlockEntityInfo, packets::round_trip, text::literal};
|
||||
|
||||
#[test]
|
||||
fn merchant_cost_predicate_is_a_typed_list_without_patch_counts() {
|
||||
// ItemCost.STREAM_CODEC: item=7, count=1, one typed component,
|
||||
// potion_contents=53, potion present (water=0), no color/effects/name.
|
||||
// Result: count=1, item=8, empty component patch. Secondary cost:
|
||||
// item=9, count=2, empty predicate, followed by the offer fields.
|
||||
let mut bytes = vec![1, 7, 1, 1, 53, 1, 0, 0, 0, 0, 1, 8, 0, 0, 1, 9, 2, 0, 0];
|
||||
bytes.extend_from_slice(&[0; 4]); // uses
|
||||
bytes.extend_from_slice(&2i32.to_be_bytes()); // max uses
|
||||
bytes.extend_from_slice(&[0; 16]); // xp, special price, multiplier, demand
|
||||
let mut r = Reader::new(&bytes);
|
||||
let offers = Offers::read(&mut r).unwrap();
|
||||
assert_eq!(r.remaining(), 0);
|
||||
assert_eq!(
|
||||
offers[0].cost_a.components,
|
||||
vec![(
|
||||
53,
|
||||
Component::Potion {
|
||||
potion: Some(0),
|
||||
color: None,
|
||||
rest: Vec::new()
|
||||
}
|
||||
)]
|
||||
);
|
||||
assert!(offers[0].cost_b.as_ref().unwrap().components.is_empty());
|
||||
let mut w = Writer::new();
|
||||
Offers::write(&offers, &mut w);
|
||||
assert_eq!(w.data, bytes);
|
||||
for len in 0..bytes.len() {
|
||||
assert!(Offers::read(&mut Reader::new(&bytes[..len])).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serverbound_abilities_carry_only_flying() {
|
||||
// ServerboundPlayerAbilitiesPacket.write: one byte, 2 when flying.
|
||||
@@ -2576,9 +2640,9 @@ mod tests {
|
||||
Clientbound::MerchantOffers(MerchantOffers {
|
||||
container: 3,
|
||||
offers: vec![NetOffer {
|
||||
cost_a: NetCost { item: 5, count: 24 },
|
||||
cost_a: Stack::new(5, 24).into(),
|
||||
result: Stack::new(7, 1),
|
||||
cost_b: Some(NetCost { item: 9, count: 1 }),
|
||||
cost_b: Some(Stack::new(9, 1).into()),
|
||||
out_of_stock: false,
|
||||
uses: 2,
|
||||
max_uses: 16,
|
||||
|
||||
@@ -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,
|
||||
@@ -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`"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -169,6 +169,7 @@ mod stored_effect_attributes_fixture;
|
||||
mod stored_effect_notifications_fixture;
|
||||
mod structure_density_fixture;
|
||||
mod structure_pieces;
|
||||
mod structure_start_nbt;
|
||||
mod suffocation_fixture;
|
||||
mod swimming_fixture;
|
||||
mod synced_bytes_fixture;
|
||||
@@ -340,6 +341,7 @@ probes! {
|
||||
stored_effect_notifications_fixture,
|
||||
structure_density_fixture,
|
||||
structure_pieces,
|
||||
structure_start_nbt,
|
||||
suffocation_fixture,
|
||||
swimming_fixture,
|
||||
synced_bytes_fixture,
|
||||
|
||||
@@ -21,7 +21,7 @@ use std::{fs, path::Path};
|
||||
|
||||
type Error = Box<dyn std::error::Error + Send + Sync>;
|
||||
|
||||
fn registry(resources: &Path) -> Result<&'static BlockRegistry, Error> {
|
||||
pub fn registry(resources: &Path) -> Result<&'static BlockRegistry, Error> {
|
||||
let facts: Value = serde_json::from_str(&fs::read_to_string(resources.join("facts.json"))?)?;
|
||||
let states = facts["states"].as_array().ok_or("facts states")?;
|
||||
let registry = BlockRegistry::from_nbt(&Tag::compound([
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
//! The structure starts a chunk owns, as `StructureStart.createTag` saves them.
|
||||
//!
|
||||
//! structure_start_nbt <client resource dir> <seed> <dimension> <cx,cz>...
|
||||
//! prints `nbt CX CZ TAG` for every start whose chunk is a target, the tag
|
||||
//! with sorted keys and typed numbers (VanillaStructureStartOracle's
|
||||
//! `canonical`), or `unsaved CX CZ ID` when Horizon cannot save it yet.
|
||||
use horizon_core::position::ChunkPos;
|
||||
use horizon_world::{
|
||||
nbt::Tag,
|
||||
worldgen::generator::{GeneratorData, OverworldGenerator},
|
||||
};
|
||||
|
||||
type Error = Box<dyn std::error::Error + Send + Sync>;
|
||||
|
||||
/// `VanillaStructureStartOracle.canonical`.
|
||||
fn canonical(tag: &Tag) -> String {
|
||||
let join = |parts: Vec<String>| parts.join(",");
|
||||
match tag {
|
||||
Tag::Compound(c) => {
|
||||
let mut keys: Vec<_> = c.keys().map(|k| (k.to_string_lossy(), k)).collect();
|
||||
keys.sort_by(|a, b| a.0.encode_utf16().cmp(b.0.encode_utf16()));
|
||||
let parts = keys
|
||||
.into_iter()
|
||||
.map(|(name, key)| format!("{name}:{}", canonical(&c[key])))
|
||||
.collect();
|
||||
format!("{{{}}}", join(parts))
|
||||
}
|
||||
Tag::List(l) => format!("[{}]", join(l.iter().map(canonical).collect())),
|
||||
Tag::Byte(v) => format!("{v}b"),
|
||||
Tag::Short(v) => format!("{v}s"),
|
||||
Tag::Int(v) => v.to_string(),
|
||||
Tag::Long(v) => format!("{v}L"),
|
||||
Tag::Float(bits) => format!("{}f", *bits as i32),
|
||||
Tag::Double(bits) => format!("{}d", *bits as i64),
|
||||
Tag::String(s) => format!(
|
||||
"\"{}\"",
|
||||
s.to_string_lossy()
|
||||
.replace('\\', "\\\\")
|
||||
.replace('"', "\\\"")
|
||||
),
|
||||
Tag::IntArray(a) => format!("[I;{}]", join(a.iter().map(i32::to_string).collect())),
|
||||
Tag::LongArray(a) => format!("[L;{}]", join(a.iter().map(i64::to_string).collect())),
|
||||
Tag::ByteArray(a) => format!(
|
||||
"[B;{}]",
|
||||
join(a.iter().map(|b| (*b as i8).to_string()).collect())
|
||||
),
|
||||
Tag::End => "END".into(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn main() -> Result<(), Error> {
|
||||
let mut args = crate::args().skip(1);
|
||||
let resources = std::path::PathBuf::from(args.next().ok_or("client resource directory")?);
|
||||
let seed: i64 = args.next().ok_or("seed")?.parse()?;
|
||||
let dimension = format!("minecraft:{}", args.next().ok_or("dimension")?);
|
||||
let registry = crate::structure_pieces::registry(&resources)?;
|
||||
let data = GeneratorData::load(&resources.join("worldgen"), registry)?;
|
||||
let generator = OverworldGenerator::for_dimension(data, seed, "minecraft:normal", &dimension)?;
|
||||
let mut worker = generator.worker();
|
||||
for target in args {
|
||||
let (x, z) = target.split_once(',').ok_or("target cx,cz")?;
|
||||
let chunk = ChunkPos::new(x.parse()?, z.parse()?);
|
||||
for start in worker.structure_starts(chunk) {
|
||||
if start.chunk != chunk {
|
||||
continue;
|
||||
}
|
||||
match start.save() {
|
||||
Some(tag) => println!("nbt {} {} {}", chunk.x, chunk.z, canonical(&tag)),
|
||||
None => println!("unsaved {} {} {}", chunk.x, chunk.z, start.name),
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -114,6 +114,7 @@ mod place;
|
||||
mod portal;
|
||||
pub mod portal_travel;
|
||||
mod query;
|
||||
pub(crate) use query::data_2d;
|
||||
mod rails;
|
||||
mod redstone;
|
||||
pub mod removal;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -79,6 +79,15 @@ impl Rotation {
|
||||
pub fn get_rotated(self, other: Self) -> Self {
|
||||
Self::ALL[(self as usize + other as usize) % 4]
|
||||
}
|
||||
/// `Rotation.name()`, as structure pieces save it.
|
||||
pub fn saved_name(self) -> &'static str {
|
||||
match self {
|
||||
Self::None => "NONE",
|
||||
Self::Clockwise90 => "CLOCKWISE_90",
|
||||
Self::Clockwise180 => "CLOCKWISE_180",
|
||||
Self::Counterclockwise90 => "COUNTERCLOCKWISE_90",
|
||||
}
|
||||
}
|
||||
}
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
|
||||
pub enum Mirror {
|
||||
@@ -86,6 +95,16 @@ pub enum Mirror {
|
||||
LeftRight,
|
||||
FrontBack,
|
||||
}
|
||||
impl Mirror {
|
||||
/// `Mirror.name()`, as structure pieces save it.
|
||||
pub fn saved_name(self) -> &'static str {
|
||||
match self {
|
||||
Self::None => "NONE",
|
||||
Self::LeftRight => "LEFT_RIGHT",
|
||||
Self::FrontBack => "FRONT_BACK",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Inclusive integer box (vanilla BoundingBox).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
|
||||
|
||||
@@ -381,19 +381,22 @@ pub fn pieces(
|
||||
} else {
|
||||
vec!["minecraft:structure_block".into(), "minecraft:air".into()]
|
||||
};
|
||||
EndCityPiece(TemplatePiece::new(
|
||||
"minecraft:ecp",
|
||||
&format!("minecraft:end_city/{}", d.name),
|
||||
d.template,
|
||||
d.position,
|
||||
d.rotation,
|
||||
Mirror::None,
|
||||
[0; 3],
|
||||
Arc::new(ProcessorList::default()),
|
||||
true,
|
||||
ignore,
|
||||
TemplateHandler::Plain,
|
||||
))
|
||||
EndCityPiece(
|
||||
TemplatePiece::new(
|
||||
"minecraft:ecp",
|
||||
&format!("minecraft:end_city/{}", d.name),
|
||||
d.template,
|
||||
d.position,
|
||||
d.rotation,
|
||||
Mirror::None,
|
||||
[0; 3],
|
||||
Arc::new(ProcessorList::default()),
|
||||
true,
|
||||
ignore,
|
||||
TemplateHandler::Plain,
|
||||
),
|
||||
d.depth,
|
||||
)
|
||||
})
|
||||
.collect(),
|
||||
)
|
||||
@@ -401,11 +404,21 @@ pub fn pieces(
|
||||
|
||||
/// EndCityPieces.EndCityPiece.
|
||||
#[derive(Debug)]
|
||||
pub struct EndCityPiece(TemplatePiece);
|
||||
/// The template piece and its genDepth (the collision tag of its batch).
|
||||
pub struct EndCityPiece(TemplatePiece, i32);
|
||||
impl LegacyPiece for EndCityPiece {
|
||||
fn bounding_box(&self) -> BoundingBox {
|
||||
self.0.bounding_box
|
||||
}
|
||||
/// `EndCityPiece`: the template piece's tag, `OW` for the pieces that
|
||||
/// overwrite air (only structure blocks ignored).
|
||||
fn save(&self) -> Option<crate::nbt::Compound> {
|
||||
let mut tag = self.0.saved();
|
||||
tag.insert("GD".into(), crate::nbt::Tag::Int(self.1));
|
||||
let overwrite = self.0.ignore.len() == 1;
|
||||
tag.insert("OW".into(), crate::nbt::Tag::Byte(i8::from(overwrite)));
|
||||
Some(tag)
|
||||
}
|
||||
fn type_name(&self) -> &'static str {
|
||||
"minecraft:ecp"
|
||||
}
|
||||
|
||||
@@ -294,6 +294,8 @@ mod tests {
|
||||
source_x: junction[0],
|
||||
source_ground_y: junction[1],
|
||||
source_z: junction[2],
|
||||
delta_y: 0,
|
||||
dest_projection: Projection::Rigid,
|
||||
}],
|
||||
waterlogging: false,
|
||||
})
|
||||
@@ -303,6 +305,7 @@ mod tests {
|
||||
Arc::new(PoolElement::Single {
|
||||
template: Arc::new(StructureTemplate::empty()),
|
||||
processors: Default::default(),
|
||||
processors_source: serde_json::Value::Null,
|
||||
legacy: false,
|
||||
projection,
|
||||
waterlogging: None,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -5,6 +5,7 @@ use super::{
|
||||
};
|
||||
use crate::{
|
||||
heightmap::HeightmapType,
|
||||
nbt::{Compound, Tag},
|
||||
worldgen::{
|
||||
DecodeError,
|
||||
block_feature::FeatureBlockBindings,
|
||||
@@ -30,12 +31,24 @@ pub enum Projection {
|
||||
Rigid,
|
||||
TerrainMatching,
|
||||
}
|
||||
impl Projection {
|
||||
/// `StructureTemplatePool.Projection.getSerializedName`.
|
||||
pub fn saved_name(self) -> &'static str {
|
||||
match self {
|
||||
Self::Rigid => "rigid",
|
||||
Self::TerrainMatching => "terrain_matching",
|
||||
}
|
||||
}
|
||||
}
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum PoolElement {
|
||||
Empty,
|
||||
Single {
|
||||
template: Arc<StructureTemplate>,
|
||||
processors: ProcessorList,
|
||||
/// The element's `processors` as written: a list id or an inline list,
|
||||
/// saved back as it was.
|
||||
processors_source: Value,
|
||||
legacy: bool,
|
||||
projection: Projection,
|
||||
waterlogging: Option<bool>,
|
||||
@@ -136,6 +149,7 @@ impl PoolElement {
|
||||
resources,
|
||||
bindings,
|
||||
)?,
|
||||
processors_source: value["processors"].clone(),
|
||||
legacy: kind == "minecraft:legacy_single_pool_element",
|
||||
projection: projection()?,
|
||||
waterlogging: match value
|
||||
@@ -178,6 +192,64 @@ impl PoolElement {
|
||||
_ => return Err(DecodeError::Field("pool element type")),
|
||||
})
|
||||
}
|
||||
/// `StructurePoolElement.CODEC`'s encoding, as a jigsaw piece saves it.
|
||||
pub fn save(&self) -> Tag {
|
||||
let text = |s: &str| Tag::String(s.into());
|
||||
match self {
|
||||
Self::Empty => Tag::compound([("element_type", text("minecraft:empty_pool_element"))]),
|
||||
Self::Single {
|
||||
template,
|
||||
processors_source,
|
||||
legacy,
|
||||
projection,
|
||||
waterlogging,
|
||||
..
|
||||
} => {
|
||||
let kind = if *legacy {
|
||||
"minecraft:legacy_single_pool_element"
|
||||
} else {
|
||||
"minecraft:single_pool_element"
|
||||
};
|
||||
let mut entries = vec![
|
||||
("element_type", text(kind)),
|
||||
("location", text(&template.location)),
|
||||
(
|
||||
"processors",
|
||||
super::processor::json_to_nbt(processors_source),
|
||||
),
|
||||
("projection", text(projection.saved_name())),
|
||||
];
|
||||
if let Some(apply) = waterlogging {
|
||||
let settings = if *apply {
|
||||
"apply_waterlogging"
|
||||
} else {
|
||||
"ignore_waterlogging"
|
||||
};
|
||||
entries.push(("override_liquid_settings", text(settings)));
|
||||
}
|
||||
Tag::compound(entries)
|
||||
}
|
||||
Self::Feature {
|
||||
feature,
|
||||
projection,
|
||||
} => Tag::compound([
|
||||
("element_type", text("minecraft:feature_pool_element")),
|
||||
("feature", text(feature)),
|
||||
("projection", text(projection.saved_name())),
|
||||
]),
|
||||
Self::List {
|
||||
elements,
|
||||
projection,
|
||||
} => Tag::compound([
|
||||
("element_type", text("minecraft:list_pool_element")),
|
||||
(
|
||||
"elements",
|
||||
Tag::List(elements.iter().map(Self::save).collect()),
|
||||
),
|
||||
("projection", text(projection.saved_name())),
|
||||
]),
|
||||
}
|
||||
}
|
||||
pub fn projection(&self) -> Projection {
|
||||
match self {
|
||||
Self::Empty => Projection::TerrainMatching,
|
||||
@@ -466,6 +538,9 @@ pub struct Junction {
|
||||
pub source_x: i32,
|
||||
pub source_ground_y: i32,
|
||||
pub source_z: i32,
|
||||
/// Saved only (`JigsawJunction.deltaY`, `destProjection`).
|
||||
pub delta_y: i32,
|
||||
pub dest_projection: Projection,
|
||||
}
|
||||
#[derive(Debug)]
|
||||
pub struct PoolPiece {
|
||||
@@ -517,6 +592,12 @@ impl PoolPiece {
|
||||
source_x: int(j, "source_x")?,
|
||||
source_ground_y: int(j, "source_ground_y")?,
|
||||
source_z: int(j, "source_z")?,
|
||||
delta_y: int(j, "delta_y")?,
|
||||
dest_projection: match j["dest_proj"].as_str() {
|
||||
Some("rigid") => Projection::Rigid,
|
||||
Some("terrain_matching") => Projection::TerrainMatching,
|
||||
_ => return Err(DecodeError::Field("dest_proj")),
|
||||
},
|
||||
})
|
||||
})
|
||||
.collect::<Result<_, DecodeError>>()?;
|
||||
@@ -535,6 +616,45 @@ impl PoolPiece {
|
||||
waterlogging: v["liquid_settings"].as_str() != Some("ignore_waterlogging"),
|
||||
})
|
||||
}
|
||||
/// `PoolElementStructurePiece`'s tag (always genDepth 0, no orientation).
|
||||
pub fn save(&self) -> Compound {
|
||||
let mut tag = super::legacy::piece_tag("minecraft:jigsaw", self.bounding_box, None, 0);
|
||||
let junctions = self
|
||||
.junctions
|
||||
.iter()
|
||||
.map(|j| {
|
||||
Tag::compound([
|
||||
("source_x", Tag::Int(j.source_x)),
|
||||
("source_ground_y", Tag::Int(j.source_ground_y)),
|
||||
("source_z", Tag::Int(j.source_z)),
|
||||
("delta_y", Tag::Int(j.delta_y)),
|
||||
(
|
||||
"dest_proj",
|
||||
Tag::String(j.dest_projection.saved_name().into()),
|
||||
),
|
||||
])
|
||||
})
|
||||
.collect();
|
||||
for (key, value) in [
|
||||
("PosX", Tag::Int(self.position[0])),
|
||||
("PosY", Tag::Int(self.position[1])),
|
||||
("PosZ", Tag::Int(self.position[2])),
|
||||
("ground_level_delta", Tag::Int(self.ground_level_delta)),
|
||||
("pool_element", self.element.save()),
|
||||
("rotation", Tag::String(self.rotation.saved_name().into())),
|
||||
("junctions", Tag::List(junctions)),
|
||||
] {
|
||||
tag.insert(key.into(), value);
|
||||
}
|
||||
// LiquidSettings.APPLY_WATERLOGGING, the default, is not written.
|
||||
if !self.waterlogging {
|
||||
tag.insert(
|
||||
"liquid_settings".into(),
|
||||
Tag::String("ignore_waterlogging".into()),
|
||||
);
|
||||
}
|
||||
tag
|
||||
}
|
||||
pub(super) fn moved(&mut self, dy: i32) {
|
||||
self.position[1] += dy;
|
||||
self.bounding_box = self.bounding_box.moved([0, dy, 0]);
|
||||
@@ -1204,6 +1324,8 @@ impl Placer<'_, '_> {
|
||||
source_x: target_pos[0],
|
||||
source_ground_y: junction_y - local_y + source_ground,
|
||||
source_z: target_pos[2],
|
||||
delta_y,
|
||||
dest_projection: target.projection(),
|
||||
});
|
||||
self.pieces.push(PoolPiece {
|
||||
element: target.clone(),
|
||||
@@ -1215,6 +1337,12 @@ impl Placer<'_, '_> {
|
||||
source_x: jigsaw.pos[0],
|
||||
source_ground_y: junction_y - target_local_y + target_ground,
|
||||
source_z: jigsaw.pos[2],
|
||||
delta_y: -delta_y,
|
||||
dest_projection: if source_rigid {
|
||||
Projection::Rigid
|
||||
} else {
|
||||
Projection::TerrainMatching
|
||||
},
|
||||
}],
|
||||
waterlogging: self.config.waterlogging,
|
||||
});
|
||||
|
||||
@@ -282,6 +282,11 @@ pub trait LegacyPiece: std::fmt::Debug + Send + Sync {
|
||||
fn bounding_box(&self) -> BoundingBox;
|
||||
/// The vanilla StructurePieceType id, for diagnostics and comparisons.
|
||||
fn type_name(&self) -> &'static str;
|
||||
/// `StructurePiece.createTag`; None while Horizon cannot save this type,
|
||||
/// and then its start is not saved.
|
||||
fn save(&self) -> Option<Compound> {
|
||||
None
|
||||
}
|
||||
fn post_process(&self, context: &mut PieceContext<'_>) -> Result<()>;
|
||||
/// Structure.afterPlace, run on the first piece once per chunk after all
|
||||
/// pieces were placed, with every piece's current box.
|
||||
@@ -290,6 +295,31 @@ pub trait LegacyPiece: std::fmt::Debug + Send + Sync {
|
||||
}
|
||||
}
|
||||
|
||||
/// `StructurePiece.createTag`'s own fields: the type, `BB`, `O` (the
|
||||
/// orientation's 2D data value, -1 for none) and `GD` (genDepth).
|
||||
pub fn piece_tag(
|
||||
id: &str,
|
||||
b: BoundingBox,
|
||||
orientation: Option<Direction>,
|
||||
gen_depth: i32,
|
||||
) -> Compound {
|
||||
let o = orientation.map_or(-1, crate::level::data_2d);
|
||||
[
|
||||
("id", Tag::String(id.into())),
|
||||
(
|
||||
"BB",
|
||||
Tag::IntArray(vec![
|
||||
b.min[0], b.min[1], b.min[2], b.max[0], b.max[1], b.max[2],
|
||||
]),
|
||||
),
|
||||
("O", Tag::Int(o)),
|
||||
("GD", Tag::Int(gen_depth)),
|
||||
]
|
||||
.into_iter()
|
||||
.map(|(k, v)| (JavaString::from(k), v))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// StructurePiece's orientation transform and block helpers.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct PieceFrame {
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -73,6 +73,9 @@ impl LegacyPiece for NetherFossilPiece {
|
||||
fn bounding_box(&self) -> BoundingBox {
|
||||
self.0.bounding_box
|
||||
}
|
||||
fn save(&self) -> Option<crate::nbt::Compound> {
|
||||
Some(self.0.saved())
|
||||
}
|
||||
fn type_name(&self) -> &'static str {
|
||||
"minecraft:nefos"
|
||||
}
|
||||
|
||||
@@ -325,6 +325,8 @@ pub fn post_process(piece: &TemplatePiece, c: &mut PieceContext<'_>) -> Result<(
|
||||
new_y = min_y + 1;
|
||||
}
|
||||
position[1] = new_y;
|
||||
// OceanRuinPiece.postProcess keeps the height in templatePosition.
|
||||
*piece.state.lock().unwrap() = Some(new_y);
|
||||
if let Some(markers) = piece.place_at(c, position, &[])? {
|
||||
for (marker, pos) in markers {
|
||||
if marker == "chest" {
|
||||
|
||||
@@ -910,6 +910,8 @@ impl Assembler<'_> {
|
||||
source_x: slot.target_pos[0],
|
||||
source_ground_y: junction_y - local_y + source_ground,
|
||||
source_z: slot.target_pos[2],
|
||||
delta_y,
|
||||
dest_projection: target.projection(),
|
||||
});
|
||||
slot.free.borrow_mut().removed.push(target_box);
|
||||
self.pieces.push(PoolPiece {
|
||||
@@ -922,6 +924,12 @@ impl Assembler<'_> {
|
||||
source_x: jigsaw.pos[0],
|
||||
source_ground_y: junction_y - target_local[1] + target_ground,
|
||||
source_z: jigsaw.pos[2],
|
||||
delta_y: -delta_y,
|
||||
dest_projection: if source_rigid {
|
||||
Projection::Rigid
|
||||
} else {
|
||||
Projection::TerrainMatching
|
||||
},
|
||||
}],
|
||||
waterlogging: self.config.waterlogging,
|
||||
});
|
||||
@@ -1022,6 +1030,7 @@ mod tests {
|
||||
let element = Arc::new(PoolElement::Single {
|
||||
template,
|
||||
processors: Default::default(),
|
||||
processors_source: serde_json::Value::Null,
|
||||
legacy: false,
|
||||
projection: Projection::Rigid,
|
||||
waterlogging: None,
|
||||
@@ -1038,6 +1047,8 @@ mod tests {
|
||||
source_x: 1,
|
||||
source_ground_y: 2,
|
||||
source_z: 3,
|
||||
delta_y: 0,
|
||||
dest_projection: Projection::Rigid,
|
||||
}],
|
||||
waterlogging: true,
|
||||
};
|
||||
|
||||
@@ -706,7 +706,7 @@ fn holder_set(
|
||||
}
|
||||
Ok(Arc::new(out))
|
||||
}
|
||||
fn json_to_nbt(value: &Value) -> Tag {
|
||||
pub(super) fn json_to_nbt(value: &Value) -> Tag {
|
||||
match value {
|
||||
Value::Bool(b) => Tag::Byte(i8::from(*b)),
|
||||
Value::Number(n) => match n.as_i64() {
|
||||
|
||||
@@ -25,6 +25,17 @@ pub enum Vertical {
|
||||
InNether,
|
||||
}
|
||||
impl Vertical {
|
||||
/// `RuinedPortalPiece.VerticalPlacement.getSerializedName`.
|
||||
fn saved_name(self) -> &'static str {
|
||||
match self {
|
||||
Self::OnLandSurface => "on_land_surface",
|
||||
Self::PartlyBuried => "partly_buried",
|
||||
Self::OnOceanFloor => "on_ocean_floor",
|
||||
Self::InMountain => "in_mountain",
|
||||
Self::Underground => "underground",
|
||||
Self::InNether => "in_nether",
|
||||
}
|
||||
}
|
||||
fn heightmap(self) -> HeightmapType {
|
||||
if self == Self::OnOceanFloor {
|
||||
HeightmapType::OceanFloorWg
|
||||
@@ -70,6 +81,9 @@ impl Setup {
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct PortalProps {
|
||||
placement: Vertical,
|
||||
mossiness: f32,
|
||||
air_pocket: bool,
|
||||
blackstone: bool,
|
||||
cold: bool,
|
||||
overgrown: bool,
|
||||
vines: bool,
|
||||
@@ -182,6 +196,9 @@ pub fn start(
|
||||
};
|
||||
let props = PortalProps {
|
||||
placement: setup.placement,
|
||||
mossiness: setup.mossiness,
|
||||
air_pocket,
|
||||
blackstone: setup.blackstone,
|
||||
cold,
|
||||
overgrown: setup.overgrown,
|
||||
vines: setup.vines,
|
||||
@@ -379,3 +396,28 @@ pub fn post_process(piece: &TemplatePiece, c: &mut PieceContext<'_>) -> Result<(
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
impl PortalProps {
|
||||
/// `RuinedPortalPiece`'s `VerticalPlacement` and `Properties` tags.
|
||||
pub fn save(&self) -> [(&'static str, crate::nbt::Tag); 2] {
|
||||
use crate::nbt::Tag;
|
||||
let flag = |b: bool| Tag::Byte(i8::from(b));
|
||||
[
|
||||
(
|
||||
"VerticalPlacement",
|
||||
Tag::String(self.placement.saved_name().into()),
|
||||
),
|
||||
(
|
||||
"Properties",
|
||||
Tag::compound([
|
||||
("cold", flag(self.cold)),
|
||||
("mossiness", Tag::Float(self.mossiness.to_bits())),
|
||||
("air_pocket", flag(self.air_pocket)),
|
||||
("overgrown", flag(self.overgrown)),
|
||||
("vines", flag(self.vines)),
|
||||
("replace_with_blackstone", flag(self.blackstone)),
|
||||
]),
|
||||
),
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(());
|
||||
}
|
||||
@@ -193,10 +205,87 @@ pub fn shipwreck_too_big(template: &StructureTemplate) -> bool {
|
||||
template.size[0] > 32 || template.size[1] > 32
|
||||
}
|
||||
|
||||
impl TemplatePiece {
|
||||
/// `TemplateStructurePiece.addAdditionalSaveData` and its subclasses',
|
||||
/// with the height a shipwreck settled on once placed.
|
||||
pub fn saved(&self) -> crate::nbt::Compound {
|
||||
use crate::nbt::Tag;
|
||||
let flag = |b: bool| Tag::Byte(i8::from(b));
|
||||
let adjusted = match self.handler {
|
||||
TemplateHandler::Shipwreck { .. }
|
||||
| TemplateHandler::OceanRuin { .. }
|
||||
| TemplateHandler::Igloo => *self.state.lock().unwrap(),
|
||||
_ => None,
|
||||
};
|
||||
let dy = adjusted.map_or(0, |y| y - self.position[1]);
|
||||
let template_dy = if matches!(self.handler, TemplateHandler::Igloo) {
|
||||
0
|
||||
} else {
|
||||
dy
|
||||
};
|
||||
let mut tag = super::legacy::piece_tag(
|
||||
self.type_name,
|
||||
self.bounding_box.moved([0, dy, 0]),
|
||||
Some(Direction::North),
|
||||
0,
|
||||
);
|
||||
// Mansion and End City pieces keep the name inside their folder.
|
||||
let name = self
|
||||
.template_name
|
||||
.strip_prefix("minecraft:woodland_mansion/")
|
||||
.or_else(|| self.template_name.strip_prefix("minecraft:end_city/"))
|
||||
.unwrap_or(&self.template_name);
|
||||
let rotation = || Tag::String(self.rotation.saved_name().into());
|
||||
let mut entries = vec![
|
||||
("TPX", Tag::Int(self.position[0])),
|
||||
("TPY", Tag::Int(self.position[1] + template_dy)),
|
||||
("TPZ", Tag::Int(self.position[2])),
|
||||
("Template", Tag::String(name.into())),
|
||||
];
|
||||
match self.handler {
|
||||
TemplateHandler::Igloo => entries.push(("Rot", rotation())),
|
||||
TemplateHandler::Shipwreck { beached } => entries.extend([
|
||||
("isBeached", flag(beached)),
|
||||
("Rot", rotation()),
|
||||
("height_adjusted", flag(adjusted.is_some())),
|
||||
]),
|
||||
TemplateHandler::OceanRuin {
|
||||
warm,
|
||||
large,
|
||||
integrity,
|
||||
} => entries.extend([
|
||||
("Rot", rotation()),
|
||||
("Integrity", Tag::Float(integrity.to_bits())),
|
||||
(
|
||||
"BiomeType",
|
||||
Tag::String(if warm { "WARM" } else { "COLD" }.into()),
|
||||
),
|
||||
("IsLarge", flag(large)),
|
||||
]),
|
||||
TemplateHandler::RuinedPortal(props) => {
|
||||
entries.push(("Rotation", rotation()));
|
||||
entries.push(("Mirror", Tag::String(self.mirror.saved_name().into())));
|
||||
entries.extend(props.save());
|
||||
}
|
||||
TemplateHandler::Mansion => entries.extend([
|
||||
("Rot", rotation()),
|
||||
("Mi", Tag::String(self.mirror.saved_name().into())),
|
||||
]),
|
||||
TemplateHandler::Plain => entries.push(("Rot", rotation())),
|
||||
}
|
||||
for (key, value) in entries {
|
||||
tag.insert(key.into(), value);
|
||||
}
|
||||
tag
|
||||
}
|
||||
}
|
||||
impl LegacyPiece for TemplatePiece {
|
||||
fn bounding_box(&self) -> BoundingBox {
|
||||
self.bounding_box
|
||||
}
|
||||
fn save(&self) -> Option<crate::nbt::Compound> {
|
||||
Some(self.saved())
|
||||
}
|
||||
fn after_place(&self, c: &mut PieceContext<'_>, pieces: &[BoundingBox]) -> Result<()> {
|
||||
match self.handler {
|
||||
TemplateHandler::Mansion => super::mansion::after_place(c, pieces),
|
||||
@@ -232,6 +321,8 @@ impl LegacyPiece for TemplatePiece {
|
||||
self.position[1] + height - 90 - 1,
|
||||
self.position[2],
|
||||
];
|
||||
// The box stays moved; templatePosition goes back after placing.
|
||||
*self.state.lock().unwrap() = Some(position[1]);
|
||||
if let Some(markers) = self.place_at(c, position, &[])? {
|
||||
for (marker, pos) in markers {
|
||||
if marker == "chest" {
|
||||
@@ -362,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 {
|
||||
@@ -419,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),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -432,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"
|
||||
}
|
||||
@@ -500,6 +616,7 @@ impl LegacyPiece for SwampHutPiece {
|
||||
c.world
|
||||
.entity(super::legacy::spawn_nbt(id, pos, true, true))?;
|
||||
}
|
||||
*self.spawned.lock().unwrap() = true;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -694,6 +694,13 @@ impl Piece {
|
||||
Self::Legacy(p) => p.type_name(),
|
||||
}
|
||||
}
|
||||
/// `StructurePiece.createTag`; None for a type Horizon cannot save yet.
|
||||
pub fn save(&self) -> Option<crate::nbt::Compound> {
|
||||
match self {
|
||||
Self::Pool(p) => Some(p.save()),
|
||||
Self::Legacy(p) => p.save(),
|
||||
}
|
||||
}
|
||||
}
|
||||
fn legacy(pieces: Vec<impl LegacyPiece + 'static>) -> Vec<Piece> {
|
||||
pieces
|
||||
@@ -735,6 +742,26 @@ pub struct StructureStart {
|
||||
/// Piece union, inflated by 12 for terrain-adapting structures.
|
||||
pub bounding_box: BoundingBox,
|
||||
}
|
||||
impl StructureStart {
|
||||
/// `StructureStart.createTag` in its own chunk; None while one of its
|
||||
/// pieces cannot be saved. `references` counts `addReference` calls,
|
||||
/// which only locating structures makes: 0 in a freshly made world.
|
||||
pub fn save(&self) -> Option<crate::nbt::Tag> {
|
||||
use crate::nbt::Tag;
|
||||
let children: Option<Vec<Tag>> = self
|
||||
.pieces
|
||||
.iter()
|
||||
.map(|p| p.save().map(Tag::Compound))
|
||||
.collect();
|
||||
Some(Tag::compound([
|
||||
("id", Tag::String(self.name.as_str().into())),
|
||||
("ChunkX", Tag::Int(self.chunk.x)),
|
||||
("ChunkZ", Tag::Int(self.chunk.z)),
|
||||
("references", Tag::Int(0)),
|
||||
("Children", Tag::List(children?)),
|
||||
]))
|
||||
}
|
||||
}
|
||||
|
||||
/// A block of a noise column (ChunkGenerator.getBaseColumn), by what
|
||||
/// structures ask of it.
|
||||
@@ -935,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).
|
||||
|
||||
@@ -176,6 +176,7 @@ Capability `command`: vanilla commands run as the player, with the player's perm
|
||||
| Function | Signature | What it does |
|
||||
| --- | --- | --- |
|
||||
| `run` | `func(line: string)` | `line` without its slash; its feedback goes to the chat as if typed. |
|
||||
| `run-quiet` | `func(lines: list<string>)` | `lines` (each without its slash, at most 256 a call) as `run` does, but with nothing in the chat: no feedback and no failure. In single player and on Horizon's servers, each runs exactly as the player typing it would (the same rights), in order, 64 a tick, and the server logs one line a batch; one that carries a chat message (`say`, `msg`) is refused. On any other server they go as chat commands, and there their feedback shows: a mod running many should say so. |
|
||||
|
||||
## `ui`
|
||||
|
||||
|
||||
@@ -38,7 +38,7 @@ don't import it); faster, or fixing behaviour to what the reference already says
|
||||
|
||||
A new WIT function doesn't break older mods, but a mod that calls it can't run on a Horizon from before it. So every
|
||||
function the game offers mods has an **API level**, the one it arrived at (`crates/horizon-mods/src/api_level.rs`):
|
||||
level 0 is the contract of Horizon 0.2.67 to 0.2.69, level 1 adds `render.set-minimap` and server mods' `players`, level 2 `game.raycast`. A mod needs the level of
|
||||
level 0 is the contract of Horizon 0.2.67 to 0.2.69, level 1 adds `render.set-minimap` and server mods' `players`, level 2 `game.raycast`, level 3 `command.run-quiet`. A mod needs the level of
|
||||
the newest function it imports (a component imports only the functions it calls). The level is read from the file,
|
||||
never declared:
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
|
Before Width: | Height: | Size: 733 B After Width: | Height: | Size: 733 B |
|
Before Width: | Height: | Size: 424 B After Width: | Height: | Size: 424 B |
|
Before Width: | Height: | Size: 537 B After Width: | Height: | Size: 537 B |
+1
-1
@@ -87,7 +87,7 @@ folder, loads no runtime and plays exactly like vanilla 26.3.
|
||||
| `chat` | `chat` | `show(text)`: a line only the local player sees, after the mod's name (8 per call) |
|
||||
| `hud` | `hud` | `set-panel(corner, lines)`, `clear()`: up to 16 lines of 256 characters |
|
||||
| `storage` | `storage` | `get(key)`, `set(key, value)`, `delete(key)`: a per-mod folder of up to 64 files, 4 MiB total, at most 1 MiB written per call |
|
||||
| `command` | `command` | `run(line)`: a vanilla command run as the player, with the player's permissions, after the hook returns (8 per call); mods' own commands are not run |
|
||||
| `command` | `command` | `run(line)`: a vanilla command run as the player, with the player's permissions, after the hook returns (8 per call); mods' own commands are not run. `run-quiet(lines)`: up to 256 a call, the same rights, nothing in the chat (single player and Horizon's servers run them 64 a tick and log one line a batch; elsewhere they go as chat commands and their feedback shows) |
|
||||
| `ui` | `ui` | `open-screen`, `close-screen`, `set-settings`: a screen the mod owns and a page in Options > Mods, of text, button, item and list widgets the game lays out and draws with its own menus' code. **Experimental:** the manifest needs `experimental = ["ui"]` |
|
||||
| `render` | `render` | `set-hud(shapes)`: up to 1024 fills, four-cornered shapes, GUI sprites, items and text, kept on the HUD (over the hotbar, under chat and screens, hidden with F1) until replaced; `set-corner(corner, w, h, shapes)`: a box the game places in a corner, stacked with its overlays and other panels, as `hud`'s panels are; `text-width(text)`; `frame-stats()`: the game's FPS, min and 1% low; `gui-size()`; `shake(strength, seconds)`: the view turns, the player doesn't, scaled by Distortion Effects; `set-zoom(zoom)`: the field of view divided by up to 10, eased or at once, with slower turning, while playing and not through a spyglass (the strongest of several mods' zooms counts); `set-minimap(minimap)`: a map of the world around the player in a corner, as a map item colours it (the floor underground and under roofs), square or round, turning with the player or north up, with the player's arrow; the game keeps its tiles within its own frame budget (one map at a time, the first loaded mod's). The game's own HUD renderer draws them: a mod never supplies shader code. **Experimental:** `experimental = ["render"]` |
|
||||
| `input` | `input` | `claim-items(ids)`: the use button on those items is the mod's (`use-start`, `use-stop` with the ticks held; the game doesn't place, eat or swing); `register-key(name, label, key)`: up to 16 keys (`key.keyboard.r`), heard while playing as `key-down` / `key-up` (the game's own mapping on that key still runs; a screen opening lets held keys up), mouse buttons as `key.mouse.4`. The player rebinds them on the mod's page in Options > Mods, where the game lists them first as Key Binds rows (click, then press a key or mouse button; Esc binds none), even for a mod without `ui`; the choice is kept in `horizon-mods.txt` (`key.<mod>.<name>:key.keyboard.z`) and wins over `register-key`'s default; `claim-wheel(on)`: the wheel's steps become `wheel` events and the hotbar doesn't move (the one input a mod takes from the game; a mod's keys never do); `held()`; `mapping(name)`: a movement or click mapping's key label and whether it is held; `clicks-per-second()`; `poll()` returns the events, at most 256 kept. **Experimental:** `experimental = ["input"]` |
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user