mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-07 17:21:49 -10:00
Nightly: merge #3048 at c2ce8ea2dd
This commit is contained in:
commit
9b772b2fec
345 files changed
+21285
-2661
No files matched your search
@@ -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
@@ -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" }
|
||||
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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"]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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(_)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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]),
|
||||
};
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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::*;
|
||||
|
||||
+779
-214
File diff suppressed because it is too large.
Load diff
@@ -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) {
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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]);
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"));
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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| {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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([(
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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
Reference in new issue
Block a user