Nightly: merge #3048 at c2ce8ea2dd

This commit is contained in:
github-actions[bot] committed 2026-10-28 02:52:35 -04:00
commit 9b772b2fec
345 files changed
+21285 -2661

No files matched your search

+10 -2
View File
@@ -75,6 +75,7 @@ python3 tools/parity.py --changed-files paths.txt --list # which harnesses cov
python3 tools/parity.py mob_ai sculk # named harnesses
python3 tools/lan_e2e.py --check chest # two real games over LAN (server-thread host); --in-frame compares the fallback
python3 tools/agent_control_e2e.py # horizon-ctl / MCP end to end
python3 tools/prune_worktrees.py # list every worktree's PR state; --apply sweeps merged/closed ones
```
- Harnesses and the game need the vanilla data in `.local/` (`minecraft/`, `horizon-client/26.3`, `datagen/`,
@@ -96,6 +97,10 @@ python3 tools/agent_control_e2e.py # horizon-ctl / MCP end to end
`target/fast/horizon-ctl --socket $PWD/ctl.sock launch --world W --creative --offscreen --wait --resources $PWD/.local/horizon-client/26.3`,
then `horizon-ctl --socket … state|keys|turn|command|screenshot|quit`
([docs/horizon-ctl-commands.md](docs/horizon-ctl-commands.md)).
- One worktree per agent/PR; when the PR merges or closes, the worktree is dead weight — its `target/`
alone is 5–25 GB. `python3 tools/prune_worktrees.py` lists each linked worktree with its PR state;
`--apply` stashes anything uncommitted and removes the done ones (the branch stays; `--no-pr` also
removes worktrees whose branch never got a PR). Never remove the worktree your own thread runs in.
## Vanilla reference
@@ -162,8 +167,11 @@ python3 tools/agent_control_e2e.py # horizon-ctl / MCP end to end
"Server thread" with one level per dimension; the window joins it as a client through memory (`remote`),
exactly as a LAN guest or a dedicated server's player does. This is what players run. Open
`[server-thread]` issues list the gaps left.
2. **In-frame (`--in-frame`, `--no-server-thread` or `HORIZON_SERVER_THREAD=0`):** the window's `Game` ticks
the world itself, one level that follows the player (`fn enter_dimension`). Deterministic rule fixtures still use this path; player-facing integration uses the default server thread.
2. **Legacy in-frame (deprecated; no removal release scheduled):** `--in-frame`,
`--no-server-thread` and `HORIZON_SERVER_THREAD=0` warn but still work.
Remove these overrides to use the default server thread. The window's `Game` ticks the world itself,
one level that follows the player (`fn enter_dimension`). Deterministic rule fixtures still use this
path; player-facing integration and the command harness use the default server thread.
LAN guests and dedicated servers always use the server code, so a fix there serves both. Test both paths
when you touch player-facing gameplay:
+4 -2
View File
@@ -61,8 +61,10 @@ large_enum_variant = "allow"
# wgpu-hal 30.0.1 with gfx-rs/wgpu#10124: wgpu 30 creates a Vulkan descriptor
# pool with no pool sizes while it creates the device, which Vulkan allows only
# since 1.3.215, and Intel's Windows driver for 6th-10th gen iGPUs (HD 520/530,
# UHD 620/630) crashes on it with an access violation (#746). Drop this and the
# folder once a wgpu release has the fix.
# UHD 620/630) crashes on it with an access violation (#746). It also fixes the
# Metal backend's lookup of the CoreGraphics HDR colour space names, which handed
# CoreGraphics a symbol's address as the string and crashed HDR output on macOS.
# Drop this and the folder once a wgpu release has both fixes.
[patch.crates-io]
wgpu-hal = { path = "third_party/wgpu-hal" }
+1 -1
View File
@@ -31,7 +31,7 @@ Horizon is Minecraft: Java Edition 26.3 rewritten in Rust: a native game that re
Horizon is installed through **the Horizon launcher**. Get it from [playhorizon.dev](https://playhorizon.dev) (the Download button picks your system) or from [the launcher releases](https://github.com/horizon-rust/horizon/releases?q=launcher-v&expanded=true), for Linux x86-64, macOS on Apple Silicon or Windows x64. The launcher installs and updates the game, prepares its files from your own copy of Minecraft, and updates itself.
**You need** a Microsoft account that owns Minecraft: Java Edition (the launcher signs in with it, as other launchers do; see [Microsoft account](docs/microsoft-account.md)); Minecraft: Java Edition 26.3, played once (the official launcher, Prism, MultiMC and other launchers all work); a Java 25 runtime, for the one-time preparation; and a GPU with Vulkan on Linux and Windows, or any Apple Silicon Mac. Step by step, with macOS's first-open prompt and the first launch: [Install and first launch](https://playhorizon.dev/docs/install).
**You need** a Microsoft account that owns Minecraft: Java Edition (the launcher signs in with it, as other launchers do; see [Microsoft account](docs/microsoft-account.md)); Minecraft: Java Edition 26.3, played once (the official launcher, Prism, MultiMC and other launchers all work); a Java 25 runtime, for the one-time preparation; and a GPU with Vulkan or OpenGL 3.3 on Linux, Vulkan or DirectX 12 on Windows, or any Apple Silicon Mac. Step by step, with macOS's first-open prompt and the first launch: [Install and first launch](https://playhorizon.dev/docs/install).
<table>
<tr>
+3 -3
View File
@@ -146,7 +146,7 @@ Single player now runs a small server in the background, as vanilla does. We are
<details><summary>Technical</summary>
Since [#1514](https://github.com/horizon-rust/horizon/issues/1514) single player runs on the server thread (`game/integrated.rs`) and the window joins it as a client; `--in-frame` keeps the old path. The open `[server-thread]` issues list the gaps. In review: a tool that changes a block ends the click ([#1797](https://github.com/horizon-rust/horizon/issues/1797)), potions keep their potion over the connection ([#1796](https://github.com/horizon-rust/horizon/issues/1796)), mobs' aggression and arm swings ([#1802](https://github.com/horizon-rust/horizon/issues/1802)). [docs/server-levels.md](docs/server-levels.md).
Since [#1514](https://github.com/horizon-rust/horizon/issues/1514) single player runs on the server thread (`game/integrated.rs`) and the window joins it as a client. The legacy in-frame selectors are deprecated; removal is documented in [docs/server-levels.md](docs/server-levels.md). The open `[server-thread]` issues list the gaps. In review: a tool that changes a block ends the click ([#1797](https://github.com/horizon-rust/horizon/issues/1797)), potions keep their potion over the connection ([#1796](https://github.com/horizon-rust/horizon/issues/1796)), mobs' aggression and arm swings ([#1802](https://github.com/horizon-rust/horizon/issues/1802)). docs/server-levels.md.
</details>
@@ -492,11 +492,11 @@ Port `PistonMovingBlockEntity.moveCollidedEntities` and `moveStuckEntities` (hon
#### One single-player path: remove the in-frame world
Single player will have one way to run, the server thread, so every fix works everywhere and there are fewer bugs. The older in-frame mode goes once everything passes without it.
Single player will have one way to run, the server thread, so every fix works everywhere and there are fewer bugs. The older in-frame mode is deprecated; no removal release is scheduled.
<details><summary>Technical</summary>
`--in-frame` (also `--no-server-thread` and `HORIZON_SERVER_THREAD=0`) still ticks the world inside the window's `Game`, and most harnesses test that path. Delete it once `lan_e2e --server-thread` and the parity harnesses pass on the server thread, which also halves the bug surface. See also 'Single player as a server' and [docs/server-levels.md](docs/server-levels.md).
`--in-frame`, `--no-server-thread` and `HORIZON_SERVER_THREAD=0` are deprecated, warn at startup, and will be removed in a future release. Remove these overrides to use the default server thread. Deterministic rule fixtures still test the old path; migrate the remaining parity coverage before deleting the internal in-frame world. See also 'Single player as a server' and [docs/server-levels.md](docs/server-levels.md).
</details>
+102 -51
View File
@@ -11,13 +11,18 @@ pub fn parse_snbt(input: &str) -> Result<(Value, usize), String> {
let mut p = Parser {
s: input.as_bytes(),
i: 0,
depth: 0,
};
let v = p.value()?;
Ok((v, p.i))
}
/// Nesting deeper than NBT's own limit (`NbtAccounter`'s 512) is refused
/// rather than overflowing the stack.
const SNBT_MAX_DEPTH: usize = 512;
struct Parser<'a> {
s: &'a [u8],
i: usize,
depth: usize,
}
impl Parser<'_> {
fn ws(&mut self) {
@@ -41,57 +46,22 @@ impl Parser<'_> {
self.ws();
match self.peek() {
Some(b'{') => {
self.i += 1;
let mut map = Map::new();
self.ws();
if self.peek() == Some(b'}') {
self.i += 1;
return Ok(Value::Object(map));
}
loop {
self.ws();
let key = self.string()?;
self.expect(b':')?;
map.insert(key, self.value()?);
self.ws();
match self.peek() {
Some(b',') => self.i += 1,
Some(b'}') => {
self.i += 1;
return Ok(Value::Object(map));
}
_ => return Err("expected ',' or '}'".into()),
}
if self.depth >= SNBT_MAX_DEPTH {
return Err(format!("tag nesting too deep at {}", self.i));
}
self.depth += 1;
let v = self.object();
self.depth -= 1;
v
}
Some(b'[') => {
self.i += 1;
let mut list = Vec::new();
self.ws();
// Typed arrays [B;...], [I;...], [L;...].
if matches!(
self.s.get(self.i..self.i + 2),
Some([b'B' | b'I' | b'L', b';'])
) {
self.i += 2;
}
self.ws();
if self.peek() == Some(b']') {
self.i += 1;
return Ok(Value::Array(list));
}
loop {
list.push(self.value()?);
self.ws();
match self.peek() {
Some(b',') => self.i += 1,
Some(b']') => {
self.i += 1;
return Ok(Value::Array(list));
}
_ => return Err("expected ',' or ']'".into()),
}
if self.depth >= SNBT_MAX_DEPTH {
return Err(format!("tag nesting too deep at {}", self.i));
}
self.depth += 1;
let v = self.array();
self.depth -= 1;
v
}
Some(b'"' | b'\'') => Ok(Value::String(self.string()?)),
Some(_) => {
@@ -105,10 +75,20 @@ impl Parser<'_> {
_ => {
let digits = word
.trim_end_matches(['b', 'B', 's', 'S', 'l', 'L', 'f', 'F', 'd', 'D']);
match (digits.parse::<i64>(), digits.parse::<f64>()) {
(Ok(n), _) if digits.len() + 1 >= word.len() => Value::from(n),
(_, Ok(f)) if digits.len() + 1 >= word.len() => Value::from(f),
_ => Value::String(word),
// Only digits (and an exponent) make a number: `nan`
// or `Infinity`, which Rust's parse would take, stays
// a string.
if digits
.bytes()
.any(|c| c.is_ascii_alphabetic() && c != b'e' && c != b'E')
{
Value::String(word)
} else {
match (digits.parse::<i64>(), digits.parse::<f64>()) {
(Ok(n), _) if digits.len() + 1 >= word.len() => Value::from(n),
(_, Ok(f)) if digits.len() + 1 >= word.len() => Value::from(f),
_ => Value::String(word),
}
}
}
})
@@ -116,6 +96,59 @@ impl Parser<'_> {
None => Err("expected a value".into()),
}
}
fn object(&mut self) -> Result<Value, String> {
self.i += 1;
let mut map = Map::new();
self.ws();
if self.peek() == Some(b'}') {
self.i += 1;
return Ok(Value::Object(map));
}
loop {
self.ws();
let key = self.string()?;
self.expect(b':')?;
map.insert(key, self.value()?);
self.ws();
match self.peek() {
Some(b',') => self.i += 1,
Some(b'}') => {
self.i += 1;
return Ok(Value::Object(map));
}
_ => return Err("expected ',' or '}'".into()),
}
}
}
fn array(&mut self) -> Result<Value, String> {
self.i += 1;
let mut list = Vec::new();
self.ws();
// Typed arrays [B;...], [I;...], [L;...].
if matches!(
self.s.get(self.i..self.i + 2),
Some([b'B' | b'I' | b'L', b';'])
) {
self.i += 2;
}
self.ws();
if self.peek() == Some(b']') {
self.i += 1;
return Ok(Value::Array(list));
}
loop {
list.push(self.value()?);
self.ws();
match self.peek() {
Some(b',') => self.i += 1,
Some(b']') => {
self.i += 1;
return Ok(Value::Array(list));
}
_ => return Err("expected ',' or ']'".into()),
}
}
}
fn unquoted(&mut self) -> String {
let start = self.i;
while let Some(c) = self.peek() {
@@ -346,4 +379,22 @@ mod tests {
let t = to_text(&v, &mut |_| Text::empty());
assert_eq!(t.spans[0].style.font, None);
}
#[test]
fn snbt_nesting_is_bounded() {
let nest = |n: usize| format!("{}1{}", "[".repeat(n), "]".repeat(n));
assert!(parse_snbt(&nest(512)).is_ok());
assert!(parse_snbt(&nest(513)).is_err());
assert!(parse_snbt(&nest(100_000)).is_err());
}
#[test]
fn bare_words_that_look_numeric_stay_strings() {
let (v, _) = parse_snbt("{text:nan}").unwrap();
assert_eq!(v["text"], Value::String("nan".into()));
let (v, _) = parse_snbt("{text:Infinity}").unwrap();
assert_eq!(v["text"], Value::String("Infinity".into()));
let (v, _) = parse_snbt("{n:1.5}").unwrap();
assert_eq!(v["n"], Value::from(1.5));
}
}
+37 -1
View File
@@ -40,6 +40,10 @@ static BIDIRECTIONAL: AtomicBool = AtomicBool::new(false);
/// A source of resource files: the prepared root plus packs, highest priority last.
pub trait ResourceSource {
fn read(&self, path: &str) -> Option<Vec<u8>>;
/// Whether `path` is there, without reading it where the source can tell.
fn exists(&self, path: &str) -> bool {
self.read(path).is_some()
}
/// The namespaces under `assets/` (resource packs may add their own).
fn namespaces(&self) -> Vec<String> {
vec!["minecraft".into()]
@@ -68,6 +72,9 @@ impl ResourceSource for std::path::PathBuf {
fn read(&self, path: &str) -> Option<Vec<u8>> {
std::fs::read(self.join(path)).ok()
}
fn exists(&self, path: &str) -> bool {
self.join(path).is_file()
}
fn namespaces(&self) -> Vec<String> {
std::fs::read_dir(self.join("assets"))
.into_iter()
@@ -190,7 +197,7 @@ pub fn available(sources: &[&dyn ResourceSource]) -> Vec<LanguageInfo> {
for (code, info) in value["language"].as_object().into_iter().flatten() {
// Only languages whose file exists somewhere are selectable.
let file = format!("assets/minecraft/lang/{code}.json");
if code != DEFAULT && !sources.iter().any(|s| s.read(&file).is_some()) {
if code != DEFAULT && !sources.iter().any(|s| s.exists(&file)) {
continue;
}
found.insert(
@@ -259,6 +266,13 @@ mod tests {
assert_eq!(en.get("horizon.options.controller"), Some("Controller"));
let ru = Language::load(&[&empty], "ru_ru");
assert_eq!(ru.get("horizon.options.controller"), Some("Геймпад"));
// Every bundled string carries both languages: a key added to one file
// and forgotten in the other shows as a key name on screen.
for key in en.map().keys() {
assert!(ru.get(key).is_some(), "{key} has no ru_ru value");
}
assert_eq!(en.get("horizon.pages.mods"), Some("Mods"));
assert_eq!(ru.get("horizon.pages.mods"), Some("Моды"));
}
#[test]
@@ -284,4 +298,26 @@ mod tests {
);
assert_eq!(langs[1].label(), "Français (France)");
}
#[test]
fn languages_are_listed_without_reading_their_files() {
struct NoLangReads;
impl ResourceSource for NoLangReads {
fn read(&self, path: &str) -> Option<Vec<u8>> {
assert!(!path.contains("/lang/"), "read {path}");
(path == "assets/pack.mcmeta").then(|| {
br#"{"language":{"fr_fr":{"name":"French"},"de_de":{"name":"German"}}}"#
.to_vec()
})
}
fn exists(&self, path: &str) -> bool {
path == "assets/minecraft/lang/fr_fr.json"
}
}
let langs = available(&[&NoLangReads]);
assert_eq!(
langs.iter().map(|l| l.code.as_str()).collect::<Vec<_>>(),
["en_us", "fr_fr"]
);
}
}
+62 -4
View File
@@ -662,10 +662,22 @@ pub fn name_shown(own: Option<&Team>, viewer: Option<&Team>, v: NameView) -> boo
}
!v.hud_hidden && !v.is_camera && visible && !v.is_vehicle
}
/// `ServerPlayer.die`: whether a player's death message reaches the player
/// themselves (the team broadcasts leave out the dying player).
pub fn own_death_message_shown(team: Option<&Team>) -> bool {
team.is_none_or(|t| t.death_message_visibility == Visibility::Always)
/// `ServerPlayer.die`: whether `other` hears the death message of `dying`
/// (`broadcastSystemMessage`, `broadcastSystemToTeam` or
/// `broadcastSystemToAllExceptTeam`, by the team's death message visibility).
pub fn death_message_reaches(board: &Scoreboard, dying: &str, other: &str) -> bool {
let Some(team) = board.players_team(dying) else {
return true;
};
let same_team = board
.players_team(other)
.is_some_and(|t| t.name == team.name);
match team.death_message_visibility {
Visibility::Always => true,
Visibility::Never => false,
Visibility::HideForOtherTeams => same_team && other != dying,
Visibility::HideForOwnTeam => !same_team,
}
}
// ---- objectives and scores ---------------------------------------------
@@ -1551,3 +1563,49 @@ mod tests {
);
}
}
/// `DamageSource.getLocalizedDeathMessage`: the cause's item with a custom
/// name gets the `<id>.item` message, with the item's name as a third
/// argument.
pub fn death_message_args(
key: &str,
victim: &Text,
cause: &Text,
item: Option<&Text>,
) -> (String, Vec<Text>) {
match item {
Some(item) => (
format!("{key}.item"),
vec![victim.clone(), cause.clone(), item.clone()],
),
None => (key.to_owned(), vec![victim.clone(), cause.clone()]),
}
}
#[cfg(test)]
mod death_message_tests {
use super::*;
#[test]
fn the_death_message_names_the_causes_item_when_it_has_a_custom_name() {
let victim = Text::literal("Steve");
let cause = Text::literal("Zombie");
// DamageSource.getLocalizedDeathMessage: the item variant, with the
// item's name as the third argument.
let (key, args) = death_message_args(
"death.attack.arrow",
&victim,
&cause,
Some(&Text::literal("My Sword")),
);
assert_eq!(key, "death.attack.arrow.item");
assert_eq!(args.len(), 3);
assert_eq!(args[0].plain(), "Steve");
assert_eq!(args[1].plain(), "Zombie");
assert_eq!(args[2].plain(), "My Sword");
// Without a custom name the message keeps its two arguments.
let (key, args) = death_message_args("death.attack.arrow", &victim, &cause, None);
assert_eq!(key, "death.attack.arrow");
assert_eq!(args.len(), 2);
}
}
+30 -25
View File
@@ -400,31 +400,7 @@ impl Text {
continue;
}
let Some(code) = chars.next() else { break };
let code = code.to_ascii_lowercase();
let next = if let Some(c) = COLORS.iter().find(|c| c.0 == code) {
// Style.applyLegacyFormat: a colour clears the decorations.
Style {
color: Some(c.2),
bold: Some(false),
italic: Some(false),
underlined: Some(false),
strikethrough: Some(false),
obfuscated: Some(false),
..style.clone()
}
} else {
let mut s = style.clone();
match code {
'k' => s.obfuscated = Some(true),
'l' => s.bold = Some(true),
'm' => s.strikethrough = Some(true),
'n' => s.underlined = Some(true),
'o' => s.italic = Some(true),
'r' => s = span.style.clone(),
_ => {}
}
s
};
let next = legacy_format(&style, code, &span.style);
if next != style {
if !current.is_empty() {
out.spans.push(Span {
@@ -583,6 +559,35 @@ fn style_json(style: &Style) -> Vec<(String, Value)> {
/// TranslatableContents.decomposeTemplate: `%s` takes the next argument,
/// `%n$s` a numbered one and `%%` is a percent sign. Malformed templates are
/// shown verbatim.
/// `Style.applyLegacyFormat` for the code after a `§`, `§r` going back to
/// `reset` (`StringDecomposer.iterateFormatted`); an unknown code changes nothing.
pub fn legacy_format(style: &Style, code: char, reset: &Style) -> Style {
let code = code.to_ascii_lowercase();
if let Some(c) = COLORS.iter().find(|c| c.0 == code) {
// A colour clears the decorations.
return Style {
color: Some(c.2),
bold: Some(false),
italic: Some(false),
underlined: Some(false),
strikethrough: Some(false),
obfuscated: Some(false),
..style.clone()
};
}
let mut s = style.clone();
match code {
'k' => s.obfuscated = Some(true),
'l' => s.bold = Some(true),
'm' => s.strikethrough = Some(true),
'n' => s.underlined = Some(true),
'o' => s.italic = Some(true),
'r' => s = reset.clone(),
_ => {}
}
s
}
pub fn format_translation(template: &str, args: &[Text]) -> Text {
let mut out = Text::default();
let mut literal = String::new();
+41
View File
@@ -324,6 +324,24 @@ pub fn ease_in_out(t: f64) -> f64 {
0.5 - 0.5 * (std::f64::consts::PI * t).cos()
}
/// `MouseHandler.grabMouse` / `releaseMouse`: the mouse position is the
/// window's centre whenever the pointer's grab state changes, so a screen
/// opened from gameplay starts with the cursor in the middle. A release of
/// an already free pointer (or a grab of an already grabbed one) leaves the
/// position where it is, as vanilla does.
pub fn mouse_position_after_grab_change(
grab: bool,
was_grabbed: bool,
size: [u32; 2],
scale: f32,
) -> Option<(f32, f32)> {
if grab == was_grabbed {
None
} else {
Some((size[0] as f32 / 2.0 / scale, size[1] as f32 / 2.0 / scale))
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -485,4 +503,27 @@ mod tests {
assert_eq!(look.on_scroll(0.0, 0.4, false, 1.0), None);
assert_eq!(look.on_scroll(0.0, 0.7, false, 1.0), Some((1, (0, 1))));
}
#[test]
fn the_mouse_position_is_the_centre_when_the_grab_changes() {
// A grab of a free pointer and a release of a grabbed one both put
// the position at the window's centre (`MouseHandler.grabMouse`,
// `MouseHandler.releaseMouse`).
assert_eq!(
mouse_position_after_grab_change(true, false, [1920, 1080], 1.0),
Some((960.0, 540.0))
);
assert_eq!(
mouse_position_after_grab_change(false, true, [1920, 1080], 2.0),
Some((480.0, 270.0))
);
// Without a change of the grab state the position stays where it is.
assert_eq!(
mouse_position_after_grab_change(false, false, [1920, 1080], 1.0),
None
);
assert_eq!(
mouse_position_after_grab_change(true, true, [1920, 1080], 1.0),
None
);
}
}
@@ -11,5 +11,57 @@
"horizon.options.look_deadzone": "Look Deadzone",
"horizon.options.button_glyphs": "Button Glyphs",
"horizon.options.menu_navigation": "Menu Navigation",
"horizon.options.on_screen_keyboard": "On-Screen Keyboard"
"horizon.options.on_screen_keyboard": "On-Screen Keyboard",
"horizon.options.chat_delay": "Chat Delay",
"horizon.options.lightbar.health": "Health",
"horizon.options.lightbar.dimension": "Dimension",
"horizon.options.glyphs.auto": "Auto",
"horizon.options.glyphs.xbox": "Xbox",
"horizon.options.glyphs.playstation": "PlayStation",
"horizon.options.glyphs.nintendo": "Nintendo",
"horizon.options.menu_focus": "Focus",
"horizon.options.menu_pointer": "Pointer",
"horizon.options.keyboard.auto": "Auto",
"horizon.options.keyboard.horizon": "Horizon",
"horizon.options.keyboard.steam": "Steam",
"horizon.options.unavailable": "This feature is not yet available in Horizon.",
"horizon.options.search": "Search settings...",
"horizon.pages.mods": "Mods",
"horizon.pages.shader_packs": "Shader Packs",
"horizon.pages.controller": "Controller Settings",
"horizon.pages.controller_binds": "Controller Binds",
"horizon.pages.extras": "Horizon Extras",
"horizon.pages.settings": "Horizon Settings",
"horizon.pages.agent": "AI Agent",
"horizon.pages.settings.tooltip": "Horizon's own settings: graphics, controllers, extras and mods.",
"horizon.pages.graphics.tooltip": "Graphics, simply: each setting says what it costs.",
"horizon.pages.graphics_advanced.tooltip": "Every graphics setting, Horizon's included, with what each costs.",
"horizon.pages.controller.tooltip": "Controllers: their buttons, sticks, vibration and menu navigation.",
"horizon.pages.extras.tooltip": "Optional additions, all off by default: the game stays vanilla until you turn one on.",
"horizon.pages.agent.tooltip": "Let your own AI agent play: horizon-ctl and MCP clients on this computer.",
"horizon.pages.shader_packs.tooltip": "Shader packs from the shaderpacks folder: Horizon's own and those in the shaders/ format. One runs at a time.",
"horizon.pages.mods.tooltip": "Mods made for Horizon, from the horizon-mods folder. Each runs in a sandbox and stays off until you turn it on.",
"horizon.pages.open.graphics": "Graphics...",
"horizon.pages.open.graphics_advanced": "Advanced Graphics...",
"horizon.pages.open.shader_packs": "Shader Packs...",
"horizon.pages.open.controller": "Controller Settings...",
"horizon.pages.open.extras": "Extras...",
"horizon.pages.open.mods": "Mods...",
"horizon.pages.open.agent": "AI Agent...",
"horizon.pages.open.horizon_graphics": "Horizon Graphics...",
"horizon.pages.open.controller_binds": "Controller Binds...",
"horizon.pages.open.horizon_settings": "Horizon Settings...",
"horizon.mods.empty": "No mods: put .hmod files in the horizon-mods folder",
"horizon.mods.stopped": " (Stopped)",
"horizon.mods.dev": " (Dev)",
"horizon.mods.settings": "Settings",
"horizon.mods.open_folder": "Open Mod Folder",
"horizon.shader_packs.empty": "No shader packs: put them in the shaderpacks folder",
"horizon.shader_packs.kind_horizon": " (Horizon)",
"horizon.agent.copy_details": "Copy Connection Details",
"horizon.multiplayer.join": "Join",
"horizon.multiplayer.cancel_reconnecting": "Cancel Reconnecting",
"horizon.multiplayer.join_friend": "Join a Friend",
"horizon.multiplayer.invite_code": "Invite Code",
"horizon.multiplayer.relay": "Relay (optional)"
}
@@ -11,5 +11,57 @@
"horizon.options.look_deadzone": "Мёртвая зона обзора",
"horizon.options.button_glyphs": "Символы кнопок",
"horizon.options.menu_navigation": "Навигация по меню",
"horizon.options.on_screen_keyboard": "Экранная клавиатура"
"horizon.options.on_screen_keyboard": "Экранная клавиатура",
"horizon.options.chat_delay": "Задержка чата",
"horizon.options.lightbar.health": "Здоровье",
"horizon.options.lightbar.dimension": "Измерение",
"horizon.options.glyphs.auto": "Авто",
"horizon.options.glyphs.xbox": "Xbox",
"horizon.options.glyphs.playstation": "PlayStation",
"horizon.options.glyphs.nintendo": "Nintendo",
"horizon.options.menu_focus": "Фокус",
"horizon.options.menu_pointer": "Указатель",
"horizon.options.keyboard.auto": "Авто",
"horizon.options.keyboard.horizon": "Horizon",
"horizon.options.keyboard.steam": "Steam",
"horizon.options.unavailable": "Эта возможность в Horizon пока недоступна.",
"horizon.options.search": "Поиск настроек...",
"horizon.pages.mods": "Моды",
"horizon.pages.shader_packs": "Шейдерпаки",
"horizon.pages.controller": "Настройки контроллера",
"horizon.pages.controller_binds": "Назначения кнопок контроллера",
"horizon.pages.extras": "Дополнения Horizon",
"horizon.pages.settings": "Настройки Horizon",
"horizon.pages.agent": "ИИ-агент",
"horizon.pages.settings.tooltip": "Собственные настройки Horizon: графика, контроллеры, дополнения и моды.",
"horizon.pages.graphics.tooltip": "Графика без лишнего: у каждой настройки написано, чего она стоит.",
"horizon.pages.graphics_advanced.tooltip": "Все графические настройки, включая собственные настройки Horizon, и их цена.",
"horizon.pages.controller.tooltip": "Контроллеры: кнопки, стики, вибрация и навигация по меню.",
"horizon.pages.extras.tooltip": "Дополнительные возможности, все выключены по умолчанию: игра остаётся ванильной, пока вы не включите что-то.",
"horizon.pages.agent.tooltip": "Пусть играет ваш ИИ-агент: horizon-ctl и MCP-клиенты на этом компьютере.",
"horizon.pages.shader_packs.tooltip": "Шейдерпаки из папки shaderpacks: собственные паки Horizon и паки в формате shaders/. Одновременно работает один.",
"horizon.pages.mods.tooltip": "Моды для Horizon из папки horizon-mods. Каждый работает в песочнице и выключен, пока вы его не включите.",
"horizon.pages.open.graphics": "Графика...",
"horizon.pages.open.graphics_advanced": "Продвинутая графика...",
"horizon.pages.open.shader_packs": "Шейдерпаки...",
"horizon.pages.open.controller": "Настройки контроллера...",
"horizon.pages.open.extras": "Дополнения...",
"horizon.pages.open.mods": "Моды...",
"horizon.pages.open.agent": "ИИ-агент...",
"horizon.pages.open.horizon_graphics": "Графика Horizon...",
"horizon.pages.open.controller_binds": "Назначения кнопок...",
"horizon.pages.open.horizon_settings": "Настройки Horizon...",
"horizon.mods.empty": "Модов нет: положите файлы .hmod в папку horizon-mods",
"horizon.mods.stopped": " (остановлен)",
"horizon.mods.dev": " (разработка)",
"horizon.mods.settings": "Настройки",
"horizon.mods.open_folder": "Открыть папку модов",
"horizon.shader_packs.empty": "Шейдерпаков нет: положите их в папку shaderpacks",
"horizon.shader_packs.kind_horizon": " (Horizon)",
"horizon.agent.copy_details": "Скопировать данные подключения",
"horizon.multiplayer.join": "Подключиться",
"horizon.multiplayer.cancel_reconnecting": "Отменить переподключение",
"horizon.multiplayer.join_friend": "Играть с другом",
"horizon.multiplayer.invite_code": "Код приглашения",
"horizon.multiplayer.relay": "Ретранслятор (необязательно)"
}
@@ -161,6 +161,7 @@ pub fn main() -> Result<()> {
"sound {} {:08x} {:08x}",
match sound {
ArrowSound::HitGround => "hit",
ArrowSound::CrossbowHit => "crossbow_hit",
ArrowSound::Splash => "splash",
ArrowSound::Extinguish => "extinguish",
ArrowSound::TridentHitGround => "trident_hit_ground",
@@ -87,7 +87,11 @@ pub fn main() {
}
"tick" => {
out.push_str("tick");
for (p, _) in entities.tickers(|p| states.get(&p).copied().unwrap_or(0)) {
for (p, _) in entities
.tickers(|p| states.get(&p).copied().unwrap_or(0))
.iter()
.copied()
{
out.push_str(&format!(" {},{},{}", p.0, p.1, p.2));
}
out.push('\n');
@@ -25,6 +25,7 @@ fn stack(spec: &str) -> LootStack {
ominous_amplifier: None,
stew_effects: Vec::new(),
names: Vec::new(),
components: Vec::new(),
};
for e in parts {
let (name, level) = e.rsplit_once(':').unwrap_or((e, "1"));
@@ -142,8 +142,8 @@ const GENES: [&str; 7] = [
"aggressive",
];
const MEMORIES: [&str; 7] = [
"long_jump_cooldown",
"ram_cooldown",
"long_jump_cooling_down",
"ram_cooldown_ticks",
"attack_target_cooldown",
"hunted_recently",
"dig_cooldown",
@@ -506,6 +506,7 @@ pub fn main() -> horizon_client::Result<()> {
difficulty,
data: &data,
created: Vec::new(),
jockey_rider: None,
};
group = finalize::finalize(&mut cx, &mut mob, reason, group);
let created = std::mem::take(&mut cx.created);
@@ -90,6 +90,7 @@ mod subtitle_fixture;
mod tab_list_fixture;
mod team_rules_fixture;
mod text_display_fixture;
mod text_display_render_fixture;
mod toggle_keys_probe;
mod tooltip_display_fixture;
mod trading_fixture;
@@ -180,6 +181,7 @@ probes! {
toggle_keys_probe,
tooltip_display_fixture,
text_display_fixture,
text_display_render_fixture,
trading_fixture,
translucency_fixture,
weather_fixture,
@@ -67,8 +67,9 @@ struct Scenario {
/// The player hits a mob for 1 at the start of a tick: (tick, mob index).
hurts: Vec<(i32, usize)>,
/// The player feeds an animal at the start of a tick (Animal.mobInteract):
/// (tick, mob index); the scenario only feeds each animal its food.
feeds: Vec<(i32, usize)>,
/// (tick, mob index, None); the scenario only feeds each animal its food.
/// `interact` uses another item on a horse: (tick, mob index, Some(item)).
feeds: Vec<(i32, usize, Option<String>)>,
hand: Option<String>,
mobs: Vec<MobSpec>,
}
@@ -108,7 +109,14 @@ fn parse(text: &str) -> Vec<Scenario> {
}
"heart" => s.hearts.push(f.iter().map(|x| (*x).to_owned()).collect()),
"hurt" => s.hurts.push((f[1].parse().unwrap(), f[2].parse().unwrap())),
"feed" => s.feeds.push((f[1].parse().unwrap(), f[2].parse().unwrap())),
"feed" => s
.feeds
.push((f[1].parse().unwrap(), f[2].parse().unwrap(), None)),
"interact" => s.feeds.push((
f[1].parse().unwrap(),
f[2].parse().unwrap(),
Some(f[3].to_owned()),
)),
"player" => {
s.player = Some([
f[1].parse().unwrap(),
@@ -1112,6 +1120,7 @@ pub fn main() -> horizon_client::Result<()> {
others_grid: $grid,
others_index: None,
daylight: (world.day_time.rem_euclid(24000)) < 12000,
monsters_burn: !(12542..23460).contains(&world.day_time.rem_euclid(24000)),
game_time: world.game_time,
day_time: world.day_time,
raining: s.rain,
@@ -1128,6 +1137,7 @@ pub fn main() -> horizon_client::Result<()> {
sea_level: -63,
items: Some(&items),
lid_openers: &lid_openers,
max_cramming: 24,
}
};
}
@@ -1207,10 +1217,22 @@ pub fn main() -> horizon_client::Result<()> {
}
events.append(&mut mobs[i].outbox);
}
for &(tick, i) in &s.feeds {
for (tick, i, item) in &s.feeds {
let (tick, i) = (*tick, *i);
if tick != t || i >= mobs.len() || mobs[i].removed {
continue;
}
// Horse.mobInteract's held-item branch (makeMad).
if let Some(item) = item {
horizon_client::mobs::equine::held_item(
&mut mobs[i],
item,
false,
item == "minecraft:golden_dandelion",
&mut events,
);
continue;
}
if let Some(Some((name, volume, pitch))) =
horizon_client::mobs::ai::feed_animal(&mut mobs[i], &mut level_random)
{
@@ -792,7 +792,7 @@ pub fn main() -> horizon_client::Result<()> {
chunk,
&mut gen_random,
&mut rules,
&mut spawn,
&mut |l, r, _| spawn(l, r).is_some(),
&mut |e| match e {
GenEvent::Column(x, z) => log.push(format!("col {x} {z}")),
GenEvent::Spawn(t, p, yaw) => spawns.push(format!(
@@ -0,0 +1,70 @@
//! A text display's text for differential tests against vanilla's
//! `TextDisplayRenderer.submitInner` (tools/test_text_display_render_parity.py):
//! decodes each case's entity data from vanilla's wire bytes, lays the text out
//! with `horizon_client::text_display` and prints the pose and every quad in
//! text units [x, y, z, ARGB], as VanillaTextDisplayRenderOracle does.
//!
//! usage: text_display_render_fixture RESOURCES INPUTS OUTPUT
use horizon_client::{
hud::{HudAtlas, HudBatch},
hud_layout::background_color,
text_display::{self, Lines, TextDisplayData},
};
use horizon_protocol::{ids, packets::play::Clientbound};
use serde_json::{Value, json};
use std::{error::Error, io::Write};
pub fn main() -> Result<(), Box<dyn Error>> {
let args: Vec<String> = crate::args().collect();
let [_, resources, inputs, output] = args.as_slice() else {
return Err("usage: text_display_render_fixture RESOURCES INPUTS OUTPUT".into());
};
let atlas = HudAtlas::build(std::path::Path::new(resources))?;
let mut out = std::io::BufWriter::new(std::fs::File::create(output)?);
for line in std::fs::read_to_string(inputs)?.lines() {
let input: Value = serde_json::from_str(line)?;
let hex = input["wire"].as_str().unwrap_or_default();
let bytes: Vec<u8> = (0..hex.len())
.step_by(2)
.map(|i| u8::from_str_radix(&hex[i..i + 2], 16))
.collect::<Result<_, _>>()?;
let Clientbound::SetEntityData(packet) =
Clientbound::decode(ids::play::clientbound::SET_ENTITY_DATA, &bytes)?
else {
return Err("not entity data".into());
};
let mut d = TextDisplayData::default();
d.apply(&packet.values);
let lines = Lines::split(&atlas.font, &d.text, d.line_width);
let mut batch = HudBatch::new([1, 1]);
let default_background = background_color(
input["chat_only"].as_bool().unwrap_or(false),
input["background_opacity"].as_f64().unwrap_or(0.0),
0.25,
);
text_display::draw(&mut batch, &atlas, &d, &lines, default_background);
let quads: Vec<Value> = batch
.vertices
.as_chunks::<6>()
.0
.iter()
.map(|q| {
let corners: Vec<Value> = [0, 1, 2, 5]
.map(|i| {
let v = q[i];
let [r, g, b, a] = v.color.to_le_bytes().map(u32::from);
let argb = (a << 24 | r << 16 | g << 8 | b) as i32;
json!([v.pos[0], v.pos[1], v.depth, argb])
})
.into();
corners.into()
})
.collect();
writeln!(
out,
"{}",
json!({"pose": lines.pose().to_cols_array(), "quads": quads})
)?;
}
Ok(())
}
@@ -7,6 +7,36 @@ pub fn main() -> Result<(), Box<dyn Error>> {
let args: Vec<String> = crate::args().collect();
let mut output = String::new();
for line in std::fs::read_to_string(&args[1])?.lines() {
if let Some(name) = line.strip_prefix("name\t") {
let components = horizon_client::item_data::command_components(&format!(
"[minecraft:custom_name={name}]"
))?;
let (_, extra) = horizon_client::item_data::decode_components(Some(&components));
let stack = ItemStack {
extra,
..ItemStack::new("minecraft:stick", 1)
};
let text = horizon_client::item_tooltip::custom_name_text(&stack).unwrap();
output.push_str("name\t");
for span in text.spans {
for c in span.text.chars() {
let style = &span.style;
write!(
output,
"{},{},{},{},{},{},{}|",
c as u32,
style.color.unwrap_or(horizon_client::text::WHITE),
style.is_bold(),
style.is_italic(),
style.underlined == Some(true),
style.strikethrough == Some(true),
style.obfuscated == Some(true)
)?;
}
}
output.push('\n');
continue;
}
let fields: Vec<&str> = line.split('\t').collect();
let hide_tooltip: bool = fields[0].parse()?;
let creative: bool = fields[1].parse()?;
@@ -0,0 +1,58 @@
//! M-11 gate: block assets loaded through the sprite disk cache are byte-identical
//! to the ones decoded from the PNGs. Deletes the cache file in ROOT, loads (decodes
//! and writes it), loads again (reads it) and compares the sprite arrays.
//! cargo run --profile fast -p horizon-client --example sprite_cache_parity -- ROOT
use horizon_client::assets::{Assets, RenderAssets};
use std::{
hash::{DefaultHasher, Hash, Hasher},
path::Path,
time::Instant,
};
fn digest(render: &RenderAssets) -> u64 {
let mut h = DefaultHasher::new();
render.pixels.hash(&mut h);
(
render.sprite_size,
render.sprite_minimum_size,
render.texture_layers,
)
.hash(&mut h);
render.sprite_layers.hash(&mut h);
render.translucent_layers.hash(&mut h);
format!("{:?}", render.sprite_mips).hash(&mut h);
for animation in &render.animations {
animation.layer.hash(&mut h);
for frame in 0..animation.frame_count() {
animation.pixels(frame).hash(&mut h);
}
for tick in 0..400 {
format!("{:?}", animation.blend(tick)).hash(&mut h);
}
}
h.finish()
}
fn main() -> horizon_client::Result<()> {
let root = std::env::args()
.nth(1)
.ok_or("usage: sprite_cache_parity ROOT")?;
let root = Path::new(&root);
let assets = Assets::load(root)?;
let _ = std::fs::remove_file(root.join("horizon-sprites.cache"));
let start = Instant::now();
let cold = digest(&assets.reload(root)?);
let cold_ms = start.elapsed().as_secs_f64() * 1000.;
let size = std::fs::metadata(root.join("horizon-sprites.cache"))?.len();
let start = Instant::now();
let warm = digest(&assets.reload(root)?);
let warm_ms = start.elapsed().as_secs_f64() * 1000.;
println!(
"cold={cold:016x} ({cold_ms:.1} ms) warm={warm:016x} ({warm_ms:.1} ms) cache_bytes={size}"
);
if cold != warm {
return Err("cached sprites differ from decoded sprites".into());
}
println!("PARITY OK");
Ok(())
}
+70 -2
View File
@@ -444,6 +444,8 @@ pub struct EntityView {
pub gamemode: Option<String>,
/// Lightning: entities it struck.
pub struck: Vec<EntityView>,
/// Lightning: blocks it set on fire (`LightningBolt.blocksSetOnFire`).
pub blocks_set_on_fire: i32,
pub fishing_open_water: Option<bool>,
pub variant: Option<String>,
/// The data components the entity reports (`Entity.get`): id and value.
@@ -538,6 +540,11 @@ pub fn item(v: &Value, it: &ItemView, w: &dyn World) -> bool {
{
return false;
}
if let Some(d) = comps["minecraft:damage"].as_u64()
&& u64::from(it.damage) != d
{
return false;
}
let preds = &v["predicates"];
if let Some(list) = preds["minecraft:enchantments"].as_array() {
for e in list {
@@ -734,7 +741,7 @@ pub fn entity(v: &Value, e: &EntityView, w: &dyn World, origin: [f64; 3]) -> boo
.is_some_and(|t| entity(&p["looking_at"], t, w, origin)))
}
"minecraft:type_specific/lightning" => {
range_i(&p["blocks_set_on_fire"], 0)
range_i(&p["blocks_set_on_fire"], i64::from(e.blocks_set_on_fire))
&& (p["entity_struck"].is_null()
|| e.struck
.iter()
@@ -818,7 +825,13 @@ pub fn damage(v: &Value, d: &DamageView, w: &dyn World, origin: [f64; 3]) -> boo
{
return false;
}
let ty = &v["type"];
damage_source(&v["type"], d, w, origin)
}
/// `DamageSourcePredicate`: the damage type's tags and the entities behind it.
pub fn damage_source(ty: &Value, d: &DamageView, w: &dyn World, origin: [f64; 3]) -> bool {
if ty.is_null() {
return true;
}
let tags_ok = ty["tags"].as_array().into_iter().flatten().all(|t| {
let tag = t["id"].as_str().unwrap_or("");
let tag = tag.strip_prefix('#').unwrap_or(tag);
@@ -864,6 +877,22 @@ pub fn item_entity(
&& item(&v["item"], stack, w)
}
/// `RecipeCraftedTrigger.TriggerInstance.matches`: each ingredient predicate
/// takes a different one of the stacks used, in order.
pub fn recipe_ingredients(v: &Value, used: &[ItemView], w: &dyn World) -> bool {
let Some(preds) = v.as_array() else {
return true;
};
let mut remaining: Vec<&ItemView> = used.iter().collect();
preds.iter().all(|p| {
let Some(i) = remaining.iter().position(|it| item(p, it, w)) else {
return false;
};
remaining.remove(i);
true
})
}
/// A `ContextAwarePredicate`: a list of loot conditions (or one condition).
pub fn conditions(v: &Value, ctx: &Entities, w: &dyn World) -> bool {
match v {
@@ -1326,8 +1355,47 @@ mod tests {
fn in_tag(&self, _: &str, tag: &str, id: &str) -> bool {
(tag == "minecraft:stone_tool_materials" && id == "minecraft:cobblestone")
|| (tag == "minecraft:sulfur_cube_archetype/explosive" && id == "minecraft:tnt")
|| (tag == "minecraft:is_projectile"
&& matches!(id, "minecraft:fireball" | "minecraft:arrow"))
}
}
/// Return to Sender's `killing_blow`: only the ghast's own fireball, as a
/// projectile, kills it for the advancement; a sword or an arrow does not.
#[test]
fn killing_blow_needs_the_fireball() {
let blow = serde_json::json!({
"direct_entity": {"minecraft:entity_type": "minecraft:fireball"},
"tags": [{"expected": true, "id": "#minecraft:is_projectile"}]
});
let entity = |kind: &str| EntityView {
kind: kind.into(),
..Default::default()
};
let player = entity("minecraft:player");
let fireball = DamageView {
kind: "minecraft:fireball".into(),
direct: Some(entity("minecraft:fireball")),
source: Some(player.clone()),
..Default::default()
};
assert!(damage_source(&blow, &fireball, &Flat, [0.0; 3]));
let sword = DamageView {
kind: "minecraft:player_attack".into(),
direct: Some(player.clone()),
source: Some(player.clone()),
is_direct: true,
..Default::default()
};
assert!(!damage_source(&blow, &sword, &Flat, [0.0; 3]));
let arrow = DamageView {
kind: "minecraft:arrow".into(),
direct: Some(entity("minecraft:arrow")),
source: Some(player),
..Default::default()
};
assert!(!damage_source(&blow, &arrow, &Flat, [0.0; 3]));
assert!(damage_source(&Value::Null, &sword, &Flat, [0.0; 3]));
}
#[test]
fn uh_oh_accepts_either_tnt_event_only_for_adult_sulfur_cubes() {
let path = Path::new(env!("CARGO_MANIFEST_DIR")).join(
+13
View File
@@ -130,6 +130,8 @@ pub enum ArrowSound {
Splash,
/// The arrow's `soundEvent` (`entity.arrow.hit`), for blocks and entities.
HitGround,
/// An arrow shot from a crossbow: `item.crossbow.hit`.
CrossbowHit,
/// `entity.generic.extinguish_fire`.
Extinguish,
/// A thrown trident's `getDefaultHitGroundSoundEvent` (`item.trident.hit_ground`).
@@ -162,6 +164,8 @@ pub struct ArrowState {
/// `AbstractArrow.firedFromWeapon`: the crossbow or bow it was shot from.
pub fired_from_weapon: Option<crate::items::ItemStack>,
pub no_physics: bool,
/// `Arrow.ID_EFFECT_COLOR` as a client has it from its server (-1: none).
pub effect_color: i32,
/// `AbstractArrow.getPierceLevel`.
pub pierce: u8,
/// `piercingIgnoreEntityIds`: the entities a piercing arrow has gone
@@ -212,6 +216,7 @@ impl ArrowState {
pickup_item: crate::items::ItemStack::new("minecraft:arrow", 1),
fired_from_weapon: None,
no_physics: false,
effect_color: -1,
pierce: 0,
pierced: Vec::new(),
fire_ticks: 0,
@@ -782,8 +787,16 @@ impl Ticker<'_, '_, '_, '_> {
/// `playSound(soundEvent, 1, 1.2 / (random * 0.2 + 0.9))`.
fn hit_sound(&mut self) {
let pitch = 1.2 / (self.st.random.next_float() * 0.2 + 0.9);
// CrossbowItem.createProjectile: its arrows hit with the crossbow's sound.
let crossbow = self
.st
.fired_from_weapon
.as_ref()
.is_some_and(|s| &*s.item == "minecraft:crossbow");
let sound = if self.trident() {
ArrowSound::TridentHitGround
} else if crossbow {
ArrowSound::CrossbowHit
} else {
ArrowSound::HitGround
};
+2 -1
View File
@@ -196,7 +196,7 @@ impl Assets {
impl RenderAssets {
pub fn load(root: &Path, assets: &Assets) -> Result<Self> {
let registry = assets.registry;
let mut sprites = crate::sprites::Sprites::default();
let mut sprites = crate::sprites::Sprites::with_disk_cache(root);
for name in MATERIALS {
sprites.insert(root, &format!("minecraft:block/{name}"))?;
}
@@ -217,6 +217,7 @@ impl RenderAssets {
for stage in 0..10 {
sprites.insert(root, &format!("minecraft:block/destroy_stage_{stage}"))?;
}
sprites.save_disk_cache();
sprites.finish();
// `finish` grows the buffer by doubling: keep only the bytes in use.
sprites.pixels.shrink_to_fit();
+8 -1
View File
@@ -187,7 +187,14 @@ pub fn list_textures(root: &Path) -> Vec<String> {
};
for e in entries.flatten() {
let p = e.path();
if p.is_dir() {
// `file_type` comes with the listing; `Path::is_dir` is a stat per
// entry, kept for links (which it follows).
let dir = match e.file_type() {
Ok(t) if t.is_symlink() => p.is_dir(),
Ok(t) => t.is_dir(),
Err(_) => p.is_dir(),
};
if dir {
stack.push(p);
} else if p.extension().is_some_and(|x| x == "png")
&& let Ok(rel) = p.strip_prefix(&base)
@@ -425,7 +425,7 @@ pub const COMMANDS: &[Cmd] = &[
&[BOX[0], BOX[1], BOX[2], BOX[3], BOX[4], BOX[5], p("block", Ty::Str, "block state")], mcp: true, local: false),
cmd!("container", World, "container X Y Z [ITEM COUNT]", "list a container's items, or add items to it", "", ["horizon-ctl container 3 64 1", "horizon-ctl container 3 64 1 diamond 3"],
&[BLOCK_XYZ[0], BLOCK_XYZ[1], BLOCK_XYZ[2], opt("item", Ty::Str, "item to add"), opt("count", Ty::Int, "how many")], mcp: true, local: false),
cmd!("render-distance", World, "render-distance N", "chunks, 2..64", "", ["horizon-ctl render-distance 8"], &[p("chunks", Ty::Int, "render distance")], mcp: false, local: false),
cmd!("render-distance", World, "render-distance N", "chunks, 2..128", "", ["horizon-ctl render-distance 8"], &[p("chunks", Ty::Int, "render distance")], mcp: false, local: false),
cmd!("wait", World, "wait TICKS", "wait until TICKS game ticks have passed (20 per second)",
"Keys and mouse buttons held with key ... down or mouse ... down stay held while it waits. Release them with key ... up, release or stop.",
["horizon-ctl wait 40", "horizon-ctl key w down && horizon-ctl wait 40 && horizon-ctl key w up"],
+59 -3
View File
@@ -20,12 +20,68 @@ pub struct SpawnEntry {
/// A `ConstantInt` count (sampling draws nothing).
pub constant: bool,
}
/// A `natural_mob_spawns` / `spawn_overrides` entry list.
pub fn parse_entries(list: &Value) -> Vec<SpawnEntry> {
list.as_array()
.into_iter()
.flatten()
.filter_map(|e| {
let count = &e["count"];
let (min, max, constant) = match count {
Value::Number(n) => {
let n = n.as_u64().unwrap_or(1) as u32;
(n, n, true)
}
c if c["type"].as_str() == Some("minecraft:constant") => {
let n = c["value"].as_u64().unwrap_or(1) as u32;
(n, n, true)
}
c => (
c["min_inclusive"].as_u64().unwrap_or(1) as u32,
c["max_inclusive"].as_u64().unwrap_or(1) as u32,
false,
),
};
Some(SpawnEntry {
entity: e["type"].as_str()?.to_owned(),
weight: e["weight"].as_u64().unwrap_or(1) as u32,
min,
max,
constant,
})
})
.collect()
}
/// `MobSpawnSettings.MobSpawnCost`.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct SpawnCost {
pub energy_budget: f64,
pub charge: f64,
}
/// The `spawn_costs` of a `natural_mob_spawns` value.
pub fn parse_costs(costs: &Value) -> HashMap<String, SpawnCost> {
costs
.as_object()
.into_iter()
.flatten()
.map(|(k, c)| {
(
k.clone(),
SpawnCost {
energy_budget: c["energy_budget"].as_f64().unwrap_or(0.0),
charge: c["charge"].as_f64().unwrap_or(0.0),
},
)
})
.collect()
}
#[derive(Clone, Debug)]
pub struct BiomeInfo {
/// `natural_mob_spawns` by category (creature, monster, ...).
pub spawns: HashMap<String, Vec<SpawnEntry>>,
/// `natural_mob_spawns` spawn costs by entity type.
pub spawn_costs: HashMap<String, crate::mobs::spawn_selection::SpawnCost>,
pub spawn_costs: HashMap<String, SpawnCost>,
/// The `gameplay/creature_world_gen_spawn_probability` attribute.
pub creature_probability: f32,
/// The `gameplay/increased_fire_burnout` attribute (jungles, swamps, peaks,
@@ -150,12 +206,12 @@ impl Biomes {
};
let categories = &natural["spawns_by_category"];
for (category, entries) in categories.as_object().into_iter().flatten() {
let list = crate::mobs::spawn_selection::parse_entries(entries);
let list = parse_entries(entries);
spawns.insert(category.clone(), list);
}
list.push(BiomeInfo {
spawns,
spawn_costs: crate::mobs::spawn_selection::parse_costs(&natural["spawn_costs"]),
spawn_costs: parse_costs(&natural["spawn_costs"]),
creature_probability:
attributes["minecraft:gameplay/creature_world_gen_spawn_probability"]
.as_f64()
+10 -9
View File
@@ -26,6 +26,7 @@
use crate::chunk::block_entity::BlockEntity;
use crate::fast_hash::{FxHashMap, FxHashSet};
use std::collections::HashMap;
use std::sync::Arc;
pub type Pos = (i32, i32, i32);
@@ -46,7 +47,7 @@ pub enum Ticking {
pub struct BlockEntities {
map: HashMap<Pos, BlockEntity>,
/// `Level.blockEntityTickers`: positions and the slot each was given.
tickers: Vec<(Pos, u32)>,
tickers: Arc<Vec<(Pos, u32)>>,
/// `LevelChunk.tickersInLevel`: the live slot of each ticking position.
live: FxHashMap<Pos, u32>,
/// Positions that may need a slot, in the order vanilla would have made
@@ -233,7 +234,7 @@ impl BlockEntities {
}
pub fn clear(&mut self) {
self.map.clear();
self.tickers.clear();
self.tickers = Arc::default();
self.live.clear();
self.added.clear();
self.pending.clear();
@@ -274,7 +275,7 @@ impl BlockEntities {
/// and freed ones leave it. `state` reads the block state at a position.
/// Returns the slots to tick, in order; check each with `is_live` before
/// ticking it, as the ticks before it can free it.
pub fn tickers(&mut self, state: impl Fn(Pos) -> u32) -> Vec<(Pos, u32)> {
pub fn tickers(&mut self, state: impl Fn(Pos) -> u32) -> Arc<Vec<(Pos, u32)>> {
for (pos, token) in std::mem::take(&mut self.added) {
if self.pending.get(&pos) != Some(&token) {
continue;
@@ -288,21 +289,21 @@ impl BlockEntities {
}
let slot = self.next();
self.live.insert(pos, slot);
self.tickers.push((pos, slot));
Arc::make_mut(&mut self.tickers).push((pos, slot));
}
if !std::mem::take(&mut self.freed) && self.out.is_empty() {
return self.tickers.clone();
return Arc::clone(&self.tickers);
}
self.out.clear();
let (live, map) = (&mut self.live, &self.map);
self.tickers.retain(|(p, slot)| {
Arc::make_mut(&mut self.tickers).retain(|(p, slot)| {
let mine = live.get(p) == Some(slot);
if mine && !map.contains_key(p) {
live.remove(p);
}
mine && map.contains_key(p)
});
self.tickers.clone()
Arc::clone(&self.tickers)
}
/// Whether a slot from `tickers` still ticks.
pub fn is_live(&self, (pos, slot): (Pos, u32)) -> bool {
@@ -329,8 +330,8 @@ mod tests {
}
fn order(b: &mut BlockEntities, states: &HashMap<Pos, u32>) -> Vec<Pos> {
b.tickers(|p| states.get(&p).copied().unwrap_or(0))
.into_iter()
.map(|(p, _)| p)
.iter()
.map(|(p, _)| *p)
.collect()
}
@@ -435,7 +435,8 @@ pub struct BellTick {
pub ended: Option<(Vec<([f64; 3], i32)>, Vec<u64>)>,
}
fn closer_to_center_than(pos: [i32; 3], p: [f64; 3], distance: f64) -> bool {
/// `Vec3i.closerToCenterThan`: `p` is within `distance` of the block's centre.
pub fn closer_to_center_than(pos: [i32; 3], p: [f64; 3], distance: f64) -> bool {
let dx = f64::from(pos[0]) + 0.5 - p[0];
let dy = f64::from(pos[1]) + 0.5 - p[1];
let dz = f64::from(pos[2]) + 0.5 - p[2];
+16
View File
@@ -30,6 +30,22 @@ pub fn potion_name_key(item: &str, potion: Option<&str>) -> String {
let suffix = potion.and_then(self::potion).map_or("empty", |p| p.name);
format!("item.minecraft.{prefix}.effect.{suffix}")
}
/// `PotionContents.getAllEffects`: the potion's effects, then the stack's
/// `custom_effects` (id, duration, amplifier). Unknown effect ids are dropped.
pub fn contents_effects(stack: &ItemStack) -> Vec<(&'static str, i32, i32)> {
let potion = stack.potion().and_then(self::potion);
let custom = stack.custom_potion_effects();
potion
.map_or(&[][..], |d| d.effects)
.iter()
.copied()
.chain(
custom
.iter()
.filter_map(|(id, duration, amp)| Some((effect(id)?.id, *duration, *amp))),
)
.collect()
}
/// Roman amplifier suffix (`potion.potency.N`) and duration lines of a potion.
pub fn tooltip(
potion: Option<&str>,
@@ -74,9 +74,28 @@ pub enum BlockEntity {
/// `DaylightDetectorBlockEntity`: no data, only the ticker
/// (`DaylightDetectorBlock.tickEntity`).
DaylightDetector,
/// Suspicious sand and gravel: saved loot and the transient brushed face.
Brushable(Box<Brushable>),
/// A shelf holds three displayed item stacks.
Shelf(Box<Shelf>),
/// `ChiseledBookShelfBlockEntity`: a `Container` of six one-book slots.
ChiseledBookshelf(Box<ChiseledBookshelf>),
}
/// `BrushableBlockEntity`: the loot table is resolved on the first brush.
#[derive(Clone, Debug, Default)]
pub struct Brushable {
pub loot_table: Option<horizon_world::nbt::Tag>,
pub loot_seed: Option<horizon_world::nbt::Tag>,
pub item: Option<ItemStack>,
/// Direction's legacy network id; not saved to disk by vanilla.
pub hit_direction: Option<i32>,
}
/// `ShelfBlockEntity`'s displayed slots and alignment flag.
#[derive(Clone, Debug, Default)]
pub struct Shelf {
pub items: [Option<ItemStack>; 3],
pub align_to_bottom: bool,
}
/// `ChiseledBookShelfBlockEntity`: six slots and `lastInteractedSlot`.
#[derive(Clone, Debug, PartialEq)]
pub struct ChiseledBookshelf {
+119 -25
View File
@@ -15,33 +15,11 @@ pub struct Confirm {
pub buttons: [(f32, f32); 2],
}
impl Confirm {
pub fn new(font: &Font, (w, h): (f32, f32), title: &Text, message: &Text) -> Self {
let message: Vec<Text> = font
.split_lines(message, w - 50.0)
.into_iter()
.map(|(line, _)| line)
.take(15)
.collect();
let tw = font.width_text(title);
let mw = message
.iter()
.map(|l| font.width_text(l))
.fold(0.0, f32::max);
let mh = 9.0 * message.len() as f32;
let col = tw.max(mw).max(304.0);
let total = 9.0 + 8.0 + mh + 8.0 + 36.0;
let (lx, ly) = (((w - col) / 2.0).trunc(), ((h - total) / 2.0).trunc());
let cell = |cw: f32| lx + ((col - cw) / 2.0).trunc();
let mid = cell(mw) + (mw / 2.0).trunc();
let mut lines = vec![(title.clone(), cell(tw), ly)];
for (i, line) in message.into_iter().enumerate() {
let x = mid - (font.width_text(&line) / 2.0).trunc();
lines.push((line, x, ly + 17.0 + 9.0 * i as f32));
}
let (bx, by) = (cell(304.0), ly + 9.0 + 8.0 + mh + 8.0 + 16.0);
pub fn new(font: &Font, size: (f32, f32), title: &Text, message: &Text) -> Self {
let Choice { lines, buttons, .. } = Choice::new(font, size, title, message, 2, 150.0);
Self {
lines,
buttons: [(bx, by), (bx + 154.0, by)],
buttons: [buttons[0], buttons[1]],
}
}
/// The answer a click at `cursor` gives: true for yes, false for no.
@@ -84,3 +62,119 @@ impl Confirm {
}
}
}
/// `ConfirmScreen` with a row of `count` buttons `width` wide, 4 apart: `ConfirmLinkScreen`'s
/// yes, copy and no are three of 100. `Confirm` is its two of 150.
pub struct Choice {
/// Each line and where it is drawn.
pub lines: Vec<(Text, f32, f32)>,
/// The buttons' corners, left to right.
pub buttons: Vec<(f32, f32)>,
pub width: f32,
}
impl Choice {
pub fn new(
font: &Font,
(w, h): (f32, f32),
title: &Text,
message: &Text,
count: usize,
width: f32,
) -> Self {
let message: Vec<Text> = font
.split_lines(message, w - 50.0)
.into_iter()
.map(|(line, _)| line)
.take(15)
.collect();
let tw = font.width_text(title);
let mw = message
.iter()
.map(|l| font.width_text(l))
.fold(0.0, f32::max);
let mh = 9.0 * message.len() as f32;
let row = width * count as f32 + 4.0 * count.saturating_sub(1) as f32;
let col = tw.max(mw).max(row);
let total = 9.0 + 8.0 + mh + 8.0 + 36.0;
let (lx, ly) = (((w - col) / 2.0).trunc(), ((h - total) / 2.0).trunc());
let cell = |cw: f32| lx + ((col - cw) / 2.0).trunc();
let mid = cell(mw) + (mw / 2.0).trunc();
let mut lines = vec![(title.clone(), cell(tw), ly)];
for (i, line) in message.into_iter().enumerate() {
let x = mid - (font.width_text(&line) / 2.0).trunc();
lines.push((line, x, ly + 17.0 + 9.0 * i as f32));
}
let (bx, by) = (cell(row), ly + 9.0 + 8.0 + mh + 8.0 + 16.0);
Self {
lines,
buttons: (0..count)
.map(|i| (bx + (width + 4.0) * i as f32, by))
.collect(),
width,
}
}
/// The button a click at `cursor` hits, counted from the left.
pub fn pressed_at(&self, cursor: (f32, f32)) -> Option<usize> {
self.buttons
.iter()
.position(|&(x, y)| ui::hovered(cursor, x, y, self.width, 20.0))
}
/// The lines and the buttons, over whatever is behind.
pub fn draw(
&self,
batch: &mut HudBatch,
atlas: &HudAtlas,
cursor: (f32, f32),
labels: &[Text],
) {
for (line, x, y) in &self.lines {
batch.text_styled(atlas, line, *x, *y, u32::MAX, true);
}
for (&(bx, by), label) in self.buttons.iter().zip(labels) {
let hover = ui::hovered(cursor, bx, by, self.width, 20.0);
ui::button(batch, atlas, bx, by, self.width, 20.0, label, true, hover);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::hud::Rect;
use std::{collections::HashMap, path::Path};
fn font() -> Font {
// The missing-glyph metrics let these tests run without game assets.
let rect = Rect {
u0: 0.0,
v0: 0.0,
u1: 1.0,
v1: 1.0,
w: 1,
h: 1,
};
Font::load(
Path::new("/nonexistent-horizon-confirm-test-resources"),
&HashMap::new(),
&[],
(1, 1),
(0, 0),
rect,
)
}
#[test]
fn three_buttons_sit_four_apart_in_the_middle() {
let (title, message) = (Text::literal("Open?"), Text::literal("https://x"));
let c = Choice::new(&font(), (480.0, 270.0), &title, &message, 3, 100.0);
let xs: Vec<f32> = c.buttons.iter().map(|b| b.0).collect();
// 3 x 100 + 2 x 4 = 308 wide, centred on 480.
assert_eq!(xs, vec![86.0, 190.0, 294.0]);
assert_eq!(c.pressed_at((191.0, c.buttons[1].1 + 1.0)), Some(1));
assert_eq!(c.pressed_at((188.0, c.buttons[1].1 + 1.0)), None);
// Confirm keeps its two 150-wide buttons where they were.
let two = Confirm::new(&font(), (480.0, 270.0), &title, &message);
assert_eq!(two.buttons[0].0, 88.0);
assert_eq!(two.buttons[1].0, 242.0);
}
}
+24 -5
View File
@@ -2,8 +2,8 @@
//! kinds, ticking as `AbstractFurnaceBlockEntity.serverTick` with fuel times
//! from the data-driven `context_int_provider`/`context_float_provider` files.
pub use crate::chunk::block_entity::{
Banner, Beacon, BlockEntity, Campfire, ChiseledBookshelf, Crafter, CreakingHeart, EndGateway,
Furnace, FurnaceKind, Jukebox, Lectern, Skull, SkullSkin,
Banner, Beacon, BlockEntity, Brushable, Campfire, ChiseledBookshelf, Crafter, CreakingHeart,
EndGateway, Furnace, FurnaceKind, Jukebox, Lectern, Shelf, Skull, SkullSkin,
};
use crate::{
crafting::Recipes,
@@ -194,6 +194,14 @@ impl BlockEntity {
recipes_used: HashMap::new(),
}));
}
if matches!(
name,
"minecraft:suspicious_sand"
| "minecraft:suspicious_gravel"
| "minecraft:brushable_block"
) {
return Some(Self::Brushable(Box::default()));
}
if name == "minecraft:creaking_heart" {
return Some(Self::CreakingHeart(Box::default()));
}
@@ -256,6 +264,9 @@ impl BlockEntity {
if name == "minecraft:campfire" || name == "minecraft:soul_campfire" {
return Some(Self::Campfire(Box::default()));
}
if name == "minecraft:shelf" || name.ends_with("_shelf") {
return Some(Self::Shelf(Box::default()));
}
if name == "minecraft:chiseled_bookshelf" {
return Some(Self::ChiseledBookshelf(Box::default()));
}
@@ -298,8 +309,10 @@ impl BlockEntity {
Self::Skull(_) => "minecraft:skull",
Self::EndGateway(_) => "minecraft:end_gateway",
Self::DaylightDetector => "minecraft:daylight_detector",
Self::Brushable(_) => "minecraft:brushable_block",
Self::Campfire(_) => "minecraft:campfire",
Self::ChiseledBookshelf(_) => "minecraft:chiseled_bookshelf",
Self::Shelf(_) => "minecraft:shelf",
}
}
pub fn contents(&self) -> Vec<ItemStack> {
@@ -318,13 +331,15 @@ impl BlockEntity {
| Self::CommandBlock(_)
| Self::EndGateway(_)
| Self::Skull(_)
| Self::DaylightDetector => Vec::new(),
| Self::DaylightDetector
| Self::Brushable(_) => Vec::new(),
Self::Lectern(l) => l.book.iter().cloned().collect(),
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(),
Self::ChiseledBookshelf(b) => b.items.iter().flatten().cloned().collect(),
Self::Shelf(b) => b.items.iter().flatten().cloned().collect(),
}
}
pub fn slots(&self) -> &[Option<ItemStack>] {
@@ -340,9 +355,11 @@ impl BlockEntity {
| Self::Banner(_)
| Self::EndGateway(_)
| Self::Skull(_)
| Self::DaylightDetector => &[],
| Self::DaylightDetector
| Self::Brushable(_) => &[],
Self::Crafter(c) => &c.items,
Self::ChiseledBookshelf(b) => &b.items,
Self::Shelf(b) => &b.items,
Self::CreakingHeart(_)
| Self::Spawner(_)
| Self::TrialSpawner(_)
@@ -366,9 +383,11 @@ impl BlockEntity {
| Self::Banner(_)
| Self::EndGateway(_)
| Self::Skull(_)
| Self::DaylightDetector => &mut [],
| Self::DaylightDetector
| Self::Brushable(_) => &mut [],
Self::Crafter(c) => &mut c.items,
Self::ChiseledBookshelf(b) => &mut b.items,
Self::Shelf(b) => &mut b.items,
Self::CreakingHeart(_)
| Self::Spawner(_)
| Self::TrialSpawner(_)
+19
View File
@@ -308,6 +308,25 @@ fn deliver(home: &Path, report: Report) -> Option<PathBuf> {
}
}
/// The report for a hung server tick, written by the watchdog thread
/// (`ServerWatchdog`): the stuck thread's stack is not available to Rust, so
/// the report carries none.
pub fn report_watchdog(home: &Path, side: &'static str, message: String) -> Option<PathBuf> {
deliver(
home,
Report {
time: Local::now(),
description: "Watching Server".into(),
error: format!("{message} (Watching Server)"),
stack: String::new(),
thread: thread_name(),
side,
details: system_details(),
log: crate::logging::recent_lines(),
},
)
}
fn panic_message(info: &std::panic::PanicHookInfo) -> String {
let payload = info.payload();
let text = payload
+126 -25
View File
@@ -37,9 +37,11 @@ pub enum Kind {
columns: i32,
button_width: i32,
},
/// `DialogListDialog`: its dialogs as sent (ids, a tag or inline dialogs).
/// `DialogListDialog`: its dialogs as sent (ids, a tag or inline dialogs),
/// and one button per dialog they resolve to (`set_list`).
DialogList {
dialogs: Tag,
entries: Vec<Button>,
exit: Option<Button>,
columns: i32,
button_width: i32,
@@ -325,29 +327,36 @@ impl Control {
fn parse(t: &Tag) -> Option<Self> {
let label = || t.get("label").map(text);
Some(match path(&string(t.get("type"))?) {
"text" => Self::Text {
width: int(t, "width", 200),
label: label()?,
label_visible: boolean(t, "label_visible", true),
initial: string(t.get("initial")).unwrap_or_default(),
max_length: int(t, "max_length", 32),
multiline: t.get("multiline").map(|m| {
let get = |key| number(m.get(key)).map(|v| v as i32);
(get("max_lines"), get("height"))
}),
},
"text" => {
let initial = string(t.get("initial")).unwrap_or_default();
let max_length = int(t, "max_length", 32);
// TextInput: the default text may not be longer than the length.
if initial.encode_utf16().count() > max_length.max(0) as usize {
return None;
}
Self::Text {
width: int(t, "width", 200),
label: label()?,
label_visible: boolean(t, "label_visible", true),
initial,
max_length,
multiline: t.get("multiline").map(|m| {
let get = |key| number(m.get(key)).map(|v| v as i32);
(get("max_lines"), get("height"))
}),
}
}
"boolean" => Self::Boolean {
label: label()?,
initial: boolean(t, "initial", false),
on_true: string(t.get("on_true")).unwrap_or_else(|| "true".into()),
on_false: string(t.get("on_false")).unwrap_or_else(|| "false".into()),
},
"single_option" => Self::SingleOption {
width: int(t, "width", 200),
"single_option" => {
// SingleOptionInput.Entry.CODEC: a full entry or just its id.
options: list(t.get("options"))
let options: Vec<_> = list(t.get("options"))
.iter()
.filter_map(|o| match o {
.map(|o| match o {
Tag::String(id) => Some((id.to_string_lossy(), None, false)),
o => Some((
string(o.get("id"))?,
@@ -355,10 +364,18 @@ impl Control {
boolean(o, "initial", false),
)),
})
.collect(),
label: label()?,
label_visible: boolean(t, "label_visible", true),
},
.collect::<Option<_>>()?;
// A non-empty list with at most one initial entry.
if options.is_empty() || options.iter().filter(|o| o.2).count() > 1 {
return None;
}
Self::SingleOption {
width: int(t, "width", 200),
options,
label: label()?,
label_visible: boolean(t, "label_visible", true),
}
}
"number_range" => {
let float = |key| number(t.get(key)).map(|v| v as f32);
Self::NumberRange {
@@ -395,6 +412,44 @@ impl Control {
)),
}
}
/// The text on a cycle button or slider holding `value`
/// (`CycleButton.createFullName`, `NumberRangeInput.computeLabel`).
pub fn message(&self, value: &InputValue) -> Option<Text> {
match (self, value) {
(
Self::SingleOption {
options,
label,
label_visible,
..
},
InputValue::Option(i),
) => {
let (id, display, _) = options.get(*i)?;
let shown = display.clone().unwrap_or_else(|| Text::literal(id));
Some(if *label_visible {
Text::translate("options.generic_value", &[label.clone(), shown])
} else {
shown
})
}
(
Self::NumberRange {
label,
label_format,
..
},
InputValue::Slider(_),
) => {
let shown = number_string(self.scaled(value)?);
Some(Text::translate(
label_format,
&[label.clone(), Text::literal(shown)],
))
}
_ => None,
}
}
/// `ValueGetter.asTemplateSubstitution`.
pub fn substitution(&self, value: &InputValue) -> String {
match (self, value) {
@@ -536,6 +591,7 @@ impl Dialog {
},
"dialog_list" => Kind::DialogList {
dialogs: t.get("dialogs")?.clone(),
entries: Vec::new(),
exit: button("exit_action"),
columns: int(t, "columns", 2),
button_width: int(t, "button_width", 150),
@@ -543,20 +599,21 @@ impl Dialog {
_ => return None,
};
// DialogBody.COMPACT_LIST_CODEC: one body, or a list of them.
// A body or input it cannot read fails the whole dialog.
let body = match t.get("body") {
Some(Tag::List(items)) => items.iter().filter_map(Body::parse).collect(),
Some(one) => Body::parse(one).into_iter().collect(),
Some(Tag::List(items)) => items.iter().map(Body::parse).collect::<Option<_>>()?,
Some(one) => vec![Body::parse(one)?],
None => Vec::new(),
};
let inputs = list(t.get("inputs"))
.iter()
.filter_map(|i| {
.map(|i| {
Some(Input {
key: string(i.get("key"))?,
control: Control::parse(i)?,
})
})
.collect();
.collect::<Option<_>>()?;
Some(Self {
kind,
title: text(t.get("title")?),
@@ -588,7 +645,51 @@ impl Dialog {
Kind::Notice(b) => vec![b],
Kind::Confirmation { yes, no } => vec![yes, no],
Kind::MultiAction { actions, exit, .. } => actions.iter().chain(exit).collect(),
Kind::ServerLinks { exit, .. } | Kind::DialogList { exit, .. } => exit.iter().collect(),
Kind::ServerLinks { exit, .. } => exit.iter().collect(),
Kind::DialogList { entries, exit, .. } => entries.iter().chain(exit).collect(),
}
}
/// `DialogListDialogScreen.createListActions`: a button for each of `dialogs`
/// (what the list names), labelled by its external title, opening it.
pub fn set_list(&mut self, dialogs: &[Tag]) {
let Kind::DialogList {
entries,
button_width,
..
} = &mut self.kind
else {
return;
};
*entries = dialogs
.iter()
.filter_map(|tag| {
let sub = Dialog::parse(tag)?;
Some(Button {
label: sub.external_title.unwrap_or(sub.title),
tooltip: None,
width: *button_width,
action: Some(Action::Static(Click::ShowDialog(tag.clone()))),
})
})
.collect();
}
/// The dialogs a `dialog_list`'s `dialogs` names: the entries of a `#tag`
/// (by `tag`), registry ids (by `registry`) and dialogs written inline.
pub fn list_entries(
dialogs: &Tag,
tag: &dyn Fn(&str) -> Vec<Tag>,
registry: &dyn Fn(&str) -> Option<Tag>,
) -> Vec<Tag> {
let one = |t: &Tag| match t {
Tag::String(id) => registry(&id.to_string_lossy()),
inline => Some(inline.clone()),
};
match dialogs {
Tag::String(id) if id.to_string_lossy().starts_with('#') => {
tag(&id.to_string_lossy()[1..])
}
Tag::List(items) => items.iter().filter_map(one).collect(),
single => one(single).into_iter().collect(),
}
}
/// What `status` reports of it.
+125 -55
View File
@@ -31,6 +31,44 @@ pub enum Part {
Check { input: usize, rect: [f32; 4] },
/// A button of `Dialog::buttons`.
Button { index: usize, rect: [f32; 4] },
/// A single option input's cycle button.
Cycle { input: usize, rect: [f32; 4] },
/// A number range input's slider.
Slider { input: usize, rect: [f32; 4] },
/// An item body's widget; `body` indexes `Dialog::body`.
Item { body: usize, rect: [f32; 4] },
}
impl Part {
/// The part moved by `(dx, dy)`.
fn moved(self, dx: f32, dy: f32) -> Self {
match self {
Self::Line(t, x, y) => Self::Line(t, x + dx, y + dy),
Self::Field { input, rect } => Self::Field {
input,
rect: shift(rect, dx, dy),
},
Self::Check { input, rect } => Self::Check {
input,
rect: shift(rect, dx, dy),
},
Self::Button { index, rect } => Self::Button {
index,
rect: shift(rect, dx, dy),
},
Self::Cycle { input, rect } => Self::Cycle {
input,
rect: shift(rect, dx, dy),
},
Self::Slider { input, rect } => Self::Slider {
input,
rect: shift(rect, dx, dy),
},
Self::Item { body, rect } => Self::Item {
body,
rect: shift(rect, dx, dy),
},
}
}
}
#[derive(Clone, Debug)]
@@ -55,21 +93,7 @@ struct Element {
impl Element {
/// Its parts moved to `(x, y)`.
fn at(self, x: f32, y: f32) -> impl Iterator<Item = Part> {
self.parts.into_iter().map(move |p| match p {
Part::Line(t, px, py) => Part::Line(t, px + x, py + y),
Part::Field { input, rect } => Part::Field {
input,
rect: shift(rect, x, y),
},
Part::Check { input, rect } => Part::Check {
input,
rect: shift(rect, x, y),
},
Part::Button { index, rect } => Part::Button {
index,
rect: shift(rect, x, y),
},
})
self.parts.into_iter().map(move |p| p.moved(x, y))
}
}
fn shift([x, y, w, h]: [f32; 4], dx: f32, dy: f32) -> [f32; 4] {
@@ -100,9 +124,43 @@ fn message(font: &Font, contents: &Text, width: f32) -> Element {
}
}
/// `InputControlHandlers`: the control for input `i`, if Horizon has it.
fn input(font: &Font, i: usize, control: &Control) -> Option<Element> {
Some(match control {
/// `DialogBodyHandlers.ItemHandler`: the item widget, with its description 2
/// to the right of it, both centred on each other vertically.
fn item(
font: &Font,
body: usize,
description: Option<&(Text, i32)>,
(w, h): (f32, f32),
) -> Element {
let widget = |rect_h: f32, y: f32| Part::Item {
body,
rect: [0.0, y, w, rect_h],
};
let Some((contents, width)) = description else {
return Element {
w,
h,
parts: vec![widget(h, 0.0)],
};
};
let text = message(font, contents, *width as f32);
let height = h.max(text.h);
let mut parts = vec![widget(h, centre(height, h))];
parts.extend(
text.parts
.into_iter()
.map(|p| p.moved(w + 2.0, centre(height, text.h))),
);
Element {
w: w + 2.0 + text.w,
h: height,
parts,
}
}
/// `InputControlHandlers`: the control for input `i`.
fn input(font: &Font, i: usize, control: &Control) -> Element {
match control {
Control::Text {
width,
label,
@@ -111,14 +169,14 @@ fn input(font: &Font, i: usize, control: &Control) -> Option<Element> {
} => {
let w = *width as f32;
if !label_visible {
return Some(Element {
return Element {
w,
h: 20.0,
parts: vec![Part::Field {
input: i,
rect: [0.0, 0.0, w, 20.0],
}],
});
};
}
// CommonLayouts.labeledElement: the label, 4 below it the box,
// both on the left.
@@ -145,8 +203,25 @@ fn input(font: &Font, i: usize, control: &Control) -> Option<Element> {
}],
}
}
Control::SingleOption { .. } | Control::NumberRange { .. } => return None,
})
// CycleButton and AbstractSliderButton: one 20 high button whose
// message holds the label.
Control::SingleOption { width, .. } => Element {
w: *width as f32,
h: 20.0,
parts: vec![Part::Cycle {
input: i,
rect: [0.0, 0.0, *width as f32, 20.0],
}],
},
Control::NumberRange { width, .. } => Element {
w: *width as f32,
h: 20.0,
parts: vec![Part::Slider {
input: i,
rect: [0.0, 0.0, *width as f32, 20.0],
}],
},
}
}
/// `DialogScreen.packControlsIntoColumns`: full rows in a grid 2 apart, what
@@ -210,9 +285,20 @@ impl Layout {
let mut elements: Vec<Element> = dialog
.body
.iter()
.filter_map(|b| match b {
Body::Message { contents, width } => Some(message(font, contents, *width as f32)),
Body::Item { .. } => None,
.enumerate()
.map(|(i, b)| match b {
Body::Message { contents, width } => message(font, contents, *width as f32),
Body::Item {
description,
width,
height,
..
} => item(
font,
i,
description.as_ref(),
(*width as f32, *height as f32),
),
})
.collect();
elements.extend(
@@ -220,26 +306,24 @@ impl Layout {
.inputs
.iter()
.enumerate()
.filter_map(|(i, input_)| input(font, i, &input_.control)),
.map(|(i, input_)| input(font, i, &input_.control)),
);
let buttons = dialog.buttons();
let (listed, footer_buttons, footer_h) = match &dialog.kind {
Kind::MultiAction {
actions,
exit,
columns: n,
exit, columns: n, ..
}
| Kind::DialogList {
exit, columns: n, ..
} => {
let widths: Vec<f32> = actions.iter().map(|b| b.width as f32).collect();
let listed = buttons.len() - usize::from(exit.is_some());
let widths: Vec<f32> = buttons[..listed].iter().map(|b| b.width as f32).collect();
elements.push(columns(&widths, *n as usize, 0));
// ButtonListDialogScreen: the exit button in the footer, else a 5 high one.
let footer = exit.as_ref().map(|_| actions.len()).into_iter().collect();
(
actions.len(),
footer,
if exit.is_some() { BAR } else { 5.0 },
)
let footer = exit.as_ref().map(|_| listed).into_iter().collect();
(listed, footer, if exit.is_some() { BAR } else { 5.0 })
}
Kind::ServerLinks { exit, .. } | Kind::DialogList { exit, .. } => {
Kind::ServerLinks { exit, .. } => {
let footer = exit.as_ref().map(|_| 0).into_iter().collect();
(0, footer, if exit.is_some() { BAR } else { 5.0 })
}
@@ -303,24 +387,10 @@ impl Layout {
/// whether it is in the body (clipped to its area).
pub fn placed(&self, scroll: f32) -> impl Iterator<Item = (Part, bool)> + '_ {
let dy = self.area[1] - scroll;
let body = self.body.iter().map(move |p| {
let p = match p.clone() {
Part::Line(t, x, y) => Part::Line(t, x, y + dy),
Part::Field { input, rect } => Part::Field {
input,
rect: shift(rect, 0.0, dy),
},
Part::Check { input, rect } => Part::Check {
input,
rect: shift(rect, 0.0, dy),
},
Part::Button { index, rect } => Part::Button {
index,
rect: shift(rect, 0.0, dy),
},
};
(p, true)
});
let body = self
.body
.iter()
.map(move |p| (p.clone().moved(0.0, dy), true));
body.chain(self.footer.iter().map(|p| (p.clone(), false)))
}
}
+29 -10
View File
@@ -148,6 +148,15 @@ pub fn instant(id: &str, amp: i32, scale: f64) -> Option<EffectAction> {
_ => None,
}
}
/// `MobEffectInstance.withScaledDuration`: max(floor(d * scale), 1), leaving
/// an infinite (-1) or zero duration alone.
pub fn scaled_duration(duration: i32, scale: f32) -> i32 {
if duration == -1 || duration == 0 {
duration
} else {
((duration as f32 * scale).floor() as i32).max(1)
}
}
/// What an omen's `applyEffectTick` decided.
#[derive(Clone, Debug, PartialEq)]
pub enum OmenTick {
@@ -308,19 +317,29 @@ impl PlayerEffects {
pub fn potion_effects(
potion: Option<&str>,
scale: f32,
) -> (Vec<EffectInstance>, Vec<EffectAction>) {
let effects = potion
.and_then(potions::potion)
.map_or(&[][..], |d| d.effects);
Self::contents_effects(effects, scale)
}
/// `PotionContents.forEachEffect`'s effects, as `potion_effects`.
pub fn contents_effects(
effects: &[(&'static str, i32, i32)],
scale: f32,
) -> (Vec<EffectInstance>, Vec<EffectAction>) {
let mut lasting = Vec::new();
let mut now = Vec::new();
if let Some(def) = potion.and_then(potions::potion) {
for (id, duration, amp) in def.effects {
let id = potions::effect(id).map_or("speed", |e| e.id);
if potions::effect(id).is_some_and(|e| e.instant) {
now.extend(instant(id, *amp, 1.0));
} else {
// withScaledDuration: max(floor(d * scale), 1).
let d = ((*duration as f32) * scale).floor() as i32;
lasting.push(EffectInstance::new(id, d.max(1), *amp));
}
for (id, duration, amp) in effects {
let id = potions::effect(id).map_or("speed", |e| e.id);
if potions::effect(id).is_some_and(|e| e.instant) {
now.extend(instant(id, *amp, 1.0));
} else {
lasting.push(EffectInstance::new(
id,
scaled_duration(*duration, scale),
*amp,
));
}
}
(lasting, now)
+159
View File
@@ -0,0 +1,159 @@
//! End gateway travel for the entities that step into one:
//! `EndGatewayBlock.entityInside` (the gateway takes the entity in and starts
//! its 40 tick cooldown), `Entity.setAsInsidePortal` and `Entity.handlePortal`
//! (the entity goes through on the next tick), and
//! `TheEndGatewayBlockEntity.getPortalPosition` (where it lands).
use crate::nether_portal::{ENTITY_DELAY, PortalState};
use horizon_core::{geometry::Vec3, position::BlockPos};
/// The End gateway travel of one entity.
#[derive(Clone, Copy, Debug, Default)]
pub struct Traveller {
/// The entity's portal process and cooldown (`Entity.portalProcess`).
state: PortalState,
}
impl Traveller {
/// `Entity.setAsInsidePortal`: the entity is in the gateway and goes
/// through it on the next tick.
pub fn enter(&mut self, gateway: BlockPos) {
self.state.set_inside(gateway, ENTITY_DELAY);
}
/// `Entity.handlePortal`: the entity goes through the gateway it entered
/// (the End gateway has no transition time), or stays where it is.
pub fn travel(&mut self, allowed: bool) -> Option<BlockPos> {
self.state.handle(allowed, 0, ENTITY_DELAY)
}
/// Whether the entity's travel is over: it has no portal process left and
/// its portal cooldown has run out.
pub fn is_done(&self) -> bool {
self.state.cooldown == 0 && self.state.process.is_none()
}
/// The entity's portal cooldown, the ticks it stays out of a portal.
pub fn cooldown(&self) -> i32 {
self.state.cooldown
}
}
/// The End gateway block an entity's box swept through
/// (`Entity.checkInsideBlocks`, which calls `EndGatewayBlock.entityInside`).
pub fn swept_gateway(
from: Vec3,
to: Vec3,
width: f32,
height: f32,
mut is_gateway: impl FnMut(BlockPos) -> bool,
) -> Option<BlockPos> {
let half = f64::from(width) / 2.0;
let bounds = horizon_core::geometry::Aabb::new(
Vec3::new(to.x - half, to.y, to.z - half),
Vec3::new(to.x + half, to.y + f64::from(height), to.z + half),
)
.deflate(Vec3::new(1.0e-5, 1.0e-5, 1.0e-5));
let mut found = None;
let _ =
horizon_core::geometry::sweep_intersected_blocks(from, to, bounds, 1_000_000, |p, _| {
if is_gateway(p) {
found = Some(p);
return false;
}
true
});
found
}
/// The bottom centre of a block (`Vec3.atBottomCenterOf`): where a traveller
/// lands at a gateway's exit.
pub fn bottom_center(p: [i32; 3]) -> Vec3 {
Vec3::new(
f64::from(p[0]) + 0.5,
f64::from(p[1]),
f64::from(p[2]) + 0.5,
)
}
#[cfg(test)]
mod tests {
use super::*;
/// The gateway of the issue: a block at [702, 100, 0] in the End.
const GATEWAY: [i32; 3] = [702, 100, 0];
fn at(x: f64, y: f64, z: f64) -> Vec3 {
Vec3::new(x, y, z)
}
fn is_gateway(p: BlockPos) -> bool {
[p.x, p.y, p.z] == GATEWAY
}
#[test]
fn an_item_and_a_mob_in_a_gateway_are_found_in_it() {
// A dropped item, 0.25 wide, standing in the gateway block.
let item = at(702.5, 100.5, 0.5);
assert_eq!(
swept_gateway(item, item, 0.25, 0.25, is_gateway),
Some(BlockPos::new(702, 100, 0))
);
// A cow, 0.9 wide and 1.4 tall, in the same block.
let cow = at(702.5, 100.0, 0.5);
assert_eq!(
swept_gateway(cow, cow, 0.9, 1.4, is_gateway),
Some(BlockPos::new(702, 100, 0))
);
}
#[test]
fn an_entity_out_of_the_gateway_is_not_in_it() {
// Two blocks above the gateway: nothing to travel through.
let above = at(702.5, 102.5, 0.5);
assert_eq!(swept_gateway(above, above, 0.25, 0.25, is_gateway), None);
// Beside the gateway, outside its block.
let beside = at(704.5, 100.5, 0.5);
assert_eq!(swept_gateway(beside, beside, 0.9, 1.4, is_gateway), None);
}
#[test]
fn an_entity_falling_through_the_gateway_goes_through_it() {
// A cow falling past the gateway block passes through it
// (Entity.checkInsideBlocks visits the blocks along the way).
let from = at(702.5, 103.0, 0.5);
let to = at(702.5, 99.0, 0.5);
assert_eq!(
swept_gateway(from, to, 0.9, 1.4, is_gateway),
Some(BlockPos::new(702, 100, 0))
);
}
#[test]
fn an_entity_goes_through_the_gateway_on_the_tick_after_it_entered() {
let gateway = BlockPos::new(702, 100, 0);
let mut traveller = Traveller::default();
// Nothing happens while the entity is outside the gateway.
assert_eq!(traveller.travel(true), None);
// It enters the gateway, and goes through on the next tick.
traveller.enter(gateway);
assert_eq!(traveller.travel(true), Some(gateway));
// Its portal cooldown keeps it from going back at once.
assert_eq!(traveller.cooldown(), ENTITY_DELAY);
assert!(!traveller.is_done());
}
#[test]
fn an_entity_that_may_not_use_portals_stays_in_the_gateway() {
let gateway = BlockPos::new(702, 100, 0);
let mut traveller = Traveller::default();
traveller.enter(gateway);
// A mob that cannot use a portal does not go through.
assert_eq!(traveller.travel(false), None);
}
#[test]
fn a_traveller_lands_at_the_bottom_centre_of_the_exit() {
// TheEndGatewayBlockEntity.getPortalPosition: the exit's bottom
// centre, the same spot the player arrives at.
assert_eq!(bottom_center([8, 100, 0]), at(8.5, 100.0, 0.5));
}
}
+23 -1
View File
@@ -56,6 +56,9 @@ struct Out {
@location(11) glint_uv: vec2<f32>,
// 1 drops the quad's back-facing pixels (Vertex::normal w = 1), like a culling render type.
@location(12) @interpolate(flat) cull: u32,
// HDR tier of a light-giving particle (mesher::hdr_tier), from the overlay's
// high bits (particles.rs): fs_hdr_mark marks the frame with it.
@location(13) @interpolate(flat) tier: u32,
}
// Shipped light.glsl; normals are already transformed and RGBA8_SNORM packed.
fn entity_light(normal: vec3<f32>, nether: bool) -> f32 {
@@ -125,8 +128,9 @@ fn entity_vertex(position: vec3<f32>, uv: vec2<f32>,
@vertex fn vs_entity(@location(0) position: vec3<f32>, @location(1) uv: vec2<f32>,
@location(2) overlay: u32, @location(3) color: vec4<f32>, @location(4) light: vec4<u32>,
@location(5) normal: vec4<f32>, @location(6) glint_uv: vec2<f32>) -> Out {
var out = entity_vertex(position, uv, overlay, color, light, normal);
var out = entity_vertex(position, uv, overlay & 0xffffu, color, light, normal);
out.glint_uv = glint_uv;
out.tier = overlay >> 16u;
return out;
}
// Retained bolt-local geometry, translated using the same f64 camera
@@ -263,11 +267,29 @@ fn crumbling_uv(uv: vec2<f32>, packed_origin: vec2<f32>, sprite_size: vec2<f32>,
// rendertype_outline.fsh: the vertex colour (the team colour; outline vertices carry no
// normal, so no cardinal light) wherever the texture is not fully clear.
// rendertype_entity_shadow.fsh: the shadow texture times the vertex colour,
// then fog, with no alpha cutout, so a faint shadow in dim light still draws.
@fragment fn fs_entity_shadow(in: Out) -> @location(0) vec4<f32> {
var color = textureSample(atlas, atlas_sampler, in.uv) * in.color;
color *= in.light_color;
let f = max(linear_fog(in.spherical, globals.fog.x, globals.fog.y),
linear_fog(in.cylindrical, globals.fog.z, globals.fog.w));
return vec4(mix(color.rgb, globals.fog_color.rgb, f * globals.fog_color.a), color.a);
}
@fragment fn fs_outline(in: Out) -> @location(0) vec4<f32> {
if textureSample(atlas, atlas_sampler, in.uv).a == 0.0 { discard; }
return vec4(in.color.rgb, 1.0);
}
// HDR output: a light-giving particle's pixels marked in the frame's alpha the
// way terrain.wgsl marks a light source's, drawn again after the entities with
// only alpha written, so its colour stays as blended.
@fragment fn fs_hdr_mark(in: Out) -> @location(0) vec4<f32> {
if in.tier == 0u || textureSample(atlas, atlas_sampler, in.uv).a < 0.1 { discard; }
return vec4(0.0, 0.0, 0.0, 0.7 * f32(4u - min(in.tier, 4u)) / 4.0);
}
fn celestial_color(in: Out) -> vec4<f32> {
return textureSample(atlas, atlas_sampler, in.uv) * in.color * in.light_color;
}
+234 -15
View File
@@ -87,6 +87,11 @@ impl Base<'_> {
}
}
/// The mobs that save `Age` and `AgeLocked`.
fn saves_age(s: crate::mobs::Species) -> bool {
use crate::mobs::{Species, drops::can_be_baby, finalize::ageable};
s == Species::Tadpole || (ageable(s) && can_be_baby(s))
}
/// `LivingEntity` + `Mob` (+ `AgeableMob`, creeper and skeleton specifics).
/// The mobs whose class extends vanilla's `Animal` (and so save `InLove`):
/// not allays, villagers, golems, dolphins or fish.
@@ -129,6 +134,27 @@ fn is_animal(id: &str) -> bool {
| "zombie_nautilus"
)
}
/// The `NeutralMob`s whose anger is `Extra::anger`: they write and read
/// `anger_end_time` and `angry_at`. The bee keeps its own anger, and a
/// piglin's is a brain memory, not `NeutralMob`'s.
fn persistent_anger(s: crate::mobs::Species) -> bool {
use crate::mobs::Species as S;
matches!(
s,
S::Wolf | S::IronGolem | S::PolarBear | S::Enderman | S::ZombifiedPiglin
)
}
/// The single player's UUID (`offline_uuid("Player")`): the entity the
/// neutral mobs are angry at.
fn player_uuid() -> [i32; 4] {
let u = horizon_protocol::crypto::offline_uuid("Player");
[
(u >> 96) as u32 as i32,
(u >> 64) as u32 as i32,
(u >> 32) as u32 as i32,
u as u32 as i32,
]
}
pub fn mob_to_nbt(m: &Mob, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
let base = Base {
id: &m.kind.id,
@@ -159,6 +185,22 @@ pub fn mob_to_nbt(m: &Mob, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
] {
put(&mut tag, key, value);
}
// `NeutralMob.addPersistentAngerSaveData`: the anger ends at an
// absolute game time, and the target is the entity the mob is angry at.
if persistent_anger(m.species()) {
put(
&mut tag,
"anger_end_time",
Tag::Long(if m.extra.anger_end > 0 {
m.extra.anger_end
} else {
-1
}),
);
if m.extra.angry_at == Some(crate::mobs::Who::Player) {
put(&mut tag, "angry_at", Tag::IntArray(player_uuid().to_vec()));
}
}
// `Entity.saveWithoutId` writes the flag only when set.
if m.name_visible {
put(&mut tag, "CustomNameVisible", Tag::Byte(1));
@@ -249,19 +291,14 @@ pub fn mob_to_nbt(m: &Mob, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
map.entry(key.into()).or_insert(value);
}
let id = m.kind.id.as_str();
// Only breedable animals have an age; a loaded creature that was saved without
// one (a frog) stays without.
let aged = raw.is_none_or(|r| r.get("Age").is_some());
if (m.kind.category == "creature" || m.species() == crate::mobs::Species::Hoglin) && aged {
// AgeableMob.addAdditionalSaveData, only if canBeABaby (not a frog or a
// parrot); a tadpole saves its own age.
let species = m.species();
if saves_age(species) {
map.insert("Age".into(), Tag::Int(m.extra.age));
map.insert("AgeLocked".into(), Tag::Byte(i8::from(m.extra.age_locked)));
map.insert("ForcedAge".into(), Tag::Int(m.extra.forced_age));
for (key, value) in [
("Age", Tag::Int(0)),
("ForcedAge", Tag::Int(0)),
("AgeLocked", Tag::Byte(0)),
] {
map.entry(key.into()).or_insert(value);
if species != crate::mobs::Species::Tadpole {
map.insert("ForcedAge".into(), Tag::Int(m.extra.forced_age));
}
}
// Animal.addAdditionalSaveData: the love ticks left, whatever the save had.
@@ -300,6 +337,7 @@ pub fn mob_to_nbt(m: &Mob, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
map.entry(key.into()).or_insert(value);
}
}
// Rabbit.addAdditionalSaveData (the legacy id; 99 is the killer bunny).
"minecraft:rabbit" => {
map.insert("RabbitType".into(), Tag::Int(i32::from(m.extra.variant)));
map.entry("MoreCarrotTicks".into()).or_insert(Tag::Int(0));
@@ -493,6 +531,8 @@ pub fn mob_to_nbt(m: &Mob, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
}
"minecraft:villager" => {
map.insert("VillagerData".into(), villager_data(m));
// A loaded villager with VillagerData is finalized.
map.insert("VillagerDataFinalized".into(), Tag::Byte(1));
let t = m.extra.trade.as_deref().cloned().unwrap_or_default();
map.insert("Xp".into(), Tag::Int(t.xp));
map.insert("LastRestock".into(), Tag::Long(t.last_restock));
@@ -683,9 +723,64 @@ pub fn mob_to_nbt(m: &Mob, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
) {
map.insert("IsBaby".into(), Tag::Byte(i8::from(m.is_baby())));
}
// NeutralMob.addPersistentAngerSaveData; the bee saves its own.
if matches!(
id,
"minecraft:iron_golem"
| "minecraft:wolf"
| "minecraft:polar_bear"
| "minecraft:zombified_piglin"
| "minecraft:enderman"
) {
map.entry("anger_end_time".into()).or_insert(Tag::Long(-1));
}
if let Some(a) = m.extra.attributes.as_deref() {
put_attributes(map, m, a);
}
// The attribute their load makes, which AttributeMap.pack then saves:
// Zombie and Piglin.setBaby (even for an adult) the speed, Rabbit.setVariant
// the attack damage.
use crate::mobs::finalize::Attr;
let made = if crate::mobs::doors::is_zombie(species) || species == crate::mobs::Species::Piglin
{
Some(Attr::MovementSpeed)
} else if species == crate::mobs::Species::Rabbit {
Some(Attr::AttackDamage)
} else {
None
};
if let Some(attr) = made {
let base = crate::mobs::finalize::base_value(m, attr);
let mut list = match map.get(&"attributes".into()) {
Some(Tag::List(l)) => l.clone(),
_ => Vec::new(),
};
let attr_id = format!("minecraft:{}", attr.name());
if !list
.iter()
.any(|t| text(t.get("id")).as_deref() == Some(attr_id.as_str()))
{
list.push(Tag::compound([
("id", string(&attr_id)),
("base", Tag::Double(base.to_bits())),
]));
map.insert("attributes".into(), Tag::List(list));
}
}
// Mob.addAdditionalSaveData: the chances that are not the default.
if let Some(chances) = &m.extra.drop_chances {
let set: Vec<(&str, Tag)> = crate::mobs::drops::SLOTS
.iter()
.zip(chances.iter())
.filter(|(_, c)| **c != crate::mobs::drops::DEFAULT_DROP_CHANCE)
.map(|(slot, c)| (*slot, Tag::Float(c.to_bits())))
.collect();
if set.is_empty() {
map.remove(&"drop_chances".into());
} else {
map.insert("drop_chances".into(), Tag::compound(set));
}
}
crate::mobs::raider::write_nbt(m, map);
crate::mobs::riders::write_nbt(m, map);
// LivingEntity equipment: what the mob holds now.
@@ -714,12 +809,13 @@ pub fn mob_to_nbt(m: &Mob, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
.into_iter()
.enumerate()
{
// Enchanted gear is saved with its `minecraft:enchantments`.
// Enchanted or trimmed gear is saved with its components.
if let (Some(id), Some(g)) = (item, crate::mobs::gear::gear(m))
&& !g.slots[index].is_empty()
&& (!g.slots[index].is_empty() || g.trims[index].is_some())
{
let mut stack = crate::items::ItemStack::new(id, 1);
stack.data_mut().enchantments = g.slots[index].clone();
stack.data_mut().trim = g.trims[index].clone();
equipment.insert(slot.into(), crate::save::item_to_nbt(&stack));
continue;
}
@@ -907,7 +1003,20 @@ pub fn mob_from_nbt(tag: &Tag, types: &HashMap<String, Arc<MobType>>, id: u32) -
continue;
};
let v = f64::from_bits(*bits);
// A mount's rolled health and speed stay attribute bases, which
// the server syncs (its hearts over the rider's hotbar).
let equine = crate::mobs::equine::is_equine(m.species());
match id.as_str() {
"minecraft:max_health" if equine => crate::mobs::finalize::set_base(
&mut m,
crate::mobs::finalize::Attr::MaxHealth,
v,
),
"minecraft:movement_speed" if equine => crate::mobs::finalize::set_base(
&mut m,
crate::mobs::finalize::Attr::MovementSpeed,
v,
),
"minecraft:max_health" => m.attrs.max_health = v as f32,
"minecraft:movement_speed" => m.attrs.speed = v,
"minecraft:attack_damage" => m.attrs.attack_damage = v as f32,
@@ -934,6 +1043,16 @@ pub fn mob_from_nbt(tag: &Tag, types: &HashMap<String, Arc<MobType>>, id: u32) -
.get("mainhand")
.and_then(|i| text(i.get("id")))
.map(Into::into);
// The custom name of the main hand item (`ItemStack.hasCustomName`).
m.extra.main_hand_name = eq
.get("mainhand")
.and_then(|i| i.get("components"))
.and_then(|c| c.get("minecraft:custom_name"))
.map(|t| {
horizon_client_core::scoreboard::comp_text(
&horizon_client_core::scoreboard::tag_to_json(t),
)
});
m.extra.off_hand = eq
.get("offhand")
.and_then(|i| text(i.get("id")))
@@ -949,10 +1068,12 @@ pub fn mob_from_nbt(tag: &Tag, types: &HashMap<String, Arc<MobType>>, id: u32) -
m.extra.legs = slot("legs");
m.extra.feet = slot("feet");
}
// Their enchantments.
// Their enchantments and trims.
for (index, key) in crate::mobs::gear::KEYS.iter().enumerate() {
if let Some(stack) = eq.get(key).and_then(crate::save::item_from_nbt) {
crate::mobs::gear::set(&mut m, index, stack.enchantments().to_vec());
let trim = stack.data().and_then(|d| d.trim.clone());
crate::mobs::gear::set_trim(&mut m, index, trim);
}
}
} else {
@@ -964,11 +1085,31 @@ pub fn mob_from_nbt(tag: &Tag, types: &HashMap<String, Arc<MobType>>, id: u32) -
// Mob.readAdditionalSaveData overrides what the constructor set (copper
// golems and elder guardians start persistent).
m.persistent = flag(tag, "PersistenceRequired");
// `NeutralMob.readPersistentAngerSaveData`: the anger end time and the
// target come back with the mob.
if persistent_anger(m.species()) {
m.extra.anger_end = number(tag.get("anger_end_time")).unwrap_or(-1);
if tag
.get("angry_at")
.is_some_and(|t| uuid_from_tag(Some(t)) == Some(player_uuid()))
{
m.extra.angry_at = Some(crate::mobs::Who::Player);
}
}
m.extra.pick_up_loot = flag(tag, "CanPickUpLoot");
m.custom_name = custom_name(tag);
m.custom_name_text = tag.get("CustomName").map(name_text);
m.extra.left_handed = flag(tag, "LeftHanded");
if m.kind.category == "creature" || m.species() == crate::mobs::Species::Hoglin {
if let Some(Tag::Compound(c)) = tag.get("drop_chances") {
let mut chances = [crate::mobs::drops::DEFAULT_DROP_CHANCE; 8];
for (chance, slot) in chances.iter_mut().zip(crate::mobs::drops::SLOTS) {
if let Some(Tag::Float(v)) = c.get(&slot.into()) {
*chance = f32::from_bits(*v);
}
}
m.extra.drop_chances = Some(Box::new(chances));
}
if saves_age(m.species()) {
m.extra.age = number(tag.get("Age")).unwrap_or(0) as i32;
m.extra.forced_age = number(tag.get("ForcedAge")).unwrap_or(0) as i32;
m.extra.age_locked = flag(tag, "AgeLocked");
@@ -1508,6 +1649,10 @@ pub fn arrow_to_nbt(
// Projectile: LeftOwner only when set; Owner as the UUID.
map.remove(&"LeftOwner".into());
map.remove(&"inBlockState".into());
map.remove(&"weapon".into());
}
if let Some(weapon) = &a.fired_from_weapon {
put(&mut tag, "weapon", item_to_nbt(weapon));
}
if p.left_owner {
put(&mut tag, "LeftOwner", Tag::Byte(1));
@@ -1554,6 +1699,7 @@ pub fn arrow_from_nbt(
a.fire_ticks = number(tag.get("Fire")).unwrap_or(0) as i16 as i32;
a.fall_distance = float(tag.get("fall_distance")).unwrap_or(0.0);
a.pickup_item = item;
a.fired_from_weapon = tag.get("weapon").and_then(item_from_nbt);
p.stuck = a.in_ground;
Some((p, tag.get("inBlockState").cloned()))
}
@@ -2892,6 +3038,33 @@ mod sheep_nbt_tests {
}
}
#[test]
fn a_baby_villager_keeps_its_age() {
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../.local/horizon-client")
.join(horizon_core::VANILLA_VERSION);
if !root.join("items.json").exists() {
return;
}
let data = crate::mobs::MobTypes::load(&root).unwrap();
let types: HashMap<String, Arc<MobType>> = data
.types
.iter()
.map(|(k, v)| (k.clone(), Arc::new(v.clone())))
.collect();
let saved = Tag::compound([
("id", string("minecraft:villager")),
("Pos", doubles(&[0.5, 64.0, 0.5])),
("Age", Tag::Int(-21359)),
]);
let m = mob_from_nbt(&saved, &types, 1).unwrap();
assert_eq!(m.extra.age, -21359);
let tag = mob_to_nbt(&m, [1, 2, 3, 4], None);
assert_eq!(number(tag.get("Age")), Some(-21359));
assert_eq!(number(tag.get("AgeLocked")), Some(0));
assert_eq!(number(tag.get("VillagerDataFinalized")), Some(1));
}
#[test]
fn foxes_rabbits_and_goats_keep_their_kind() {
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
@@ -2979,3 +3152,49 @@ mod fireball_nbt_tests {
);
}
}
#[cfg(test)]
mod neutral_anger_tests {
use super::*;
/// #2784: a neutral mob's anger survives the save: the anger ends at an
/// absolute game time and the target comes back with it
/// (`NeutralMob.addPersistentAngerSaveData` / `readPersistentAngerSaveData`).
#[test]
fn a_neutral_mobs_anger_survives_the_save() {
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../.local/horizon-client")
.join(horizon_core::VANILLA_VERSION);
if !root.join("items.json").exists() {
return;
}
let data = crate::mobs::MobTypes::load(&root).unwrap();
let types: HashMap<String, Arc<MobType>> = data
.types
.iter()
.map(|(k, v)| (k.clone(), Arc::new(v.clone())))
.collect();
let mut m = Mob::new(
1,
types["minecraft:wolf"].clone(),
Vec3::new(0.0, 64.0, 0.0),
0.0,
);
m.extra.anger = 400;
m.extra.anger_end = 3000;
m.extra.angry_at = Some(crate::mobs::Who::Player);
let tag = mob_to_nbt(&m, [1, 2, 3, 4], None);
assert_eq!(number(tag.get("anger_end_time")), Some(3000));
assert!(tag.get("angry_at").is_some());
let back = mob_from_nbt(&tag, &types, 2).unwrap();
assert_eq!(back.extra.anger_end, 3000);
assert_eq!(back.extra.angry_at, Some(crate::mobs::Who::Player));
// A mob that is no NeutralMob writes no anger.
let pig = Mob::new(3, types["minecraft:pig"].clone(), Vec3::ZERO, 0.0);
let tag = mob_to_nbt(&pig, [1, 2, 3, 5], None);
assert!(tag.get("anger_end_time").is_none());
// The anger that is left when the mob comes back.
assert_eq!(crate::mobs::anger_left(3000, 1000), 2000);
assert_eq!(crate::mobs::anger_left(3000, 4000), 0);
}
}
+5 -1
View File
@@ -173,12 +173,16 @@ pub fn passes_cobweb(
stuck
}
pub fn hurt_mobs(mobs: &mut [Mob], position: Vec3, amount: f32) {
hurt_mobs_with_kind(mobs, position, amount, DamageKind::FallingBlock);
}
pub fn hurt_mobs_with_kind(mobs: &mut [Mob], position: Vec3, amount: f32, kind: DamageKind) {
if amount <= 0.0 {
return;
}
let bounds = bounds(position);
let source = MobDamage {
kind: DamageKind::FallingBlock,
kind,
attacker: None,
from: Some([position.x, position.y, position.z]),
};
+60
View File
@@ -38,6 +38,8 @@ pub enum ItemPose {
charge_duration: f32,
charged: bool,
},
/// A `stab` swing (`SpearAnimations.firstPersonAttack`).
SpearAttack,
}
/// `submitArmWithItem`: the pose of an item in the hand on the `left` or
@@ -92,6 +94,10 @@ pub fn item_pose(left: bool, inverse_height: f32, attack: f32, pose: ItemPose) -
* Mat4::from_rotation_y((invert * 65.0f32).to_radians())
* Mat4::from_rotation_z((invert * -85.0f32).to_radians());
}
if matches!(pose, ItemPose::SpearAttack) {
first_person_attack(&mut m, invert, attack);
return m;
}
let charging = match pose {
ItemPose::Bow { ticks_used } => {
let power = ticks_used / 20.0;
@@ -154,6 +160,53 @@ pub fn item_pose(left: bool, inverse_height: f32, attack: f32, pose: ItemPose) -
m
}
/// `Mth.inverseLerp` clamped to 0..1 (`SpearAnimations.progress`).
pub(crate) fn progress(time: f32, start: f32, end: f32) -> f32 {
if end == start {
return if time >= end { 1.0 } else { 0.0 };
}
((time - start) / (end - start)).clamp(0.0, 1.0)
}
pub(crate) fn in_quad(x: f32) -> f32 {
x * x
}
pub(crate) fn out_back(x: f32) -> f32 {
let x = x - 1.0;
1.0 + 2.701_58 * x * x * x + 1.701_58 * x * x
}
pub(crate) fn in_out_sine(x: f32) -> f32 {
use horizon_core::geometry::trig::Mth;
-((std::f32::consts::PI * x).mcos() - 1.0) / 2.0
}
pub(crate) fn in_out_expo(x: f32) -> f32 {
if x < 0.5 {
if x == 0.0 {
0.0
} else {
2.0f32.powf(20.0 * x - 10.0) / 2.0
}
} else if x == 1.0 {
1.0
} else {
(2.0 - 2.0f32.powf(-20.0 * x + 10.0)) / 2.0
}
}
/// `SpearAnimations.firstPersonAttack`.
fn first_person_attack(m: &mut glam::Mat4, invert: f32, attack: f32) {
let starting = in_out_sine(progress(attack, 0.0, 0.05));
let middle = out_back(progress(attack, 0.05, 0.2));
let ending = in_out_expo(progress(attack, 0.4, 1.0));
*m *= glam::Mat4::from_translation(glam::Vec3::new(
invert * 0.1 * (starting - middle),
-0.075 * (starting - ending),
0.65 * (starting - middle),
));
*m *= glam::Mat4::from_rotation_x((-70.0 * (starting - ending)).to_radians());
*m *= glam::Mat4::from_translation(glam::Vec3::new(0.0, 0.0, -0.25 * (ending - middle)));
}
/// How `submitArmWithItem` holds a stack with a `minecraft:map_id`: in both
/// hands when it is the main hand's and the off hand is empty
/// (`renderTwoHandedMap`), else in its own hand (`renderOneHandedMap`).
@@ -1060,6 +1113,13 @@ mod continuous_use_tests {
);
assert_ne!(swing, item_pose(false, 0.0, 0.3, ItemPose::Using));
}
#[test]
fn spear_attack_stabs_forward_and_back() {
assert_ne!(
item_pose(false, 0.0, 0.1, ItemPose::SpearAttack),
item_pose(false, 0.0, 0.6, ItemPose::SpearAttack)
);
}
}
#[cfg(test)]
+25 -5
View File
@@ -203,6 +203,13 @@ pub struct FogInputs<'a> {
/// `BossHealthOverlay.shouldCreateWorldFog`: a boss bar with
/// `createWorldFog` is shown (the Ender Dragon's).
pub boss_world_fog: bool,
/// The camera entity is a spectator (`LavaFogEnvironment`).
pub spectator: bool,
/// The camera entity has Fire Resistance (`LavaFogEnvironment`).
pub fire_resistance: bool,
/// `GameRenderer.nightVisionScale` while Night Vision is on without
/// Darkness, else 0 (`FogRenderer.computeFogColor`'s brightenFactor).
pub night_vision: f32,
}
/// `FogRenderer.setupFog` + `computeFogColor`.
@@ -272,7 +279,11 @@ pub fn setup_fog(tables: &FogTables, state: &FogState, input: &FogInputs) -> Fog
color[1] += (color[1] * 0.6 - color[1]) * t;
color[2] += (color[2] * 0.6 - color[2]) * t;
}
let brighten = if eye_in_water { water_vision } else { 0.0 };
let brighten = if eye_in_water {
water_vision
} else {
input.night_vision
};
if color.iter().all(|&c| c != 0.0) {
let scale = 1.0 / color[0].max(color[1]).max(color[2]);
color = color.map(|c| c + (c * scale - c) * brighten);
@@ -288,10 +299,16 @@ pub fn setup_fog(tables: &FogTables, state: &FogState, input: &FogInputs) -> Fog
};
match input.fog_type {
FogType::Lava => {
fog.environmental_start = 0.25;
fog.environmental_end = 1.0;
fog.sky_end = 1.0;
fog.cloud_end = 1.0;
// LavaFogEnvironment.setupFog.
(fog.environmental_start, fog.environmental_end) = if input.spectator {
(-8.0, rd * 0.5)
} else if input.fire_resistance {
(0.0, 5.0)
} else {
(0.25, 1.0)
};
fog.sky_end = fog.environmental_end;
fog.cloud_end = fog.environmental_end;
}
FogType::PowderSnow => {
fog.environmental_start = 0.0;
@@ -405,6 +422,9 @@ mod tests {
darkness: None,
darken_world: 0.0,
boss_world_fog: false,
spectator: false,
fire_resistance: false,
night_vision: 0.0,
}
}
#[test]
+116 -1
View File
@@ -8,7 +8,7 @@
//! offsets), styled drawing and StringSplitter-style line wrapping.
use crate::{
hud::Rect,
text::{Style, Text},
text::{Span, Style, Text, legacy_format},
zip::Zip,
};
use ab_glyph::{Font as _, ScaleFont as _};
@@ -951,6 +951,80 @@ impl Font {
}
out
}
/// `Font.split`: `StringSplitter.splitLines(text, width, Style.EMPTY)` over
/// the text's parts with their `§` codes in. A code styles what follows
/// (`§r` back to no style while measuring), and the part a line breaks in
/// goes on in the style at the break. Lines keep their codes for drawing.
pub fn split_formatted(&self, t: &Text, max_width: f32) -> Vec<Text> {
let max_width = max_width.max(1.0);
let mut parts: Vec<(Vec<char>, Style)> = t
.spans
.iter()
.filter(|s| !s.text.is_empty())
.map(|s| (s.text.chars().collect(), s.style.clone()))
.collect();
let mut flat: Vec<char> = parts.iter().flat_map(|p| p.0.iter().copied()).collect();
let mut out = Vec::new();
let mut force_new_line = false;
// One LineBreakFinder per line, over the parts left.
while let Some((at, split_style)) = self.line_break(&parts, max_width) {
let tail = flat[at];
let new_line = tail == '\n';
let skip = usize::from(new_line || tail == ' ');
force_new_line = new_line;
out.push(split_at(&mut parts, at, skip, &split_style));
flat.drain(..at + skip);
}
if !parts.is_empty() {
let mut rest = Text::default();
rest.spans = parts
.into_iter()
.map(|(text, style)| Span {
text: text.into_iter().collect(),
style,
})
.collect();
out.push(rest);
} else if force_new_line {
out.push(Text::empty());
}
out
}
/// `StringSplitter.LineBreakFinder` fed by `StringDecomposer.iterateFormatted`:
/// where the parts break (a character index) and the style there.
fn line_break(&self, parts: &[(Vec<char>, Style)], max_width: f32) -> Option<(usize, Style)> {
let (mut width, mut had_visible, mut last_space) = (0.0f32, false, None);
let mut offset = 0;
for (contents, part_style) in parts {
let mut style = part_style.clone();
let mut i = 0;
while i < contents.len() {
let c = contents[i];
if c == '§' {
let &code = contents.get(i + 1)?;
style = legacy_format(&style, code, &Style::default());
i += 2;
continue;
}
let at = offset + i;
if c == '\n' {
return Some((at, style));
}
if c == ' ' {
last_space = Some((at, style.clone()));
}
let advance = self.advance(c, &style);
width += advance;
if had_visible && width > max_width {
return Some(last_space.unwrap_or((at, style)));
}
had_visible |= advance != 0.0;
i += 1;
}
offset += contents.len();
}
None
}
/// `ComponentRenderUtils.wrapComponents`: the split lines, wrapped ones
/// indented by an unstyled space; one empty line for empty text.
pub fn wrap_components(&self, t: &Text, max_width: i32) -> Vec<Text> {
@@ -1044,6 +1118,47 @@ impl Font {
}
}
/// `StringSplitter.FlatComponents.splitAt`: the parts before `at` as a line,
/// `skip` characters dropped, and the part it ends in restyled as `style`.
fn split_at(parts: &mut Vec<(Vec<char>, Style)>, at: usize, skip: usize, style: &Style) -> Text {
let mut line = Text::default();
let mut push = |text: &[char], style: &Style| {
line.spans.push(Span {
text: text.iter().collect(),
style: style.clone(),
})
};
// Each part done is removed, so the one at hand is always the first.
let (mut position, mut in_skip) = (at, false);
while let Some((text, part_style)) = parts.first() {
let size = text.len();
if !in_skip {
if position > size {
push(text, part_style);
parts.remove(0);
position -= size;
continue;
}
if position > 0 {
push(&text[..position], part_style);
}
position += skip;
in_skip = true;
}
if position <= size {
if position == size {
parts.remove(0);
} else {
parts[0] = (text[position..].to_vec(), style.clone());
}
break;
}
parts.remove(0);
position -= size;
}
line
}
#[cfg(test)]
mod tests {
use super::*;
File diff suppressed because it is too large. Load diff
+386 -12
View File
@@ -74,6 +74,22 @@ impl Toast {
(fully_for as f64 / (5000.0 * multiplier / n).max(1.0) % n) as usize
}
}
/// Where the player's fall, lava ride and Nether trip began, the blast that
/// last hit the player, and the effects the last tick saw (`ServerPlayer`'s
/// fields of the same names). A guest keeps their own, as `levitation` does.
#[derive(Clone, Debug, Default)]
pub(super) struct TravelMarks {
/// `startingToFallPosition`.
pub(super) fall: Option<[f64; 3]>,
/// `currentExplosionImpactPos`, and whether a wind charge made the blast.
pub(super) blast: Option<([f64; 3], bool)>,
/// `enteredLavaOnVehiclePosition`.
pub(super) lava_ride: Option<[f64; 3]>,
/// `enteredNetherPosition`.
pub(super) nether: Option<[f64; 3]>,
/// The (effect, amplifier) pairs `EffectsChangedTrigger` last saw.
pub(super) effects: Vec<(&'static str, i32)>,
}
#[derive(Default)]
pub(super) struct AdvState {
pub(super) tree: Arc<Tree>,
@@ -89,6 +105,8 @@ pub(super) struct AdvState {
pub(super) last_inventory: u64,
/// `ServerPlayer.levitationStartPos` and `levitationStartTime`.
pub(super) levitation: Option<([f64; 3], u64)>,
/// The fall, lava ride, Nether trip, blast and effects `tick_marks` follows.
pub(super) marks: TravelMarks,
/// While the game acts for a network player (`as_guest`): the
/// advancements whose progress changed (`progressChanged`), the recipes
/// unlocked and the announcements, for their client and everyone's.
@@ -408,6 +426,14 @@ impl Game<'_> {
equipment[4] = slot(&m.extra.legs);
equipment[5] = slot(&m.extra.feet);
}
// A wolf's armor is its body slot, with its durability.
if m.species() == horizon_client::mobs::Species::Wolf {
equipment[6] = m.extra.head.as_ref().map(|id| {
let mut armor = ItemStack::new(id, 1);
armor.damage = m.extra.body_damage.max(0) as u32;
item_view(&armor)
});
}
EntityView {
kind: m.kind.id.clone(),
position: [m.position.x, m.position.y, m.position.z],
@@ -562,10 +588,17 @@ impl Game<'_> {
.announced
.push(Text::translate(key, &[player, name]));
}
None => self.system_message(Text::translate(
key,
&[Text::literal(super::chat::PLAYER_NAME), name],
)),
None => {
let text = Text::translate(key, &[self.player_display_text(), name]);
// The guests hear of the host's advancement too.
if let Some(host) = &self.lan {
host.hub
.system_message(horizon_client::lan::data::component_nbt(
&text.component(),
));
}
self.system_message(text);
}
}
}
}
@@ -685,6 +718,7 @@ impl Game<'_> {
if let Some((start, since)) = self.adv.levitation {
self.trigger_levitation(start, (self.ticks - since) as i64);
}
self.tick_marks();
if self.ticks.is_multiple_of(20) {
let me = self.player_entity();
self.trigger("minecraft:location", |c, w| {
@@ -719,6 +753,32 @@ impl Game<'_> {
adv::conditions(&c["player"], &pctx, w) && adv::conditions(&c[&key], &ectx, w)
});
}
/// `KilledTrigger` (`player_killed_entity`): the entity killed and the
/// killing blow's damage source (none: any `killing_blow` fails).
pub(super) fn trigger_killed(&mut self, target: &EntityView, blow: Option<&adv::DamageView>) {
let me = self.player_entity();
let target = target.clone();
let blow = blow.cloned();
self.trigger("minecraft:player_killed_entity", move |c, w| {
let pctx = adv::Entities {
this: Some(&me),
origin: me.position,
tool: None,
block_pos: None,
};
let ectx = adv::Entities {
this: Some(&target),
..pctx
};
let blow_ok = c["killing_blow"].is_null()
|| blow
.as_ref()
.is_some_and(|d| adv::damage_source(&c["killing_blow"], d, w, me.position));
adv::conditions(&c["player"], &pctx, w)
&& adv::conditions(&c["entity"], &ectx, w)
&& blow_ok
});
}
/// `KilledByArrowTrigger`: what the arrow killed and the weapon it came from.
pub(super) fn trigger_killed_by_arrow(
&mut self,
@@ -748,6 +808,162 @@ impl Game<'_> {
&& adv::range_i(&c["duration"], duration)
});
}
/// `ServerPlayer.tick`: `trackStartFallingPosition` and the end of a fall
/// (`resetFallDistance`), `trackEnteredOrExitedLavaOnVehicle`, the way back
/// from the Nether, and `onEffectAdded`, `onEffectUpdated` and
/// `onEffectsRemoved`, which are seen as each tick ends.
fn tick_marks(&mut self) {
let Some(p) = self.player.as_ref() else {
return;
};
let at = [p.position.x, p.position.y, p.position.z];
let falling = p.fall_distance > 0.0;
let dead = p.health.dead_or_dying();
let lava = self
.riding
.and_then(|id| self.mobs.iter().find(|m| m.id == id))
.is_some_and(|m| m.fluids.in_lava);
match (falling, self.adv.marks.fall) {
(true, None) => {
self.adv.marks.fall = Some(at);
// The blast a wind charge made is spent by the fall, and counts
// only if the player was above where it hit.
if let Some((impact, wind_charge)) = self.adv.marks.blast.take()
&& wind_charge
&& impact[1] <= at[1]
{
self.trigger_fall_after_explosion(impact);
}
}
// `resetFallDistance`: a fall ends by landing, climbing or water.
(false, Some(start)) => {
self.adv.marks.fall = None;
if !dead {
self.trigger_distance("minecraft:fall_from_height", start);
}
}
_ => {}
}
match (lava, self.adv.marks.lava_ride) {
(true, None) => self.adv.marks.lava_ride = Some(at),
(true, Some(start)) => self.trigger_distance("minecraft:ride_entity_in_lava", start),
(false, Some(_)) => self.adv.marks.lava_ride = None,
(false, None) => {}
}
// Back in the Overworld from the Nether: measured from where it was entered.
if let Some(start) = self.adv.marks.nether
&& self.dim == Dim::Overworld
{
self.adv.marks.nether = None;
self.trigger_distance("minecraft:nether_travel", start);
}
let effects: Vec<(&'static str, i32)> = self
.potions
.effects
.list
.iter()
.map(|e| (e.id, e.amplifier))
.collect();
if effects != self.adv.marks.effects {
self.adv.marks.effects = effects;
self.trigger_effects_changed(None);
}
}
/// `DistanceTrigger`: the player's `player` condition, `start_position`
/// at `start`, and the `distance` from `start` to the player.
fn trigger_distance(&mut self, name: &str, start: [f64; 3]) {
let me = self.player_entity();
self.trigger(name, move |c, w| {
let pctx = adv::Entities {
this: Some(&me),
origin: me.position,
tool: None,
block_pos: None,
};
adv::conditions(&c["player"], &pctx, w)
&& adv::location(&c["start_position"], start, w)
&& (c["distance"].is_null() || adv::distance(&c["distance"], start, me.position))
});
}
/// `FallAfterExplosionTrigger` for a wind charge's blast that hit the
/// player at `impact`, measured to where the player is now.
fn trigger_fall_after_explosion(&mut self, impact: [f64; 3]) {
let me = self.player_entity();
let cause = EntityView {
kind: "minecraft:wind_charge".into(),
position: impact,
..EntityView::default()
};
self.trigger("minecraft:fall_after_explosion", move |c, w| {
let pctx = adv::Entities {
this: Some(&me),
origin: me.position,
tool: None,
block_pos: None,
};
let cctx = adv::Entities {
this: Some(&cause),
origin: me.position,
tool: None,
block_pos: None,
};
adv::conditions(&c["player"], &pctx, w)
&& adv::location(&c["start_position"], impact, w)
&& (c["distance"].is_null() || adv::distance(&c["distance"], impact, me.position))
&& adv::conditions(&c["cause"], &cctx, w)
});
}
/// `EffectsChangedTrigger`: the player's `effects`, and for an effect that
/// `source` gave, that entity (`source`).
pub(super) fn trigger_effects_changed(&mut self, source: Option<EntityView>) {
let me = self.player_entity();
self.trigger("minecraft:effects_changed", move |c, w| {
let pctx = adv::Entities {
this: Some(&me),
origin: me.position,
tool: None,
block_pos: None,
};
let effects = c["effects"].is_null()
|| adv::entity(
&serde_json::json!({ "minecraft:effects": c["effects"] }),
&me,
w,
me.position,
);
let source = c["source"].is_null()
|| source.as_ref().is_some_and(|s| {
let sctx = adv::Entities {
this: Some(s),
origin: me.position,
tool: None,
block_pos: None,
};
adv::conditions(&c["source"], &sctx, w)
});
adv::conditions(&c["player"], &pctx, w) && effects && source
});
}
/// `ItemDurabilityTrigger`: a player's item takes damage (`ItemStack.applyDamage`),
/// `stack` as it is before the damage. The durability and delta ranges are not
/// read: no advancement in the data uses them.
pub(super) fn trigger_item_durability(&mut self, stack: &ItemStack) {
if self.remote.is_some() || self.no_player_here() {
return;
}
let me = self.player_entity();
let item = item_view(stack);
self.trigger("minecraft:item_durability_changed", move |c, w| {
let pctx = adv::Entities {
this: Some(&me),
origin: me.position,
tool: None,
block_pos: None,
};
adv::conditions(&c["player"], &pctx, w)
&& (c["item"].is_null() || adv::item(&c["item"], &item, w))
});
}
/// `ServerPlayer.updateUsingItem`: USING_ITEM for the item in use, with
/// what the player looks at (`PlayerPredicate.lookingAt`).
pub(super) fn trigger_using_item(&mut self, stack: &ItemStack) {
@@ -869,9 +1085,6 @@ impl Game<'_> {
stack: &ItemStack,
target: &EntityView,
) {
if self.lan_acting.is_some() {
return;
}
let me = self.player_entity();
let it = item_view(stack);
self.trigger(name, |c, w| adv::item_entity(c, &me, target, &it, w));
@@ -1043,6 +1256,110 @@ impl Game<'_> {
adv::conditions(&c["location"], &ctx, w)
});
}
/// `LightningStrikeTrigger`: the bolt, and the mobs around it that it did
/// not hit (`bystander`), each tested from this player's position.
pub(super) fn trigger_lightning_strike(&mut self, bolt: &EntityView, viewers: &[EntityView]) {
let me = self.player_entity();
let (bolt, viewers) = (bolt.clone(), viewers.to_vec());
self.trigger("minecraft:lightning_strike", move |c, w| {
let pctx = adv::Entities {
this: Some(&me),
origin: me.position,
tool: None,
block_pos: None,
};
let bctx = adv::Entities {
this: Some(&bolt),
..pctx
};
adv::conditions(&c["player"], &pctx, w)
&& adv::conditions(&c["lightning"], &bctx, w)
&& (c["bystander"].is_null()
|| viewers.iter().any(|v| {
adv::conditions(
&c["bystander"],
&adv::Entities {
this: Some(v),
..pctx
},
w,
)
}))
});
}
/// `ChanneledLightningTrigger`: each victim condition matched by some
/// mob the bolt hit, from the player who channelled it.
pub(super) fn trigger_channeled_lightning(&mut self, victims: &[EntityView]) {
let me = self.player_entity();
let victims = victims.to_vec();
self.trigger("minecraft:channeled_lightning", move |c, w| {
let pctx = adv::Entities {
this: Some(&me),
origin: me.position,
tool: None,
block_pos: None,
};
adv::conditions(&c["player"], &pctx, w)
&& c["victims"].as_array().into_iter().flatten().all(|want| {
victims.iter().any(|v| {
adv::conditions(
want,
&adv::Entities {
this: Some(v),
..pctx
},
w,
)
})
})
});
}
/// `SpearMobsTrigger`: the living entities the spear stabbed in this use.
pub(super) fn trigger_spear_mobs(&mut self, stabbed: usize) {
let me = self.player_entity();
self.trigger("minecraft:spear_mobs", move |c, w| {
let pctx = adv::Entities {
this: Some(&me),
origin: me.position,
tool: None,
block_pos: None,
};
adv::conditions(&c["player"], &pctx, w)
&& c["count"].as_u64().is_none_or(|n| stabbed as u64 >= n)
});
}
/// `BeeNestDestroyedTrigger`: the nest's block state, the tool that broke
/// it and the bees left inside.
pub(super) fn trigger_bee_nest_destroyed(
&mut self,
state: horizon_world::registry::BlockStateId,
tool: Option<&ItemStack>,
bees: i64,
) {
let Some(definition) = self.pack.assets.registry.state(state).cloned() else {
return;
};
let me = self.player_entity();
let tool = tool.map(item_view).unwrap_or_default();
self.trigger("minecraft:bee_nest_destroyed", move |c, w| {
let pctx = adv::Entities {
this: Some(&me),
origin: me.position,
tool: None,
block_pos: None,
};
let state_ok = c["state"]
.as_object()
.into_iter()
.flatten()
.all(|(k, v)| definition.properties.get(k).map(String::as_str) == v.as_str());
adv::id_matches(&c["blocks"], "block", &definition.name, w)
&& state_ok
&& adv::item(&c["item"], &tool, w)
&& adv::range_i(&c["num_bees_inside"], bees)
&& adv::conditions(&c["player"], &pctx, w)
});
}
/// A trigger with only the player condition (slept_in_bed, started_riding).
pub(super) fn trigger_player(&mut self, name: &str) {
let me = self.player_entity();
@@ -1056,6 +1373,55 @@ impl Game<'_> {
adv::conditions(&c["player"], &pctx, w)
});
}
/// `RecipeCraftedTrigger`: the recipe and the stacks that went into it
/// (`ServerPlayer.triggerRecipeCrafted`); an empty slot is air.
pub(super) fn trigger_recipe_crafted(
&mut self,
recipe: &str,
ingredients: &[Option<ItemStack>],
) {
let me = self.player_entity();
let used: Vec<ItemView> = ingredients
.iter()
.map(|s| {
s.as_ref().map_or_else(
|| ItemView {
id: "minecraft:air".to_owned(),
..Default::default()
},
item_view,
)
})
.collect();
let recipe = recipe.to_owned();
self.trigger("minecraft:recipe_crafted", move |c, w| {
let pctx = adv::Entities {
this: Some(&me),
origin: me.position,
tool: None,
block_pos: None,
};
adv::conditions(&c["player"], &pctx, w)
&& adv::id_matches(&c["recipes"], "recipe", &recipe, w)
&& adv::recipe_ingredients(&c["ingredients"], &used, w)
});
}
/// `LootTableTrigger`: a container's loot table unpacked for this player
/// (`RandomizableContainer.unpackLootTable`).
pub(super) fn trigger_loot_table(&mut self, table: &str) {
let me = self.player_entity();
let table = table.to_owned();
self.trigger("minecraft:player_generates_container_loot", move |c, w| {
let pctx = adv::Entities {
this: Some(&me),
origin: me.position,
tool: None,
block_pos: None,
};
adv::conditions(&c["player"], &pctx, w)
&& adv::id_matches(&c["loot_tables"], "loot_table", &table, w)
});
}
/// `BredAnimalsTrigger`.
pub(super) fn trigger_bred(
&mut self,
@@ -1098,12 +1464,20 @@ impl Game<'_> {
&& test("child", child.as_ref())
});
}
/// `ChangeDimensionTrigger`.
pub(super) fn trigger_dimension(&mut self, from: Dim, to: Dim) {
let (from, to) = (from.id().to_owned(), to.id().to_owned());
/// `ChangeDimensionTrigger`, and `ServerPlayer.enteredNetherPosition`: the
/// Overworld position the player went into the Nether from (`at`) is kept
/// for the way back, and any other trip drops it.
pub(super) fn trigger_dimension(&mut self, from: Dim, to: Dim, at: [f64; 3]) {
let (from_id, to_id) = (from.id().to_owned(), to.id().to_owned());
self.trigger("minecraft:changed_dimension", move |c, _| {
c["from"].as_str().is_none_or(|f| f == from) && c["to"].as_str().is_none_or(|t| t == to)
c["from"].as_str().is_none_or(|f| f == from_id)
&& c["to"].as_str().is_none_or(|t| t == to_id)
});
if from == Dim::Overworld && to == Dim::Nether {
self.adv.marks.nether = Some(at);
} else if !(from == Dim::Nether && to == Dim::Overworld) {
self.adv.marks.nether = None;
}
}
/// `ToastManager.SLOT_COUNT`.
pub(super) const TOAST_SLOTS: usize = 5;
@@ -1175,7 +1549,7 @@ impl Game<'_> {
.iter()
.filter_map(|t| t.slot)
.chain(packs)
.fold(0, |used, slot| used | 1 << slot)
.fold(self.tutorial_toast_slots(), |used, slot| used | 1 << slot)
}
/// The `AdvancementToast` or `RecipeToast` at `index` in its slot at `y`.
fn draw_toast(&mut self, batch: &mut HudBatch, index: usize, y: f32, multiplier: f64) {
+8 -2
View File
@@ -564,7 +564,7 @@ impl Game<'_> {
"hardcore": options.hardcore,
"difficulty": horizon_client::title::DIFFICULTIES[options.difficulty()],
"allow_commands": options.commands(*creative),
"superflat": options.flat,
"world_type": options.world_type.id(),
"generate_structures": options.structures,
"bonus_chest": options.bonus_chest(),
}),
@@ -577,7 +577,7 @@ impl Game<'_> {
} => json!({
"mode": "customize-flat",
"fields": { "name": name, "seed": seed },
"superflat": options.flat,
"superflat": options.world_type == horizon_client::save::WorldPreset::Flat,
"biome": flat.settings().biome,
"layers": flat.settings().layers.iter().map(|layer| json!({
"block": layer.block,
@@ -597,6 +597,9 @@ impl Game<'_> {
Mode::Delete { world } => {
json!({ "mode": "delete-world", "folder": world.folder, "name": world.name })
}
Mode::BackupBeforeUpgrade { world } => {
json!({ "mode": "backup-before-upgrade", "folder": world.folder, "name": world.name })
}
Mode::Error { message, .. } => {
json!({ "mode": "world-operation-error", "message": message })
}
@@ -606,6 +609,9 @@ impl Game<'_> {
json!({ "mode": "multiplayer", "disconnected": reason })
}
Mode::Multiplayer(_) => json!({ "mode": "multiplayer" }),
Mode::MultiplayerWarning { check } => {
json!({ "mode": "multiplayer-warning", "check": check })
}
}
}
+24 -7
View File
@@ -89,6 +89,16 @@ impl Game<'_> {
}
}
}
/// The UUID of the player an allay likes (`LIKED_PLAYER`).
fn allay_liked_uuid(&self, i: usize) -> [i32; 4] {
// An allay saved before its player was kept belongs to the host.
self.mobs[i]
.extra
.owner
.filter(|u| *u != [0, 0, 0, 1])
.or(self.level.player_uuid)
.unwrap_or([0, 0, 0, 1])
}
/// `AllayAi.getLikedPlayer`: the player the allay was given an item by,
/// found by UUID among the host and the guests of the level (the window
/// is a guest on the server thread), when they are not a spectator and
@@ -98,13 +108,7 @@ impl Game<'_> {
if !m.extra.tame {
return None;
}
// An allay saved before its player was kept belongs to the host.
let uuid = m
.extra
.owner
.filter(|u| *u != [0, 0, 0, 1])
.or(self.level.player_uuid)
.unwrap_or([0, 0, 0, 1]);
let uuid = self.allay_liked_uuid(i);
let host = self
.player_view()
.filter(|p| !p.spectator && self.level.player_uuid.unwrap_or([0, 0, 0, 1]) == uuid)
@@ -159,6 +163,19 @@ impl Game<'_> {
];
let len = (d[0] * d[0] + d[1] * d[1] + d[2] * d[2]).sqrt().max(1e-6);
let v = [d[0] / len * 0.2, d[1] / len * 0.3, d[2] / len * 0.2];
// AllayAi.throwItem: the liked player hears of the block below the deposit.
let below = [
target.x.floor() as i32,
(target.y - 1.0).floor() as i32,
target.z.floor() as i32,
];
let uuid = self.allay_liked_uuid(i);
let stack = ItemStack::new(&item, 1);
self.for_each_player(|g| {
if g.acting_uuid() == uuid && !g.spectator() {
g.trigger_block("minecraft:allay_drop_item_on_block", below, &stack);
}
});
let m = &mut self.mobs[i];
m.extra.ints[1] -= 1;
m.extra.ints[3] = 60;
+32 -7
View File
@@ -138,7 +138,13 @@ impl Game<'_> {
pub(super) fn worn_armor_layers(&self, m: &Mob) -> Vec<(&Part, [f32; 4], u32)> {
let e = &m.extra;
self.armor_layers(
[&e.head, &e.chest, &e.legs, &e.feet].map(|s| s.as_deref().map(|i| (i, None))),
[
(e.head.as_deref(), e.armor[0].as_ref()),
(e.chest.as_deref(), e.armor[1].as_ref()),
(e.legs.as_deref(), e.armor[2].as_ref()),
(e.feet.as_deref(), e.armor[3].as_ref()),
]
.map(|(item, stack)| item.map(|i| (i, stack))),
)
}
/// The layers of the armour items `worn` (with their stacks when known,
@@ -179,9 +185,10 @@ impl Game<'_> {
) else {
continue;
};
let color = undyed.map_or(0xFFFF_FFFF, |c| {
0xFF00_0000 | (c & 0xFF) << 16 | (c & 0xFF00) | (c >> 16) & 0xFF
});
// EquipmentLayerRenderer.getColorForLayer: a dyeable layer
// takes the stack's dye colour, the colour it has when undyed
// otherwise.
let color = horizon_client::mobs::render::armor_tint(*undyed, stack);
out.push((part, [r.u0, r.v0, r.u1, r.v1], color));
// EquipmentLayerRenderer: the trim layer over the armour.
if let Some(r) = stack
@@ -207,6 +214,7 @@ impl Game<'_> {
.armor
.iter()
.chain(guests.flat_map(|g| g.armor.iter()))
.chain(self.mobs.iter().flat_map(|m| m.extra.armor.iter()))
.zip(
["helmet", "chestplate", "leggings", "boots"]
.into_iter()
@@ -491,6 +499,25 @@ impl Game<'_> {
if !self.before_block(d) || self.mob_hit().is_some_and(|m| m.1 < d) {
return false;
}
// The stand's box and id, before the hit may remove it.
let (area, id) = (self.armor_stands[i].bounds(), self.armor_stands[i].id);
let (_, _, _, magic) = self.melee_damage("minecraft:armor_stand", true);
let hurt = self.punch_armor_stand(i);
if hurt {
self.attack_effects(
area,
Some(id as i32),
super::mace::Spared::Stand(id),
magic.then_some(id as i32),
);
self.swing_arm();
}
hurt
}
/// `ArmorStand.hurtServer` for a player's attack (also a sweep's): a
/// creative player breaks it, otherwise the first hit wobbles it and a
/// second within five ticks breaks it.
pub(super) fn punch_armor_stand(&mut self, i: usize) -> bool {
let time = self.world.game_time;
let at = self.armor_stands[i].position;
let at = [at.x, at.y, at.z];
@@ -695,9 +722,7 @@ impl Game<'_> {
else {
continue;
};
let color = undyed.map_or(0xFFFF_FFFF, |c| {
0xFF00_0000 | (c & 0xFF) << 16 | (c & 0xFF00) | (c >> 16) & 0xFF
});
let color = horizon_client::mobs::render::armor_tint(*undyed, Some(stack));
// ArmorStandRenderer's HumanoidArmorLayer: the armour glint
// when `stack.hasFoil()`, as on a living entity.
let item_glint = self
+97
View File
@@ -0,0 +1,97 @@
//! Beds' `occupied` (`BedBlock.OCCUPIED`): the head of a bed someone sleeps
//! in, which the foot copies (`updateShape`). `LivingEntity.startSleeping`
//! sets it and `stopSleeping` clears it. A villager's come with its mob
//! events (`MobEvent::BedOccupied`); a player's bed is set when it lies
//! down (`try_sleep`) and freed in the level's tick once nobody sleeps in it
//! any more, whichever way they got up: waking, the Leave Bed button, death,
//! a broken bed, or leaving the game.
use super::{Dim, Game};
use horizon_client::Result;
impl Game<'_> {
/// `setBlockAndUpdate(head, state.setValue(OCCUPIED, occupied))`, when
/// the block at `head` is still a bed and not already so.
pub(super) fn set_bed_occupied(&mut self, head: [i32; 3], occupied: bool) {
let registry = &self.pack.assets.registry;
let Some(mut state) = registry
.state(self.world.block(head[0], head[1], head[2]))
.cloned()
else {
return;
};
let value = if occupied { "true" } else { "false" };
if state.properties.get("occupied").is_none_or(|v| v == value) {
return;
}
state.properties.insert("occupied".into(), value.into());
let Some(id) = registry.id(&state) else {
return;
};
if let Err(e) = self.level_set(head, id) {
log::warn!("could not mark the bed at {head:?} occupied={value}: {e}");
}
}
/// `LivingEntity.startSleeping` for the player in place (the host, or
/// a guest during its turn): the bed it lies in is occupied.
pub(super) fn occupy_bed(&mut self, head: [i32; 3]) {
self.set_bed_occupied(head, true);
if !self.player_beds.contains(&(self.dim, head)) {
self.player_beds.push((self.dim, head));
}
}
/// Whether a player in the level in place still sleeps in `head`.
fn player_in_bed(&self, head: [i32; 3]) -> bool {
self.sleeping == Some(head)
|| self.lan.as_ref().is_some_and(|h| {
h.hub
.guests
.iter()
.any(|g| g.bed == Some(head) && g.dimension == self.dim.id())
})
}
/// `LivingEntity.stopSleeping` for the players who got up: the beds of
/// this level nobody sleeps in any more are free again. Runs in each
/// level's tick, after the players' packets and sleep ticks, never inside
/// a guest's turn (where the hub is taken out of `Game`).
pub(super) fn free_left_beds(&mut self) {
let dim = self.dim;
let left: Vec<[i32; 3]> = self
.player_beds
.iter()
.filter(|(d, head)| *d == dim && !self.player_in_bed(*head))
.map(|(_, head)| *head)
.collect();
for head in left {
self.player_beds.retain(|b| *b != (dim, head));
self.set_bed_occupied(head, false);
}
}
/// The levels with a bed a player marked occupied: they tick until the
/// bed is freed.
pub(super) fn player_bed_levels(&self) -> impl Iterator<Item = Dim> + '_ {
self.player_beds.iter().map(|(d, _)| *d)
}
/// `PlayerList.removeAll` before the server's last save: every player
/// leaves (`ServerPlayer.disconnect` stops its sleep), so no bed is
/// saved occupied by a player who is not in it when the world opens
/// again. Each bed is freed in its own level.
pub(super) fn free_player_beds(&mut self) -> Result<()> {
if self.player_beds.is_empty() {
return Ok(());
}
let back = self.dim;
for (dim, head) in std::mem::take(&mut self.player_beds) {
self.enter_level(None, dim)?;
// The in-frame game has only the level it is in.
if self.dim == dim {
self.set_bed_occupied(head, false);
}
}
self.enter_level(None, back)
}
}
@@ -7,6 +7,7 @@ use super::Game;
use horizon_client::{
block_entity_anim::{
BellEntity, ConduitServer, ConduitTarget, EndGateway, Shulker, ShulkerStatus, Ticker,
closer_to_center_than,
},
block_entity_render::BlockAnim,
collisions::{SceneCollisions, no_collision_empty},
@@ -49,11 +50,16 @@ impl Game<'_> {
}
Some(Ticker::Bell) => {
let gathered = self.bell_gather(pos);
self.be_anims
.bells
.entry(pos)
.or_default()
.trigger_event(a, b, time, || gathered);
let rung =
self.be_anims
.bells
.entry(pos)
.or_default()
.trigger_event(a, b, time, || gathered);
// The server's copy tells the living entities within 32 blocks.
if rung && self.remote.is_none() {
self.bell_heard(pos, time);
}
}
Some(Ticker::Gateway) => {
let initial = self.gateway_loaded(pos);
@@ -200,6 +206,26 @@ impl Game<'_> {
}
out
}
/// `BellBlockEntity.updateEntities`: every gathered entity alive within 32
/// blocks of the bell's centre gets `HEARD_BELL_TIME` (only villagers
/// have a brain that reads it).
fn bell_heard(&mut self, pos: [i32; 3], now: i64) {
let nearby = self
.be_anims
.bells
.get(&pos)
.and_then(|b| b.nearby.clone())
.unwrap_or_default();
for m in &mut self.mobs {
if m.alive()
&& !m.removed
&& nearby.contains(&u64::from(m.id))
&& closer_to_center_than(pos, [m.position.x, m.position.y, m.position.z], 32.0)
{
horizon_client::mobs::villager_brain::heard_bell(m, now);
}
}
}
/// `ShulkerBoxBlock.canOpen`: a closed box opens only when its lid has
/// room to rise (`level.noCollision` of the half-open lid box).
pub(super) fn shulker_can_open(&self, pos: [i32; 3]) -> bool {
@@ -23,12 +23,16 @@ impl Game<'_> {
let states = self.ticking_states();
self.world.block_entities.set_states(states);
}
self.forget_gone_hoppers();
// Only lingering cooldowns are dropped; a hopper that ticks checks
// its own block. Once a second is soon enough for the rest.
if self.world.game_time % 20 == 0 {
self.forget_gone_hoppers();
}
let ticking = self.block_ticking_area();
let mut spawners = Vec::new();
let mut trial_spawners = Vec::new();
self.item_columns = Some(Default::default());
for slot in self.world.block_entity_tickers() {
for slot in self.world.block_entity_tickers().iter().copied() {
let pos = slot.0;
// Ticks before this one can remove it (`TickingBlockEntity.isRemoved`).
if !self.world.block_entities.is_live(slot)
+6 -2
View File
@@ -811,15 +811,19 @@ impl Game<'_> {
true
}
/// `VehicleEntity.hurtServer` from the player's attack.
pub(super) fn attack_boat(&mut self, damage: f32) -> bool {
pub(super) fn attack_boat(&mut self) -> bool {
let Some((i, d)) = self.boat_hit() else {
return false;
};
if !self.before_block(d) || self.mob_hit().is_some_and(|m| m.1 < d) {
return false;
}
self.vehicle_attack_sounds();
let area = self.boats[i].bounds();
// Player.attack's full damage against the boat's entity type.
let (kind, id) = (self.boats[i].entity_type(), self.boats[i].id as i32);
let (damage, _, _, magic) = self.melee_damage(&kind, false);
self.hurt_boat(i, damage, self.creative);
self.vehicle_attack_sounds(area, magic.then_some(id));
self.swing_arm();
true
}
+23 -2
View File
@@ -95,6 +95,11 @@ impl Game<'_> {
/// `BookViewScreen.mouseClicked`: the click event of the text under the
/// cursor on the page.
fn book_link_at(&self, page: &Text, left: f32) -> Option<horizon_client::text::ClickEvent> {
self.book_style_at(page, left)?.click
}
/// `ActiveTextCollector` over the page: the style of the character
/// under the cursor, for its click and hover events.
fn book_style_at(&self, page: &Text, left: f32) -> Option<horizon_client::text::Style> {
let (mx, my) = self.cursor;
let row = ((my - 32.0) / 9.0).floor();
if row < 0.0 || row >= LINES as f32 {
@@ -109,7 +114,7 @@ impl Game<'_> {
for (c, style) in line.chars() {
x -= self.gui.atlas.font.advance(c, style);
if x < 0.0 {
return style.click.clone();
return Some(style.clone());
}
}
None
@@ -139,7 +144,12 @@ impl Game<'_> {
}
}
ClickEvent::OpenUrl(url) => self.click_url(url),
ClickEvent::RunCommand(command) => self.click_command(&command),
// closeContainerOnServer, then clickCommandAction with no screen
// after the command: the book closes and the command runs.
ClickEvent::RunCommand(command) => {
self.close_book();
self.click_command(&command);
}
ClickEvent::SuggestCommand(command) => {
// defaultHandleClickEvent: the chat screen takes it over.
if self.player.is_some() {
@@ -147,6 +157,7 @@ impl Game<'_> {
self.open_chat(&command);
}
}
// Screen.defaultHandleClickEvent: copied, the book stays open.
ClickEvent::CopyToClipboard(value) => {
use horizon_client::clipboard::Clipboard;
self.clipboard.set(&value);
@@ -1311,6 +1322,16 @@ impl Game<'_> {
true,
);
}
// HoveredTextEffects.TOOLTIP_AND_CURSOR: the hovered words'
// show_text hover event, as the chat shows it.
if let Some(hover) = pages
.get(*page)
.and_then(|t| self.book_style_at(t, left))
.and_then(|s| s.hover)
{
let lines = self.wrapped_rows(&hover, (w as i32 / 2).max(200));
self.draw_tooltip_lines(batch, &lines, mx, my);
}
}
}
}
+98 -6
View File
@@ -1,8 +1,11 @@
//! Brushing suspicious sand and gravel: `BrushItem.onUseTick` and
//! `BrushableBlockEntity`. The block's loot table stays in its saved block
//! entity (like a decorated pot's) until the first counted stroke rolls it.
use super::{Game, Hit, decorated_pot, spill, view_vector};
use super::{Game, Hit, decorated_pot, view_vector};
use horizon_client::{
block_entity_render::vertex_light,
containers::BlockEntity,
mesher::Vertex,
particles::{ParticleOptions, ParticleType},
save,
};
@@ -114,6 +117,20 @@ impl Game<'_> {
let time = self.world.game_time;
let mut brush = self.brushing.remove(&pos).unwrap_or_default();
brush.hit_face.get_or_insert(face);
let direction = match brush.hit_face.unwrap() {
[0, -1, 0] => 0,
[0, 1, 0] => 1,
[0, 0, -1] => 2,
[0, 0, 1] => 3,
[-1, 0, 0] => 4,
[1, 0, 0] => 5,
_ => return false,
};
if let Some(BlockEntity::Brushable(b)) =
self.world.block_entities.get_mut(&(pos[0], pos[1], pos[2]))
{
b.hit_direction = Some(direction);
}
brush.resets_at = time + 40;
if time < brush.cooldown_ends_at {
self.brushing.insert(pos, brush);
@@ -139,12 +156,22 @@ impl Game<'_> {
/// `BrushableBlockEntity.unpackLootTable`: the table's roll becomes the
/// block entity's item.
fn unpack_brush_loot(&mut self, pos: [i32; 3]) {
let Some(mut tag) = spill::saved_entity(&self.world, pos) else {
let key = (pos[0], pos[1], pos[2]);
let Some(BlockEntity::Brushable(b)) = self.world.block_entities.get(&key) else {
return;
};
if tag.get("LootTable").is_none() {
if b.loot_table.is_none() {
return;
}
let mut tag = save::encode_block_entity(
key,
&BlockEntity::Brushable(b.clone()),
"minecraft:brushable_block",
);
if let Some(horizon_world::nbt::Tag::String(table)) = tag.get("LootTable") {
let table = table.to_string_lossy();
self.trigger_loot_table(&table);
}
let loot = horizon_client::structure_blocks::loot_data(&self.options.resources);
decorated_pot::unpack_loot(
&mut tag,
@@ -153,7 +180,12 @@ impl Game<'_> {
&mut self.rand_sequences,
self.level.seed,
);
spill::put_saved_entity(&mut self.world, pos, tag);
if let Some(BlockEntity::Brushable(b)) = self.world.block_entities.get_mut(&key) {
b.loot_table = tag.get("LootTable").cloned();
b.loot_seed = tag.get("LootTableSeed").cloned();
b.item = tag.get("item").and_then(save::item_from_nbt);
}
self.world.modified.insert((pos[0] >> 4, pos[2] >> 4));
}
/// `BrushableBlockEntity.brushingCompleted`: the item drops out of the
@@ -166,8 +198,10 @@ impl Game<'_> {
_ => "minecraft:air",
};
// dropContent: the item at the middle of the block beside that face.
let item = spill::saved_entity(&self.world, pos)
.and_then(|tag| tag.get("item").and_then(save::item_from_nbt));
let item = match self.world.block_entities.get(&(pos[0], pos[1], pos[2])) {
Some(BlockEntity::Brushable(b)) => b.item.clone(),
_ => None,
};
if let Some(mut item) = item {
let face = face.unwrap_or([0, 1, 0]);
let take = 10 + self.world.random.next_int_bound(21).unwrap_or(0);
@@ -221,12 +255,70 @@ impl Game<'_> {
if brush.count != 0 {
self.brushing.insert(pos, brush);
self.schedule_brushable_tick(pos);
} else if let Some(BlockEntity::Brushable(b)) =
self.world.block_entities.get_mut(&(pos[0], pos[1], pos[2]))
{
b.hit_direction = None;
}
if before != after {
self.set_dusted(pos, after);
}
}
/// `BrushableBlockRenderer`: reveal the item from the first brushed face.
pub(super) fn brushable_geometry(&self, camera: glam::DVec3, out: &mut Vec<Vertex>) {
use horizon_assets::item_model::DisplayContext;
for (key, entity) in &self.world.block_entities {
let BlockEntity::Brushable(b) = entity else {
continue;
};
let (Some(item), Some(face)) = (&b.item, b.hit_direction) else {
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;
}
let state = self.world.block(key.0, key.1, key.2);
let progress = self
.pack
.assets
.registry
.state(state)
.and_then(|s| s.properties.get("dusted"))
.and_then(|s| s.parse::<u8>().ok())
.unwrap_or(0);
if progress == 0 {
continue;
}
let offset = f32::from(progress) * 0.075;
let (translation, adjacent, east_west) = match face {
0 => (glam::Vec3::new(0.5, -0.23 - offset, 0.5), [0, -1, 0], false),
1 => (glam::Vec3::new(0.5, 0.25 + offset, 0.5), [0, 1, 0], false),
2 => (glam::Vec3::new(0.5, 0.0, 0.25 - offset), [0, 0, -1], false),
3 => (glam::Vec3::new(0.5, 0.0, 0.73 + offset), [0, 0, 1], false),
4 => (glam::Vec3::new(0.25 - offset, 0.0, 0.5), [-1, 0, 0], true),
5 => (glam::Vec3::new(0.73 + offset, 0.0, 0.5), [1, 0, 0], true),
_ => continue,
};
let item = self.resolve_item(item, DisplayContext::Fixed, None, 0);
if item.is_empty() {
continue;
}
let pose = glam::Mat4::from_translation(
(at - camera).as_vec3() + glam::Vec3::new(0.0, 0.5, 0.0) + translation,
) * glam::Mat4::from_rotation_y(
(86.0_f32 + if east_west { 90.0 } else { 0.0 }).to_radians(),
) * glam::Mat4::from_scale(glam::Vec3::splat(0.5));
let light = self.world.light(
key.0 + adjacent[0],
key.1 + adjacent[1],
key.2 + adjacent[2],
);
self.submit_item_world(&item, pose, vertex_light(light), out);
}
}
/// Level event 3008 (`LevelEventHandler`): the finished block's sound
/// and break particles.
pub(super) fn brush_complete_event(&mut self, pos: [i32; 3], data: i32) {
@@ -155,7 +155,7 @@ impl MapData {
m.unlimited = self.unlimited;
m
}
/// `MapItemSavedData.locked`: the same pixels, locked.
/// `MapItemSavedData.locked`: the same pixels and banner markers, locked.
pub(super) fn locked_copy(&self) -> MapData {
let mut m = MapData::fresh(
f64::from(self.center[0]),
@@ -165,6 +165,7 @@ impl MapData {
);
m.center = self.center;
m.colors = self.colors.clone();
m.banners = self.banners.clone();
m.tracking = self.tracking;
m.unlimited = self.unlimited;
m.locked = true;
+133 -76
View File
@@ -12,7 +12,11 @@ use horizon_client::{
screen_draw::Measure,
text::{ClickEvent, Style, Text},
};
use std::collections::BTreeMap;
use std::{
collections::BTreeMap,
path::{Path, PathBuf},
sync::{Arc, Mutex},
};
/// The tree the chat parses and completes with: a server's, once it has
/// sent one (ClientPacketListener.commands), else the game's own.
@@ -123,6 +127,64 @@ impl UiState {
}
}
/// The ids `build_registries` lists from the prepared resources' folders.
/// The folders don't change while the game runs, so each root is read once
/// instead of at every world opening (thousands of files).
struct ResourceIds {
recipes: Vec<String>,
damage_types: Vec<String>,
loot_tables: Vec<String>,
templates: Vec<String>,
}
static RESOURCE_IDS: Mutex<Vec<(PathBuf, Arc<ResourceIds>)>> = Mutex::new(Vec::new());
fn resource_ids(root: &Path) -> Arc<ResourceIds> {
let mut cache = RESOURCE_IDS.lock().unwrap_or_else(|e| e.into_inner());
if let Some((_, ids)) = cache.iter().find(|(cached, _)| cached == root) {
return ids.clone();
}
let data = root.join("data/minecraft");
let ids = Arc::new(ResourceIds {
recipes: ids_under(&data.join("recipe"), "json"),
damage_types: ids_under(&data.join("damage_type"), "json"),
loot_tables: ids_under(&data.join("loot_table"), "json"),
templates: ids_under(&root.join("worldgen/structure/minecraft"), "nbt"),
});
cache.push((root.to_owned(), ids.clone()));
ids
}
/// The `minecraft:` ids of the `extension` files under `root`, sorted.
fn ids_under(root: &Path, extension: &str) -> Vec<String> {
let mut out = Vec::new();
let mut stack = vec![root.to_owned()];
while let Some(dir) = stack.pop() {
let Ok(entries) = std::fs::read_dir(&dir) else {
continue;
};
for e in entries.flatten() {
let path = e.path();
// The listing's own type, without another look at each file; a
// link is followed, as `Path::is_dir` does.
let is_dir = e
.file_type()
.is_ok_and(|t| t.is_dir() || (t.is_symlink() && path.is_dir()));
if is_dir {
stack.push(path);
} else if path.extension().and_then(|x| x.to_str()) == Some(extension)
&& let Ok(rel) = path.strip_prefix(root)
{
out.push(format!(
"minecraft:{}",
rel.with_extension("").to_string_lossy().replace('\\', "/")
));
}
}
}
out.sort();
out
}
impl Game<'_> {
/// `PlayerList.isOp` for the single-player owner: the world's Allow Commands.
pub(super) fn allow_commands(&self) -> bool {
@@ -146,61 +208,11 @@ impl Game<'_> {
let permission = owner_permission(self.allow_commands());
let reg = &mut self.ui.registries;
reg.permission = permission;
{
let dir = |sub: &str| -> Vec<String> {
let root = self.options.resources.join("data/minecraft").join(sub);
let mut out = Vec::new();
let mut stack = vec![root.clone()];
while let Some(d) = stack.pop() {
let Ok(entries) = std::fs::read_dir(&d) else {
continue;
};
for e in entries.flatten() {
let p = e.path();
if p.is_dir() {
stack.push(p);
} else if p.extension().and_then(|x| x.to_str()) == Some("json")
&& let Ok(rel) = p.strip_prefix(&root)
{
out.push(format!(
"minecraft:{}",
rel.with_extension("").to_string_lossy().replace('\\', "/")
));
}
}
}
out.sort();
out
};
self.ui.registries.recipes = dir("recipe");
self.ui.registries.damage_types = dir("damage_type");
self.ui.registries.loot_tables = dir("loot_table");
self.ui.registries.templates = {
let root = self.options.resources.join("worldgen/structure/minecraft");
let mut out = Vec::new();
let mut stack = vec![root.clone()];
while let Some(d) = stack.pop() {
let Ok(entries) = std::fs::read_dir(&d) else {
continue;
};
for e in entries.flatten() {
let p = e.path();
if p.is_dir() {
stack.push(p);
} else if p.extension().and_then(|x| x.to_str()) == Some("nbt")
&& let Ok(rel) = p.strip_prefix(&root)
{
out.push(format!(
"minecraft:{}",
rel.with_extension("").to_string_lossy().replace('\\', "/")
));
}
}
}
out.sort();
out
};
}
let ids = resource_ids(&self.options.resources);
self.ui.registries.recipes = ids.recipes.clone();
self.ui.registries.damage_types = ids.damage_types.clone();
self.ui.registries.loot_tables = ids.loot_tables.clone();
self.ui.registries.templates = ids.templates.clone();
self.ui.registries.sounds = self.audio.event_names();
self.ui.registries.attributes = [
"armor",
@@ -808,16 +820,19 @@ impl Game<'_> {
&self,
url: &str,
size: (f32, f32),
) -> horizon_client::confirm_screen::Confirm {
) -> horizon_client::confirm_screen::Choice {
let warning = Text::translate("chat.link.warning", &[]).with_color(0xFF_CCCC);
let message = Text::literal(url)
.append(Text::literal("\n"))
.append(warning);
horizon_client::confirm_screen::Confirm::new(
// ConfirmLinkScreen.addButtons: yes, copy and no, 100 wide each.
horizon_client::confirm_screen::Choice::new(
&self.gui.atlas.font,
size,
&Text::translate("chat.link.confirm", &[]),
&message,
3,
100.0,
)
}
pub(super) fn draw_link_prompt(&self, batch: &mut HudBatch) {
@@ -827,12 +842,13 @@ impl Game<'_> {
let atlas = &self.gui.atlas;
let (w, h) = (batch.width, batch.height);
horizon_client::ui::menu_background(batch, atlas, 0.0, 0.0, w, h, true);
let (yes, no) = (
let labels = [
Text::translate("gui.yes", &[]),
Text::translate("chat.copy", &[]),
Text::translate("gui.no", &[]),
);
];
self.link_prompt_layout(url, (w, h))
.draw(batch, atlas, self.cursor, [&yes, &no]);
.draw(batch, atlas, self.cursor, &labels);
}
/// The prompt's answer: the link opens on yes; either way the chat is back.
pub(super) fn answer_link_prompt(&mut self, yes: bool) {
@@ -843,15 +859,20 @@ impl Game<'_> {
}
}
pub(super) fn link_prompt_click(&mut self) {
let Some(url) = &self.link_prompt else {
let Some(url) = self.link_prompt.clone() else {
return;
};
let answer = self
.link_prompt_layout(url, self.gui_size())
.answer_at(self.cursor);
if let Some(yes) = answer {
let pressed = self
.link_prompt_layout(&url, self.gui_size())
.pressed_at(self.cursor);
if let Some(button) = pressed {
self.audio.play_button_click();
self.answer_link_prompt(yes);
if button == LINK_COPY {
// ConfirmLinkScreen.copyToClipboard, then the callback's no.
use horizon_client::clipboard::Clipboard;
self.clipboard.set(&url);
}
self.answer_link_prompt(button == LINK_YES);
}
}
/// `Screen.clickCommandAction`: run a clicked command as the player
@@ -949,7 +970,7 @@ impl Game<'_> {
}
}
/// `Font.split`: a tooltip text wrapped at `width`, one white row per line.
fn wrapped_rows(&self, text: &Text, width: i32) -> Vec<(Text, u32)> {
pub(super) fn wrapped_rows(&self, text: &Text, width: i32) -> Vec<(Text, u32)> {
let split = self
.gui
.atlas
@@ -992,6 +1013,7 @@ impl Game<'_> {
spawn_mobs: r.bool("spawn_mobs"),
spawn_monsters: r.bool("spawn_monsters"),
fire_spread_radius_around_player: r.int("fire_spread_radius_around_player"),
spread_vines: r.bool("spread_vines"),
}
}
/// Apply rules mirrored in other state (natural regeneration, difficulty).
@@ -1016,10 +1038,7 @@ impl Game<'_> {
self.ui.rules = GameRules::load(&self.world_root);
self.ui.clock = WorldClock::default();
// PrimaryLevelData: the difficulty comes back from level.dat's difficulty_settings.
self.ui.difficulty = horizon_client::title::DIFFICULTIES
.iter()
.position(|d| *d == self.level.difficulty)
.map_or(2, |i| i as u8);
self.ui.difficulty = self.level.difficulty_id();
if let Ok(bytes) = std::fs::read(self.world_root.join("data/minecraft/world_clocks.dat"))
&& let Ok(raw) = horizon_world::compression::Compression::Gzip.decode(&bytes, 1 << 20)
&& let Ok((root, _)) =
@@ -1116,9 +1135,19 @@ impl Game<'_> {
None => self.combat_death_message(),
};
self.ui.death_text = Some(message.clone());
// ServerPlayer.die: a team that hides death messages keeps it out of
// the dying player's own chat (the death screen still shows it).
if horizon_client::scoreboard::own_death_message_shown(self.player_team()) {
// ServerPlayer.die: the team's death message visibility decides who
// hears it, the dying player's own chat included (the death screen
// still shows it).
let board = &self.score.board;
if let Some(host) = &self.lan {
host.hub.system_message_to(
horizon_client::lan::data::component_nbt(&message.component()),
|other| {
horizon_client::scoreboard::death_message_reaches(board, PLAYER_NAME, other)
},
);
}
if horizon_client::scoreboard::death_message_reaches(board, PLAYER_NAME, PLAYER_NAME) {
self.system_message(message);
}
}
@@ -1139,7 +1168,10 @@ impl Game<'_> {
return;
};
let path = dir.join(name);
renderer.screenshot = Some(path.clone());
renderer.screenshot = Some(horizon_client::gfx::ScreenshotRequest {
path: path.clone(),
size: None,
});
self.ui.screenshot_message = Some(path);
}
/// After a frame: report a finished F2 screenshot in chat.
@@ -1210,3 +1242,28 @@ pub fn screenshot_name(dir: &std::path::Path) -> String {
fn owner_permission(allow_commands: bool) -> u8 {
if allow_commands { 4 } else { 0 }
}
/// `ConfirmLinkScreen`'s buttons, left to right: yes, copy, no.
const LINK_YES: usize = 0;
const LINK_COPY: usize = 1;
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn resource_ids_are_listed_once_per_root() {
let root = std::env::temp_dir().join(format!("horizon-ids-{}", std::process::id()));
let recipe = root.join("data/minecraft/recipe");
std::fs::create_dir_all(recipe.join("sub")).unwrap();
std::fs::write(recipe.join("b.json"), "{}").unwrap();
std::fs::write(recipe.join("sub/a.json"), "{}").unwrap();
std::fs::write(recipe.join("notes.txt"), "").unwrap();
let first = resource_ids(&root);
assert_eq!(first.recipes, ["minecraft:b", "minecraft:sub/a"]);
assert!(first.loot_tables.is_empty());
std::fs::write(recipe.join("c.json"), "{}").unwrap();
assert!(Arc::ptr_eq(&first, &resource_ids(&root)));
let _ = std::fs::remove_dir_all(&root);
}
}
@@ -0,0 +1,278 @@
//! A server's Code of Conduct bound to the game (`ClientConfigurationPacketListenerImpl.
//! handleCodeOfConduct` and `CodeOfConductScreen`): the text in a scroll area,
//! the "do not notify again" box, Acknowledge and Disconnect, and the server
//! list entry that keeps the accepted text. The server waits in configuration
//! until the answer, so the connection stays open while the screen shows.
use super::{Game, Screen, server_packs::saves};
use horizon_client::{
Result,
dialog_screen::BOX,
font::Font,
hud::{HudBatch, rgba},
multiplayer,
text::Text,
ui,
};
use horizon_core::random::java_string_hash;
use horizon_protocol::servers;
/// The text's line height.
const LINE: f32 = 9.0;
/// The footer's button: `Button.DEFAULT_WIDTH` and the row's spacing of 8.
const BUTTON: (f32, f32) = (150.0, 20.0);
const GAP: f32 = 8.0;
/// The text box's cell padding (`s.padding(12)`) and the text's padding inside it.
const MARGIN: f32 = 12.0;
const PAD: f32 = 4.0;
/// Narrowest column: the two buttons and their gap.
const COLUMN: f32 = 2.0 * BUTTON.0 + GAP;
/// An open `CodeOfConductScreen`.
pub(super) struct Prompt {
text: String,
/// `text.hashCode()`: what the server list keeps once it is accepted.
hash: i32,
/// `stopShowing`: the box is ticked.
stop_showing: bool,
scroll: ui::Scroll,
}
/// Where the screen's parts go (`WarningScreen`: title, the text box with 12 px
/// around it, the checkbox, then the buttons, 8 px apart): the title and the
/// wrapped text in its box (`area`: x, y, w, h), then the check and the
/// two buttons.
struct Layout {
title: (Text, f32, f32),
lines: Vec<Text>,
area: [f32; 4],
/// The text's full height with its padding: more than the area's scrolls.
content: f32,
check: [f32; 4],
label: Text,
buttons: [[f32; 4]; 2],
}
impl Prompt {
fn layout(&self, font: &Font, (w, h): (f32, f32)) -> Layout {
let title = Text::translate("multiplayer.codeOfConduct.title", &[]).bold();
let label = Text::translate("multiplayer.codeOfConduct.check", &[]);
// `FittingMultiLineTextWidget`: `width - 100` wide, `height - 100` at
// most, the text 4 px inside.
let col = (w - 100.0).max(COLUMN);
let lines: Vec<Text> = font
.split_lines(&Text::literal(self.text.as_str()), col - 2.0 * PAD)
.into_iter()
.map(|(line, _)| line)
.collect();
let content = LINE * lines.len() as f32 + 2.0 * PAD;
let box_h = content.min((h - 100.0).max(LINE + 2.0 * PAD));
let fixed = LINE + GAP + MARGIN + MARGIN + GAP + BOX + GAP + BUTTON.1;
let total = fixed + box_h;
let ly = ((h - total) / 2.0).trunc();
let cell = |cw: f32| ((w - cw) / 2.0).trunc();
let area_y = ly + LINE + GAP + MARGIN;
let check_y = area_y + box_h + MARGIN + GAP;
let check_w = BOX + 4.0 + font.width_text(&label);
let button_y = check_y + BOX + GAP;
let bx = cell(COLUMN);
Layout {
title: (title.clone(), cell(font.width_text(&title)), ly),
lines,
area: [cell(col), area_y, col, box_h],
content,
check: [cell(check_w), check_y, check_w, BOX],
label,
buttons: [
[bx, button_y, BUTTON.0, BUTTON.1],
[bx + BUTTON.0 + GAP, button_y, BUTTON.0, BUTTON.1],
],
}
}
}
impl Game<'_> {
/// The server list entry of the joined address, if it has one.
fn accepted_code_of_conduct(&self) -> Option<i32> {
let address = self.server_packs.address.as_deref()?;
servers::load(&multiplayer::servers_file(&saves()))
.into_iter()
.find(|s| s.ip == address)
.map(|s| s.accepted_code_of_conduct)
}
/// `ServerList.saveSingleServer`: the joined address's entry keeps `accepted`.
fn store_code_of_conduct(&self, accepted: i32) {
let Some(address) = &self.server_packs.address else {
return;
};
let file = multiplayer::servers_file(&saves());
let mut list = servers::load(&file);
let mut found = false;
for entry in list.iter_mut().filter(|s| &s.ip == address) {
entry.accepted_code_of_conduct = accepted;
found = true;
}
if found && let Err(e) = servers::save(&file, &list) {
log::warn!("Horizon: could not save {}: {e}", file.display());
}
}
/// `handleCodeOfConduct`: text accepted before for this server is
/// accepted at once; otherwise the screen opens over the connecting one.
pub(super) fn handle_code_of_conduct(&mut self, text: String) {
let Some(r) = &self.remote else {
return;
};
let hash = java_string_hash(text.encode_utf16());
if self.accepted_code_of_conduct() == Some(hash) {
r.session.accept_code_of_conduct();
return;
}
self.capture(false);
self.code_of_conduct = Some(Prompt {
text,
hash,
stop_showing: false,
scroll: ui::Scroll::default(),
});
}
/// `CodeOfConductScreen.onResult`: accepting tells the server, declining
/// ends the connection. The entry keeps the text only when it was
/// accepted with the box ticked, else the stored text is cleared.
fn answer_code_of_conduct(&mut self, accepted: bool) -> Result<()> {
let Some(prompt) = self.code_of_conduct.take() else {
return Ok(());
};
if accepted {
if let Some(r) = &self.remote {
r.session.accept_code_of_conduct();
}
if self.player.is_some() && self.screen == Screen::Playing {
self.capture(true);
}
}
let kept = if accepted && prompt.stop_showing {
prompt.hash
} else {
0
};
self.store_code_of_conduct(kept);
if accepted {
return Ok(());
}
let reason = Text::translate("multiplayer.disconnect.code_of_conduct", &[]);
self.leave_server(Some(reason.plain()))
}
pub(super) fn draw_code_of_conduct(&self, batch: &mut HudBatch) {
let Some(prompt) = &self.code_of_conduct else {
return;
};
let atlas = &self.gui.atlas;
let (w, h) = (batch.width, batch.height);
if self.player.is_none() {
// In place of the connecting screen, on its background.
batch.fill(atlas, 0.0, 0.0, w, h, rgba(20, 20, 20, 255));
}
ui::menu_background(batch, atlas, 0.0, 0.0, w, h, self.player.is_some());
let layout = prompt.layout(&atlas.font, (w, h));
let cursor = self.cursor;
let [ax, ay, aw, ah] = layout.area;
let offset = prompt
.scroll
.amount
.clamp(0.0, (layout.content - ah).max(0.0));
let (title, tx, ty) = &layout.title;
batch.text_styled(atlas, title, *tx, *ty, u32::MAX, true);
ui::nine_slice(
batch,
atlas,
"gui/sprites/widget/text_field",
ax,
ay,
aw,
ah,
u32::MAX,
);
let start = batch.vertices.len();
for (i, line) in layout.lines.iter().enumerate() {
let y = ay + PAD + i as f32 * LINE - offset;
batch.text_styled(atlas, line, ax + PAD, y, u32::MAX, true);
}
batch.clip_since(start, ax + 1.0, ay + 1.0, ax + aw - 1.0, ay + ah - 1.0);
if layout.content > ah {
// AbstractTextAreaWidget.scrollBarX: the box's right edge.
ui::scrollbar(batch, atlas, ax + aw, ay, ay + ah, layout.content, offset);
}
let [x, y, cw, ch] = layout.check;
let hover = ui::hovered(cursor, x, y, cw, ch);
let sprite = match (prompt.stop_showing, hover) {
(true, true) => "gui/sprites/widget/checkbox_selected_highlighted",
(true, false) => "gui/sprites/widget/checkbox_selected",
(false, true) => "gui/sprites/widget/checkbox_highlighted",
(false, false) => "gui/sprites/widget/checkbox",
};
batch.sprite_scaled(atlas, sprite, x, y, BOX, BOX, u32::MAX);
batch.text_styled(
atlas,
&layout.label,
x + BOX + 4.0,
y + 4.0,
rgba(224, 224, 224, 255),
true,
);
let labels = [
Text::translate("gui.acknowledge", &[]),
Text::translate("menu.disconnect", &[]),
];
for ([x, y, bw, bh], label) in layout.buttons.into_iter().zip(&labels) {
let hover = ui::hovered(cursor, x, y, bw, bh);
ui::button(batch, atlas, x, y, bw, bh, label, true, hover);
}
}
/// A left click: the box toggles, a button answers.
pub(super) fn code_of_conduct_click(&mut self) -> Result<()> {
let size = self.gui_size();
let cursor = self.cursor;
let Some(prompt) = self.code_of_conduct.as_mut() else {
return Ok(());
};
let layout = prompt.layout(&self.gui.atlas.font, size);
let [x, y, w, h] = layout.check;
let toggled = ui::hovered(cursor, x, y, w, h);
if toggled {
prompt.stop_showing = !prompt.stop_showing;
}
let answer = layout
.buttons
.iter()
.position(|&[x, y, w, h]| ui::hovered(cursor, x, y, w, h))
.map(|i| i == 0);
if !toggled && answer.is_none() {
return Ok(());
}
self.audio.play_button_click();
match answer {
Some(accepted) => self.answer_code_of_conduct(accepted),
None => Ok(()),
}
}
/// The mouse wheel over the screen: the text scrolls 10 a step.
pub(super) fn code_of_conduct_scroll(&mut self, delta: f32) {
let size = self.gui_size();
let Some(prompt) = self.code_of_conduct.as_mut() else {
return;
};
let layout = prompt.layout(&self.gui.atlas.font, size);
let [_, ay, _, ah] = layout.area;
prompt
.scroll
.wheel(delta, 10.0, ay, ay + ah, layout.content);
}
}
+24 -2
View File
@@ -39,6 +39,11 @@ pub(super) struct CombatTracker {
pub(super) last_hurt_mob: Option<(u32, i64)>,
/// The mob whose projectile is hitting the player now.
pub(super) projectile_owner: Option<u32>,
/// The display name of the projectile hitting the player now: the
/// `DamageSource.directEntity` the death message names when no owner is
/// known (`AbstractArrow.onHitEntity` makes an ownerless arrow its own
/// cause).
pub(super) projectile_direct: Option<Text>,
/// The projectile hitting the player now is another player's that
/// `ServerPlayer.canHarmPlayer` refuses (the `pvp` rule, an ally).
pub(super) refuse_player_hit: bool,
@@ -46,6 +51,9 @@ pub(super) struct CombatTracker {
/// stand and the seconds their weapon disables a shield for
/// (`getSecondsToDisableBlocking`).
pub(super) player_attacker: Option<([f64; 3], f32)>,
/// While set, a sweep attack's reach to the other players is kept here
/// instead of landing, for the caller that holds the LAN host.
pub(super) sweeps: Option<Vec<super::lan_pvp::Sweep>>,
/// The mob exploding now (`ServerExplosion.source`): its team's friendly
/// fire decides whom the blast may hurt.
pub(super) explosion_source: Option<u32>,
@@ -208,6 +216,7 @@ impl Game<'_> {
}
self.combat.pending.clear();
self.combat.pending_direct.clear();
self.combat.projectile_direct = None;
self.combat.fall_before = self.player.as_ref().map_or(0.0, |p| p.fall_distance);
}
/// `FallLocation.getCurrentFallLocation` and the fall distance so far.
@@ -291,6 +300,11 @@ impl Game<'_> {
None
}
}
/// The custom name of a mob's main hand item, when it has one.
fn mob_main_hand_name(&self, id: u32) -> Option<Text> {
let m = self.mobs.iter().find(|m| m.id == id)?;
m.extra.main_hand_name.clone()
}
/// `CombatTracker.getDeathMessage`.
pub(super) fn combat_death_message(&mut self) -> Text {
self.combat_death_message_for(Text::literal(PLAYER_NAME))
@@ -344,8 +358,16 @@ impl Game<'_> {
}
// `getLocalizedDeathMessage`: the causing entity's name, or the
// direct entity's when nothing caused the damage.
if let Some((_, name)) = killer {
return Text::translate(&key, &[me, name]);
if let Some((id, name)) = killer {
// DamageSource.getLocalizedDeathMessage: the cause's item with a
// custom name gets the `<id>.item` message.
let (key, args) = horizon_client::scoreboard::death_message_args(
&key,
&me,
&name,
self.mob_main_hand_name(id).as_ref(),
);
return Text::translate(&key, &args);
}
if let Some(name) = direct {
return Text::translate(&key, &[me, name]);
+110 -20
View File
@@ -925,24 +925,7 @@ impl Game<'_> {
Ok(1)
}
"say" | "me" | "msg" => self.cmd_say(id, ctx),
"list" => {
let names = if ctx.has("uuids") {
Text::translate(
"commands.list.nameAndId",
&[
Text::literal(PLAYER_NAME),
Text::literal(self.player_uuid_string()),
],
)
} else {
Text::literal(PLAYER_NAME)
};
self.system_message(Text::translate(
"commands.list.players",
&[Text::literal("1"), Text::literal("8"), names],
));
Ok(1)
}
"list" => self.cmd_list(ctx),
_ => Err(err("command.failed", &[])),
}
}
@@ -1376,7 +1359,8 @@ impl Game<'_> {
self.player = Some(player);
return Err(err("command.failed", &[]));
}
self.trigger_dimension(from, dimension);
let at = player.position;
self.trigger_dimension(from, dimension, [at.x, at.y, at.z]);
let target = Vec3::new(pos[0], pos[1], pos[2]);
player.position = target;
player.velocity = Vec3::ZERO;
@@ -1851,7 +1835,7 @@ impl Game<'_> {
if self.ui.difficulty == 0 && t.category == "monster" {
return Err(err("commands.summon.failed.peaceful", &[]));
}
self.spawn_mob(&short, pos, true);
self.spawn_mob_spawned(&short, pos);
name = Text::translate(&format!("entity.minecraft.{short}"), &[]);
} else {
match short.as_str() {
@@ -2085,6 +2069,49 @@ impl Game<'_> {
self.world.modified.insert((pos[0] >> 4, pos[2] >> 4));
true
}
/// `RandomizableContainer.unpackLootTable` for a player who opens a
/// container: a roll the container still holds fires
/// `player_generates_container_loot` for its table, and from then on the
/// container saves its items, not the table.
pub(super) fn unpack_opened_loot(&mut self, key: (i32, i32, i32), name: &str) {
let Some(rolled) = self
.world
.chunks
.get(&(key.0 >> 4, key.2 >> 4))
.and_then(|c| c.saved.as_ref())
.and_then(|s| s.rolled_loot.iter().find(|r| r.pos == key))
.cloned()
else {
return;
};
// The roll is still there only while the container holds exactly its items.
let still_rolled = self
.world
.block_entities
.get_mut(&key)
.is_some_and(|entity| {
let mut tag = horizon_client::save::encode_block_entity(key, entity, name);
match &mut tag {
horizon_world::nbt::Tag::Compound(map) => rolled.restore(map),
_ => false,
}
});
if still_rolled {
self.trigger_loot_table(&rolled.table);
}
let Some(chunk) = self.world.chunks.get_mut(&(key.0 >> 4, key.2 >> 4)) else {
return;
};
let chunk = std::sync::Arc::make_mut(chunk);
let mut extras = chunk
.saved
.take()
.map(std::sync::Arc::unwrap_or_clone)
.unwrap_or_default();
extras.rolled_loot.retain(|r| r.pos != key);
chunk.saved = Some(std::sync::Arc::new(extras));
self.world.modified.insert((key.0 >> 4, key.2 >> 4));
}
/// RandomizableContainer.tryLoadLootTable for a tag a command gives a
/// container (`setblock`, `data`): an empty container with a `LootTable`
/// is rolled now, as a loaded chunk's are (`save::roll_loaded_loot`), and
@@ -2573,6 +2600,9 @@ impl Game<'_> {
)));
}
self.ui.difficulty = i;
// The level data holds the world's difficulty: it survives the
// reopen, so it follows the command.
self.level.set_difficulty(i);
self.sync_rules();
if i == 0 {
// Peaceful removes hostile mobs.
@@ -3092,6 +3122,66 @@ impl Game<'_> {
}
out
}
/// ListPlayersCommand: every online player, the host of a hosted world
/// first and then the guests in join order, with the count and the max.
fn cmd_list(&mut self, ctx: &Context) -> R<i32> {
// A single-player owner is a placed guest of the in-process server, so
// it is the host and needs no entry of its own.
let owner_listed = self
.lan
.as_ref()
.is_some_and(|h| h.hub.singleplayer_owner.is_some());
// The host is online unless this is a dedicated server, which has none.
let host = (self.options.dedicated.is_none() && !owner_listed).then(|| match &self.lan {
Some(h) => (h.host_uuid, h.host_name.clone()),
None => (
horizon_protocol::crypto::offline_uuid(PLAYER_NAME),
PLAYER_NAME.to_owned(),
),
});
let guests = self
.lan
.iter()
.flat_map(|h| h.hub.guests.iter())
.filter(|g| g.placed)
.map(|g| (g.uuid, g.name.clone()));
let players: Vec<(u128, String)> = host.into_iter().chain(guests).collect();
let max = self
.lan
.as_ref()
.map_or(horizon_client::lan::MAX_PLAYERS, |h| {
h.hub.lan.shared.max_players()
});
let uuids = ctx.has("uuids");
let mut names = Text::empty();
for (k, (uuid, name)) in players.iter().enumerate() {
if k > 0 {
// ComponentUtils.formatList: gray commas.
names.push(Text::colored(", ", text::GRAY));
}
if uuids {
names.push(Text::translate(
"commands.list.nameAndId",
&[
Text::literal(name.as_str()),
Text::literal(horizon_protocol::crypto::uuid_string(*uuid)),
],
));
} else {
names.push(self.player_text(name, *uuid));
}
}
let count = players.len();
self.system_message(Text::translate(
"commands.list.players",
&[
Text::literal(count.to_string()),
Text::literal(max.to_string()),
names,
],
));
Ok(count as i32)
}
fn cmd_say(&mut self, id: &str, ctx: &Context) -> R<i32> {
let raw = match ctx.get(if id == "me" { "action" } else { "message" }) {
Some(Arg::Str(s)) => s.clone(),
@@ -64,6 +64,17 @@ pub(in crate::game) struct ExecState {
pub function_depth: u32,
}
/// A modifier that brings the sources to `max_command_forks` ends the chain
/// (`BuildContexts.ERROR_FORK_LIMIT_REACHED`); the error is only shown when
/// no fork came before it (`CommandSourceStack.handleError`).
fn fork_limit_reached(forked: bool, limit: usize) -> R<i32> {
if forked {
Ok(0)
} else {
Err(err("command.forkLimit", &[&limit]))
}
}
/// Java's `(int)`/`(short)`/`(byte)` narrowing of a double.
fn java_int(v: f64) -> i32 {
v as i32
@@ -303,6 +314,8 @@ impl Game<'_> {
/// ExecuteCommand: `ctx` is the context holding the `execute` literal.
pub(in crate::game) fn run_execute(&mut self, ctx: &Context) -> R<i32> {
// BuildContexts: a modifier may not bring the sources to this many.
let fork_limit = self.ui.rules.int("max_command_forks") as usize;
let mut sources = vec![self.command_source.unwrap_or_else(|| self.player_source())];
let mut c = ctx;
// A fork modifier (as, at, positioned as, rotated as, facing entity,
@@ -354,6 +367,9 @@ impl Game<'_> {
let mut kept = Vec::new();
for s in sources {
let (keep, _) = self.with_source(s, |g| g.condition(c, want))?;
if kept.len() + usize::from(keep) >= fork_limit {
return fork_limit_reached(forked, fork_limit);
}
if keep {
kept.push(s);
}
@@ -369,7 +385,11 @@ impl Game<'_> {
};
let mut next = Vec::new();
for s in sources {
next.extend(self.with_source(s, |g| g.modify(word, c, s))?);
let new = self.with_source(s, |g| g.modify(word, c, s))?;
if next.len() + new.len() >= fork_limit {
return fork_limit_reached(forked, fork_limit);
}
next.extend(new);
}
sources = next;
}
@@ -1053,6 +1053,7 @@ impl Game<'_> {
impl Game<'_> {
/// The player's profile id as `UUID.toString()`: the saved id, else the
/// offline id of the player's name.
#[cfg_attr(not(feature = "mods"), allow(dead_code))]
pub(in crate::game) fn player_uuid_string(&self) -> String {
let id = self.level.player_uuid.map_or_else(
|| horizon_protocol::crypto::offline_uuid(super::super::chat::PLAYER_NAME),
@@ -6,7 +6,7 @@ use horizon_protocol::packets::play::{self, Clientbound as C};
impl Game<'_> {
/// ComponentArgument with selectors resolved for the source
/// (ComponentUtils.updateForEntity: names joined by ", ").
fn component_arg(&mut self, ctx: &Context, name: &str) -> R<Text> {
pub(in crate::game) fn component_arg(&mut self, ctx: &Context, name: &str) -> R<Text> {
let Some(Arg::Str(json)) = ctx.get(name) else {
return Ok(Text::empty());
};
@@ -113,7 +113,9 @@ impl Game<'_> {
"explosion" | "player_explosion" | "bad_respawn_point" => DamageKind::Explosion,
"fall" | "ender_pearl" | "fly_into_wall" => DamageKind::Fall,
"on_fire" => DamageKind::OnFire,
"in_fire" | "campfire" | "hot_floor" => DamageKind::InFire,
"in_fire" => DamageKind::InFire,
"hot_floor" => DamageKind::HotFloor,
"campfire" => DamageKind::Campfire,
"lava" => DamageKind::Lava,
"drown" => DamageKind::Drown,
"dry_out" => DamageKind::DryOut,
@@ -124,6 +126,21 @@ impl Game<'_> {
"freeze" => DamageKind::Freeze,
"in_wall" => DamageKind::InWall,
"thorns" => DamageKind::Thorns,
"cramming" => DamageKind::Cramming,
"falling_anvil" => DamageKind::FallingAnvil,
"falling_block" => DamageKind::FallingBlock,
"fireworks" => DamageKind::Fireworks,
"generic_kill" => DamageKind::GenericKill,
"lightning_bolt" => DamageKind::LightningBolt,
"out_of_world" => DamageKind::OutOfWorld,
"outside_border" => DamageKind::OutsideBorder,
"sonic_boom" => DamageKind::SonicBoom,
"spit" => DamageKind::Spit,
"stalagmite" => DamageKind::Stalagmite,
"starve" => DamageKind::Starve,
"sulfur_cube_hot" => DamageKind::SulfurCubeHot,
"wind_charge" => DamageKind::WindCharge,
"wither" => DamageKind::Wither,
_ => DamageKind::Generic,
}
}
+8 -4
View File
@@ -22,7 +22,7 @@ const HELP: &[&str] = &[
"tp X Y Z teleport the player",
"hotbar 1-9 select a hotbar slot",
"give ITEM [COUNT] add items to the inventory",
"render-distance N chunks, 2..64",
"render-distance N chunks, 2..128",
"lod-distance N far surface chunks, 0 disables, up to1024",
"time TICKS set the time of day",
"summon KIND [X Y Z] [baby] spawn a mob (in front of the player without a position)",
@@ -514,8 +514,9 @@ impl Game<'_> {
}
"render-distance" => {
let n: i32 = arg(1)?.parse()?;
if !(2..=64).contains(&n) {
return Err("render distance must be 2..64".into());
let max = horizon_client::options_file::MAX_RENDER_DISTANCE;
if !(2..=max).contains(&n) {
return Err(format!("render distance must be 2..{max}").into());
}
self.settings.render_distance = n;
self.apply_settings();
@@ -585,7 +586,10 @@ impl Game<'_> {
arg(1)?;
// The rest of the line: a path may hold spaces.
let path = line.trim_start().strip_prefix("screenshot").unwrap_or("");
renderer.screenshot = Some(path.trim().into());
renderer.screenshot = Some(horizon_client::gfx::ScreenshotRequest {
path: path.trim().into(),
size: None,
});
self.pending_screenshot = Some(reply.clone());
Ok(None)
}
+333 -44
View File
@@ -277,6 +277,19 @@ impl Game<'_> {
pub(super) fn spawn_mob(&mut self, kind: &str, at: [f64; 3], persistent: bool) -> Option<u32> {
self.spawn_mob_for(kind, at, persistent, false)
}
/// `SummonCommand` and `SpawnEggItem`: vanilla spawns the mob, but it
/// never calls `Mob.setPersistenceRequired` for it, so the mob is not
/// persistent: the despawn rule holds it like any other mob, and only
/// its NBT can make it persistent.
pub(super) fn spawn_mob_spawned(&mut self, kind: &str, at: [f64; 3]) -> Option<u32> {
// The spawn is not a natural one (no jockeys, the patrol leader roll
// stays), but the mob is not persistent.
let id = self.spawn_mob_for(kind, at, true, false)?;
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == id) {
m.persistent = false;
}
Some(id)
}
/// `spawn_mob` for a patrol or raid (`EntitySpawnReason.PATROL` / `EVENT`),
/// which never rolls a patrol leader.
pub(super) fn spawn_mob_for(
@@ -334,7 +347,7 @@ impl Game<'_> {
}
};
let id = self
.spawn_mob(kind, at, true)
.spawn_mob_spawned(kind, at)
.ok_or_else(|| format!("unknown or unsupported mob {kind}"))?;
if baby && let Some(m) = self.mobs.iter_mut().find(|m| m.id == id) {
m.extra.age = if m.species() == Species::Sniffer {
@@ -447,6 +460,9 @@ impl Game<'_> {
others_grid: None,
others_index: None,
daylight: self.dim != Dim::Nether && self.sky_darken() < 4,
// day.json: false from tick 12542, true from 23460 (a step track).
monsters_burn: self.dim == Dim::Overworld
&& !(12542..23460).contains(&self.world.day_time.rem_euclid(24000)),
game_time: self.world.game_time,
day_time: self.world.day_time,
raining: weather && rain > 0.2,
@@ -454,6 +470,7 @@ impl Game<'_> {
nether: self.dim == Dim::Nether,
block_tags: &self.res.items().block_tags,
biomes: self.res.biomes(),
max_cramming: self.ui.rules.int("max_entity_cramming"),
mob_griefing: self.ui.rules.bool("mob_griefing"),
mob_drops: self.ui.rules.bool("mob_drops"),
end: self.dim == Dim::End,
@@ -497,9 +514,9 @@ impl Game<'_> {
};
spawning::LocalDifficulty::new(base, self.world.day_time, inhabited, moon)
}
/// Advance every mob one tick, then apply their events.
pub(super) fn tick_creatures(&mut self) -> Result<()> {
self.attack_ticks = self.attack_ticks.saturating_add(1);
/// Generation's animals for the chunks generated since, once lit: chunk
/// generation places them whether the level ticks or not.
pub(super) fn spawn_fresh_chunks(&mut self) {
let fresh: Vec<(i32, i32)> = self
.world
.chunks
@@ -513,6 +530,10 @@ impl Game<'_> {
}
self.spawn_generation_creatures(key.0, key.1);
}
}
/// Advance every mob one tick, then apply their events.
pub(super) fn tick_creatures(&mut self) -> Result<()> {
self.attack_ticks = self.attack_ticks.saturating_add(1);
self.tick_leads();
self.spawn_natural();
// ServerChunkCache.tickChunks: the custom spawners tick only with spawn_mobs.
@@ -732,6 +753,7 @@ impl Game<'_> {
self.guardian_bubbles();
self.villager_reputation_tick();
self.merchant_tick();
self.villager_item_sense();
self.villager_pickups();
for m in dead {
self.mob_died(&m);
@@ -782,6 +804,12 @@ impl Game<'_> {
}
/// LivingEntity.die / dropAllDeathLoot.
fn mob_died(&mut self, m: &Mob) {
// LivingEntity.die: a sleeper gets up, and its bed is free again.
if let Some(bed) = horizon_client::mobs::villager::sleeping_pos(m) {
self.wake_villager(m.id);
// The body may be gone already.
self.set_bed_occupied(bed, false);
}
let killer = self.last_hurt_by_player.remove(&m.id);
let by_player = killer.is_some() || m.ai.last_hurt_by_player > 0;
let mut dying = m.clone();
@@ -789,14 +817,30 @@ impl Game<'_> {
// takes the experience (before the loot is dropped). It takes what
// would drop, whoever killed the mob.
self.sculk_mob_died(m);
let mut catalyst_xp = 0;
let consumed =
self.catalyst_takes_experience([m.position.x, m.position.y, m.position.z], |_| {
if horizon_client::mobs::drops::drops_experience(&dying) {
catalyst_xp = if horizon_client::mobs::drops::drops_experience(&dying) {
horizon_client::mobs::drops::base_experience(&mut dying).max(0) as u32
} else {
0
}
};
catalyst_xp
});
// CatalystListener: with experience to take, the player who last hurt
// the mob gets KILL_MOB_NEAR_SCULK_CATALYST.
if consumed && catalyst_xp > 0 {
let who = match killer {
Some(Some(conn)) => Some(super::commands::Target::Guest(conn)),
Some(None) if self.lan_acting.is_none() => Some(super::commands::Target::Player),
_ => None,
};
if let Some(who) = who {
self.for_player(who, |g| {
g.trigger_player("minecraft:kill_mob_near_sculk_catalyst")
});
}
}
if by_player {
// A guest's kill is theirs: their statistics, scores and
// advancements (LivingEntity.die credits lastHurtByPlayer).
@@ -856,7 +900,42 @@ impl Game<'_> {
self.stats.custom("mob_kills", 1);
self.score_player_killed(super::commands::Target::Mob(m.id));
let view = self.mob_entity(m);
self.trigger_entity("minecraft:player_killed_entity", "entity", &view);
let blow = m.last_damage.map(|(d, _)| self.damage_view(d));
self.trigger_killed(&view, blow.as_ref());
}
/// A mob's damage source as advancement predicates see it: its type, the
/// causing entity, and the projectile when one dealt it.
fn damage_view(&self, d: Damage) -> horizon_client::advancements::DamageView {
let source = match d.attacker {
Some(Who::Player) => Some(self.player_entity()),
Some(Who::Mob(id)) => self
.mobs
.iter()
.find(|o| o.id == id)
.map(|o| self.mob_entity(o)),
None => None,
};
let projectile = match d.kind {
DamageKind::Arrow => Some("minecraft:arrow"),
DamageKind::Fireball => Some("minecraft:fireball"),
_ => None,
};
let direct = match projectile {
Some(kind) => Some(horizon_client::advancements::EntityView {
kind: kind.into(),
position: d.from.unwrap_or_default(),
..Default::default()
}),
None => source.clone(),
};
horizon_client::advancements::DamageView {
kind: damage_type(d.kind, matches!(d.attacker, Some(Who::Player))).to_owned(),
// DamageSource.isDirect: the causing entity is the direct one.
is_direct: projectile.is_none(),
direct,
source,
..Default::default()
}
}
/// `dropAllDeathLoot` through `mobs::drops`: the entity loot table from the
/// world's random sequence, equipment by drop chance, species extras and
@@ -1104,13 +1183,20 @@ impl Game<'_> {
volume,
spread,
} => {
// The client's own level random: nothing drawn from the server's.
let mut r = horizon_core::random::LegacyRandom::new(
self.world.game_time.wrapping_mul(341_873_128_712),
);
let pitch = (r.next_float() - r.next_float()) * spread + 1.0;
self.audio
.play(&format!("minecraft:{sound}"), Some(at), volume, pitch)
// WitherBoss.customServerAiStep: the spawn roar is global
// level event 1023 at the block position.
if sound == "entity.wither.spawn" {
let pos = at.map(|v| v.floor() as i32);
self.global_level_event(1023, pos, 0);
} else {
// The client's own level random: nothing drawn from the server's.
let mut r = horizon_core::random::LegacyRandom::new(
self.world.game_time.wrapping_mul(341_873_128_712),
);
let pitch = (r.next_float() - r.next_float()) * spread + 1.0;
self.audio
.play(&format!("minecraft:{sound}"), Some(at), volume, pitch)
}
}
MobEvent::SoundId(name, at, volume, pitch) => {
self.audio.play(&name, Some(at), volume, pitch)
@@ -1193,14 +1279,18 @@ impl Game<'_> {
owner,
} => self.add_fangs(at, yaw, delay, owner),
MobEvent::Shoot(..) => {}
MobEvent::ExplosionSource { mob, charged } => {
MobEvent::ExplosionSource { mob, charged, name } => {
self.combat.explosion_source = Some(mob);
// The creeper causes the blast's damage, but it is out of
// the mob list already: its name comes with the event.
self.note_named_attacker("minecraft:explosion", mob, name);
if charged {
self.combat.charged_exploders.push(mob);
}
}
MobEvent::Explode(at, radius, species) => {
// The exploding mob causes the damage (Creeper.explodeCreeper).
let noted = self.combat.explosion_source.is_some();
let source_mob = self.combat.explosion_source.or_else(|| {
self.mobs
.iter()
@@ -1211,7 +1301,7 @@ impl Game<'_> {
.map(|m| m.id)
.next()
});
if let Some(id) = source_mob {
if !noted && let Some(id) = source_mob {
self.note_attacker("minecraft:explosion", id);
}
self.combat.explosion_source = source_mob;
@@ -1279,8 +1369,10 @@ impl Game<'_> {
MobEvent::Hiss { mob } => {
if let Some(m) = self.mobs.iter().find(|m| m.id == mob) {
let at = m.center();
let sound = horizon_client::mobs::finalize::variant_sound(m, "hiss")
.unwrap_or("entity.cat.hiss");
self.audio
.play("minecraft:entity.cat.hiss", Some(at), 1.0, 1.0);
.play(&format!("minecraft:{sound}"), Some(at), 1.0, 1.0);
}
}
MobEvent::Tamed { .. } | MobEvent::Buck { .. } => self.mount_event(&e),
@@ -1296,6 +1388,26 @@ impl Game<'_> {
}
}
},
MobEvent::Bonemeal { pos } => {
let at = BlockPos::new(pos[0], pos[1], pos[2]);
if self.with_level(|b, level, ticks| b.grow_crop(level, ticks, at)) {
self.level_events
.push(horizon_world::level::LevelEvent::LevelEvent {
id: 1505,
pos: at,
data: 15,
});
let _ = self.process_level_events();
}
}
MobEvent::RingBell { bell } => {
let pos = BlockPos::new(bell[0], bell[1], bell[2]);
let rang = self
.with_level(|b, level, ticks| b.bell_attempt_to_ring(level, ticks, pos));
if rang {
let _ = self.process_level_events();
}
}
MobEvent::ChestOpen { chest, open } => self.golem_chest_open(chest, open),
MobEvent::ChestSlot { chest, slot, stack } => {
self.golem_chest_slot(chest, slot, stack)
@@ -1381,6 +1493,7 @@ impl Game<'_> {
// `Level.setBlockAndUpdate`: neighbour and shape updates, so a crop
// above trampled farmland breaks and drops.
MobEvent::SetBlock { pos, state } => self.level_set(pos, state)?,
MobEvent::BedOccupied { pos, occupied } => self.set_bed_occupied(pos, occupied),
MobEvent::SetDoorOpen { pos, open } => {
let pos = BlockPos::new(pos[0], pos[1], pos[2]);
self.with_level(|b, level, ticks| b.set_door_open(level, ticks, pos, open));
@@ -1551,7 +1664,7 @@ impl Game<'_> {
}
}
MobEvent::Experience { at, amount } => self.spawn_breeding_orb(at, amount),
MobEvent::AxolotlSupportPlayer => {
MobEvent::AxolotlSupportPlayer { mob } => {
let previous = self
.potions
.effects
@@ -1570,6 +1683,12 @@ impl Game<'_> {
.effects
.list
.retain(|e| e.id != "mining_fatigue");
let source = self
.mobs
.iter()
.find(|m| m.id == mob)
.map(|m| self.mob_entity(m));
self.trigger_effects_changed(source);
}
MobEvent::DolphinTreasure { mob } => {
// ServerLevel.findNearestMapStructure(#dolphin_located, 50).
@@ -1857,6 +1976,9 @@ impl Game<'_> {
&& let Some(child) = self.mobs.iter_mut().find(|m| m.id == id)
{
child.extra.age = -24000;
// getBreedOffspring creates the baby without finalizeSpawn: it keeps
// the default (`classic`) sound variant.
child.extra.sound_variant = None;
// A llama's baby was drawn in the parent's tick, with the
// experience.
if let Some(o) = llama {
@@ -1952,8 +2074,12 @@ impl Game<'_> {
}
// Offspring inherit a parent's variant (pigs/cows/chickens) or mixed wool.
child.extra.variant = p.extra.variant;
// Fox.getBreedOffspring: either parent's variant.
if p.species() == Species::Fox && !coin {
// Fox, Pig, Cow and Chicken.getBreedOffspring: either parent's variant.
if matches!(
p.species(),
Species::Fox | Species::Pig | Species::Cow | Species::Chicken
) && !coin
{
child.extra.variant = q.as_ref().map_or(p.extra.variant, |q| q.extra.variant);
}
if p.species() == Species::Sheep {
@@ -2083,6 +2209,10 @@ impl Game<'_> {
if let Some(a) = attacker.or(owner) {
self.note_attacker(damage_type, a);
}
// The projectile is the direct entity: named when it has no owner.
if let Some(name) = self.combat.projectile_direct.take() {
self.note_direct_entity(damage_type, name);
}
// DamageSource.scalesWithDifficulty: a mob (or its projectile's
// owner) caused the hit, not a player.
let living_cause = attacker.or(owner).is_some();
@@ -2532,6 +2662,53 @@ impl Game<'_> {
}
}
}
/// `onProjectileHit` of chorus flowers and decorated pots: an impact
/// projectile (`#impact_projectiles`) breaks the block when
/// `Projectile.mayInteract` and `Projectile.mayBreak` allow it, which takes
/// the `projectiles_can_break_blocks` rule and a shooter that may break
/// blocks (`canBreakBlockInAdventureMode`: never a mob, never an
/// adventure player). A pot is cracked first (flags 260), so it shatters.
fn projectile_breaks_block(&mut self, p: &Projectile, pos: [i32; 3]) {
use horizon_client::command::args::GameMode;
let impact = matches!(
p.kind,
ProjectileKind::Arrow { .. }
| ProjectileKind::Trident
| ProjectileKind::Snowball
| ProjectileKind::Egg
| ProjectileKind::SmallFireball
| ProjectileKind::LargeFireball { .. }
| ProjectileKind::WitherSkull { .. }
| ProjectileKind::WindCharge
| ProjectileKind::BreezeWindCharge
);
let shooter_may_break = match p.owner {
Some(Who::Player) => self.mode != GameMode::Adventure,
Some(Who::Mob(_)) => false,
None => true,
};
if !impact || !shooter_may_break || !self.ui.rules.bool("projectiles_can_break_blocks") {
return;
}
let state = self.world.block(pos[0], pos[1], pos[2]);
let at = BlockPos::new(pos[0], pos[1], pos[2]);
match &*self.res.facts().get(state).name {
"minecraft:chorus_flower" => {}
"minecraft:decorated_pot" => {
let registry = &self.pack.assets.registry;
let Some(mut pot) = registry.state(state).cloned() else {
return;
};
pot.properties.insert("cracked".into(), "true".into());
let Ok(cracked) = registry.resolve(&pot) else {
return;
};
self.with_level(|b, level, ticks| b.set_block(level, ticks, at, cracked, 260));
}
_ => return,
}
let _ = self.destroy_block_dropping(pos);
}
/// What an arrow's tick asked for: its particles and sounds.
fn arrow_event(&mut self, at: Vec3, event: horizon_client::arrows::ArrowEvent) {
use horizon_client::arrows::{ArrowEvent, ArrowParticle, ArrowSound, BlockReaction};
@@ -2559,6 +2736,7 @@ impl Game<'_> {
let name = match sound {
ArrowSound::Splash => "minecraft:entity.generic.splash",
ArrowSound::HitGround => "minecraft:entity.arrow.hit",
ArrowSound::CrossbowHit => "minecraft:item.crossbow.hit",
ArrowSound::Extinguish => "minecraft:entity.generic.extinguish_fire",
ArrowSound::TridentHitGround => "minecraft:item.trident.hit_ground",
ArrowSound::TridentHit => "minecraft:item.trident.hit",
@@ -2695,11 +2873,32 @@ impl Game<'_> {
&& face != [0; 3]
{
self.bell_projectile_hit(p, pos, at, face);
self.projectile_breaks_block(p, pos);
}
self.combat.projectile_owner = match p.owner {
Some(Who::Mob(id)) => Some(id),
_ => None,
};
self.combat.projectile_direct = match hit {
Hit::Entity {
who: Who::Player, ..
} => {
let spectral = p
.arrow
.as_ref()
.is_some_and(|a| &*a.pickup_item.item == "minecraft:spectral_arrow");
let id = if spectral {
"minecraft:spectral_arrow"
} else {
p.kind.id()
};
Some(Text::translate(
&format!("entity.{}", id.replace(':', ".")),
&[],
))
}
_ => None,
};
let at = match hit {
Hit::Entity { at, .. } | Hit::Block { at, .. } => at,
Hit::None => return Ok(()),
@@ -2821,7 +3020,7 @@ impl Game<'_> {
}
// AbstractArrow.onHitBlock plays its sound itself (ArrowEvent).
(ProjectileKind::Trident, Hit::Block { .. }) => {}
(ProjectileKind::Arrow { effect, .. }, Hit::Entity { who, .. }) => {
(ProjectileKind::Arrow { .. }, Hit::Entity { who, .. }) => {
let mut arrow = p.clone();
let amount = arrow_damage(&mut arrow, &mut self.world.random) + p.bonus.damage;
let player_fire = self.player.as_ref().map(|pl| pl.fire_ticks);
@@ -2833,17 +3032,11 @@ impl Game<'_> {
}])?;
}
let hurt = match who {
Who::Player => {
let hurt = self.mob_hits_player(
amount,
[p.previous.x, p.previous.y, p.previous.z],
"minecraft:arrow",
);
if hurt && let Some((e, t)) = effect {
self.give_player_effect(e, *t, 0);
}
hurt
}
Who::Player => self.mob_hits_player(
amount,
[p.previous.x, p.previous.y, p.previous.z],
"minecraft:arrow",
),
Who::Mob(id) => {
let hurt = self
.mobs
@@ -2900,7 +3093,56 @@ impl Game<'_> {
.find(|m| m.id == id)
.map_or(0.0, |m| m.attrs.knockback_resistance),
};
let push = f64::from(p.bonus.punch) * 0.6 * (1.0 - resistance).max(0.0);
let knockback = if !hurt {
0.0
} else if let Some(weapon) =
p.arrow.as_ref().and_then(|a| a.fired_from_weapon.as_ref())
{
let victim = match who {
Who::Player => Some("minecraft:player"),
Who::Mob(id) => self
.mobs
.iter()
.find(|m| m.id == id)
.map(|m| m.kind.id.as_str()),
};
let owner = match p.owner {
Some(Who::Player) => Some("minecraft:player"),
Some(Who::Mob(id)) => self
.mobs
.iter()
.find(|m| m.id == id)
.map(|m| m.kind.id.as_str()),
None => None,
};
let arrow_type = if p
.arrow
.as_ref()
.is_some_and(|a| &*a.pickup_item.item == "minecraft:spectral_arrow")
{
"minecraft:spectral_arrow"
} else {
"minecraft:arrow"
};
let ctx = horizon_client::enchant::EffectContext {
this: victim,
attacker: owner,
direct_attacker: Some(arrow_type),
damage_type: Some("minecraft:arrow"),
items: Some(self.res.items()),
..Default::default()
};
self.res.stations().enchantments.modify_value(
Some(weapon),
"minecraft:knockback",
&ctx,
&mut self.world.random,
0.0,
)
} else {
p.bonus.punch as f32
};
let push = f64::from(knockback) * 0.6 * (1.0 - resistance).max(0.0);
if hurt && push > 0.0 && flight > 1e-6 {
let k = push / flight;
self.handle_mob_events(vec![MobEvent::Push {
@@ -2908,6 +3150,9 @@ impl Game<'_> {
velocity: [p.velocity.x * k, 0.1, p.velocity.z * k],
}])?;
}
if hurt {
self.arrow_post_hurt_effects(p, who);
}
match (hurt, who) {
(true, _) => self.finish_arrow_hit(arrow, EntityHit::Hurt),
// What the player took no damage from (a raised shield)
@@ -3269,7 +3514,16 @@ impl Game<'_> {
}
{
let item = self.held().map(|h| h.item.to_string()).unwrap_or_default();
let used = self.held().cloned();
if self.interact_leash(index, &item) {
if let Some(stack) = &used {
let target = self.mob_entity(&self.mobs[index]);
self.trigger_item_entity(
"minecraft:player_interacted_with_entity",
stack,
&target,
);
}
self.swing_arm();
return Ok(true);
}
@@ -3843,6 +4097,11 @@ impl Game<'_> {
if !handled {
return Ok(false);
}
// PlayerInteractTrigger: the item used on the mob, once the interaction took.
if let Some(stack) = &held {
let target = self.mob_entity(&self.mobs[index]);
self.trigger_item_entity("minecraft:player_interacted_with_entity", stack, &target);
}
// Turtle.ageBoundaryReached: food that finishes a baby's growth.
if baby_turtle && !self.mobs[index].is_baby() {
self.handle_mob_events(vec![MobEvent::GiftLoot {
@@ -3886,6 +4145,9 @@ impl Game<'_> {
}
}
if let Some(stack) = armor_drop {
// Mob.shearItem: PLAYER_SHEARED_EQUIPMENT with the armor and the wolf.
let target = self.mob_entity(&self.mobs[index]);
self.trigger_item_entity("minecraft:player_sheared_equipment", &stack, &target);
self.spawn_item(stack, [center[0], center[1] + 0.5, center[2]], [0.0; 3], 10);
}
for (item, count) in drops {
@@ -3975,6 +4237,7 @@ impl Game<'_> {
.get(&stack.item)
.map_or(0, |i| i.max_damage);
if max > 0 {
self.trigger_item_durability(&stack);
let loss = self.durability_loss(&stack, 16);
let slot = self.held_slot();
if let Some(held) = self.stack_in_mut(slot).as_mut() {
@@ -4014,7 +4277,10 @@ impl Game<'_> {
let Some(p) = self.player.as_ref() else {
return Ok(false);
};
let (velocity, on_ground) = (p.velocity, p.ground.on_ground);
// Projectile.shootFromRotation adds getKnownMovement: what the thrower
// last moved, not a velocity the server kept for a guest.
let on_ground = p.ground.on_ground;
let velocity = self.known_player_movement();
let start = Vec3::new(eye[0], eye[1] - 0.1, eye[2]);
let mut proj = Projectile::new(kind.clone(), start, Some(Who::Player));
proj.shoot_from_rotation(
@@ -4060,7 +4326,7 @@ impl Game<'_> {
};
let parent = &self.mobs[index];
if !parent.alive()
|| stack.item.strip_suffix("_spawn_egg") != Some(parent.kind.id.as_str())
|| !horizon_client::mobs::finalize::spawn_egg_is_for(&stack.item, &parent.kind.id)
|| !horizon_client::mobs::drops::can_be_baby(parent.species())
// WanderingTrader.getBreedOffspring returns null: no offspring, the
// click carries on to trading.
@@ -4075,15 +4341,11 @@ impl Game<'_> {
let (species, variant, color) =
(parent.species(), parent.extra.variant, parent.extra.color);
let target = self.mob_entity(&self.mobs[index]);
let Some(id) = self.spawn_mob(&kind, at, true) else {
let Some(id) = self.spawn_mob_spawned(&kind, at) else {
return false;
};
if let Some(child) = self.mobs.iter_mut().find(|m| m.id == id) {
horizon_client::mobs::finalize::set_baby(child, true);
child.extra.variant = variant;
if species == Species::Sheep {
child.extra.color = color;
}
horizon_client::mobs::finalize::spawn_egg_offspring(child, species, variant, color);
}
self.trigger_item_entity("minecraft:player_interacted_with_entity", &stack, &target);
self.consume_held();
@@ -4121,7 +4383,7 @@ impl Game<'_> {
f64::from(p[1]),
f64::from(p[2]) + 0.5,
];
if self.spawn_mob(&kind, at, true).is_none() {
if self.spawn_mob_spawned(&kind, at).is_none() {
return Ok(false);
}
self.swing_arm();
@@ -4387,7 +4649,15 @@ impl Game<'_> {
let Some(model) = self.arrow_model.as_ref() else {
continue;
};
let Some(r) = atlas.sprites.get("entity/projectiles/arrow").copied() else {
// SpectralArrowRenderer, and TippableArrowRenderer's `getColor() > 0`.
let texture = if a.is_spectral_arrow() {
"entity/projectiles/arrow_spectral"
} else if a.arrow.as_ref().is_some_and(|s| s.effect_color > 0) {
"entity/projectiles/arrow_tipped"
} else {
"entity/projectiles/arrow"
};
let Some(r) = atlas.sprites.get(texture).copied() else {
continue;
};
let m = glam::Mat4::from_translation(c)
@@ -4546,6 +4816,7 @@ pub(super) fn damage_type(kind: horizon_client::mobs::DamageKind, player: bool)
K::Explosion => "minecraft:explosion",
K::Fall => "minecraft:fall",
K::FallingBlock => "minecraft:falling_block",
K::FallingStalactite => "minecraft:falling_stalactite",
K::OnFire => "minecraft:on_fire",
K::InFire => "minecraft:in_fire",
K::Lava => "minecraft:lava",
@@ -4561,6 +4832,20 @@ pub(super) fn damage_type(kind: horizon_client::mobs::DamageKind, player: bool)
K::SonicBoom => "minecraft:sonic_boom",
K::Freeze => "minecraft:freeze",
K::OutOfWorld => "minecraft:out_of_world",
K::Cramming => "minecraft:cramming",
K::FallingAnvil => "minecraft:falling_anvil",
K::Fireworks => "minecraft:fireworks",
K::GenericKill => "minecraft:generic_kill",
K::LightningBolt => "minecraft:lightning_bolt",
K::OutsideBorder => "minecraft:outside_border",
K::Spit => "minecraft:spit",
K::Stalagmite => "minecraft:stalagmite",
K::Starve => "minecraft:starve",
K::SulfurCubeHot => "minecraft:sulfur_cube_hot",
K::WindCharge => "minecraft:wind_charge",
K::Wither => "minecraft:wither",
K::Campfire => "minecraft:campfire",
K::HotFloor => "minecraft:hot_floor",
}
}
/// A Horizon item stack as a loot stack (id, count, damage, enchantments).
@@ -4580,6 +4865,7 @@ fn to_loot(stack: &ItemStack) -> horizon_world::loot::LootStack {
ominous_amplifier: None,
stew_effects: Vec::new(),
names: Vec::new(),
components: Vec::new(),
}
}
/// A generated loot stack as a Horizon item stack.
@@ -4602,6 +4888,9 @@ fn from_loot(stack: &horizon_world::loot::LootStack) -> ItemStack {
for (id, text) in &stack.names {
item.data_mut().set_loot_name(id, text);
}
for (id, value) in &stack.components {
item.data_mut().set_loot_component(id, value);
}
item
}
impl Game<'_> {
@@ -4644,7 +4933,7 @@ impl Game<'_> {
];
// setPortalCooldown: the new piglin does not travel at once.
if let Some(id) = self.spawn_mob("minecraft:zombified_piglin", at, false) {
self.set_portal_cooldown(id);
self.set_portal_cooldown(id, horizon_client::nether_portal::ENTITY_DELAY);
}
}
}
@@ -416,6 +416,13 @@ impl Game<'_> {
],
);
}
// Horizon's own: HDR's output while the setting is on, under the GPU (a group of its
// own would move others to the other column).
if self.settings.horizon.hdr
&& let Some(r) = self.renderer.as_ref()
{
d.group("system", [r.hdr_line()]);
}
// Horizon's own: the shader pack's GPU time per pass (an empty group would move others).
let pass_lines = self.renderer.as_ref().map_or_else(Vec::new, |r| {
debug_overlay::shader_pass_lines(&r.shader_pass_times())
@@ -131,6 +131,8 @@ impl Game<'_> {
map.insert("item".into(), save::item_to_nbt(item));
}
spill::put_saved_entity(&mut self.world, pos, tag);
// The sherds and item go to the guests (BlockEntityData).
self.mark_block_entity_updated((pos[0], pos[1], pos[2]));
self.consume_held();
self.audio.play(
"minecraft:block.decorated_pot.insert",
+210 -47
View File
@@ -26,12 +26,105 @@ use horizon_world::nbt::{JavaString, Tag};
use std::{
io::Write,
path::PathBuf,
sync::{Arc, Mutex, OnceLock, mpsc},
sync::{
Arc, Mutex, OnceLock,
atomic::{AtomicBool, AtomicU64, Ordering},
mpsc,
},
time::{Duration, Instant},
};
mod status;
/// What the tick loop tells `ServerWatchdog`: when the tick in progress was
/// due (`getNextTickTime`), how long a tick is, and whether the server runs.
struct Watchdog {
base: Instant,
next_nanos: AtomicU64,
tick_nanos: AtomicU64,
running: AtomicBool,
}
impl Watchdog {
/// `DedicatedServer.initServer`: a thread that stops a server whose tick
/// runs `max_tick_time` ms past its due time; none when that is 0 or less.
fn start(max_tick_time: i64) -> Option<Arc<Self>> {
let max = Duration::from_millis(u64::try_from(max_tick_time).ok().filter(|m| *m > 0)?);
let watchdog = Arc::new(Self {
base: Instant::now(),
next_nanos: AtomicU64::new(0),
tick_nanos: AtomicU64::new(50_000_000),
running: AtomicBool::new(true),
});
let shared = watchdog.clone();
std::thread::Builder::new()
.name("Server Watchdog".into())
.spawn(move || shared.run(max))
.ok()?;
Some(watchdog)
}
/// The tick loop is about to run the tick due at `next`.
fn tick_due(&self, next: Instant, tick: Duration) {
let nanos = next.saturating_duration_since(self.base).as_nanos();
self.next_nanos
.store(u64::try_from(nanos).unwrap_or(u64::MAX), Ordering::Relaxed);
let tick = u64::try_from(tick.as_nanos()).unwrap_or(u64::MAX);
self.tick_nanos.store(tick, Ordering::Relaxed);
}
/// `ServerWatchdog.run`.
fn run(&self, max: Duration) {
let max = u64::try_from(max.as_nanos()).unwrap_or(u64::MAX);
while self.running.load(Ordering::Relaxed) {
let next = self.next_nanos.load(Ordering::Relaxed);
let now = u64::try_from(self.base.elapsed().as_nanos()).unwrap_or(u64::MAX);
let behind = now.saturating_sub(next);
if behind > max {
let took = format!("{:.2}", behind as f32 / 1e9);
let should = format!(
"{:.2}",
self.tick_nanos.load(Ordering::Relaxed) as f32 / 1e9
);
log_from(
"Server Watchdog",
"ERROR",
&format!("A single server tick took {took} seconds (should be max {should})"),
);
log_from(
"Server Watchdog",
"ERROR",
"Considering it to be crashed, server will forcibly shutdown.",
);
let message = format!(
"ServerWatchdog detected that a single server tick took {took} seconds (should be max {should})"
);
match horizon_client::crash::report_watchdog(
std::path::Path::new("."),
"server",
message,
) {
Some(path) => log_from(
"Server Watchdog",
"ERROR",
&format!(
"This crash report has been saved to: {}",
path.canonicalize().unwrap_or(path).display()
),
),
None => log_from(
"Server Watchdog",
"ERROR",
"We were unable to save this crash report to disk.",
),
}
std::process::exit(1);
}
// Thread.sleep(nextTickTime + maxTickTime - now).
let wait = (next + max).saturating_sub(now).max(1_000_000);
std::thread::sleep(Duration::from_nanos(wait));
}
}
}
/// Double.toString for the magnitudes positions have (no exponent form
/// between 1e-3 and 1e7).
pub(super) fn java_double(v: f64) -> String {
@@ -298,14 +391,20 @@ impl Lists {
}
}
/// The world presets of 26.3's `worldgen/world_preset`.
const WORLD_PRESETS: [&str; 6] = [
"minecraft:normal",
"minecraft:flat",
"minecraft:large_biomes",
"minecraft:amplified",
"minecraft:single_biome_surface",
/// DedicatedServerProperties.LEGACY_PRESET_NAMES: the old `level-type`
/// names, before the presets had their own ids.
const LEGACY_PRESET_NAMES: [(&str, horizon_client::save::WorldPreset); 2] = [
("default", horizon_client::save::WorldPreset::Normal),
(
"largebiomes",
horizon_client::save::WorldPreset::LargeBiomes,
),
];
/// World presets of 26.3's `worldgen/world_preset` that Horizon does not
/// generate: they are valid `level-type`s and use `minecraft:normal`.
const UNGENERATED_PRESETS: [&str; 2] = [
"minecraft:debug_all_block_states",
"minecraft:flat_all_dimensions",
];
/// Identifier.tryParse: `namespace:path`, or a path in `minecraft`.
@@ -322,31 +421,47 @@ impl Game<'_> {
/// DedicatedServerProperties.createDimensions for a world that doesn't
/// exist yet: `level-type`'s preset, a flat world's `generator-settings`
/// and `generate-structures`, written before the world opens so that
/// streaming reads them. Horizon generates the normal and flat presets.
/// streaming reads them.
fn write_world_gen(&self, dir: &std::path::Path, props: &ServerProperties) -> Result<()> {
let level_type = &props.level_type;
let preset = parse_identifier(level_type)
.filter(|id| WORLD_PRESETS.contains(&id.as_str()))
.unwrap_or_else(|| {
log_from(
"Worker-Main-1",
"WARN",
&format!(
"Failed to parse level-type {level_type}, defaulting to minecraft:normal"
),
);
"minecraft:normal".to_owned()
});
let flat = preset == "minecraft:flat";
if !flat && preset != "minecraft:normal" {
let identifier = parse_identifier(level_type);
if let Some(id) = identifier
.as_deref()
.filter(|id| UNGENERATED_PRESETS.contains(id))
{
log_from(
"Worker-Main-1",
"WARN",
&format!(
"Horizon doesn't generate the {preset} world preset yet: using minecraft:normal"
"Horizon doesn't generate the {id} world preset yet: using minecraft:normal"
),
);
}
let preset = identifier
.as_deref()
.and_then(horizon_client::save::WorldPreset::of_id)
.or_else(|| {
LEGACY_PRESET_NAMES
.iter()
.find(|(name, _)| *name == level_type)
.map(|(_, preset)| *preset)
})
.unwrap_or_else(|| {
if !identifier
.as_deref()
.is_some_and(|id| UNGENERATED_PRESETS.contains(&id))
{
log_from(
"Worker-Main-1",
"WARN",
&format!(
"Failed to parse level-type {level_type}, defaulting to minecraft:normal"
),
);
}
horizon_client::save::WorldPreset::Normal
});
let flat = preset == horizon_client::save::WorldPreset::Flat;
let settings = if flat {
let biomes = self.res.biomes();
let (settings, errors) = horizon_client::flatgen::settings_from_json(
@@ -362,7 +477,7 @@ impl Game<'_> {
None
};
let options = horizon_client::save::WorldGen {
flat,
preset,
structures: props.generate_structures,
bonus_chest: false,
};
@@ -585,7 +700,8 @@ impl Game<'_> {
}
self.settings.render_distance = props.view_distance.clamp(2, 32);
self.settings.simulation_distance = props.simulation_distance.clamp(2, 32);
self.streaming.radius = self.settings.render_distance;
// Nothing is kept around the spawn while nobody is on.
self.streaming.radius = -1;
self.settle_spawn()?;
let gate_lists = lists.clone();
let gate: LoginGate = Arc::new(move |address, uuid, name| {
@@ -607,6 +723,7 @@ impl Game<'_> {
motd: props.motd.clone(),
max_players: props.max_players.max(0) as usize,
compression_threshold: props.network_compression_threshold,
rate_limit: props.rate_limit,
enable_status: props.enable_status,
hide_online_players: props.hide_online_players,
favicon,
@@ -703,8 +820,13 @@ impl Game<'_> {
let pause_after = props.pause_when_empty_seconds * 20;
let idle_timeout = props.player_idle_timeout;
let mut next = Instant::now();
self.fns.permission = Some(props.function_permission_level.clamp(0, 4) as u8);
let watchdog = Watchdog::start(props.max_tick_time);
while !state.stop {
let tick_start = Instant::now();
if let Some(watchdog) = &watchdog {
watchdog.tick_due(next, self.clock.tick_duration());
}
while let Ok(line) = state.console.try_recv() {
self.console_line(&mut state, &line);
}
@@ -745,13 +867,45 @@ impl Game<'_> {
state.ticked(Instant::now());
self.wait_for_next_server_tick(&mut next, paused);
}
if let Some(watchdog) = &watchdog {
watchdog.running.store(false, Ordering::Relaxed);
}
self.stop_dedicated()
}
/// A dedicated server of its own, not single player's server thread
/// (which runs with `options.dedicated` too).
pub(super) fn standalone_server(&self) -> bool {
self.options.dedicated.is_some() && self.serving.is_none()
}
/// Load the spawn area and find the world spawn as single player does,
/// without keeping a player there.
/// without keeping a player there. A dedicated server only finds the world
/// spawn (MinecraftServer.setInitialSpawn): a player's spawn is found as
/// one joins.
pub(super) fn settle_spawn(&mut self) -> Result<()> {
let deadline = Instant::now() + Duration::from_secs(600);
if self.standalone_server() {
if self.level.spawn.is_none() {
let [x, y, z] = loop {
self.update_world();
if let Some(pos) = self.find_world_spawn()? {
break pos;
}
if Instant::now() > deadline {
return Err("the spawn area did not load".into());
}
std::thread::sleep(Duration::from_millis(2));
};
self.level.spawn = Some([f64::from(x), f64::from(y), f64::from(z)]);
self.level.world_spawn.get_or_insert([x, y, z]);
self.streaming.extra.clear();
}
self.spawn = self
.level
.spawn
.map(|s| horizon_core::geometry::Vec3::new(s[0] + 0.5, s[1], s[2] + 0.5));
return Ok(());
}
while self.player.is_none() {
self.update_world();
self.spawn_player()?;
@@ -764,24 +918,28 @@ impl Game<'_> {
Ok(())
}
/// The chunks to keep loaded: the players' (lan_update_streaming), or
/// the spawn chunk while nobody is online.
/// The chunks to keep loaded: the players' (lan_update_streaming). While
/// nobody is online a dedicated server keeps none (vanilla has no spawn
/// ticket), an integrated one the spawn chunk's.
pub(super) fn update_dedicated_streaming(&mut self) {
let center = self
.lan
.as_ref()
.and_then(|h| {
h.hub
.guests
.iter()
.find(|g| h.hub.here(g))
.map(|g| g.chunk())
})
.or_else(|| {
self.spawn
.map(|s| ((s.x.floor() as i32) >> 4, (s.z.floor() as i32) >> 4))
});
if let Some(c) = center {
let guest = self.lan.as_ref().and_then(|h| {
h.hub
.guests
.iter()
.find(|g| h.hub.here(g))
.map(|g| g.chunk())
});
if self.standalone_server() {
self.streaming.radius = if guest.is_some() {
self.settings.render_distance
} else {
-1
};
}
let spawn = self
.spawn
.map(|s| ((s.x.floor() as i32) >> 4, (s.z.floor() as i32) >> 4));
if let Some(c) = guest.or(spawn) {
self.streaming.center = c;
}
}
@@ -1354,10 +1512,13 @@ impl Game<'_> {
fn stop_dedicated(&mut self) -> Result<()> {
log("INFO", "Stopping server");
log("INFO", "Saving players");
if let Some(h) = self.lan.as_mut() {
for g in &h.hub.guests {
horizon_client::lan::playerdata::save(g, &self.world_root);
if let Some(host) = self.lan.take() {
for g in &host.hub.guests {
// The vehicle the guest rides, for the file that names it.
let vehicle = self.root_vehicle_uuid(g.vehicle);
horizon_client::lan::playerdata::save(g, &self.world_root, vehicle);
}
self.lan = Some(host);
}
if let Some(h) = self.lan.as_ref() {
for g in &h.hub.guests {
@@ -1375,6 +1536,8 @@ impl Game<'_> {
}
std::thread::sleep(Duration::from_millis(100));
log("INFO", "Saving worlds");
// ServerPlayer.disconnect: nobody is left in a bed.
self.free_player_beds()?;
self.save_game()?;
self.log_saved_levels();
self.lan_close(false);
+189 -5
View File
@@ -8,7 +8,7 @@ use horizon_client::{
Result,
clipboard::EditShortcut,
confirm_screen::Confirm,
dialog::{AfterAction, Click, Control, Dialog, InputValue},
dialog::{AfterAction, Body, Click, Control, Dialog, InputValue, Kind},
dialog_screen::{BOX, Layout, Part, WARNING_SPRITES, waiting_button},
hud::{HudBatch, rgba},
text::Text,
@@ -26,6 +26,8 @@ struct Open {
fields: Vec<Option<ui::EditBox>>,
focus: Option<usize>,
scroll: ui::Scroll,
/// The number range input whose slider is held.
dragging: Option<usize>,
}
/// The dialog screens a server can open.
@@ -38,6 +40,17 @@ pub(super) struct DialogState {
warning: bool,
}
/// `CycleButton.cycleValue`: the next option (`delta` 1) or the one before
/// it, wrapping.
fn cycle(open: &mut Open, input: usize, delta: i32) {
let Control::SingleOption { options, .. } = &open.dialog.inputs[input].control else {
return;
};
if let Some(InputValue::Option(i)) = open.values.get_mut(input) {
*i = (*i as i32 + delta).rem_euclid(options.len().max(1) as i32) as usize;
}
}
fn warning_texts() -> (Text, Text, [Text; 2]) {
let t = |key: &str| Text::translate(key, &[]);
(
@@ -57,10 +70,35 @@ impl Game<'_> {
/// `showDialog`: the dialog replaces an open one (and a waiting screen);
/// under the warning screen, it is what Back returns to.
pub(super) fn show_dialog(&mut self, tag: &Tag) {
let Some(dialog) = Dialog::parse(tag) else {
let Some(mut dialog) = Dialog::parse(tag) else {
log::warn!("Horizon: failed to show dialog for data {tag:?}");
return;
};
if let Kind::DialogList { dialogs, .. } = &dialog.kind {
let entries = self.remote.as_ref().map(|r| {
let list = r.registries.registries.get("minecraft:dialog");
let by_id = |id: &str| {
let name = if id.contains(':') {
id.to_owned()
} else {
format!("minecraft:{id}")
};
list?.iter().find(|(n, _)| *n == name)?.1.clone()
};
let by_tag = |name: &str| {
let name = if name.contains(':') {
name.to_owned()
} else {
format!("minecraft:{name}")
};
let ids = r.registries.dialog_tags.get(&name).into_iter().flatten();
ids.filter_map(|&i| list?.get(usize::try_from(i).ok()?)?.1.clone())
.collect()
};
Dialog::list_entries(dialogs, &by_tag, &by_id)
});
dialog.set_list(&entries.unwrap_or_default());
}
let values = dialog.inputs.iter().map(|i| i.control.initial()).collect();
let fields = dialog
.inputs
@@ -87,6 +125,7 @@ impl Game<'_> {
fields,
focus: None,
scroll: ui::Scroll::default(),
dragging: None,
});
self.dialogs.waiting = None;
self.capture(false);
@@ -123,6 +162,17 @@ impl Game<'_> {
/// `DialogScreen.runAction`: the click event, then the screen the dialog's
/// `after_action` leaves.
fn run_dialog_action(&mut self, click: Option<Click>, after: AfterAction) -> Result<()> {
// defaultHandleClickEvent: an open_url never sets the screen itself, so
// the dialog stays unless the confirmation takes over (Web Links off,
// or Prompt on Links off, opens nothing or the browser behind it).
if let Some(Click::OpenUrl(url)) = &click
&& !(self.settings.chat_links && self.settings.file.bool("chatLinksPrompt"))
{
if self.settings.chat_links {
super::packs::open_uri(url);
}
return Ok(());
}
match after {
AfterAction::None => {}
AfterAction::Close => self.dialogs.open = None,
@@ -153,7 +203,11 @@ impl Game<'_> {
use horizon_client::clipboard::Clipboard;
self.clipboard.set(&value);
}
Some(other) => log::info!("Horizon: dialog action {other:?} is not supported yet"),
// ConfirmLinkScreen over the screen the dialog leaves.
Some(Click::OpenUrl(url)) => self.click_url(url),
// Screen.insertText: the screen after the dialog takes no text.
Some(Click::SuggestCommand(_)) => {}
Some(other) => log::error!("Don't know how to handle {other:?}"),
None => {}
}
if !self.dialog_open() {
@@ -262,7 +316,8 @@ impl Game<'_> {
let Some(layout) = self.dialog_layout() else {
return;
};
let Some(open) = self.dialogs.open.as_mut() else {
// Out of `self` while it draws: an item body draws through `self`.
let Some(mut open) = self.dialogs.open.take() else {
return;
};
let atlas = &self.gui.atlas;
@@ -291,6 +346,7 @@ impl Game<'_> {
let buttons = open.dialog.buttons();
let in_area = |y: f32| y >= ay && y < ay + ah;
let mut tooltip = None;
let mut item_tip = None;
let mut start = None;
for (part, clipped) in layout.placed(open.scroll.amount) {
if clipped && start.is_none() {
@@ -333,6 +389,64 @@ impl Game<'_> {
true,
);
}
Part::Cycle { input, rect } => {
let [x, y, cw, ch] = rect;
let hover = in_area(cursor.1) && ui::hovered(cursor, x, y, cw, ch);
let Some(label) = open
.values
.get(input)
.and_then(|v| open.dialog.inputs[input].control.message(v))
else {
continue;
};
ui::button(batch, atlas, x, y, cw, ch, &label, true, hover);
}
Part::Slider { input, rect } => {
let [x, y, sw, sh] = rect;
let hover = in_area(cursor.1) && ui::hovered(cursor, x, y, sw, sh);
let Some((InputValue::Slider(position), Some(label))) = open
.values
.get(input)
.map(|v| (v.clone(), open.dialog.inputs[input].control.message(v)))
else {
continue;
};
let held = open.dragging == Some(input);
ui::slider(
batch,
atlas,
x,
y,
sw,
&label,
position,
hover || held,
true,
);
}
Part::Item { body, rect } => {
let [x, y, iw, ih] = rect;
let Some(Body::Item {
item,
show_decorations,
show_tooltip,
..
}) = open.dialog.body.get(body)
else {
continue;
};
let Some(stack) = horizon_items::item_nbt::item_from_nbt(item) else {
continue;
};
// ItemDisplayWidget: the item at its corner, then its decorations.
self.draw_item_model(batch, &stack, x, y);
if *show_decorations {
self.draw_item_decorations(batch, &stack, x, y, None);
}
if *show_tooltip && in_area(cursor.1) && ui::hovered(cursor, x, y, iw, ih) {
item_tip = Some(stack);
}
}
Part::Button { index, rect } => {
let [x, y, bw, bh] = rect;
let hover =
@@ -371,6 +485,10 @@ impl Game<'_> {
.collect();
ui::tooltip(batch, atlas, &lines, cursor);
}
self.dialogs.open = Some(open);
if let Some(stack) = item_tip {
self.draw_stack_tooltip(batch, &stack, cursor.0, cursor.1);
}
}
/// A left click on a dialog screen.
@@ -420,6 +538,7 @@ impl Game<'_> {
self.dialogs.warning = true;
return Ok(());
}
let shift = self.modifiers.shift_key();
let Some(open) = self.dialogs.open.as_mut() else {
return Ok(());
};
@@ -450,6 +569,23 @@ impl Game<'_> {
}
action = Some(None);
}
Part::Cycle {
input,
rect: [x, y, w, h],
} if ui::hovered(cursor, x, y, w, h) => {
// CycleButton.onPress: Shift goes back.
cycle(open, input, if shift { -1 } else { 1 });
action = Some(None);
}
Part::Slider {
input,
rect: [x, y, w, h],
} if ui::hovered(cursor, x, y, w, h) => {
open.dragging = Some(input);
if let Some(v) = open.values.get_mut(input) {
*v = InputValue::Slider(ui::slider_value(x, w, cursor.0));
}
}
Part::Button {
index,
rect: [x, y, w, h],
@@ -479,17 +615,65 @@ impl Game<'_> {
self.run_dialog_action(click, after)
}
/// The mouse wheel over a dialog: its body scrolls 10 a step.
/// The mouse wheel over a dialog: a cycle button under it takes the
/// step, else the body scrolls 10 a step.
pub(super) fn dialog_scroll(&mut self, delta: f32) {
let Some(layout) = self.dialog_layout() else {
return;
};
let cursor = self.cursor;
if let Some(open) = self.dialogs.open.as_mut() {
let [_, ay, _, ah] = layout.area;
if delta != 0.0 && cursor.1 >= ay && cursor.1 < ay + ah {
let hovered = layout
.placed(open.scroll.amount)
.find_map(|(part, _)| match part {
Part::Cycle {
input,
rect: [x, y, w, h],
} if ui::hovered(cursor, x, y, w, h) => Some(input),
_ => None,
});
if let Some(input) = hovered {
// CycleButton.mouseScrolled: up goes back.
cycle(open, input, if delta > 0.0 { -1 } else { 1 });
return;
}
}
open.scroll.wheel(delta, 10.0, ay, ay + ah, layout.content);
}
}
/// The pointer moved with a slider held (`AbstractSliderButton.onDrag`).
pub(super) fn dialog_drag(&mut self) {
let Some(input) = self.dialogs.open.as_ref().and_then(|o| o.dragging) else {
return;
};
let Some(layout) = self.dialog_layout() else {
return;
};
let x = self.cursor.0;
let Some(open) = self.dialogs.open.as_mut() else {
return;
};
let slider = layout.body.iter().find_map(|p| match p {
Part::Slider { input: i, rect } if *i == input => Some(*rect),
_ => None,
});
if let (Some([sx, _, sw, _]), Some(value)) = (slider, open.values.get_mut(input)) {
*value = InputValue::Slider(ui::slider_value(sx, sw, x));
}
}
/// The left button released: a held slider lets go and clicks.
pub(super) fn dialog_release(&mut self) {
if let Some(open) = self.dialogs.open.as_mut()
&& open.dragging.take().is_some()
{
self.audio.play_button_click();
}
}
/// A key pressed on a dialog screen: Escape closes what allows it, Tab
/// moves between the text inputs, the rest goes to the focused one
/// (`shortcut`: the press as an edit shortcut; `text`: what it types).
+62 -23
View File
@@ -801,10 +801,7 @@ impl Game<'_> {
}
if !self.fight.killed {
if self.dragon.is_none() {
// createNewDragon: over the podium, holding pattern, a random yaw.
let yaw = self.world.random.next_float() * 360.0;
let seed = self.world.random.next_long();
self.dragon = Some(Dragon::new(Vec3::new(0.0, 128.0, 0.0), yaw, seed));
self.create_new_dragon();
}
self.fight.crystal_scan += 1;
if self.fight.crystal_scan >= 100 {
@@ -912,8 +909,9 @@ impl Game<'_> {
// The blast's damage source carries the player, so the dragon
// remembers them before the blast can kill it.
self.fight.blast_by = by;
let _ =
self.server_explode(at, 6.0, super::explosion::BlockInteraction::Destroy, false);
// EndCrystal.hurtServer: ExplosionInteraction.BLOCK.
let interaction = self.destroy_type("block_explosion_drop_decay");
let _ = self.server_explode(at, 6.0, interaction, false);
self.fight.blast_by = None;
}
if respawn_crystal {
@@ -1083,7 +1081,7 @@ impl Game<'_> {
self.stats.add("killed", "minecraft:ender_dragon", 1);
self.stats.custom("mob_kills", 1);
self.score_player_killed(super::commands::Target::Mob(id));
self.trigger_entity("minecraft:player_killed_entity", "entity", view);
self.trigger_killed(view, None);
}
/// Player melee on the dragon's parts or a crystal under the crosshair.
/// The dragon part under the crosshair within reach: its id and distance.
@@ -1122,22 +1120,21 @@ impl Game<'_> {
if !self.before_block(d) || self.mob_hit().is_some_and(|m| m.1 < d) {
return false;
}
let hand_damage = self.attack_item_info().map_or(0.0, |i| i.attack_damage);
let delay = horizon_client::attack_strength::delay(self.main_hand_attack_speed());
let scale = ((self.attack_ticks as f32 + 0.5) / delay).clamp(0.0, 1.0);
let mut amount =
(1.0 + hand_damage + self.effect_attack_bonus()) * (0.2 + scale * scale * 0.8);
let falling = self
.player
.as_ref()
.is_some_and(|p| !p.ground.on_ground && p.velocity.y < 0.0 && !p.sprinting);
if scale > 0.9 && falling {
amount *= 1.5;
}
amount += self.enchant_damage_bonus("minecraft:ender_dragon") * scale;
self.attack_ticks = 0;
// A dragon part is no LivingEntity: `canCriticalAttack` is false.
let (amount, _, _, _) = self.melee_damage("minecraft:ender_dragon", false);
self.swing_arm();
self.hit_special(id, amount, true, false);
// The part's box.
let area = self
.special_targets()
.into_iter()
.find(|(part, _)| *part == id)
.map(|(_, b)| b);
let hurt = self.hit_special(id, amount, true, false);
// The sweep and the sounds read the charge, which the hit then spends.
if let (Some(area), Hurt::Landed { .. }) = (area, hurt) {
self.attack_effects(area, None, super::mace::Spared::Nothing, None);
}
self.attack_ticks = 0;
true
}
/// The 24 nodes (`findClosestNode` builds them on first use).
@@ -2104,6 +2101,7 @@ impl Game<'_> {
75,
(96.0 * a.sin()).floor() as i32,
];
self.level_event_at(3000, pos, 0);
let _ = self.place_gateway_at(pos);
}
if !self.fight.previously_killed
@@ -2230,12 +2228,20 @@ impl Game<'_> {
.filter(|p| p.alive && !p.creative && !p.spectator)
.collect();
let mut harmed: Vec<(FightWho, f32)> = Vec::new();
// DragonSittingFlamingPhase.end discards the flame cloud.
let flame_over = self
.dragon
.as_ref()
.is_some_and(|d| !matches!(d.phase, Phase::Flaming { .. }));
let mut clouds = std::mem::take(&mut self.breath_clouds);
clouds.retain_mut(|c| {
// Lingering potions' clouds tick with the potions.
if c.potion.is_some() {
return true;
}
if c.from_dragon && flame_over {
return false;
}
c.age += 1;
if c.age >= c.duration {
return false;
@@ -3213,10 +3219,12 @@ impl Game<'_> {
}
}
}
// DragonRespawnStage.SUMMONING_PILLARS: ExplosionInteraction.BLOCK.
let interaction = self.destroy_type("block_explosion_drop_decay");
let _ = self.server_explode(
[f64::from(cx) + 0.5, f64::from(h), f64::from(cz) + 0.5],
5.0,
super::explosion::BlockInteraction::Destroy,
interaction,
false,
);
// EndSpikeFeature.placeSpike with crystal_invulnerable and the beam.
@@ -3307,6 +3315,12 @@ impl Game<'_> {
}
}
}
/// `EnderDragonFight.createNewDragon`: over the podium, holding pattern, a random yaw.
fn create_new_dragon(&mut self) {
let yaw = self.world.random.next_float() * 360.0;
let seed = self.world.random.next_long();
self.dragon = Some(Dragon::new(Vec3::new(0.0, 128.0, 0.0), yaw, seed));
}
/// The END stage: the portal crystals blow up (no block damage) and a
/// new dragon takes over.
fn finish_respawn(&mut self) {
@@ -3333,6 +3347,31 @@ impl Game<'_> {
}
}
self.fight.killed = false;
// setRespawnStage(END): the new dragon is summoned for every player
// of the fight (the boss bar's players: alive within 192 blocks).
self.create_new_dragon();
let view = self
.dragon
.as_ref()
.map(|d| horizon_client::advancements::EntityView {
kind: "minecraft:ender_dragon".to_owned(),
position: [d.position.x, d.position.y, d.position.z],
..Default::default()
});
if let Some(view) = view {
self.for_each_player(|g| {
let near = g.player.as_ref().is_some_and(|p| {
!p.health.dead_or_dying()
&& p.position.x.powi(2)
+ (p.position.y - 128.0).powi(2)
+ p.position.z.powi(2)
<= 192.0 * 192.0
});
if near {
g.trigger_entity("minecraft:summoned_entity", "entity", &view);
}
});
}
self.fight.dirty = true;
self.count_crystals();
}
@@ -73,8 +73,13 @@ impl Game<'_> {
}
/// `getEnchantedDamage - base`: damage bonus of the held weapon against a mob type.
pub(super) fn enchant_damage_bonus(&mut self, target: &str) -> f32 {
self.enchanted_damage(target, 0.0)
}
/// `Player.getEnchantedDamage`: `base` run through the held weapon's
/// damage effects against a mob type.
pub(super) fn enchanted_damage(&mut self, target: &str, base: f32) -> f32 {
let Some(held) = self.held().cloned() else {
return 0.0;
return base;
};
let ctx = EffectContext {
this: Some(target),
@@ -90,7 +95,7 @@ impl Game<'_> {
"minecraft:damage",
&ctx,
&mut self.world.random,
0.0,
base,
)
}
/// Strength / weakness on the attack damage attribute.
@@ -456,6 +461,7 @@ impl Game<'_> {
}
hits.push(1.0 + self.world.random.next_float() * 4.0);
// ChangeItemDamage -> ItemStack.hurtAndBreak: unbreaking applies.
self.trigger_item_durability(&stack);
let loss = self.durability_loss(&stack, 2);
if let Some(s) = self.armor[slot].as_mut() {
s.damage += loss;
@@ -541,7 +547,7 @@ impl Game<'_> {
/// `ammo` is the item being fired: `Enchantment.itemContext` hands it to the
/// `ammo_use` conditions as the tool, so Infinity only frees plain arrows.
pub(super) fn bow_enchantments(&mut self, ammo: &str) -> (f64, bool) {
let Some(bow) = self.inventory[self.selected].clone() else {
let Some(bow) = self.held().cloned() else {
return (0.0, false);
};
let e = &self.res.stations().enchantments;
@@ -576,7 +582,7 @@ impl Game<'_> {
/// `EnchantmentHelper.onProjectileSpawned` ignite effects of the held bow
/// (flame): the seconds the new arrow burns for.
pub(super) fn bow_projectile_ignite(&mut self) -> Option<f32> {
let bow = self.inventory[self.selected].clone()?;
let bow = self.held().cloned()?;
let ctx = EffectContext {
this: Some("minecraft:arrow"),
items: Some(self.res.items()),
@@ -714,6 +720,7 @@ impl Game<'_> {
let Some(stack) = self.armor[3].clone() else {
return;
};
self.trigger_item_durability(&stack);
let loss = self.durability_loss(&stack, amount);
let max = self
.res
+232 -15
View File
@@ -3,6 +3,7 @@
//! sky. The dragon fight itself lives in game/dragon.rs: the podium is
//! inactive until the dragon dies.
use super::{Dim, Game};
use horizon_client::nether_portal::ENTITY_DELAY;
use horizon_client::{
Result,
containers::{BlockEntity, EndGateway},
@@ -16,10 +17,18 @@ use horizon_client::{
};
use horizon_core::{
geometry::Vec3,
position::BlockPos,
random::{LegacyRandom, RandomSource},
};
use horizon_world::nbt::Tag;
use std::{collections::HashMap, sync::Arc};
/// A list of doubles, as an NBT list of them.
fn doubles(values: [f64; 3]) -> Tag {
Tag::List(values.iter().map(|v| Tag::Double(v.to_bits())).collect())
}
/// ServerLevel.END_SPAWN_POINT.
pub(super) const END_SPAWN: [i32; 3] = [100, 50, 0];
/// EnderDragonFight gateway ring radius and height.
@@ -38,6 +47,18 @@ pub(super) enum GatewayJob {
Known { exit: [i32; 3], exact: bool },
}
/// An entity travelling through an End gateway: its saved compound and the
/// job that finds where it lands (`TheEndGatewayBlockEntity.getPortalPosition`).
#[derive(Clone, Debug)]
struct GatewayArrival {
/// The entity's compound, as it waits for the exit's chunks.
tag: Tag,
/// The gateway's exit, or the search for one.
job: GatewayJob,
/// The spot to load before the job is resolved.
target: Vec3,
}
impl GatewayJob {
/// TheEndGatewayBlockEntity.getPortalPosition, with legacy save fallbacks.
fn for_gateway(
@@ -94,6 +115,11 @@ pub(super) struct EndState {
job: Option<GatewayJob>,
/// Ticks until the player can use a gateway again (TheEndGatewayBlockEntity cooldown).
gateway_cooldown: u32,
gateways: HashMap<u32, horizon_client::end_gateway::Traveller>,
/// The entities that went through a gateway, waiting for its exit.
arrivals: Vec<GatewayArrival>,
/// The chunks kept loaded for those arrivals.
areas: Vec<(i32, i32)>,
/// The Overworld generator, for stronghold positions (ServerLevel.findNearestMapStructure).
locator: std::sync::Mutex<Option<Arc<VanillaTerrain>>>,
/// Thrown eyes of ender in flight.
@@ -376,14 +402,8 @@ impl Game<'_> {
}
self.consume_held();
self.swing_arm();
let center = hit.pos.map(|v| f64::from(v) + 0.5);
// LevelEvent 1503: END_PORTAL_FRAME_FILL.
self.audio.play(
"minecraft:block.end_portal_frame.fill",
Some(center),
1.0,
1.0,
);
// EnderEyeItem.useOn emits this event for all nearby players.
self.level_event_at(1503, hit.pos, 0);
if let Some(c) = self.portal_ring_center(hit.pos) {
for dz in -1..=1 {
for dx in -1..=1 {
@@ -937,6 +957,17 @@ impl Game<'_> {
self.save_gateway_exits()
}
/// The exit a gateway already leads to: its block entity, or the side file
/// that kept it for a gateway whose chunk is not loaded.
fn known_gateway_exit(&mut self, gateway: [i32; 3]) -> Option<([i32; 3], bool)> {
let key = (gateway[0], gateway[1], gateway[2]);
let saved = match self.world.block_entities.get(&key) {
Some(BlockEntity::EndGateway(g)) => g.exit_portal.map(|exit| (exit, g.exact_teleport)),
_ => None,
};
saved.or_else(|| self.gateway_exits().get(&gateway).copied())
}
/// EndGatewayBlock.entityInside and TheEndGatewayBlockEntity.getPortalPosition:
/// travel within the End, waiting for the destination chunks to load.
fn enter_gateway(&mut self, gateway: [i32; 3]) -> Result<()> {
@@ -1033,13 +1064,7 @@ impl Game<'_> {
/// The teleport position of a gateway job, creating the exit gateway (and
/// an island when the chunk has no landing spot) for new destinations.
pub(super) fn resolve_gateway(&mut self, job: GatewayJob, target: Vec3) -> Result<Vec3> {
let bottom_center = |p: [i32; 3]| {
Vec3::new(
f64::from(p[0]) + 0.5,
f64::from(p[1]),
f64::from(p[2]) + 0.5,
)
};
let bottom_center = horizon_client::end_gateway::bottom_center;
let find_exit = |game: &Self, exit: [i32; 3]| {
let p = game.tallest_block([exit[0], exit[1] + 2, exit[2]], 5, false);
[p[0], p[1] + 1, p[2]]
@@ -1049,6 +1074,14 @@ impl Game<'_> {
find_exit(self, e)
}))),
GatewayJob::Find { gateway, exact } => {
// The player and the entities may resolve the same gateway: the
// first one to arrive finds the exit, the others use it.
let known = self.known_gateway_exit(gateway);
if let Some((exit, exact)) = known {
return Ok(bottom_center(exit_target(exit, exact, |e| {
find_exit(self, e)
})));
}
// findExitPortalXZPosTentative over the loaded chunks.
let dir = glam::DVec2::new(f64::from(gateway[0]), f64::from(gateway[2]))
.normalize_or_zero();
@@ -1410,6 +1443,190 @@ impl Game<'_> {
}
}
}
// ---- End gateway travel for the entities ----
/// `EndGatewayBlock.entityInside` for the items and mobs in the End: the
/// gateway blocks their box swept through. A gateway that is cooling down
/// (`TheEndGatewayBlockEntity.triggerCooldown`) takes nobody in.
pub(super) fn find_end_gateway_portals(&mut self) {
if self.remote.is_some() || self.dim != Dim::End {
return;
}
let items: Vec<(u32, Vec3, f32, f32)> = self
.dropped
.iter()
.map(|e| (e.id, e.position, 0.25, 0.25))
.collect();
let mobs: Vec<(u32, Vec3, f32, f32)> = self
.mobs
.iter()
.filter(|m| self.mob_can_use_portal(m))
.map(|m| (m.id, m.position, m.width(), m.height()))
.collect();
let mut inside: Vec<(u32, BlockPos)> = Vec::new();
for (id, to, width, height) in items.into_iter().chain(mobs) {
let from = self.portals.before.get(&id).copied().unwrap_or(to);
let found = horizon_client::end_gateway::swept_gateway(from, to, width, height, |p| {
&*self.res.facts().get(self.world.block(p.x, p.y, p.z)).name
== "minecraft:end_gateway"
});
if let Some(pos) = found {
inside.push((id, pos));
}
}
for (id, pos) in inside {
// One entity at a time: the gateway cools down for 40 ticks.
if !self.gateway_enter([pos.x, pos.y, pos.z]) {
continue;
}
self.end.gateways.entry(id).or_default().enter(pos);
}
}
/// `Entity.handlePortal` for the entities in an End gateway: they go
/// through to the gateway's exit, where they wait for its chunks.
pub(super) fn handle_end_gateway_portals(&mut self) {
if self.remote.is_some() || self.dim != Dim::End {
return;
}
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))))
.collect();
let present: std::collections::HashSet<u32> = ids.iter().map(|(id, _)| *id).collect();
self.end.gateways.retain(|id, _| present.contains(id));
let mut leaving: Vec<(u32, BlockPos)> = Vec::new();
for (id, allowed) in ids {
let Some(state) = self.end.gateways.get_mut(&id) else {
continue;
};
// The End gateway has no transition time (Portal.getPortalTransitionTime).
if let Some(entry) = state.travel(allowed) {
leaving.push((id, entry));
}
if state.is_done() {
self.end.gateways.remove(&id);
}
}
for (id, entry) in leaving {
self.send_through_gateway(id, entry);
}
}
/// `Entity.teleportToPortalDestination` for an entity that goes through an
/// End gateway: it is saved and waits for the exit's chunks.
fn send_through_gateway(&mut self, id: u32, gateway: BlockPos) {
let tag = if let Some(i) = self.dropped.iter().position(|e| e.id == id) {
let uuid = self.uuid_for(id);
let tag = horizon_client::entity_nbt::item_entity_to_nbt(
&self.dropped[i],
uuid,
self.persist_raw(id),
);
self.dropped.remove(i);
self.forget_entity(id);
tag
} else if let Some(i) = self.mobs.iter().position(|m| m.id == id) {
let mut tag = self.mob_tree_nbt(i);
// Entity.removeAfterChangingDimensions drops the leash.
if let Tag::Compound(c) = &mut tag {
c.remove(&"leash".into());
}
// The mobs riding it travel with it (their tags are in its compound).
let mut gone = vec![id];
let mut k = 0;
while k < gone.len() {
let v = gone[k];
gone.extend(
self.mobs
.iter()
.filter(|m| m.extra.mount == Some(v))
.map(|m| m.id),
);
k += 1;
}
self.mobs.retain(|m| !gone.contains(&m.id));
for g in gone {
self.forget_entity(g);
}
tag
} else {
return;
};
let (job, target) = self.gateway_job([gateway.x, gateway.y, gateway.z]);
self.end.arrivals.push(GatewayArrival { tag, job, target });
}
/// The entities that reached a gateway's exit: they land where the
/// gateway leads, once its chunks have loaded.
pub(super) fn resolve_end_gateway_arrivals(&mut self) -> Result<()> {
if self.remote.is_some() || self.dim != Dim::End {
return Ok(());
}
let mut i = 0;
while i < self.end.arrivals.len() {
let (cx, cz) = (
(self.end.arrivals[i].target.x.floor() as i32) >> 4,
(self.end.arrivals[i].target.z.floor() as i32) >> 4,
);
if !self.world.chunks.contains_key(&(cx, cz)) {
i += 1;
continue;
}
let (job, target) = (self.end.arrivals[i].job, self.end.arrivals[i].target);
let at = match self.resolve_gateway(job, target) {
Ok(at) => at,
Err(e) => {
// The traveller waits for the exit: the failure is retried.
log::warn!("End gateway: {e}");
i += 1;
continue;
}
};
let mut tag = self.end.arrivals.remove(i).tag;
if let Tag::Compound(c) = &mut tag {
c.insert("Pos".into(), doubles([at.x, at.y, at.z]));
c.insert("Motion".into(), doubles([0.0, 0.0, 0.0]));
// setPortalCooldown before the teleport.
c.insert("PortalCooldown".into(), Tag::Int(ENTITY_DELAY));
}
let key = ((at.x.floor() as i32) >> 4, (at.z.floor() as i32) >> 4);
match self
.world
.loaded_entities
.iter_mut()
.find(|(k, _)| *k == key)
{
Some((_, tags)) => tags.push(tag),
None => self.world.loaded_entities.push((key, vec![tag])),
}
}
Ok(())
}
/// The chunks the entities waiting to arrive need, kept loaded like the
/// areas a server keeps around its players.
pub(super) fn keep_gateway_destinations(&mut self) {
let areas = &self.end.areas;
self.streaming
.extra
.retain(|&(c, r)| !(r == 1 && areas.contains(&c)));
let mut areas = Vec::new();
for a in &self.end.arrivals {
let c = (
(a.target.x.floor() as i32) >> 4,
(a.target.z.floor() as i32) >> 4,
);
if !areas.contains(&c) {
areas.push(c);
self.streaming.extra.push((c, 1));
}
}
self.end.areas = areas;
}
}
#[cfg(test)]
@@ -71,10 +71,18 @@ impl Game<'_> {
Some(r) => self.settings.render_distance.min(r.view_distance),
None => self.settings.render_distance,
};
(f64::from(distance) / 8.0).clamp(1.0, 2.5) * f64::from(self.settings.entity_distance)
entity_view_scale(distance, self.settings.entity_distance)
}
}
/// Entity.setViewScale: `Mth.clamp(renderDistance / 8, 1, 2.5) *
/// entityDistanceScaling`. Every entity's draw distance is scaled by it, so
/// a small mob is drawn nearer than a big one and a longer render distance
/// draws both further away.
pub(super) fn entity_view_scale(render_distance: i32, entity_distance: f32) -> f64 {
(f64::from(render_distance) / 8.0).clamp(1.0, 2.5) * f64::from(entity_distance)
}
/// Entity.shouldRenderAtSqrDistance's default range: 64 times the bounding
/// box's mean edge, scaled by the view scale.
pub(super) fn default_range(size: glam::DVec3, view_scale: f64) -> f64 {
@@ -135,3 +143,41 @@ impl Game<'_> {
self.entity_in_view(camera, position, range, lo, hi)
}
}
#[cfg(test)]
mod tests {
use super::*;
/// `Entity.setViewScale`: the entity distance option times the render
/// distance's boost — 1 at eight chunks, 1.5 at twelve, 2.5 at twenty.
#[test]
fn the_view_scale_follows_the_render_distance() {
assert_eq!(entity_view_scale(4, 1.0), 1.0);
assert_eq!(entity_view_scale(8, 1.0), 1.0);
assert_eq!(entity_view_scale(12, 1.0), 1.5);
assert_eq!(entity_view_scale(20, 1.0), 2.5);
assert_eq!(entity_view_scale(32, 1.0), 2.5);
// The entity distance option scales it down.
assert_eq!(entity_view_scale(12, 0.5), 0.75);
}
/// `Entity.shouldRenderAtSqrDistance`: a chicken's box (0.4 by 0.7 by
/// 0.4) is drawn up to 48 blocks away at the vanilla defaults, not the
/// 32 blocks a missing view scale leaves (#2273).
#[test]
fn a_chicken_is_drawn_forty_eight_blocks_away() {
let range = default_range(glam::DVec3::new(0.4, 0.7, 0.4), entity_view_scale(12, 1.0));
assert_eq!(range, 48.0);
}
/// `Entity.shouldRenderAtSqrDistance` on a broken box: the size falls
/// back to one block.
#[test]
fn a_broken_bounding_box_falls_back_to_one_block() {
let range = default_range(
glam::DVec3::new(f64::NAN, 0.7, 0.4),
entity_view_scale(12, 1.0),
);
assert_eq!(range, 96.0);
}
}
@@ -258,6 +258,8 @@ impl Game<'_> {
if dist > 1.0 || p.health.dead_or_dying() {
return;
}
// ServerPlayer.onExplosionHit: the blast that hit the player is theirs.
self.adv.marks.blast = Some((feet, false));
let w = f64::from(p.dimensions.width) / 2.0;
let h = f64::from(p.dimensions.height);
let eye = [
@@ -503,6 +505,15 @@ impl Game<'_> {
);
}
}
/// `ServerLevel.getDestroyType`: a drop decay rule picks between decaying
/// and plain destruction.
pub(super) fn destroy_type(&self, rule: &str) -> BlockInteraction {
if self.ui.rules.bool(rule) {
BlockInteraction::DestroyWithDecay
} else {
BlockInteraction::Destroy
}
}
/// Runs an explosion whose source empties the hives it destroys
/// (`BeehiveBlock.getDrops`: primed TNT, creeper, wither skull, wither,
/// TNT minecart).
@@ -6,11 +6,33 @@ use horizon_world::level::LevelEvent;
impl Game<'_> {
pub(super) fn finish_falling_block(&mut self, mut block: FallingBlock, pos: [i32; 3]) {
let anvil = self.res.facts().get(block.state).name.ends_with("anvil");
let name = self.res.facts().get(block.state).name.clone();
let anvil = name.ends_with("anvil");
let stalactite = matches!(
&*name,
"minecraft:pointed_dripstone" | "minecraft:sulfur_spike"
);
let sound_event = match &*name {
"minecraft:pointed_dripstone" => Some(1045),
"minecraft:sulfur_spike" => Some(1052),
_ => None,
};
if let Some(impact) = block.damage.impact(block.fall_distance) {
if impact.amount > 0.0 {
falling_block::hurt_mobs(&mut self.mobs, block.position, impact.amount);
let kind = if anvil {
let mob_kind = if stalactite {
horizon_client::mobs::DamageKind::FallingStalactite
} else {
horizon_client::mobs::DamageKind::FallingBlock
};
falling_block::hurt_mobs_with_kind(
&mut self.mobs,
block.position,
impact.amount,
mob_kind,
);
let kind = if stalactite {
"minecraft:falling_stalactite"
} else if anvil {
"minecraft:falling_anvil"
} else {
"minecraft:falling_block"
@@ -58,7 +80,13 @@ impl Game<'_> {
}
}
if block.damage.cancel_drop {
if anvil {
if let Some(id) = sound_event {
self.level_events.push(LevelEvent::LevelEvent {
id,
pos: BlockPos::new(pos[0], pos[1], pos[2]),
data: 0,
});
} else if anvil {
self.level_events.push(LevelEvent::LevelEvent {
id: 1029,
pos: BlockPos::new(pos[0], pos[1], pos[2]),
@@ -99,6 +127,13 @@ impl Game<'_> {
&& block.drop_item
&& self.ui.rules.bool("entity_drops")
{
if let Some(id) = sound_event {
self.level_events.push(LevelEvent::LevelEvent {
id,
pos: BlockPos::new(pos[0], pos[1], pos[2]),
data: 0,
});
}
let item = self.res.facts().get(block.state).item.clone();
if &*item != "minecraft:air" {
self.pop_resource(
@@ -36,6 +36,8 @@ pub(super) struct FnState {
load_pending: bool,
pub schedule: Vec<Scheduled>,
next_seq: u64,
/// A dedicated server's `function-permission-level`.
pub permission: Option<u8>,
}
/// What a function reported through `return`.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
@@ -45,6 +47,16 @@ pub(super) enum Outcome {
Fail,
}
impl FnState {
/// The level function files compile at (`ServerFunctionLibrary`'s
/// `functionCompilationPermissions`): `function-permission-level` on a
/// dedicated server, game master (2) otherwise. Whoever runs a function,
/// its commands need no more than this.
fn command_level(&self) -> u8 {
self.permission.unwrap_or(2)
}
}
impl Game<'_> {
// ---- loading -----------------------------------------------------------
@@ -278,6 +290,7 @@ impl Game<'_> {
}
self.exec.function_depth += 1;
let outer = self.exec.returned.take();
let level = std::mem::replace(&mut self.ui.registries.permission, self.fns.command_level());
let inner = Source {
silent: true,
store: None,
@@ -292,6 +305,7 @@ impl Game<'_> {
break;
}
}
self.ui.registries.permission = level;
let returned = std::mem::replace(&mut self.exec.returned, outer);
self.exec.function_depth -= 1;
Ok(match returned {
@@ -572,6 +586,75 @@ impl Game<'_> {
.chain(self.fns.enabled.iter().cloned())
.collect()
}
/// `FileUtil.isPathPartPortable`: no trailing dot and no Windows device
/// name (CON, PRN, AUX, NUL, COM and LPT with a digit from 1 to 9).
fn is_portable_name(name: &str) -> bool {
let stem = name.split('.').next().unwrap_or_default();
let device = match stem.len() {
3 => matches!(stem, "com" | "con" | "prn" | "aux" | "nul"),
4 => matches!(&stem[..3], "com" | "lpt") && matches!(stem.as_bytes()[3], b'1'..=b'9'),
_ => false,
};
!device && !name.ends_with('.')
}
/// `DataPackCommand.createPack`: a pack folder with its `data` folder and a
/// `pack.mcmeta` in the world's datapacks folder.
fn create_pack(&mut self, ctx: &Context) -> R<i32> {
let id = match ctx.get("id") {
Some(Arg::Str(s)) => s.clone(),
_ => String::new(),
};
let description = self.component_arg(ctx, "description")?;
// FileUtil.isValidPathSegment: lowercase letters, digits, '-', '.' and '_'.
let valid = !id.is_empty()
&& id != "."
&& id != ".."
&& id
.chars()
.all(|c| matches!(c, 'a'..='z' | '0'..='9' | '-' | '.' | '_'));
if !valid {
return Err(err("commands.datapack.create.invalid_name", &[&id]));
}
if !Self::is_portable_name(&id) {
return Err(err("commands.datapack.create.invalid_full_name", &[&id]));
}
// Without a world there is no datapacks folder to write to.
if self.world_root.as_os_str().is_empty() {
return Err(err("commands.datapack.create.io_failure", &[&id]));
}
let datapacks = self.world_root.join("datapacks");
let pack = datapacks.join(&id);
if pack.exists() {
return Err(err("commands.datapack.create.already_exists", &[&id]));
}
// The data pack version (SharedConstants), written as a bare major
// number for both bounds, as vanilla does.
let pack_format = horizon_core::version::DATA_PACK_FORMAT.0;
let mcmeta = serde_json::json!({
"pack": {
"description": description.component(),
"min_format": pack_format,
"max_format": pack_format,
}
});
let written = std::fs::create_dir_all(&datapacks)
.and_then(|()| std::fs::create_dir(&pack))
.and_then(|()| std::fs::create_dir(pack.join("data")))
.and_then(|()| {
std::fs::write(
pack.join("pack.mcmeta"),
serde_json::to_string_pretty(&mcmeta).unwrap_or_default(),
)
});
if written.is_err() {
return Err(err("commands.datapack.create.io_failure", &[&id]));
}
self.feedback(Text::translate(
"commands.datapack.create.success",
&[Text::literal(id)],
));
Ok(1)
}
pub(super) fn cmd_datapack(&mut self, id: &str, ctx: &Context) -> R<i32> {
self.ensure_datapacks();
let name = match ctx.get("name") {
@@ -590,6 +673,7 @@ impl Game<'_> {
|| g.fns.packs.iter().any(|p| p.id == n)
};
match id {
"datapack.create" => self.create_pack(ctx),
"datapack.list" => {
let want_enabled = ctx.has("enabled") || (!ctx.has("available"));
let want_available = ctx.has("available") || (!ctx.has("enabled"));
+117 -13
View File
@@ -3,6 +3,22 @@
use super::{Game, Screen};
use horizon_client::command::args::GameMode;
pub(super) fn mode_id_of(mode: GameMode) -> i32 {
match mode {
GameMode::Survival => 0,
GameMode::Creative => 1,
GameMode::Adventure => 2,
GameMode::Spectator => 3,
}
}
pub(super) fn mode_from_id(id: i32) -> GameMode {
match id {
1 => GameMode::Creative,
2 => GameMode::Adventure,
3 => GameMode::Spectator,
_ => GameMode::Survival,
}
}
impl Game<'_> {
pub(super) fn spectator(&self) -> bool {
self.mode == GameMode::Spectator
@@ -12,21 +28,10 @@ impl Game<'_> {
matches!(self.mode, GameMode::Survival | GameMode::Creative)
}
pub(super) fn mode_id(&self) -> i32 {
match self.mode {
GameMode::Survival => 0,
GameMode::Creative => 1,
GameMode::Adventure => 2,
GameMode::Spectator => 3,
}
mode_id_of(self.mode)
}
pub(super) fn set_mode_id(&mut self, id: i32) {
let mode = match id {
1 => GameMode::Creative,
2 => GameMode::Adventure,
3 => GameMode::Spectator,
_ => GameMode::Survival,
};
self.set_mode(mode);
self.set_mode(mode_from_id(id));
}
/// `ServerPlayer.setGameMode` and `GameType.updatePlayerAbilities`.
pub(super) fn set_mode(&mut self, mode: GameMode) {
@@ -68,6 +73,105 @@ impl Game<'_> {
}
}
impl Game<'_> {
/// `ItemStack.canBreakBlockInAdventureMode` / `canPlaceOnBlockInAdventureMode`
/// for the main-hand stack: some predicate of its `can_break` or
/// `can_place_on` component (`AdventureModePredicate`: one block predicate
/// or a list) matches the block at `pos`. Predicates with `nbt` or
/// `components` need the block entity's data and never match.
fn adventure_predicate(&self, component: &str, pos: [i32; 3]) -> bool {
use horizon_world::nbt::Tag;
let Some(tag) = self
.held()
.and_then(|s| s.data())
.and_then(|d| d.other(component))
else {
return false;
};
let state = self.world.block(pos[0], pos[1], pos[2]);
let name = &self.res.facts().get(state).name;
let properties = self
.pack
.assets
.registry
.state(state)
.map(|s| &s.properties);
let matches = |p: &Tag| {
if p.get("nbt").is_some() || p.get("components").is_some() {
return false;
}
let block_ok = match p.get("blocks") {
None => true,
Some(Tag::String(id)) => self.block_in_holder_set(&id.to_string_lossy(), name),
Some(Tag::List(ids)) => ids.iter().any(|id| match id {
Tag::String(id) => self.block_in_holder_set(&id.to_string_lossy(), name),
_ => false,
}),
Some(_) => false,
};
// StatePropertiesPredicate: every property exact, or in a min/max range.
let state_ok = match p.get("state") {
None => true,
Some(Tag::Compound(want)) => want.iter().all(|(key, matcher)| {
let Some(have) = properties.and_then(|p| p.get(&key.to_string_lossy())) else {
return false;
};
match matcher {
Tag::String(value) => *have == value.to_string_lossy(),
Tag::Compound(_) => {
let bound = |k: &str| match matcher.get(k) {
Some(Tag::String(v)) => Some(v.to_string_lossy()),
_ => None,
};
let in_range = |have: &str, v: Option<String>, above: bool| {
v.is_none_or(|v| match (have.parse::<i64>(), v.parse::<i64>()) {
(Ok(h), Ok(b)) if above => h >= b,
(Ok(h), Ok(b)) => h <= b,
_ => false,
})
};
in_range(have, bound("min"), true)
&& in_range(have, bound("max"), false)
}
_ => false,
}
}),
Some(_) => false,
};
block_ok && state_ok
};
match tag {
Tag::List(list) => list.iter().any(matches),
single => matches(single),
}
}
/// A `HolderSet<Block>` entry: a block id or a `#tag`.
fn block_in_holder_set(&self, entry: &str, block: &str) -> bool {
let namespaced = |id: &str| {
if id.contains(':') {
id.to_owned()
} else {
format!("minecraft:{id}")
}
};
match entry.strip_prefix('#') {
Some(tag) => self.res.items().block_in_tag(&namespaced(tag), block),
None => namespaced(entry) == block,
}
}
/// `Player.blockActionRestricted` for the block at `pos`: spectators never
/// act on blocks, adventure players only with a main-hand item whose
/// `can_break` matches it.
pub(super) fn block_action_restricted(&self, pos: [i32; 3]) -> bool {
if self.spectator() {
return true;
}
!self.may_build() && !self.adventure_predicate("minecraft:can_break", pos)
}
/// `ItemStack.useOn` in adventure mode: the used item's `can_place_on`
/// matches the clicked block (`Player.mayUseItemAt`).
pub(super) fn can_place_on_in_adventure(&self, clicked: [i32; 3]) -> bool {
self.adventure_predicate("minecraft:can_place_on", clicked)
}
/// `ServerPlayer.setGameMode(SPECTATOR)`: `stopRiding`.
fn dismount_for_spectator(&mut self) {
let Some(id) = self.riding else {
+30 -1
View File
@@ -78,9 +78,37 @@ impl Game<'_> {
f64::from(base[1]) + 0.05,
f64::from(base[2]) + 0.5,
];
self.create_triggered(kind, at, 0.0);
let id = self.create_triggered(kind, at, 0.0);
self.trigger_golem_summoned(id);
self.update_pattern_blocks(&blocks)
}
/// `CarvedPumpkinBlock.spawnGolemInWorld`: SUMMONED_ENTITY for each player
/// whose box is within 5 blocks of the golem's box (the local player here).
fn trigger_golem_summoned(&mut self, id: Option<u32>) {
let Some(golem) = id
.and_then(|id| self.mobs.iter().find(|m| m.id == id))
.cloned()
else {
return;
};
let me = self.player_entity();
let half = f64::from(golem.width()) / 2.0;
let height = f64::from(golem.height());
let reach = 5.0;
let [x, y, z] = me.position;
let p = golem.position;
// The player's box is 0.6 wide and 1.8 tall.
let near = x + 0.3 >= p.x - half - reach
&& x - 0.3 <= p.x + half + reach
&& y + 1.8 >= p.y - reach
&& y <= p.y + height + reach
&& z + 0.3 >= p.z - half - reach
&& z - 0.3 <= p.z + half + reach;
if near {
let view = self.mob_entity(&golem);
self.trigger_entity("minecraft:summoned_entity", "entity", &view);
}
}
/// Whether placing a golem head at `head` completes a golem.
pub(super) fn completes_golem(&self, head: [i32; 3]) -> bool {
self.find_golem(head).is_some() || self.copper_golem_base(head)
@@ -186,6 +214,7 @@ impl Game<'_> {
f64::from(head[2]) + 0.5,
];
let id = self.create_triggered("minecraft:copper_golem", at, 0.0);
self.trigger_golem_summoned(id);
self.update_pattern_blocks(&cells)?;
let direction = match facing.as_str() {
"south" => horizon_core::geometry::Direction::South,
+15 -2
View File
@@ -32,6 +32,11 @@ fn attack_range_contains(range: &serde_json::Value, distance: f64, creative: boo
impl Game<'_> {
/// Opening a screen stops breaking before the next game tick (which may pause).
pub(super) fn stop_mining(&mut self) {
if self.clicks.destroying.active {
let pos = self.clicks.destroying.pos;
let state = self.world.block(pos[0], pos[1], pos[2]);
self.tutorial_destroy(state, -1.0);
}
let mut destroying = std::mem::take(&mut self.clicks.destroying);
destroying.stop(&mut GameHands {
game: self,
@@ -112,6 +117,12 @@ impl Game<'_> {
progress: d.progress,
ticks: d.ticks as u32,
});
if let Some(mining) = &self.mining {
let pos = mining.pos;
let progress = mining.progress;
let state = self.world.block(pos[0], pos[1], pos[2]);
self.tutorial_destroy(state, progress);
}
Ok(())
}
/// `BlockBehaviour.getDestroyProgress` with `Player.getDestroySpeed`.
@@ -500,8 +511,8 @@ impl HandWorld for GameHands<'_, '_> {
fn attack_entity(&mut self) -> bool {
self.game.attack()
}
fn block_action_restricted(&mut self, _: [i32; 3]) -> bool {
!self.game.may_build()
fn block_action_restricted(&mut self, pos: [i32; 3]) -> bool {
self.game.block_action_restricted(pos)
}
fn within_border(&mut self, _: [i32; 3]) -> bool {
true
@@ -527,6 +538,8 @@ impl HandWorld for GameHands<'_, '_> {
{
return false;
}
let state = self.game.world.block(pos[0], pos[1], pos[2]);
self.game.tutorial_destroy(state, 1.0);
let r = self.game.break_block(pos);
self.keep(r);
true
@@ -110,7 +110,17 @@ impl Game<'_> {
while let Some((dx, dz)) = self.spawn_spiral.current() {
if self.spawn_spiral.tested() {
let (cx, cz) = (ox + dx, oz + dz);
if !self.streaming.is_lit(cx, cz) {
if self.standalone_server() {
// getChunk(FULL) of the tested chunk alone: its 3x3 is decorated.
if !self.world.chunks.contains_key(&(cx, cz)) {
let area = [((cx, cz), -1)];
if self.streaming.extra != area {
self.streaming.extra = area.to_vec();
self.streaming.rank_full_task(cx, cz);
}
return Ok(None);
}
} else if !self.streaming.is_lit(cx, cz) {
self.streaming.center = (cx, cz);
return Ok(None);
}
+74 -14
View File
@@ -6,12 +6,12 @@
//! the world's options) with [`Integrated::call`], as vanilla's client calls
//! into its `IntegratedServer`.
//!
//! On by default. `--in-frame` (or `--no-server-thread`, or
//! `HORIZON_SERVER_THREAD=0`) runs the world in the frame instead, the
//! escape hatch. LAN guests and dedicated servers use the same code either
//! way.
//! On by default. The public in-frame selectors (`--in-frame`,
//! `--no-server-thread`, `HORIZON_SERVER_THREAD=0`) are deprecated and will
//! be removed in a future release. LAN guests and dedicated servers use the
//! same server code either way.
use super::{
Game, Options, PackResources, Resources, UiResources,
Game, Options, PackResources, Resources, UiResources, WorldIcon,
execution_role::ExecutionRole,
lan::{LanSettings, Owner},
world_loading::WorldRequest,
@@ -169,6 +169,18 @@ enum Publish {
/// of the connection and the world's name, or why it failed.
type Ready = std::result::Result<(Pipe, String), String>;
/// An integrated server loading its world: the window keeps drawing its
/// loading screen and joins once the server is ready, as vanilla's client
/// does while its server prepares (`Minecraft.doWorldLoad`), instead of
/// waiting for it frozen for the whole load.
pub(super) struct Starting {
handle: Integrated,
ready: Receiver<Ready>,
player: String,
/// The save folder, for the world's icon.
folder: String,
}
/// The world to open and the player who owns it.
struct Launch {
folder: String,
@@ -197,8 +209,8 @@ impl Game<'_> {
.as_ref()
.is_some_and(|s| s.published().is_some())
}
/// `Minecraft.doWorldLoad`: start the server thread on the world, wait
/// until it can be joined, then join it through memory.
/// `Minecraft.doWorldLoad`: start the server thread on the world; the
/// window joins it through memory once it is ready (`poll_integrated_start`).
pub(super) fn open_integrated(
&mut self,
folder: &str,
@@ -267,21 +279,67 @@ impl Game<'_> {
let _ = ready_tx.send(Err(e.to_string()));
}
})?;
let mut handle = Integrated {
let handle = Integrated {
flags,
thread: Some(thread),
tasks,
world_name: String::new(),
};
let pipe = match ready.recv() {
Ok(Ok((pipe, world_name))) => {
handle.world_name = world_name;
pipe
self.integrated_starting = Some(Starting {
handle,
ready,
player,
folder: folder.to_owned(),
});
// Harnesses, control scripts and runs without a window go on with the world open.
if self.window.is_none() || super::synchronous_startup(&self.options) {
while self.integrated_starting.is_some() {
self.poll_integrated_start()?;
if self.integrated_starting.is_some() {
std::thread::sleep(Duration::from_millis(5));
}
}
Ok(Err(e)) => return Err(e.into()),
Err(_) => return Err("the integrated server stopped".into()),
}
Ok(())
}
/// The starting server's answer, polled each frame: joined once it is ready.
pub(super) fn poll_integrated_start(&mut self) -> Result<()> {
let Some(starting) = &self.integrated_starting else {
return Ok(());
};
let (pipe, world_name) = match starting.ready.try_recv() {
Ok(Ok(ready)) => ready,
Err(std::sync::mpsc::TryRecvError::Empty) => return Ok(()),
Ok(Err(e)) => {
self.integrated_starting = None;
return Err(e.into());
}
Err(std::sync::mpsc::TryRecvError::Disconnected) => {
self.integrated_starting = None;
return Err("the integrated server stopped".into());
}
};
let Some(Starting {
mut handle,
player,
folder,
..
}) = self.integrated_starting.take()
else {
return Ok(());
};
handle.world_name = world_name;
self.connect_local(pipe, &player)?;
// IntegratedServer.getWorldScreenshotFile: the client grabs the frame
// the Select World list shows, and needs the save folder for it.
self.world_icon = Some(WorldIcon {
path: std::env::current_dir()?
.join("saves")
.join(folder)
.join("icon.png"),
last_attempt: None,
});
self.integrated = Some(handle);
Ok(())
}
@@ -435,6 +493,8 @@ impl Game<'_> {
self.lan = Some(host);
}
std::thread::sleep(Duration::from_millis(100));
// ServerPlayer.disconnect: nobody is left in a bed.
self.free_player_beds()?;
self.save_game()?;
self.lan_close(false);
self.save.flush()?;
@@ -458,6 +458,7 @@ impl Game<'_> {
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn civil_dates_follow_the_gregorian_calendar() {
@@ -465,4 +466,27 @@ mod tests {
assert_eq!(civil(19_716), (2023, 12, 25));
assert_eq!(civil(11_016), (2000, 2, 29));
}
#[test]
fn a_shield_is_used_for_the_vanilla_s_time() {
// Item.getUseDuration: an item with `blocks_attacks` (a shield) is
// used for 72000 ticks, so its use animation (the arm held up)
// runs instead of ending at once (#2289).
let mut shield = Components::new();
shield.insert("minecraft:blocks_attacks".into(), json!({}));
assert_eq!(use_duration(&shield), 72_000);
// Food is used for as long as it takes to eat it.
let mut food = Components::new();
food.insert(
"minecraft:consumable".into(),
json!({"consume_seconds": 1.6}),
);
assert_eq!(use_duration(&food), 32);
// The bow keeps its own use time.
let mut bow = Components::new();
bow.insert("minecraft:item_model".into(), json!("minecraft:bow"));
assert_eq!(use_duration(&bow), 72_000);
// Anything else is not used at all.
assert_eq!(use_duration(&Components::new()), 0);
}
}
+29
View File
@@ -5,6 +5,7 @@
use super::Game;
use horizon_client::{
containers::{BlockEntity, Jukebox},
first_person::Hand,
items::ItemStack,
mobs::Species,
particles::{ParticleOptions, ParticleType},
@@ -64,6 +65,10 @@ impl Game<'_> {
return false;
}
if self.jukebox(pos).is_some_and(|j| j.record.is_some()) {
// TRY_WITH_EMPTY_HAND: only the main hand goes on to useWithoutItem.
if self.hand != Hand::Main {
return false;
}
self.eject_record(pos);
return true;
}
@@ -100,6 +105,30 @@ impl Game<'_> {
self.trigger_block("minecraft:item_used_on_block", pos, stack);
true
}
/// `JukeboxBlock.useItemOn` on this client's level for the main hand:
/// whether the jukebox at `pos` takes the click (`has_record` pops the
/// disc out, or a `jukebox_playable` item goes in). The server does the
/// rest, see `use_jukebox`.
pub(super) fn jukebox_takes(&self, pos: [i32; 3], held: Option<&ItemStack>) -> bool {
let state = self.world.block(pos[0], pos[1], pos[2]);
if &*self.res.facts().get(state).name != "minecraft:jukebox" {
return false;
}
let has_record = self
.pack
.assets
.registry
.state(state)
.and_then(|s| s.properties.get("has_record"))
.is_some_and(|v| v == "true");
has_record
|| held.is_some_and(|stack| {
self.res
.items()
.get(&stack.item)
.is_some_and(|i| i.jukebox_song.is_some())
})
}
/// Level event 1010: the song from the jukebox, and the "Now Playing" overlay.
fn start_song(&mut self, pos: [i32; 3], song: &str) {
self.stop_sound(pos);
+84 -21
View File
@@ -328,7 +328,7 @@ fn mob_meta(
// Entity.DATA_SHARED_FLAGS_ID: on fire 0x01, swimming 0x10 (a drowned
// under water, `Drowned.setSwimming`), invisible 0x20 (the effect's flag)
// and glowing 0x40 (LivingEntity.updateGlowingStatus).
let shared = i8::from(m.fire_ticks > 0)
let shared = i8::from(m.is_on_fire())
| i8::from(m.species() == Species::Drowned && m.extra.flag) << 4
| i8::from(m.invisible || horizon_client::mobs::trader::is_invisible(m)) << 5
| i8::from(m.is_currently_glowing()) << 6;
@@ -342,9 +342,7 @@ fn mob_meta(
meta.push((6, serializer::POSE, Value::Id(i32::from(pose))));
}
// LivingEntity.DATA_SLEEPING_POS_ID: the bed a sleeping villager lies in.
if horizon_client::mobs::villager::sleeping(m)
&& let Some(bed) = m.extra.bed
{
if let Some(bed) = horizon_client::mobs::villager::sleeping_pos(m) {
meta.push((14, serializer::OPTIONAL_BLOCK_POS, Value::OptPos(Some(bed))));
}
if let Some(name) = &m.custom_name {
@@ -803,6 +801,14 @@ fn mob_meta(
}
defaults.push((18, serializer::BOOLEAN, Value::Bool(false)));
}
// Skeleton.DATA_STRAY_CONVERSION_ID (the first entry after Mob's):
// the shaking of the freezing conversion.
"minecraft:skeleton" => {
if e.counter2 > 0 {
meta.push((16, serializer::BOOLEAN, Value::Bool(true)));
}
defaults.push((16, serializer::BOOLEAN, Value::Bool(false)));
}
// AbstractVillager.DATA_UNHAPPY_COUNTER (after AgeableMob's two).
"minecraft:wandering_trader" => unhappy_counter(m, meta, defaults),
// Hoglin.DATA_IMMUNE_TO_ZOMBIFICATION (after AgeableMob's two) and
@@ -909,16 +915,23 @@ fn mob_equipment(
// slot's item (6) in `head`.
let body = horizon_client::mobs::drops::head_is_body(m.species());
let slots = [
(0u8, &e.main_hand),
(1, &e.off_hand),
(2, &e.feet),
(3, &e.legs),
(4, &e.chest),
(if body { 6 } else { 5 }, &e.head),
(0u8, &e.main_hand, None),
(1, &e.off_hand, None),
(2, &e.feet, e.armor[3].as_ref()),
(3, &e.legs, e.armor[2].as_ref()),
(4, &e.chest, e.armor[1].as_ref()),
// The body slot's item is no worn armour.
(
if body { 6 } else { 5 },
&e.head,
e.armor[0].as_ref().filter(|_| !body),
),
];
for (slot, item) in slots {
for (slot, item, worn) in slots {
if let Some(item) = item {
let stack = ItemStack {
// The worn armour keeps its stack: a dyed leather piece keeps
// its colour.
let stack = worn.cloned().unwrap_or_else(|| ItemStack {
item: item.clone(),
count: 1,
// A wolf's armor shows its cracks.
@@ -932,7 +945,7 @@ fn mob_equipment(
} else {
None
},
};
});
out.push((slot, hub.stack(&Some(stack))));
}
}
@@ -1229,15 +1242,37 @@ impl Game<'_> {
/// Save a guest when they leave or the world closes: an integrated
/// server's owner with the world (`save_game`), anyone else in their file.
pub(super) fn save_guest(&mut self, host: &LanHost, guest: &horizon_client::lan::guest::Guest) {
// The vehicle the guest rides, for the file that names it.
let vehicle = self.root_vehicle_uuid(guest.vehicle);
if host.owner.as_ref().is_some_and(|o| o.uuid == guest.uuid) {
if guest.placed {
playerdata::store(guest, &mut self.level);
playerdata::store(guest, &mut self.level, vehicle);
}
} else {
playerdata::save(guest, &self.world_root);
playerdata::save(guest, &self.world_root, vehicle);
}
self.save_guest_records(host, guest);
}
/// `ServerPlayer.loadAndSpawnParentVehicle`: a guest whose file names the
/// vehicle it rode rides it again, once the vehicle is in the world.
fn restore_guest_vehicles(&mut self, host: &mut LanHost) {
let waiting: Vec<(usize, [i32; 4])> = host
.hub
.guests
.iter()
.enumerate()
.filter_map(|(i, g)| Some((i, g.saved_vehicle?)))
.filter(|(i, _)| host.hub.guests[*i].vehicle.is_none())
.collect();
for (i, uuid) in waiting {
let Some(id) = self.vehicle_by_uuid(uuid) else {
continue;
};
let g = &mut host.hub.guests[i];
g.vehicle = Some(id);
g.saved_vehicle = None;
}
}
/// The file name of a player's advancements and statistics: an
/// integrated server's owner keeps single player's.
fn records_uuid(&self, host: &LanHost, uuid: u128) -> Option<[i32; 4]> {
@@ -1308,14 +1343,28 @@ impl Game<'_> {
}
/// The integrated server's owner into the world's player, for `Level::write`.
pub(super) fn store_owner(&mut self) {
let Some(uuid) = self
.lan
.as_ref()
.and_then(|h| h.owner.as_ref())
.map(|o| o.uuid)
else {
return;
};
let riding = self.lan.as_ref().and_then(|h| {
h.hub
.guests
.iter()
.find(|g| g.uuid == uuid && g.placed)
.and_then(|g| g.vehicle)
});
// The vehicle the owner rides, by the UUID it is saved with.
let vehicle = self.root_vehicle_uuid(riding);
let Some(host) = &self.lan else {
return;
};
let Some(uuid) = host.owner.as_ref().map(|o| o.uuid) else {
return;
};
if let Some(g) = host.hub.guests.iter().find(|g| g.uuid == uuid && g.placed) {
playerdata::store(g, &mut self.level);
playerdata::store(g, &mut self.level, vehicle);
self.save_guest_records(host, g);
}
}
@@ -1498,6 +1547,8 @@ impl Game<'_> {
m.rider = None;
}
}
// A guest whose file names the vehicle it rode rides it again.
self.restore_guest_vehicles(&mut host);
// A dedicated server runs the hub without a host player.
if state.is_some() || host.hub.config.dedicated {
let has_guests = !host.hub.guests.is_empty();
@@ -1816,10 +1867,13 @@ impl Game<'_> {
horizon_world::level::LevelEvent::LevelEvent { id, pos, data } => {
// The acting guest does not predict the egg's teleport
// particles, bone meal's growth, anvil result sounds, or a
// finished brushing (these levelEvent calls name no
// finished brushing or an eye inserted in a portal frame (these calls name no
// excluded player), nor the digger's own crack and hit
// sound (2019, 2020).
let except = if matches!(*id, 2015 | 1505 | 1029 | 1030 | 2019 | 2020 | 3008) {
let except = if matches!(
*id,
2015 | 1503 | 1505 | 1029 | 1030 | 1045 | 1052 | 2019 | 2020 | 3000 | 3008
) {
None
} else {
self.lan_acting
@@ -2040,6 +2094,14 @@ impl Game<'_> {
.iter()
.filter(|g| host.hub.here(g))
.map(|g| (g.chunk(), radius))
.chain(
self.arrival_chunks(self.dim)
.into_iter()
// An arrival keeps only its landing spot lit enough to
// resolve (the 3x3 `resolve_arrivals` checks), where it
// then ticks without a player.
.map(|chunk| (chunk, 1)),
)
.collect();
}
@@ -2136,6 +2198,7 @@ impl Game<'_> {
.is_some_and(|s| s.data().is_some_and(|d| !d.charged.is_empty()));
// ClientInformation.mainHand (HumanoidArm: 0 left, 1 right).
m.extra.main_hand_left = g.info.main_hand == 0;
m.extra.hidden_model_parts = !g.info.model_customisation & 0x7F;
// LivingEntity.DATA_EFFECT_PARTICLES: the swirls of its visible effects.
let visible = || g.effects.list.iter().filter(|e| e.visible);
m.extra.effect_particles = visible()
+124 -15
View File
@@ -82,6 +82,8 @@ pub(super) struct GuestPlay {
last_inventory: u64,
/// `ServerPlayer.levitationStartPos` and `levitationStartTime`.
levitation: Option<([f64; 3], u64)>,
/// The fall, lava ride, Nether trip and blast the guest's advancements track.
marks: super::advancements::TravelMarks,
/// `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
@@ -166,6 +168,7 @@ pub(super) struct GuestSwap {
adv_loaded: bool,
last_inventory: u64,
levitation: Option<([f64; 3], u64)>,
marks: super::advancements::TravelMarks,
death_cause: Option<String>,
/// The guest's own damage record, in place while they act.
combat: super::combat::CombatTracker,
@@ -396,6 +399,7 @@ impl Game<'_> {
let adv_loaded = std::mem::replace(&mut self.adv.loaded, true);
let last_inventory = std::mem::replace(&mut self.adv.last_inventory, play.last_inventory);
let levitation = std::mem::replace(&mut self.adv.levitation, play.levitation);
let marks = std::mem::replace(&mut self.adv.marks, std::mem::take(&mut play.marks));
let death_cause = std::mem::replace(&mut self.ui.death_cause, play.death_cause.take());
let acting = (
self.lan_acting.replace(conn),
@@ -468,6 +472,7 @@ impl Game<'_> {
adv_loaded,
last_inventory,
levitation,
marks,
death_cause,
acting,
packets,
@@ -528,6 +533,7 @@ impl Game<'_> {
adv_loaded,
last_inventory,
levitation,
marks,
death_cause,
combat,
acting,
@@ -652,6 +658,7 @@ impl Game<'_> {
self.adv.loaded = adv_loaded;
play.last_inventory = std::mem::replace(&mut self.adv.last_inventory, last_inventory);
play.levitation = std::mem::replace(&mut self.adv.levitation, levitation);
play.marks = std::mem::replace(&mut self.adv.marks, marks);
let stat_events = std::mem::take(&mut play.stats.events);
let name = g.name.clone();
self.potions.swap_player(&mut g.effects, &mut play.potions);
@@ -1038,12 +1045,23 @@ impl Game<'_> {
if spectator {
return;
}
if !host.hub.guests[gi].may_reach_block(a.pos) {
return;
}
// ServerPlayerGameMode.handleBlockBreakAction: no dig starts
// under spawn protection (the ack puts the block back).
if self.under_spawn_protection(host, gi, a.pos) {
self.spawn_protection_message(host, conn, a.pos);
return;
}
// mayInteract: a block outside the world border can't be
// dug (the ack puts it back on the client).
if !self
.border
.contains(f64::from(a.pos[0]), f64::from(a.pos[2]))
{
return;
}
let face = direction(i32::from(a.direction));
let play = host.play.entry(conn).or_default();
play.digging = Some((a.pos, face));
@@ -1057,7 +1075,7 @@ impl Game<'_> {
// still plays a note. The dig is marked begun for tick_mining.
let pos = a.pos;
self.as_guest(host, conn, |g| {
if g.may_build() && !g.creative {
if !g.block_action_restricted(pos) && !g.creative {
let _ = g.attack_block(pos);
g.mining = Some(Mining {
pos,
@@ -1070,6 +1088,15 @@ impl Game<'_> {
1 => {}
3 => {
host.hub.ack(conn, a.sequence);
if !host.hub.guests[gi].may_reach_block(a.pos) {
// Too far (handleBlockBreakAction's "too far"): the
// dig drops and the ack has the client undo its
// prediction.
let play = host.play.entry(conn).or_default();
play.digging = None;
play.mining = None;
return;
}
self.guest_stop_digging(host, conn, a.pos);
}
2 => {
@@ -1162,6 +1189,14 @@ impl Game<'_> {
if spectator {
return;
}
// handleUseItemOn: within reach, and the hit point within the block.
let inside = p
.cursor
.iter()
.all(|&c| (f64::from(c) - 0.5).abs() < 1.0000001);
if !(inside && host.hub.guests[gi].may_reach_block(p.pos)) {
return;
}
let face = direction(p.face);
let hit = Hit {
pos: p.pos,
@@ -1175,9 +1210,14 @@ impl Game<'_> {
let hand = if p.hand == 1 { Hand::Off } else { Hand::Main };
// handleUseItemOn: under spawn protection nothing is used or
// placed; the player is told and the blocks below go back.
// mayInteract: nothing is used on a block outside the world
// border either.
if self.under_spawn_protection(host, gi, p.pos) {
self.spawn_protection_message(host, conn, p.pos);
} else {
} else if self
.border
.contains(f64::from(p.pos[0]), f64::from(p.pos[2]))
{
let logged = self.world.block_log.as_ref().map_or(0, Vec::len);
let heard = self.audio.capture.as_ref().map_or(0, Vec::len);
let swung = self.as_guest(host, conn, |g| {
@@ -1213,14 +1253,32 @@ impl Game<'_> {
let hand = if p.hand == 1 { Hand::Off } else { Hand::Main };
let g = &mut host.hub.guests[gi];
(g.yaw, g.pitch) = (p.yaw, p.pitch);
let swung = self.as_guest(host, conn, |g| {
// ServerPlayer.getKnownMovement: the move their client just sent,
// which a throw adds to the projectile.
let movement = g
.moves
.last()
.map_or(Vec3::ZERO, |m| Vec3::new(m.0[0], m.0[1], m.0[2]));
let heard = self.audio.capture.as_ref().map_or(0, Vec::len);
let used = self.as_guest(host, conn, |g| {
g.known_movement = movement;
// A horn's sound, inline instruments included.
let horn = g.with_hand(hand, |g| {
let held = g.held()?;
g.res.items().instrument(held).map(|i| i.sound)
});
g.guest_use = Some(GuestUse::Item(hand));
let swings = g.swings_started;
let _ = g.use_item();
g.guest_use = None;
g.swings_started != swings
(g.swings_started != swings, horn)
});
let (swung, horn) = match used {
Some((swung, horn)) => (Some(swung), horn),
None => (None, None),
};
self.lan_swing_back(host, conn, swung);
self.guest_instrument_sound(host, conn, heard, horn);
}
S::Attack(p) => self.guest_attack(host, conn, p.id),
S::Interact(p) => {
@@ -1384,6 +1442,9 @@ impl Game<'_> {
} else {
p.difficulty as u8
};
// The level data holds the world's difficulty: it
// survives the reopen, so it follows the change.
self.level.set_difficulty(self.ui.difficulty);
self.sync_rules();
if self.ui.difficulty == 0 {
self.mobs.retain(|m| !m.hostile());
@@ -1838,8 +1899,11 @@ impl Game<'_> {
// ListPlayersCommand needs no permission and lists every player.
let words: Vec<&str> = command.trim_start_matches('/').split(' ').collect();
if words[0] == "list" && (words.len() == 1 || words[1..] == ["uuids"]) {
let me = (self.options.dedicated.is_none() && self.player.is_some())
.then_some((host.host_uuid, host.host_name.as_str()));
// A single-player owner is a placed guest already: no host entry.
let me = (self.options.dedicated.is_none()
&& self.player.is_some()
&& host.hub.singleplayer_owner.is_none())
.then_some((host.host_uuid, host.host_name.as_str()));
let content = host.hub.list_reply(me, words.len() == 2);
host.hub.send_to(
conn,
@@ -1993,8 +2057,11 @@ impl Game<'_> {
let d = vanilla::game_dispatcher();
let level = std::mem::replace(&mut self.ui.registries.permission, permission);
let parse = d.parse(command, &self.ui.registries);
let ((start, end), suggestions) = d.suggestions(&parse, command.len(), &self.ui.registries);
let ((start, end), mut suggestions) =
d.suggestions(&parse, command.len(), &self.ui.registries);
self.ui.registries.permission = level;
// ServerCommandSuggestionsProvider.limitSuggestionCount.
suggestions.truncate(1000);
let utf16 = |byte: usize| command[..byte.min(command.len())].encode_utf16().count() as i32;
let mut w = horizon_protocol::buf::Writer::new();
w.varint(id);
@@ -2322,14 +2389,25 @@ impl Game<'_> {
None => self.guest_death_message(conn, me),
};
let message = horizon_client::lan::data::component_nbt(&text.component());
let team = self.score.board.players_team(&name);
if horizon_client::scoreboard::own_death_message_shown(team) {
host.hub.system_message(message.clone());
if self.options.dedicated.is_some() {
let board = &self.score.board;
host.hub.system_message_to(message.clone(), |other| {
horizon_client::scoreboard::death_message_reaches(board, &name, other)
});
if self.options.dedicated.is_some() {
// Only a message for everyone is logged (`broadcastSystemMessage`).
if board.players_team(&name).is_none_or(|t| {
t.death_message_visibility == horizon_client::scoreboard::Visibility::Always
}) {
super::dedicated::log("INFO", &format!("System chat: {}", text.plain()));
} else if self.player.is_some() {
self.system_message(text);
}
} else if self.player.is_some()
&& horizon_client::scoreboard::death_message_reaches(
board,
&name,
super::chat::PLAYER_NAME,
)
{
self.system_message(text);
}
message
} else {
@@ -2684,6 +2762,12 @@ impl Game<'_> {
self.as_guest(host, conn, |game| {
game.sculk_step_on_player();
game.tick_player_sculk_events();
// Entity.restituteMovementAfterCollisions: GameEvent.BOUNCE.
for event in &events {
if matches!(event, MotionEvent::Bounce { .. }) {
game.post_entity_event("minecraft:bounce", horizon_client::mobs::Who::Player);
}
}
});
for event in events {
match event {
@@ -2819,6 +2903,7 @@ impl Game<'_> {
&& let Some(stack) = game.armor[1].clone()
{
// `ItemStack.hurtAndBreak`: Unbreaking can save the wear.
game.trigger_item_durability(&stack);
let loss = game.durability_loss(&stack, 1);
if let Some(elytra) = game.armor[1].as_mut() {
elytra.damage += loss;
@@ -3041,10 +3126,10 @@ impl Game<'_> {
Some(_) => {}
}
}
// Entity.take: the clients play the pickup sound themselves
// (handleTakeItemEntity).
for (item, count) in taken {
host.hub.take_item(item as i32, id, count as i32, position);
host.hub
.sound("minecraft:entity.item.pickup", position, 0.2, 1.4, 7, None);
}
}
@@ -3196,6 +3281,30 @@ impl Game<'_> {
}
}
/// `InstrumentItem.play`'s `level.playSound(player, ...)`: the others hear
/// a guest's horn now, the guest's own client played it as it used it.
fn guest_instrument_sound(
&mut self,
host: &mut LanHost,
conn: u32,
heard: usize,
horn: Option<String>,
) {
let (Some(horn), Some(capture)) = (horn, self.audio.capture.as_mut()) else {
return;
};
let mut i = heard.min(capture.len());
while i < capture.len() {
if capture[i].0 == horn {
let (name, at, volume, pitch) = capture.remove(i);
let source = horizon_client::settings::sound_source(&name) as i32;
host.hub.sound(&name, at, volume, pitch, source, Some(conn));
} else {
i += 1;
}
}
}
/// Positional sounds and level events of the tick, for the guests.
pub(super) fn lan_broadcast_effects(&mut self, host: &mut LanHost) {
let sounds = self.audio.capture.take().unwrap_or_default();
@@ -128,8 +128,8 @@ impl Game<'_> {
let dropped = std::mem::take(&mut menu.dropped);
let equipped = std::mem::take(&mut menu.equipped);
let brewed = std::mem::take(&mut menu.brewed);
let crafted = std::mem::take(&mut menu.crafted);
menu.drops.clear();
menu.crafted.clear();
store_player(menu, &mut host.hub.guests[gi]);
if !brewed.is_empty() {
let conn = host.hub.guests[gi].conn;
@@ -139,6 +139,14 @@ impl Game<'_> {
}
});
}
if !crafted.is_empty() {
let conn = host.hub.guests[gi].conn;
self.as_guest(host, conn, |g| {
for (recipe, ingredients) in &crafted {
g.trigger_recipe_crafted(recipe, ingredients);
}
});
}
if !equipped.is_empty() {
let conn = host.hub.guests[gi].conn;
self.as_guest(host, conn, |g| {
+103 -5
View File
@@ -9,7 +9,7 @@ use horizon_client::lan::{
hub::Hub,
};
use horizon_protocol::entity_data::{Value, serializer};
use horizon_world::nbt::Tag;
use horizon_world::{nbt::Tag, registry::BlockStateId};
fn state(id: u32, kind: &str, position: [f64; 3], yaw: f32, on_ground: bool) -> EntityState {
EntityState {
@@ -258,18 +258,100 @@ impl Game<'_> {
}
out.push(e);
}
// DATA_ITEM, `ItemStack.EMPTY` by default.
for o in self.ominous_spawners.iter().filter(|o| o.id != 0) {
let mut e = state(
o.id,
"minecraft:ominous_item_spawner",
o.position,
0.0,
false,
);
e.meta.push((
8,
serializer::ITEM_STACK,
Value::Item(hub.stack(&Some(o.item.clone()))),
));
out.push(e);
}
self.lan_projectile_states(host_entity, hub, out);
for o in &self.others {
let p = o.position;
let mut e = state(o.id, &o.kind, [p.x, p.y, p.z], o.yaw, false);
e.pitch = o.pitch;
e.head_yaw = o.yaw;
if let Some(tag) = self.persist.raw.get(&o.id) {
self.lan_display_data(tag, hub, &mut e);
}
out.push(e);
}
}
}
impl Game<'_> {
/// The synched data of a text, block or item display kept unsimulated,
/// from its compound (`Display.readAdditionalSaveData`), with the
/// defaults to send when a value goes back to one.
fn lan_display_data(&self, tag: &Tag, hub: &mut Hub, e: &mut EntityState) {
use horizon_client::text_display::{display_data, text_display_data};
let empty = Tag::Compound(Default::default());
let (meta, defaults) = match e.kind.as_str() {
"minecraft:text_display" => (
text_display_data(tag, false),
text_display_data(&empty, true),
),
"minecraft:block_display" => {
// DATA_BLOCK_STATE_ID, air by default.
let state = |t: Option<BlockStateId>| {
(
23,
serializer::BLOCK_STATE,
Value::Id(t.map_or(0, |s| s.0 as i32)),
)
};
let air = self.pack.assets.registry.default_state("minecraft:air");
let shown = tag.get("block_state").and_then(|t| self.state_from_nbt(t));
(vec![state(shown.or(air))], vec![state(air)])
}
"minecraft:item_display" => {
// DATA_ITEM_STACK and DATA_ITEM_DISPLAY_ID (`ItemDisplayContext`).
let item = tag
.get("item")
.and_then(horizon_client::save::item_from_nbt);
let context = match tag.get("item_display") {
Some(Tag::String(s)) => match s.to_string_lossy().as_str() {
"thirdperson_lefthand" => 1,
"thirdperson_righthand" => 2,
"firstperson_lefthand" => 3,
"firstperson_righthand" => 4,
"head" => 5,
"gui" => 6,
"ground" => 7,
"fixed" => 8,
"on_shelf" => 9,
_ => 0,
},
_ => 0,
};
(
vec![
(23, serializer::ITEM_STACK, Value::Item(hub.stack(&item))),
(24, serializer::BYTE, Value::Byte(context)),
],
vec![
(23, serializer::ITEM_STACK, Value::Item(None)),
(24, serializer::BYTE, Value::Byte(0)),
],
)
}
_ => return,
};
e.meta = display_data(tag, false);
e.meta.extend(meta);
e.defaults = display_data(&empty, true);
e.defaults.extend(defaults);
}
/// Gives the entities Horizon keeps without an id one (`Entity.id`, from
/// the same counter as the rest) before they are first tracked.
pub(super) fn lan_assign_ids(&mut self) {
@@ -308,6 +390,9 @@ impl Game<'_> {
self.rockets.iter_mut().for_each(|r| give(&mut r.id));
self.crystals.iter_mut().for_each(|c| give(&mut c.id));
self.bolts.iter_mut().for_each(|b| give(&mut b.id));
self.ominous_spawners
.iter_mut()
.for_each(|o| give(&mut o.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));
@@ -359,7 +444,12 @@ impl Game<'_> {
}
for p in self.creatures.projectiles.iter().filter(|p| p.id != 0) {
use horizon_client::projectiles::ProjectileKind as K;
let kind = p.kind.id();
// A spectral arrow is its own entity type.
let kind = if p.is_spectral_arrow() {
"minecraft:spectral_arrow"
} else {
p.kind.id()
};
let mut e = state(p.id, kind, at(p.position), p.yaw, false);
e.pitch = p.pitch;
e.velocity = at(p.velocity);
@@ -378,6 +468,11 @@ impl Game<'_> {
a.map_or(0, |a| a.pierce),
a.is_some_and(|a| a.in_ground) || p.stuck,
));
// Arrow.ID_EFFECT_COLOR, -1 by default.
let color = p.arrow_effect_color();
if color != -1 {
e.meta.push((11, serializer::INT, Value::Int(color)));
}
}
// ThrownTrident.ID_LOYALTY (none for a mob's) and ID_FOIL, on
// AbstractArrow's flags.
@@ -425,9 +520,12 @@ impl Game<'_> {
let mut e = state(t.id, "minecraft:trident", at(t.position), t.yaw, false);
e.pitch = t.pitch;
e.velocity = at(t.velocity);
// Projectile.getAddEntityPacket: the owner's id; a trident here is
// always the host player's.
e.data = owner(Some(horizon_client::mobs::Who::Player));
// Projectile.getAddEntityPacket: the owner's id, the guest's that
// threw it.
e.data = match t.guest.and_then(|conn| hub.guest(conn)) {
Some(g) => g.entity_id,
None => owner(Some(horizon_client::mobs::Who::Player)),
};
e.meta = arrow_meta(false, t.no_physics, 0, t.in_ground);
// ThrownTrident.ID_LOYALTY and ID_FOIL.
if t.loyalty != 0 {
+1 -1
View File
@@ -118,7 +118,7 @@ pub(super) fn acts_on_player(e: &MobEvent) -> bool {
..
}
| MobEvent::EffectPlayer { .. }
| MobEvent::AxolotlSupportPlayer
| MobEvent::AxolotlSupportPlayer { .. }
| MobEvent::ParrotToShoulder { .. }
)
}
@@ -1,5 +1,5 @@
//! The horse inventory for a server's players (`ServerPlayer.openHorseInventory`):
//! shift + use on a tamed horse, donkey, mule, camel or llama, or the
//! shift + use on a tamed horse, donkey, mule, camel, llama or nautilus, or the
//! inventory key while riding one (`ServerboundPlayerCommandPacket`
//! OPEN_INVENTORY), opens `HorseInventoryMenu` over the mount with
//! `ClientboundMountScreenOpenPacket`. Clicks run on the menu engine
+34 -31
View File
@@ -5,7 +5,7 @@
//! attack (blocking, armour, knockback), when the `pvp` game rule allows it
//! (`ServerPlayer.canHarmPlayer`, `isPvpAllowed`). A player's arrow on
//! another player lands the same way.
use super::{Game, lan::LanHost, lan_mobs::guest_bounds, ray_box, view_vector};
use super::{Game, lan::LanHost, lan_mobs::guest_bounds, mace::Spared, ray_box, view_vector};
use horizon_client::{
Result,
@@ -50,8 +50,6 @@ pub(super) struct Strike {
knockback_attack: bool,
/// The attacker's team, for the victim's `Player.canHarmPlayer`.
team: Option<horizon_client::scoreboard::Team>,
/// The sweep's damage to the players around the victim.
sweep_hit: Option<Sweep>,
}
/// A sweep attack's reach to the other players (`Player.doSweepAttack`),
@@ -68,8 +66,8 @@ pub(super) struct Sweep {
team: Option<horizon_client::scoreboard::Team>,
/// The target's box grown by (1, 0.25, 1).
pub(super) hit_box: Aabb,
/// The player the sweep started from, which it spares.
skip: Option<Victim>,
/// The entity id of the player the sweep started from, which it spares.
pub(super) skip: Option<i32>,
}
impl Strike {
@@ -146,12 +144,8 @@ impl Game<'_> {
pub(super) fn pvp_strike(&mut self) -> Option<Strike> {
let p = self.player.as_ref()?;
let from = [p.position.x, p.position.y, p.position.z];
let (amount, scale, crit, magic) = self.melee_damage("minecraft:player");
let (amount, scale, crit, magic) = self.melee_damage("minecraft:player", true);
let sweep = self.sweep_attack_position(scale, crit);
let sweep_hit = sweep.map(|_| {
let base = self.sweep_base_damage(scale);
self.player_sweep(base, scale)
});
let (strength, knockback_attack) = self.extra_knockback("minecraft:player", scale);
let (xd, zd) = self.extra_knockback_direction();
self.attack_ticks = 0;
@@ -176,7 +170,6 @@ impl Game<'_> {
knockback: (strength, xd, zd),
knockback_attack,
team: self.player_team().cloned(),
sweep_hit,
})
}
@@ -369,21 +362,29 @@ impl Game<'_> {
(true, false, false) => "minecraft:entity.player.attack.weak",
};
if let Some(at) = s.sweep.filter(|_| hurt) {
self.sweep_attack(at, Some(s.from));
// The other players next to the victim (Player.doSweepAttack).
let bounds = match victim {
Victim::Host => self.player.as_ref().map(|p| p.bounds()),
Victim::Guest(conn) => host.hub.guest(conn).map(guest_bounds),
// The sweep around the player that was hit, as its attacker.
let (area, id) = match victim {
Victim::Host => (self.player.as_ref().map(|p| p.bounds()), host.host_entity),
Victim::Guest(conn) => {
let g = host.hub.guest(conn);
(g.map(guest_bounds), g.map_or(-1, |g| g.entity_id))
}
};
if let (Some(sweep), Some(bounds)) = (&s.sweep_hit, bounds) {
self.sweep_players(
host,
&Sweep {
hit_box: bounds.inflate(Vec3::new(1.0, 0.25, 1.0)),
skip: Some(victim),
..sweep.clone()
},
);
let target = area.map(|area| (area, Spared::Player(id)));
match s.attacker {
None => {
// The host is checked out here: collect the sweep for the players.
self.combat.sweeps = Some(Vec::new());
self.sweep_attack(at, Some(s.from), target, s.scale);
for sweep in self.combat.sweeps.take().unwrap_or_default() {
self.sweep_players(host, &sweep);
}
}
Some(conn) => {
self.as_guest(host, conn, |g| {
g.sweep_attack(at, Some(s.from), target, s.scale)
});
}
}
} else {
self.audio.play(sound, Some(s.from), 1.0, 1.0);
@@ -400,7 +401,7 @@ impl Game<'_> {
.player
.as_ref()
.map_or([0.0; 3], |p| [p.position.x, p.position.y, p.position.z]);
let damage = (base + self.enchant_damage_bonus("minecraft:player")) * scale;
let damage = self.enchanted_damage("minecraft:player", base) * scale;
Sweep {
attacker: self.lan_acting,
from,
@@ -416,7 +417,9 @@ impl Game<'_> {
/// turn the host holds the other players (`as_guest`), so it waits for
/// the turn to end.
pub(super) fn sweep_other_players(&mut self, sweep: Sweep) {
if self.lan_acting.is_some() {
if let Some(collect) = self.combat.sweeps.as_mut() {
collect.push(sweep);
} else if self.lan_acting.is_some() {
self.creatures.sweeps.push(sweep);
} else if let Some(mut host) = self.lan.take() {
self.sweep_players(&mut host, &sweep);
@@ -446,7 +449,7 @@ impl Game<'_> {
&& p.bounds().intersects(sweep.hit_box)
&& p.position.distance_squared(from) < 9.0
{
victims.push((Victim::Host, self.player_team().cloned()));
victims.push((Victim::Host, host.host_entity, self.player_team().cloned()));
}
for g in &host.hub.guests {
if host.hub.here(g)
@@ -459,7 +462,7 @@ impl Game<'_> {
< 9.0
{
let team = self.score.board.players_team(&g.name).cloned();
victims.push((Victim::Guest(g.conn), team));
victims.push((Victim::Guest(g.conn), g.entity_id, team));
}
}
let (xd, zd) = sweep.knockback;
@@ -469,8 +472,8 @@ impl Game<'_> {
g.knock_back_player(0.4, xd, zd);
}
};
for (victim, team) in victims {
if sweep.skip == Some(victim)
for (victim, id, team) in victims {
if sweep.skip == Some(id)
|| horizon_client::scoreboard::teams_allied(team.as_ref(), sweep.team.as_ref())
|| !self.ui.rules.bool("pvp")
{
@@ -90,6 +90,14 @@ impl Game<'_> {
.any(|g| Some(g.conn) != self.lan_acting && g.vehicle == Some(vehicle))
})
}
/// Whether a server player rides `vehicle` (`Entity.isPassenger`: a
/// passenger does not use a portal on its own, the vehicle carries it
/// across, `Entity.teleportCrossDimension`).
pub(super) fn guest_rides(&self, vehicle: u32) -> bool {
self.lan
.as_ref()
.is_some_and(|h| h.hub.guests.iter().any(|g| g.vehicle == Some(vehicle)))
}
/// `Player.rideTick` for a server player, before its moves this tick:
/// it stays in its seat, steers its minecart, or gets out.
@@ -179,6 +179,17 @@ impl Game<'_> {
self.particles.add(&options, [x, y, z], [0.0; 3]);
}
}
/// `LevelEventHandler` 1503: smoke on the inserted eye.
pub(super) fn end_portal_frame_smoke(&mut self, pos: BlockPos) {
let options = ParticleOptions::simple(ParticleType::Smoke);
for _ in 0..16 {
let r = self.particles.level_random();
let x = f64::from(pos.x) + (5.0 + r.next_double() * 6.0) / 16.0;
let z = f64::from(pos.z) + (5.0 + r.next_double() * 6.0) / 16.0;
self.particles
.add(&options, [x, f64::from(pos.y) + 0.8125, z], [0.0; 3]);
}
}
/// `LevelEventHandler` 2006 (`DragonFireball.onHit`): 200 dragon breath
/// particles thrown outwards, then the explosion sound when `data` is 1.
pub(super) fn dragon_fireball_burst(&mut self, pos: BlockPos, data: i32) {
@@ -291,6 +302,12 @@ impl Game<'_> {
/// (an end portal opening, the dragon's death roar): here, and sent to
/// every guest whatever their dimension.
pub(super) fn global_level_event(&mut self, id: i32, pos: [i32; 3], data: i32) {
// ServerLevel.globalLevelEvent: without global_sound_events it is a
// plain level event, which the client has no sound for (1023, 1028
// and 1038 are only in `LevelEventHandler.globalLevelEvent`).
if !self.ui.rules.bool("global_sound_events") {
return;
}
self.global_level_event_sound(id, pos);
if self.audio.capture.is_some() {
self.lan_global_events.push((id, pos, data));
+30 -3
View File
@@ -52,8 +52,8 @@ pub(super) struct Parked {
armor_stands: Vec<horizon_client::entities::ArmorStand>,
cushions: Vec<horizon_client::entities::Cushion>,
others: Vec<commands::OtherEntity>,
boats: Vec<boats::Boat>,
carts: Vec<minecarts::Minecart>,
pub(super) boats: Vec<boats::Boat>,
pub(super) carts: Vec<minecarts::Minecart>,
jukebox_sounds: HashMap<(i32, i32, i32), u64>,
bolts: Vec<lightning::Bolt>,
crystals: Vec<dragon::Crystal>,
@@ -65,7 +65,7 @@ pub(super) struct Parked {
ominous_spawners: Vec<ominous_item_spawner::OminousItemSpawner>,
tnts: Vec<tnt::PrimedTnt>,
hoppers: HashMap<(i32, i32, i32), machines::HopperState>,
mobs: Vec<Mob>,
pub(super) mobs: Vec<Mob>,
/// The entity ids of the level's fence knots (`knot_ids`).
knot_ids: HashMap<[i32; 3], u32>,
projectiles: Vec<horizon_client::projectiles::Projectile>,
@@ -122,6 +122,12 @@ impl Levels {
.iter_mut()
.map(|(d, p)| (*d, &mut p.world.chunks))
}
/// The level not in place, in `dim`.
pub(super) fn parked_mut(&mut self, dim: Dim) -> Option<&mut Parked> {
self.parked.get_mut(&dim)
}
/// The levels that are not in place, by dimension.
pub(super) fn parked_dims(&self) -> Vec<Dim> {
self.parked.keys().copied().collect()
@@ -297,6 +303,23 @@ impl Game<'_> {
Ok(())
}
/// The entity's memory (its UUID and the compound it was loaded with)
/// moves with it from the level not in place to the level in place, so
/// it is saved in the other level as it was.
pub(super) fn move_entity_memory(&mut self, from: Dim, id: u32) {
let Some(p) = self.levels.parked.get_mut(&from) else {
return;
};
let uuid = p.persist.uuids.remove(&id);
let raw = p.persist.raw.remove(&id);
if let Some(u) = uuid {
self.persist.uuids.insert(id, u);
}
if let Some(r) = raw {
self.persist.raw.insert(id, r);
}
}
/// Put `dim`'s level in place for a command whose source is there
/// (`execute in`, `execute at` a player in another level), opening it if
/// nobody is in it; returns the level to put back afterwards. Only a
@@ -440,8 +463,12 @@ impl Game<'_> {
.is_some_and(|p| !p.streaming.forced.is_empty())
};
guests.iter().any(|g| g.dimension == d.id())
|| self.player_bed_levels().any(|b| b == *d)
|| forced
|| self.pregen_level() == Some(*d)
// A pending portal arrival keeps its destination level
// running so it can land and tick (the portal ticket).
|| !self.arrival_chunks(*d).is_empty()
})
.collect();
if dims.is_empty() {
+81 -16
View File
@@ -4,7 +4,8 @@
//! `thunderHit` (damage and burning, charged creepers, zombified piglins,
//! witches, mooshroom colours, turtles, burnt items), lightning rods, the sky
//! flash, the sounds and the branching additive bolt.
use super::Game;
use super::{Game, commands::Target};
use horizon_client::advancements::EntityView;
use horizon_client::mobs::{Damage, DamageKind, MobEvent, SpawnSetup, Species};
use horizon_core::{position::BlockPos, random::RandomSource};
@@ -18,6 +19,13 @@ pub(super) struct Bolt {
visual_only: bool,
/// Mooshrooms and copper golems change once per bolt.
converted: Vec<u32>,
/// The mobs it has struck (`hitEntities`).
hit: Vec<u32>,
/// Blocks it set on fire (`blocksSetOnFire`).
fires: i32,
/// The player who channelled it (`LightningBolt.cause`): `Some(None)` for
/// the host, `Some(Some(conn))` for a guest.
pub cause: Option<Option<u32>>,
}
impl Game<'_> {
/// `ServerChunkCache.tickSpawningChunk`: one `tickThunder` roll per
@@ -29,10 +37,13 @@ impl Game<'_> {
if rain <= 0.2 || thunder <= 0.9 || self.dim != super::Dim::Overworld {
return;
}
let (_, mut chunks) = self.spawning_chunks(&self.level_players());
let mut scratch = std::mem::take(&mut self.spawn_scratch);
self.spawning_chunks(&self.level_players(), &mut scratch);
let levels = self.tickets.levels();
chunks.retain(|&(x, z)| levels.random_ticking(x, z));
for (cx, cz) in chunks {
scratch
.candidates
.retain(|&(x, z)| levels.random_ticking(x, z));
for (cx, cz) in scratch.candidates.drain(..) {
if self.world.random.next_int_bound(100_000).unwrap_or(1) != 0 {
continue;
}
@@ -77,6 +88,7 @@ impl Game<'_> {
];
self.spawn_bolt(at, trap);
}
self.spawn_scratch = scratch;
}
/// `ServerLevel.findLightningRod`: `PoiManager.findClosest` over the
/// lightning rod points of interest within 128 blocks of `center` whose
@@ -206,6 +218,9 @@ impl Game<'_> {
seed,
visual_only,
converted: Vec::new(),
hit: Vec::new(),
fires: 0,
cause: None,
});
}
/// `ClientLevel.addEntity` for a server's lightning bolt: on a client it
@@ -224,11 +239,16 @@ impl Game<'_> {
seed,
visual_only: true,
converted: Vec::new(),
hit: Vec::new(),
fires: 0,
cause: None,
});
}
/// `spawnFire`: fire at the strike point and `extra` random neighbours,
/// wherever `BaseFireBlock.getState` fire can survive.
fn bolt_fire(&mut self, at: [f64; 3], extra: i32) {
/// wherever `BaseFireBlock.getState` fire can survive. Returns how many
/// blocks it set on fire.
fn bolt_fire(&mut self, at: [f64; 3], extra: i32) -> i32 {
let mut set = 0;
let base = [
at[0].floor() as i32,
at[1].floor() as i32,
@@ -253,8 +273,10 @@ impl Game<'_> {
});
if let Some(fire) = fire {
let _ = self.level_set(p, fire);
set += 1;
}
}
set
}
/// `LightningBolt.tick` for every bolt.
pub(super) fn tick_bolts(&mut self) {
@@ -278,7 +300,7 @@ impl Game<'_> {
if !b.visual_only {
// Only Normal and Hard start fires with the strike itself.
if self.ui.difficulty >= 2 {
self.bolt_fire(b.at, 4);
b.fires += self.bolt_fire(b.at, 4);
}
self.strike_rod(b.at);
}
@@ -293,6 +315,7 @@ impl Game<'_> {
b.life -= 1;
if b.life < 0 {
if b.flashes == 0 {
self.lightning_strike_triggers(b);
return false;
}
if b.life < -self.world.random.next_int_bound(10).unwrap_or(0) {
@@ -300,7 +323,7 @@ impl Game<'_> {
b.life = 1;
b.seed = self.world.random.next_long();
if !b.visual_only {
self.bolt_fire(b.at, 0);
b.fires += self.bolt_fire(b.at, 0);
}
}
}
@@ -310,7 +333,11 @@ impl Game<'_> {
}
self.flash = 2;
if !b.visual_only {
self.thunder_hit(b);
let struck = self.thunder_hit(b);
if let Some(cause) = b.cause {
let who = cause.map_or(Target::Player, Target::Guest);
self.for_player(who, |g| g.trigger_channeled_lightning(&struck));
}
}
}
true
@@ -355,8 +382,41 @@ impl Game<'_> {
self.with_level(|b, level, ticks| b.lightning_clear_copper(level, ticks, p));
let _ = self.process_level_events();
}
/// `thunderHit` for entities within 3 blocks (up to 9 above).
fn thunder_hit(&mut self, b: &mut Bolt) {
/// `LightningStrikeTrigger` at the bolt's discard: every player within 256
/// blocks, with the mobs in its 30-block box that it did not hit.
fn lightning_strike_triggers(&mut self, b: &Bolt) {
let at = b.at;
let bolt = EntityView {
kind: "minecraft:lightning_bolt".into(),
position: at,
blocks_set_on_fire: b.fires,
..Default::default()
};
let viewers: Vec<EntityView> = self
.mobs
.iter()
.filter(|m| m.alive() && !b.hit.contains(&m.id))
.filter(|m| {
(m.position.x - at[0]).abs() <= 15.0
&& (m.position.z - at[2]).abs() <= 15.0
&& m.position.y >= at[1] - 15.0
&& m.position.y <= at[1] + 21.0
})
.map(|m| self.mob_entity(m))
.collect();
self.for_each_player(|g| {
let Some(p) = g.player.as_ref().map(|p| p.position) else {
return;
};
let d2 = (p.x - at[0]).powi(2) + (p.y - at[1]).powi(2) + (p.z - at[2]).powi(2);
if d2 < 256.0 * 256.0 {
g.trigger_lightning_strike(&bolt, &viewers);
}
});
}
/// `thunderHit` for entities within 3 blocks (up to 9 above). Returns the
/// mobs in that box before the hit, as `CHANNELED_LIGHTNING` counts them.
fn thunder_hit(&mut self, b: &mut Bolt) -> Vec<EntityView> {
let at = b.at;
let inside = move |p: [f64; 3]| {
(p[0] - at[0]).abs() <= 3.0
@@ -364,11 +424,15 @@ impl Game<'_> {
&& p[1] >= at[1] - 3.0
&& p[1] <= at[1] + 9.0
};
let damage = Damage {
kind: DamageKind::Generic,
attacker: None,
from: Some(b.at),
};
let struck: Vec<(u32, EntityView)> = self
.mobs
.iter()
.filter(|m| m.alive() && inside([m.position.x, m.position.y, m.position.z]))
.map(|m| (m.id, self.mob_entity(m)))
.collect();
b.hit.extend(struck.iter().map(|(id, _)| *id));
// DamageSources.lightningBolt: its own type, with no position.
let damage = Damage::of(DamageKind::LightningBolt);
let mut events = Vec::new();
let peaceful = self.ui.difficulty == 0;
for m in self.mobs.iter_mut().filter(|m| m.alive()) {
@@ -461,6 +525,7 @@ impl Game<'_> {
!inside([e.position.x, e.position.y, e.position.z])
|| e.stack.item.contains("netherite")
});
struck.into_iter().map(|(_, view)| view).collect()
}
/// Simulation-order render inputs; seeded geometry lives in the renderer.
pub(super) fn bolt_instances(&self) -> Vec<horizon_client::gfx::lightning::Bolt> {
+168 -1
View File
@@ -10,7 +10,11 @@ use horizon_client::{
mobs::{Damage, Mob, Species},
particles::{ParticleOptions, ParticleType},
};
use horizon_core::{geometry::Vec3, position::BlockPos, random::RandomSource};
use horizon_core::{
geometry::{Aabb, Vec3},
position::BlockPos,
random::RandomSource,
};
use horizon_world::registry::BlockStateId;
/// `MaceItem.SMASH_ATTACK_FALL_THRESHOLD`.
@@ -38,6 +42,22 @@ pub(super) fn ally_of_player(m: &Mob, player: Option<[i32; 4]>) -> bool {
|| m.species() == Species::Allay && m.extra.tame
}
/// The entity a sweep leaves out: the one the attack hit (`target` of
/// `Player.doSweepAttack`).
#[derive(Clone, Copy, PartialEq, Eq)]
pub(super) enum Spared {
Nothing,
Mob(u32),
Stand(u32),
/// A player of the open world, by entity id.
Player(i32),
}
/// `Player.doSweepAttack`'s `1 + ratio * f`, where `f` is the attack damage
/// after `baseDamageScaleFactor` (0.2 + strength^2 * 0.8).
fn sweep_base_damage(ratio: f32, attack_damage: f32, scale: f32) -> f32 {
1.0 + ratio * attack_damage * (0.2 + scale * scale * 0.8)
}
/// A smash's knockback on the players, put off while a guest's turn holds
/// the other players.
pub(super) struct SmashPush {
@@ -209,6 +229,7 @@ impl Game<'_> {
&& max > 0
&& !self.creative
{
self.trigger_item_durability(stack);
let loss = self.durability_loss(stack, per_attack);
let cell = self.held_mut();
if let Some(held) = cell.as_mut() {
@@ -340,6 +361,136 @@ impl Game<'_> {
resistance,
);
}
/// `Player.doSweepAttack`'s victims: every other living mob and every
/// armor stand (but a marker one) in `target`'s box inflated by
/// (1, 0.25, 1) and under three blocks from the player takes
/// `getEnchantedDamage(1 + sweeping ratio * attack damage) * strength`,
/// knocked back 0.4 along the player's yaw. The player's allies and the
/// target (`spared`) are spared.
pub(super) fn sweep_damage(&mut self, target: Aabb, spared: Spared, scale: f32) {
let Some(player) = self.player.as_ref() else {
return;
};
let from = [player.position.x, player.position.y, player.position.z];
let area = target.inflate(Vec3::new(1.0, 0.25, 1.0));
let near = |at: Vec3| {
(0..3)
.map(|a| [at.x, at.y, at.z][a] - from[a])
.map(|d| d * d)
.sum::<f64>()
< 9.0
};
// The enchantment's `sweeping_damage_ratio` attribute modifier (level /
// (level + 1)) on the attribute's 0 to 1 range.
let ratio = self
.res
.stations()
.enchantments
.attribute_add(
self.inventory[self.selected].as_ref(),
"minecraft:sweeping_damage_ratio",
)
.clamp(0.0, 1.0) as f32;
// `f` of doSweepAttack: the attack damage attribute after
// baseDamageScaleFactor (a sweep is never a smash or a critical hit).
let spin = self.player.as_ref().map_or(0.0, |p| p.auto_spin_attack_dmg);
let attribute = if spin > 0.0 {
spin
} else {
1.0 + self.attack_item_info().map_or(0.0, |i| i.attack_damage)
+ self.effect_attack_bonus()
};
let base = sweep_base_damage(ratio, attribute, scale);
let (xd, zd) = self.extra_knockback_direction();
let uuid = self.level.player_uuid.or(Some([0, 0, 0, 1]));
let allies = self.player_allies();
// getEntitiesOfClass walks the entity sections (x, then z, then y)
// under the box, each in the order its entities were added.
let section = |at: Vec3| {
(
(at.x.floor() as i32) >> 4,
(at.z.floor() as i32) >> 4,
(at.y.floor() as i32) >> 4,
)
};
let mut victims: Vec<((i32, i32, i32), u32, bool)> = Vec::new();
for m in &self.mobs {
if spared != Spared::Mob(m.id)
&& m.alive()
&& !m.removed
&& !allies.contains(&m.id)
&& !ally_of_player(m, uuid)
&& m.bounds().intersects(area)
&& near(m.position)
{
victims.push((section(m.position), m.id, false));
}
}
for s in &self.armor_stands {
if !s.marker
&& spared != Spared::Stand(s.id)
&& s.bounds().intersects(area)
&& near(s.position)
{
victims.push((section(s.position), s.id, true));
}
}
victims.sort_by_key(|&(key, id, _)| (key, id));
for (_, id, is_stand) in victims {
if is_stand {
let Some(i) = self.armor_stands.iter().position(|s| s.id == id) else {
continue;
};
// knockback(0.4, ..) follows a hurtServer that landed, on a
// stand that is still there (LivingEntity.knockback).
if !self.punch_armor_stand(i) {
continue;
}
let (mut kx, mut kz) = (xd, zd);
while kx * kx + kz * kz < f64::from(1.0e-5_f32) {
let r = &mut self.world.random;
kx = (r.next_double() - r.next_double()) * 0.01;
kz = (r.next_double() - r.next_double()) * 0.01;
}
let len = (kx * kx + kz * kz).sqrt();
if let Some(s) = self.armor_stands.iter_mut().find(|s| s.id == id) {
s.velocity = Vec3::new(
s.velocity.x / 2.0 - kx / len * 0.4,
if s.on_ground {
(s.velocity.y / 2.0 + 0.4).min(0.4)
} else {
s.velocity.y
},
s.velocity.z / 2.0 - kz / len * 0.4,
);
}
continue;
}
let Some(i) = self.mobs.iter().position(|m| m.id == id) else {
continue;
};
let kind = self.mobs[i].kind.id.clone();
let kind = format!("minecraft:{}", kind.trim_start_matches("minecraft:"));
let amount = self.enchanted_damage(&kind, base) * scale;
let source = self.attack_source(from);
// hurtServer first; the knockback and post-attack effects follow
// only when it landed.
if self.hurt_by_player(i, amount, source) {
self.mobs[i].knockback(0.4, xd, zd);
self.last_hurt_by_player
.insert(self.mobs[i].id, self.lan_acting);
self.post_attack(i);
}
}
// `getEntitiesOfClass(LivingEntity.class)` takes the other players too.
let mut sweep = self.player_sweep(base, scale);
sweep.hit_box = area;
sweep.skip = match spared {
Spared::Player(id) => Some(id),
_ => None,
};
self.sweep_other_players(sweep);
}
/// `ServerPlayer.checkFallDamage` after a smash on a grounded target:
/// the landing block's particles, 50 for every block fallen (200 at most).
pub(super) fn smash_landing_particles(
@@ -409,3 +560,19 @@ impl Game<'_> {
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn sweep_damage_is_one_without_the_enchantment() {
// A fully charged diamond sword (8 attack damage): neighbours take 1.
assert_eq!(sweep_base_damage(0.0, 8.0, 1.0), 1.0);
// Sweeping Edge III adds the ratio of the (charged) attack damage.
assert_eq!(sweep_base_damage(0.75, 8.0, 1.0), 7.0);
// baseDamageScaleFactor at 0.95 strength.
let scaled = sweep_base_damage(0.5, 8.0, 0.95);
assert!((scaled - (1.0 + 0.5 * 8.0 * (0.2 + 0.95 * 0.95 * 0.8))).abs() < 1e-6);
}
}
+25 -9
View File
@@ -435,15 +435,17 @@ pub(super) fn shelf_signal(
_ => 0,
});
}
let tag = super::spill::saved_entity(world, [pos.x, pos.y, pos.z]);
// Only read from the back: a bit for each of the three slots in use.
let facing = info.properties.get("facing").and_then(|f| dir_from_name(f));
if facing.map(Direction::opposite) != Some(direction) {
return Some(0);
}
let items = horizon_client::save::decode_items(tag.as_ref().and_then(|t| t.get("Items")), 3);
let Some(BlockEntity::Shelf(shelf)) = world.block_entities.get(&(pos.x, pos.y, pos.z)) else {
return Some(0);
};
Some(
items
shelf
.items
.iter()
.enumerate()
.map(|(slot, item)| i32::from(item.is_some()) << slot)
@@ -486,6 +488,12 @@ impl Game<'_> {
return;
}
self.chiseled_bookshelf_synced(pos);
if matches!(
self.world.block_entities.get(&pos),
Some(BlockEntity::Shelf(_))
) {
self.mark_block_entity_updated(pos);
}
// A double chest's other half changes with it.
let halves: Vec<Pos> = std::iter::once(pos)
.chain(self.chest_other_half(pos))
@@ -595,6 +603,18 @@ impl Game<'_> {
/// `HopperBlockEntity.pushItemsTick` of the hopper at `pos`, if its block
/// is still a hopper (`BlockEntityType.isValid`).
pub(super) fn tick_hopper(&mut self, pos: Pos) {
let time = self.world.game_time;
let waiting = {
let state = self.hoppers.entry(pos).or_default();
state.cooldown -= 1;
state.ticked = time;
state.cooldown > 0
};
if waiting {
return;
}
// Most ticks only advance the cooldown: the block is checked when the
// hopper is about to move, not on every tick it waits.
let hopper = self
.res
.facts()
@@ -606,14 +626,10 @@ impl Game<'_> {
self.hoppers.remove(&pos);
return;
}
let time = self.world.game_time;
let state = self.hoppers.entry(pos).or_default();
state.cooldown -= 1;
state.ticked = time;
if state.cooldown <= 0 {
if let Some(state) = self.hoppers.get_mut(&pos) {
state.cooldown = 0;
self.hopper_try_move_items(pos);
}
self.hopper_try_move_items(pos);
}
/// Drops the cooldowns of hoppers whose block entity is gone.
pub(super) fn forget_gone_hoppers(&mut self) {
+15 -2
View File
@@ -66,6 +66,10 @@ pub(super) struct MapData {
pub unlimited: bool,
pub colors: Vec<u8>,
pub decorations: Vec<Decoration>,
/// `MapBanner` and `MapFrame` entries as saved (`banners`, `frames`), kept
/// so a save does not drop them.
pub banners: Vec<Tag>,
frames: Vec<Tag>,
holders: Vec<Holder>,
/// Changed since the last save / texture upload.
dirty: bool,
@@ -85,6 +89,8 @@ impl MapData {
unlimited: false,
colors: vec![0; 128 * 128],
decorations: Vec::new(),
banners: Vec::new(),
frames: Vec::new(),
holders: Vec::new(),
dirty: true,
texture_dirty: true,
@@ -234,8 +240,8 @@ impl MapData {
("trackingPosition", flag(self.tracking)),
("unlimitedTracking", flag(self.unlimited)),
("colors", Tag::ByteArray(self.colors.clone())),
("banners", Tag::List(Vec::new())),
("frames", Tag::List(Vec::new())),
("banners", Tag::List(self.banners.clone())),
("frames", Tag::List(self.frames.clone())),
]),
),
("DataVersion", Tag::Int(horizon_world::DATA_VERSION)),
@@ -257,6 +263,10 @@ impl MapData {
_ => Vec::new(),
};
colors.resize(128 * 128, 0);
let list = |k: &str| match d.get(k) {
Some(Tag::List(l)) => l.clone(),
_ => Vec::new(),
};
Some(Self {
center: [int("xCenter")?, int("zCenter")?],
scale: int("scale").unwrap_or(0).clamp(0, 4) as u8,
@@ -266,6 +276,8 @@ impl MapData {
unlimited: int("unlimitedTracking").unwrap_or(0) != 0,
colors,
decorations: Vec::new(),
banners: list("banners"),
frames: list("frames"),
holders: Vec::new(),
dirty: false,
texture_dirty: true,
@@ -739,6 +751,7 @@ impl Game<'_> {
return false;
}
let at = [x, y, z].map(|v| f64::from(v) + 0.5);
self.trigger_block("minecraft:item_used_on_block", hit.pos, stack);
self.audio
.play("minecraft:item.lodestone_compass.lock", Some(at), 1.0, 1.0);
let target = Tag::compound([(
+42 -6
View File
@@ -4,7 +4,7 @@
//! `MinecartItem` placement, `VehicleEntity` breaking, the chest, hopper,
//! furnace and TNT variants, saving, and `AbstractMinecartRenderer`'s old
//! rail-following render with the display block.
use super::{Game, Hit, Keys, explosion::BlockInteraction, view_vector};
use super::{Game, Hit, Keys, view_vector};
use horizon_client::{
blocks::Fluid,
collisions::SceneCollisions,
@@ -662,9 +662,9 @@ impl Game<'_> {
// The cart is the explosion's source: it is gone before the blast
// looks for entities, so it can't hit itself.
self.carts.retain(|x| x.id != c.id);
let _ = self.with_hive_emptying_blast(|g| {
g.server_explode(at, power, BlockInteraction::Destroy, false)
});
// ExplosionInteraction.TNT: the tnt_explosion_drop_decay rule.
let interaction = self.destroy_type("tnt_explosion_drop_decay");
let _ = self.with_hive_emptying_blast(|g| g.server_explode(at, power, interaction, false));
}
/// `AbstractMinecart.push` from each player walking into a cart
/// (`LivingEntity.pushEntities`): the window's player and every server
@@ -1029,6 +1029,17 @@ impl Game<'_> {
}
best
}
/// `ContainerEntity.unpackChestVehicleLootTable` for a player who opens a
/// chest minecart: a roll it still holds fires `player_generates_container_loot`
/// for its table, and the cart keeps its items from then on.
fn unpack_cart_loot(&mut self, i: usize) {
let Some(loot) = self.carts[i].loot.take() else {
return;
};
if loot.items == horizon_client::save::encode_items(&self.carts[i].items) {
self.trigger_loot_table(&loot.table);
}
}
/// `interact`: ride, open, fuel or ignite the cart under the crosshair.
pub(super) fn interact_cart(&mut self) -> bool {
let Some((i, d)) = self.cart_hit() else {
@@ -1048,6 +1059,7 @@ impl Game<'_> {
}
// AbstractMinecartContainer.interact: no sneak check.
CartKind::Chest | CartKind::Hopper => {
self.unpack_cart_loot(i);
let c = self.carts[i].clone();
let key = (i32::MAX - 15, CART_KEY_Y, c.id as i32);
self.world
@@ -1147,7 +1159,7 @@ impl Game<'_> {
})
}
/// `VehicleEntity.hurtServer` from the player's attack.
pub(super) fn attack_cart(&mut self, damage: f32) -> bool {
pub(super) fn attack_cart(&mut self) -> bool {
let Some((i, d)) = self.cart_hit() else {
return false;
};
@@ -1157,8 +1169,12 @@ impl Game<'_> {
let burning = self
.held()
.is_some_and(|s| s.enchantment_level("minecraft:fire_aspect") > 0);
self.vehicle_attack_sounds();
let area = self.carts[i].bounds();
// Player.attack's full damage against the cart's entity type.
let id = self.carts[i].id as i32;
let (damage, _, _, magic) = self.melee_damage(self.carts[i].kind.id(), false);
self.hurt_cart(i, damage, self.creative, burning);
self.vehicle_attack_sounds(area, magic.then_some(id));
self.swing_arm();
true
}
@@ -1224,6 +1240,20 @@ impl Game<'_> {
light: u32,
diffuse: bool,
out: &mut Vec<Vertex>,
) {
self.block_model_vertices_tinted(state, seed, m, light, diffuse, 0xFF_FFFF, out);
}
/// `block_model_vertices` with `tint` (RGB) on the faces that have a tint index.
#[allow(clippy::too_many_arguments)]
pub(super) fn block_model_vertices_tinted(
&self,
state: BlockStateId,
seed: i64,
m: glam::Mat4,
light: u32,
diffuse: bool,
tint: u32,
out: &mut Vec<Vertex>,
) {
let Ok(models) = self.pack.render.model_catalog.collect_seeded(state, seed) else {
return;
@@ -1250,6 +1280,12 @@ impl Game<'_> {
} else {
0xFF00_0000 | shade << 16 | shade << 8 | shade
};
let color = if face.quad.tint_index >= 0 {
let tint = horizon_client::mobs::render::abgr(tint);
horizon_client::hud_layout::multiply(color, tint)
} else {
color
};
let normal = if diffuse {
use horizon_client::item_render::{direction_vector, transform_normal};
let n = transform_normal(m, direction_vector(face.quad.facing));
+44 -90
View File
@@ -1,6 +1,6 @@
//! Blocks mobs carry: block items in a hand (`ItemInHandLayer`) and the
//! block an enderman holds (`CarriedBlockLayer`), drawn as shaded cubes like
//! dropped block items. Also the block models mobs wear (`SnowGolemHeadLayer`,
//! block an enderman holds (`CarriedBlockLayer`), drawn as baked block models.
//! Also the block models mobs wear (`SnowGolemHeadLayer`,
//! `MushroomCowMushroomLayer`).
use super::Game;
use horizon_client::{
@@ -9,46 +9,6 @@ use horizon_client::{
mobs::{Mob, Species, render},
};
/// The six faces of the unit cube with their shade: top, bottom, the two
/// z faces, the two x faces.
const FACES: [([[f32; 3]; 4], f32, u8); 6] = [
(
[[0., 1., 1.], [1., 1., 1.], [1., 1., 0.], [0., 1., 0.]],
1.0,
0,
),
(
[[0., 0., 0.], [1., 0., 0.], [1., 0., 1.], [0., 0., 1.]],
0.5,
0,
),
(
[[1., 0., 0.], [0., 0., 0.], [0., 1., 0.], [1., 1., 0.]],
0.8,
1,
),
(
[[0., 0., 1.], [1., 0., 1.], [1., 1., 1.], [0., 1., 1.]],
0.8,
1,
),
(
[[0., 0., 0.], [0., 0., 1.], [0., 1., 1.], [0., 1., 0.]],
0.6,
2,
),
(
[[1., 0., 1.], [1., 0., 0.], [1., 1., 0.], [1., 1., 1.]],
0.6,
2,
),
];
/// The sprites of a cube's top, z sides and x sides, and the top's tint.
struct Cube<'a> {
sprites: [&'a str; 3],
tint: u32,
}
/// `CustomHeadLayer.translateToHead` and the banner's `head` display.
fn banner_matrix(head: glam::Mat4) -> glam::Mat4 {
head * glam::Mat4::from_translation(glam::Vec3::new(0.0, -0.25, 0.0))
@@ -178,38 +138,6 @@ impl Game<'_> {
render::draw(flag, m, &flag_poses, r, color, light, 0, out);
}
}
fn push_cube(
&self,
t: glam::Mat4,
cube: &Cube,
light: u32,
overlay: u32,
out: &mut Vec<Vertex>,
) {
for (corners, shade, side) in FACES {
let name = cube.sprites[usize::from(side)];
let Some(r) = self.gui.atlas.sprites.get(name).copied() else {
continue;
};
let tint = if side == 0 { cube.tint } else { 0xFF_FFFF };
let c = |sft: u32| (((tint >> sft) & 255) as f32 * shade) as u32;
let color = 0xFF00_0000 | c(0) << 16 | c(8) << 8 | c(16);
let uvs = [[r.u0, r.v1], [r.u1, r.v1], [r.u1, r.v0], [r.u0, r.v0]];
for (corner, uv) in corners.iter().zip(uvs) {
let p = t.transform_point3(glam::Vec3::from(*corner));
out.push(Vertex {
normal: [0; 4],
pos: p.to_array(),
uv,
layer: overlay,
color,
light,
glint_uv: [0.0, 0.0],
});
}
}
}
/// The blocks one mob holds or carries.
pub(super) fn mob_block_geometry(
&self,
@@ -222,11 +150,13 @@ impl Game<'_> {
self.captain_banner_geometry(mob, camera, alpha, light, out);
self.held_item_geometry(mob, camera, alpha, light, true, out);
self.worn_block_geometry(mob, camera, alpha, light, out);
self.golem_flower_geometry(mob, camera, alpha, light, out);
if mob.species() == Species::Enderman
&& let Some(state) = mob.extra.carried
&& let Some((sprite, tint)) = self.block_face_sprite(state)
&& let Some((_, tint)) = self.block_face_sprite(state)
{
// CarriedBlockLayer: OverlayTexture.NO_OVERLAY, never the hurt tint.
// CarriedBlockLayer: OverlayTexture.NO_OVERLAY, never the hurt tint;
// the block's baked model (seed 42) on the unit cube, not centred.
let base = render::base_transform(mob, alpha as f32, camera);
let t = base
* glam::Mat4::from_translation(glam::Vec3::new(0.0, 0.6875, -0.75))
@@ -234,20 +164,43 @@ impl Game<'_> {
* glam::Mat4::from_rotation_y(45.0f32.to_radians())
* glam::Mat4::from_translation(glam::Vec3::new(0.25, 0.1875, 0.25))
* glam::Mat4::from_scale(glam::Vec3::new(-0.5, -0.5, 0.5))
* glam::Mat4::from_rotation_y(90.0f32.to_radians())
* glam::Mat4::from_translation(glam::Vec3::splat(-0.5));
self.push_cube(
t,
&Cube {
sprites: [&sprite, &sprite, &sprite],
tint,
},
light,
0,
out,
);
* glam::Mat4::from_rotation_y(90.0f32.to_radians());
self.block_model_vertices_tinted(state, 42, t, light, true, tint, out);
}
}
/// `IronGolemFlowerLayer`: the poppy in the right arm while the golem
/// offers it (`getOfferFlowerTick() > 0`), drawn with `NO_OVERLAY`.
fn golem_flower_geometry(
&self,
mob: &Mob,
camera: glam::DVec3,
alpha: f64,
light: u32,
out: &mut Vec<Vertex>,
) {
use glam::{Mat4, Vec3};
if mob.species() != Species::IronGolem
|| mob.extra.counter <= 0
|| horizon_client::mobs::trader::is_invisible(mob)
{
return;
}
let Some(state) = self.pack.assets.registry.default_state("minecraft:poppy") else {
return;
};
let Some(arm) =
render::part_transform(mob, alpha as f32, camera, self.res.mob_data(), "right_arm")
else {
return;
};
let t = arm
* Mat4::from_translation(Vec3::new(-1.1875, 1.0625, -0.9375))
* Mat4::from_translation(Vec3::splat(0.5))
* Mat4::from_scale(Vec3::splat(0.5))
* Mat4::from_rotation_x((-90.0f32).to_radians())
* Mat4::from_translation(Vec3::splat(-0.5));
self.block_model_vertices(state, 42, t, light, true, out);
}
/// `SnowGolemHeadLayer` and `MushroomCowMushroomLayer`: the carved pumpkin
/// on a snow golem's head, and the mushrooms on an adult mooshroom's back
/// and head, as baked block models.
@@ -387,9 +340,10 @@ impl Game<'_> {
owner.crossbow_charge_duration = 25;
}
use horizon_assets::item_model::DisplayContext as Context;
// PandaHoldsItemLayer shows the item as it lies on the ground.
// PandaHoldsItemLayer and FoxHeldItemLayer show the item as it lies
// on the ground.
let (context, ordinal) = match (mob.species(), left) {
(Species::Panda, _) => (Context::Ground, 7),
(Species::Panda | Species::Fox, _) => (Context::Ground, 7),
(_, true) => (Context::ThirdPersonLeftHand, 1),
(_, false) => (Context::ThirdPersonRightHand, 2),
};
Loaded 100 of 345 files, more files were not shown because too many files have changed in this diff. Show more