mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-10 02:21:33 -10:00
Merge main into #7153
Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
commit
4dcbdd27fc
178 files changed
+4027
-3953
No files matched your search
@@ -47,7 +47,7 @@ line are in the output) and the Rust fn before changing anything.
|
||||
That one line is what makes the next agent find it.
|
||||
- Fix the path players run: single player is a client of the integrated server, so server logic lives in
|
||||
`game/lan*.rs` and `src/lan/`, and what only the client's copy shows (particles, local sounds, hand
|
||||
animation) must be reached from `game/remote*.rs`. A fix on the old in-frame path alone fixes nothing.
|
||||
animation) must be reached from `game/remote*.rs`.
|
||||
- Look at the neighbours the ledger lists for the same class: a `client` or `seam` gap next to your fix is
|
||||
usually the same bug on the other path.
|
||||
|
||||
|
||||
@@ -178,28 +178,24 @@ state stays separate from the library tests. This compiles gameplay twice for ex
|
||||
- Fields that say which mode runs: `remote` (this window is a client of a server: multiplayer, LAN guest, or
|
||||
single player on its server thread), `lan` (`LanHost`: this world is open to the network), `integrated`
|
||||
(the window's own server thread), `serving` (this `Game` *is* that server thread), `options.dedicated`.
|
||||
`fn tick` returns `tick_remote()` when `remote` is set; nothing else of the local simulation runs.
|
||||
- A server's players run through the same gameplay code as the local player: `as_guest` swaps the guest's
|
||||
`fn tick` returns `tick_remote()` when `remote` is set; a server ticks its levels (`fn tick_world`).
|
||||
- A server's players run through the shared gameplay code: `as_guest` swaps the guest's
|
||||
state (position, inventory, mode, mining, item use) into `Game` for one call (`game/lan_actions.rs`), and
|
||||
`levels.rs` swaps whole dimensions in the same way. Changes reach clients through the block log, level
|
||||
events and the entity tracker (`lan/tracking.rs`, `game/lan_entities.rs`).
|
||||
- Shared read-only data: `Resources` (`res`: blocks, items, recipes, mob types, biomes) and `PackResources`
|
||||
(`pack`), both in game.rs.
|
||||
|
||||
## The two single-player paths
|
||||
## Single player runs on a server thread
|
||||
|
||||
1. **Server thread (default since #1514):** `game/integrated.rs` runs a second `Game` (`serving`) on a
|
||||
"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`): DEPRECATED, never use it, it
|
||||
will be removed soon.** The window's `Game` ticks the world itself, one level that follows the player
|
||||
(`fn enter_dimension`). Compatibility does not matter: don't add features or fixes for it, don't write new
|
||||
tests against it, and move code off it when you touch it. The deterministic rule fixtures still run on it
|
||||
until they are ported.
|
||||
`game/integrated.rs` runs a second `Game` (`serving`) on a "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 (#1514). A window's `Game` never ticks a world itself: the old in-frame path is gone
|
||||
(`--in-frame` and `--no-server-thread` only print a notice; `HORIZON_SERVER_THREAD` is ignored).
|
||||
Open `[server-thread]` issues list the gaps left.
|
||||
|
||||
LAN guests and dedicated servers always use the server code, so a fix there serves both. Test the server
|
||||
thread when you touch player-facing gameplay:
|
||||
Single player, LAN guests and dedicated servers all run the server code, so a fix there serves all three.
|
||||
Test the server thread when you touch player-facing gameplay:
|
||||
|
||||
```sh
|
||||
cargo run --profile fast -p horizon-client -- --resources .local/horizon-client/26.3 --world T # server thread
|
||||
|
||||
+3
-3
@@ -92,7 +92,7 @@ Owner: [ludovic111](https://github.com/ludovic111) · Tracking: [#3914](https://
|
||||
|
||||
<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: breeding's advancement and statistic ([#3873](https://github.com/horizon-rust/horizon/issues/3873)), the client's menu when the server replaces the player ([#3841](https://github.com/horizon-rust/horizon/issues/3841)), splashes when falling in water ([#3621](https://github.com/horizon-rust/horizon/issues/3621)). [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 open `[server-thread]` issues list the gaps. In review: breeding's advancement and statistic ([#3873](https://github.com/horizon-rust/horizon/issues/3873)), the client's menu when the server replaces the player ([#3841](https://github.com/horizon-rust/horizon/issues/3841)), splashes when falling in water ([#3621](https://github.com/horizon-rust/horizon/issues/3621)). [docs/server-levels.md](docs/server-levels.md).
|
||||
|
||||
</details>
|
||||
|
||||
@@ -234,11 +234,11 @@ Client-of-a-server fixes in `game/remote*.rs` and `horizon-protocol`: mobs' aggr
|
||||
|
||||
#### 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 has one way to run, the server thread, so every fix works everywhere and there are fewer bugs. The older in-frame mode is removed.
|
||||
|
||||
<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` (also `--no-server-thread` and `HORIZON_SERVER_THREAD=0`) ticked the world inside the window's `Game`. [#6473](https://github.com/horizon-rust/horizon/issues/6473) removes it with the code only it reached (the window's world loader and player tick, its portal travel and loading screen, the in-frame LAN host); the harnesses run on the server thread. See also 'Single player as a server' and [docs/server-levels.md](docs/server-levels.md).
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
@@ -544,7 +544,7 @@ fn the_chat_option_filters_the_lines_both_ways() {
|
||||
assert_eq!(shown(&chat), ["client", "server", "player"]);
|
||||
}
|
||||
|
||||
/// The in-frame LAN host's chat: a guest's line is a player message, so the
|
||||
/// A server's own chat: a guest's line is a player message, so the
|
||||
/// Chat option's player-message permission hides it at Commands Only and
|
||||
/// Hidden; command feedback (`/list`, `/tp`) is a system message and stays
|
||||
/// at Commands Only.
|
||||
|
||||
@@ -211,6 +211,36 @@ impl SceneCollisions<'_, '_> {
|
||||
) -> Result<Vec3> {
|
||||
self.resolve_with_context(bounds, movement, max_step, on_ground, self.context(bounds))
|
||||
}
|
||||
/// [`Self::resolve`], also stopped by the world border's walls when one is
|
||||
/// given with the moving entity's x and z: `Entity.collectColliders` adds
|
||||
/// `WorldBorder.getCollisionShape` for any entity inside and close to it.
|
||||
pub fn resolve_in_border(
|
||||
self,
|
||||
bounds: Aabb,
|
||||
movement: Vec3,
|
||||
max_step: f32,
|
||||
on_ground: bool,
|
||||
border: Option<(&BorderGeometry, [f64; 2])>,
|
||||
) -> Result<Vec3> {
|
||||
let context = self.context(bounds);
|
||||
let mut scene = EntityCollisions {
|
||||
blocks: self,
|
||||
entities: &[],
|
||||
border,
|
||||
};
|
||||
Ok(resolve_world_movement(
|
||||
&mut scene,
|
||||
MovementRequest {
|
||||
entity: EntityIdentity(0),
|
||||
context,
|
||||
bounds,
|
||||
movement,
|
||||
max_step,
|
||||
on_ground,
|
||||
candidate_budget: 1_000_000,
|
||||
},
|
||||
)?)
|
||||
}
|
||||
/// Resolve movement with the entity's captured collision context. Most
|
||||
/// callers only need the position context; entities such as falling blocks
|
||||
/// also affect contextual block shapes (for example powder snow).
|
||||
@@ -617,6 +647,25 @@ mod border_movement_tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// A minecart (0.98 wide, 0.7 tall) rolling east at its top speed of 0.4 a tick, 0.11
|
||||
/// blocks short of the wall: the wall takes all but the 0.11 (`move_cart` hands the
|
||||
/// cart's x and z to `with_border`, as every entity does in `Entity.move`).
|
||||
#[test]
|
||||
fn a_minecart_sized_body_stops_at_the_border_wall() {
|
||||
// Centre x = 4.4, half width 0.49: the cart's east face is at 4.89, the wall at 5.0.
|
||||
let bounds = Aabb::new(Vec3::new(3.91, 0.0, -0.49), Vec3::new(4.89, 0.7, 0.49));
|
||||
let border = border();
|
||||
let mut collisions = EntityCollisions::with_border(EmptyCollisions, &border, [4.4, 0.0]);
|
||||
let moved = collisions
|
||||
.resolve(bounds, Vec3::new(0.4, 0.0, 0.0), 0.0, false)
|
||||
.expect("the movement resolves");
|
||||
assert!(
|
||||
(moved.x - 0.11).abs() < 1.0e-9,
|
||||
"the cart was not stopped by the border: {moved:?}"
|
||||
);
|
||||
assert_eq!(moved.z, 0.0);
|
||||
}
|
||||
|
||||
/// Away from the border, the same body moves the whole way.
|
||||
#[test]
|
||||
fn a_body_away_from_the_border_moves_whole() {
|
||||
|
||||
@@ -3131,14 +3131,16 @@ impl Renderer {
|
||||
"fs_entity_shadow",
|
||||
wgpu::ColorWrites::ALL,
|
||||
);
|
||||
// RenderPipelines.ITEM_TRANSLUCENT: blended, depth tested. Drawn before
|
||||
// translucent terrain, it cannot write depth: the water behind a
|
||||
// glass item would fail the test and vanish (vanilla sorts these in
|
||||
// its order independent transparency pass instead).
|
||||
// RenderPipelines.ITEM_TRANSLUCENT: blended, depth tested and, like
|
||||
// every vanilla pipeline that does not turn it off, depth writing.
|
||||
// `LevelRenderer.executeClassicTransparency` draws the translucent
|
||||
// features before the translucent terrain, so without the writes
|
||||
// ice or water behind a dropped slime block or glass item would blend
|
||||
// over it and the item would all but vanish (#7031, #7033).
|
||||
let translucent_item_pipeline = entity_pipeline(
|
||||
"Translucent items",
|
||||
None,
|
||||
false,
|
||||
true,
|
||||
wgpu::CompareFunction::GreaterEqual,
|
||||
Default::default(),
|
||||
alpha_blend,
|
||||
@@ -8075,6 +8077,23 @@ impl Renderer {
|
||||
pass.draw_indexed(0..text_quads * 6, (entity_quads * 4) as i32, 0..1);
|
||||
pass.set_bind_group(0, &self.group, &[]);
|
||||
}
|
||||
// FeatureRenderDispatcher.PreparedFrame.executeTranslucent: the shadows
|
||||
// come before the translucent items, which then cover them (#7026).
|
||||
if shadow_quads > 0 && !oit_on {
|
||||
pass.set_pipeline(&self.shadow_blob_pipeline);
|
||||
pass.set_bind_group(0, &self.entity_group, &[]);
|
||||
pass.set_vertex_buffer(0, self.entity_buffer.slice(..));
|
||||
pass.draw_indexed(
|
||||
0..shadow_quads * 6,
|
||||
((base_quads
|
||||
- translucent_item_quads
|
||||
- translucent_particle_quads
|
||||
- shadow_quads)
|
||||
* 4) as i32,
|
||||
0..1,
|
||||
);
|
||||
pass.set_bind_group(0, &self.group, &[]);
|
||||
}
|
||||
if translucent_item_quads > 0 && !oit_on {
|
||||
pass.set_pipeline(&self.translucent_item_pipeline);
|
||||
pass.set_bind_group(0, &self.entity_group, &[]);
|
||||
@@ -8106,21 +8125,6 @@ impl Renderer {
|
||||
);
|
||||
pass.set_bind_group(0, &self.group, &[]);
|
||||
}
|
||||
if shadow_quads > 0 && !oit_on {
|
||||
pass.set_pipeline(&self.shadow_blob_pipeline);
|
||||
pass.set_bind_group(0, &self.entity_group, &[]);
|
||||
pass.set_vertex_buffer(0, self.entity_buffer.slice(..));
|
||||
pass.draw_indexed(
|
||||
0..shadow_quads * 6,
|
||||
((base_quads
|
||||
- translucent_item_quads
|
||||
- translucent_particle_quads
|
||||
- shadow_quads)
|
||||
* 4) as i32,
|
||||
0..1,
|
||||
);
|
||||
pass.set_bind_group(0, &self.group, &[]);
|
||||
}
|
||||
// BoatRenderer's water patch (RenderTypes.waterMask): depth only,
|
||||
// before translucent terrain, so no water shows inside the boat.
|
||||
if water_mask_quads > 0 && !oit_on {
|
||||
|
||||
@@ -10,7 +10,8 @@
|
||||
//!
|
||||
//! The game decides which draws come here (`SkinFrame`): models whose
|
||||
//! polygons all face an axis, drawn as ordinary opaque or cutout entities.
|
||||
//! The rest stays on the CPU path.
|
||||
//! The rest stays on the CPU path. Item models come too ([`SkinJob::item`]):
|
||||
//! their quads all face an axis, and their shading is baked into the colour.
|
||||
|
||||
use super::upload::Uploads;
|
||||
use crate::mesher::Vertex;
|
||||
@@ -35,6 +36,20 @@ pub struct SkinPart {
|
||||
pub normal: [f32; 9],
|
||||
}
|
||||
|
||||
impl SkinPart {
|
||||
/// An item layer's part: its pose, and its quads' colours by axis.
|
||||
pub fn item(matrix: glam::Mat4, colors: [u32; 6]) -> Self {
|
||||
let mut normal = [0.0; 9];
|
||||
for (word, color) in normal.iter_mut().zip(colors) {
|
||||
*word = f32::from_bits(color);
|
||||
}
|
||||
Self {
|
||||
matrix: matrix.to_cols_array(),
|
||||
normal,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One run of a model's vertices drawn with one texture rect, colour, light
|
||||
/// and overlay. `dst` counts from the frame's first posed vertex.
|
||||
#[repr(C)]
|
||||
@@ -51,8 +66,12 @@ pub struct SkinJob {
|
||||
pub overlay: u32,
|
||||
/// 1 when the overlay is the armour glint: the model UV rides in `glint_uv`.
|
||||
pub glint: u32,
|
||||
/// 1 for an item model's quads (`item_render::WorldSink`): the colour by the
|
||||
/// face's axis rides in the part's first 6 normal words ([`SkinPart::item`]),
|
||||
/// shaded on the CPU, and the normal is `mesher::CULL_BACK`.
|
||||
pub item: u32,
|
||||
/// Always 0: the shader reads the last as a zero it cannot fold away.
|
||||
pub spare: [u32; 3],
|
||||
pub spare: [u32; 2],
|
||||
}
|
||||
|
||||
const _: () = assert!(std::mem::size_of::<Vertex>() == 44);
|
||||
@@ -353,13 +372,19 @@ pub fn expand(model: &[SkinVertex], frame: &SkinFrame) -> Vec<Vertex> {
|
||||
(v.uv[0] + job.scroll) % 1.0
|
||||
};
|
||||
let r = job.rect;
|
||||
let (color, normal) = if job.item != 0 {
|
||||
let color = part.normal[v.axis as usize].to_bits();
|
||||
(color, crate::mesher::CULL_BACK)
|
||||
} else {
|
||||
(job.color, [packed[0], packed[1], packed[2], -127])
|
||||
};
|
||||
out[(job.dst + k) as usize] = Vertex {
|
||||
pos: pos.to_array(),
|
||||
uv: [r[0] + (r[2] - r[0]) * u, r[1] + (r[3] - r[1]) * v.uv[1]],
|
||||
layer: job.overlay,
|
||||
color: job.color,
|
||||
color,
|
||||
light: job.light,
|
||||
normal: [packed[0], packed[1], packed[2], -127],
|
||||
normal,
|
||||
glint_uv: if job.glint != 0 { v.uv } else { [0.0, 0.0] },
|
||||
};
|
||||
}
|
||||
@@ -407,8 +432,8 @@ mod tests {
|
||||
}
|
||||
|
||||
/// Two draws of a two-part model with turned, scaled parts, a scrolled
|
||||
/// texture and the armour glint: the shader writes what `expand` does,
|
||||
/// to the bit, after the vertices before them.
|
||||
/// texture and the armour glint, and an item's: the shader writes what
|
||||
/// `expand` does, to the bit, after the vertices before them.
|
||||
#[test]
|
||||
fn the_shader_writes_what_the_cpu_reference_computes() {
|
||||
let Some((device, queue)) = device() else {
|
||||
@@ -441,6 +466,18 @@ mod tests {
|
||||
part(child, normals),
|
||||
part(base * turn, glam::Mat3::from_mat4(turn * turn)),
|
||||
part(child * turn, glam::Mat3::IDENTITY),
|
||||
// Colours whose bits as floats are NaNs and denormals: copied, never computed.
|
||||
SkinPart::item(
|
||||
child * turn,
|
||||
[
|
||||
0xff80_4020,
|
||||
0x7fc0_0001,
|
||||
0x0000_0001,
|
||||
0xffff_ffff,
|
||||
0x7f80_0001,
|
||||
0x1234_5678,
|
||||
],
|
||||
),
|
||||
],
|
||||
jobs: vec![
|
||||
SkinJob {
|
||||
@@ -468,8 +505,20 @@ mod tests {
|
||||
glint: 1,
|
||||
..Default::default()
|
||||
},
|
||||
// An item's quads: colour by axis from its part, `CULL_BACK` normals.
|
||||
SkinJob {
|
||||
first: 0,
|
||||
count: 24,
|
||||
part: 4,
|
||||
dst: 72,
|
||||
rect: [0.5, 0.25, 0.5625, 0.3125],
|
||||
light: 0xa0_30,
|
||||
overlay: 2 << 16,
|
||||
item: 1,
|
||||
..Default::default()
|
||||
},
|
||||
],
|
||||
vertices: 72,
|
||||
vertices: 96,
|
||||
};
|
||||
let expected = expand(&model, &frame);
|
||||
// The posed vertices land after 5 others, which they leave alone.
|
||||
|
||||
@@ -15,8 +15,9 @@ const MODEL_WORDS: u32 = 7u;
|
||||
const PART_WORDS: u32 = 25u;
|
||||
// A job (16 words): first model vertex, vertex count, first part, first output
|
||||
// vertex, the texture rect (4 floats), scroll, colour, light, overlay,
|
||||
// whether the overlay is the armour glint (its model UV in `glint_uv`), 3 spare
|
||||
// (0: the last one is `keep`'s).
|
||||
// whether the overlay is the armour glint (its model UV in `glint_uv`), whether
|
||||
// it draws an item's quads (`SkinJob::item`), 2 spare (0: the last one is
|
||||
// `keep`'s).
|
||||
const JOB_WORDS: u32 = 16u;
|
||||
// `mesher::Vertex` (11 words): pos (3), uv (2), layer, color, light, normal
|
||||
// (4 x i8), glint_uv (2).
|
||||
@@ -87,6 +88,7 @@ fn skin(
|
||||
let light = jobs[j + 10u];
|
||||
let overlay = jobs[j + 11u];
|
||||
let glint = jobs[j + 12u] != 0u;
|
||||
let item = jobs[j + 13u] != 0u;
|
||||
keep = jobs[j + 15u];
|
||||
for (var k = lane; k < count; k += 64u) {
|
||||
let v = (first + k) * MODEL_WORDS;
|
||||
@@ -105,6 +107,13 @@ fn skin(
|
||||
if axis >= 3u {
|
||||
n = -n;
|
||||
}
|
||||
// An item's colour by axis, shaded on the CPU, and `mesher::CULL_BACK`.
|
||||
var vertex_color = color;
|
||||
var normal = pack(n);
|
||||
if item {
|
||||
vertex_color = parts[part * PART_WORDS + 16u + axis];
|
||||
normal = 1u << 24u;
|
||||
}
|
||||
let o = (dst + k) * VERTEX_WORDS;
|
||||
out[o] = bitcast<u32>(pos.x);
|
||||
out[o + 1u] = bitcast<u32>(pos.y);
|
||||
@@ -112,9 +121,9 @@ fn skin(
|
||||
out[o + 3u] = bitcast<u32>(rect.x + r(r(rect.z - rect.x) * scroll_u(uv.x, scroll)));
|
||||
out[o + 4u] = bitcast<u32>(rect.y + r(r(rect.w - rect.y) * uv.y));
|
||||
out[o + 5u] = overlay;
|
||||
out[o + 6u] = color;
|
||||
out[o + 6u] = vertex_color;
|
||||
out[o + 7u] = light;
|
||||
out[o + 8u] = pack(n);
|
||||
out[o + 8u] = normal;
|
||||
if glint {
|
||||
out[o + 9u] = bitcast<u32>(uv.x);
|
||||
out[o + 10u] = bitcast<u32>(uv.y);
|
||||
|
||||
@@ -110,7 +110,16 @@ pub(crate) fn prepare_with_models(
|
||||
.state(state)
|
||||
.ok_or("missing gallery registry state")?;
|
||||
let position = BlockPos::new((index % 4) as i32 * 4 - 7, 4, (index / 4) as i32 * 4 + 14);
|
||||
let selected = catalog.collect(state, position)?;
|
||||
// A state no definition binds (a pack's partial blockstate file
|
||||
// without vanilla's below it) is left out, as the mesher skips it,
|
||||
// instead of failing the whole pack.
|
||||
let selected = match catalog.collect(state, position) {
|
||||
Ok(selected) => selected,
|
||||
Err(e) => {
|
||||
log::warn!("Horizon: gallery block {name} has no model: {e}");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
states.push(json!({"block":actual.name,"properties":actual.properties,"state_id":state.0,
|
||||
"position":[position.x,position.y,position.z],
|
||||
"models":selected.iter().map(|(v,_)| json!({"model":v.model,"rotations":v.rotations,"uv_lock":v.uv_lock})).collect::<Vec<_>>()}));
|
||||
|
||||
@@ -105,6 +105,35 @@ fn bake_resolved(
|
||||
cube,
|
||||
})
|
||||
}
|
||||
/// The definitions a blockstate file holds, bottom first: the layers of a
|
||||
/// file the pack overlay stacked (`packs::BLOCKSTATE_LAYERS`), else the
|
||||
/// file itself.
|
||||
fn definition_layers(text: &str) -> std::result::Result<Vec<String>, String> {
|
||||
if !text.contains(crate::packs::BLOCKSTATE_LAYERS) {
|
||||
return Ok(vec![text.to_owned()]);
|
||||
}
|
||||
let value: Value = serde_json::from_str(text).map_err(|e| e.to_string())?;
|
||||
match value.get(crate::packs::BLOCKSTATE_LAYERS) {
|
||||
Some(Value::Array(layers)) => Ok(layers.iter().map(Value::to_string).collect()),
|
||||
_ => Ok(vec![text.to_owned()]),
|
||||
}
|
||||
}
|
||||
/// Each state's model from definitions bound bottom first: the topmost one
|
||||
/// that binds the state, as (its layer, its selection). A higher pack's
|
||||
/// definition replaces only the states it binds (`putAll` in
|
||||
/// `loadBlockStateDefinitionStack`).
|
||||
fn topmost(layers: Vec<Vec<Option<SelectedModels>>>) -> Vec<Option<(usize, SelectedModels)>> {
|
||||
let mut picked: Vec<Option<(usize, SelectedModels)>> = Vec::new();
|
||||
for (layer, bound) in layers.into_iter().enumerate() {
|
||||
picked.resize(picked.len().max(bound.len()), None);
|
||||
for (slot, selected) in picked.iter_mut().zip(bound) {
|
||||
if let Some(selected) = selected {
|
||||
*slot = Some((layer, selected));
|
||||
}
|
||||
}
|
||||
}
|
||||
picked
|
||||
}
|
||||
impl StateModels {
|
||||
pub fn load(
|
||||
root: &Path,
|
||||
@@ -194,21 +223,17 @@ impl StateModels {
|
||||
.collect::<Result<_>>()?;
|
||||
let possible: Vec<Properties> = serde_json::from_value(facts["states"].clone())?;
|
||||
// Vanilla's BlockStateModelLoader: a definition that does not
|
||||
// read or bind is logged and its block gets no model; the other
|
||||
// blocks still load.
|
||||
let loaded = horizon_core::prepared_files::read_to_string(
|
||||
// read or bind is logged and left out; with none left the block
|
||||
// gets no model, and the other blocks still load. A file several
|
||||
// packs provide binds each pack's definition on its own, bottom
|
||||
// first (`loadBlockStateDefinitionStack`).
|
||||
let layers = match horizon_core::prepared_files::read_to_string(
|
||||
&root.join(format!("assets/{namespace}/blockstates/{path}.json")),
|
||||
)
|
||||
.map_err(|e| e.to_string())
|
||||
.and_then(|text| BlockStateDefinition::from_json(&text).map_err(|e| e.to_string()))
|
||||
.and_then(|definition| {
|
||||
let bound = definition
|
||||
.bind(&schema, &possible)
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok((definition, bound))
|
||||
});
|
||||
let (definition, bound) = match loaded {
|
||||
Ok(loaded) => loaded,
|
||||
.and_then(|text| definition_layers(&text))
|
||||
{
|
||||
Ok(layers) => layers,
|
||||
Err(e) => {
|
||||
log::warn!(
|
||||
"Horizon: blockstate definition of {namespace}:{path} not loaded: {e}"
|
||||
@@ -216,6 +241,26 @@ impl StateModels {
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let mut loaded = Vec::with_capacity(layers.len());
|
||||
for text in &layers {
|
||||
let layer = BlockStateDefinition::from_json(text)
|
||||
.map_err(|e| e.to_string())
|
||||
.and_then(|definition| {
|
||||
let bound = definition
|
||||
.bind(&schema, &possible)
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok((definition, bound))
|
||||
});
|
||||
match layer {
|
||||
Ok(layer) => loaded.push(layer),
|
||||
Err(e) => log::warn!(
|
||||
"Horizon: blockstate definition of {namespace}:{path} not loaded: {e}"
|
||||
),
|
||||
}
|
||||
}
|
||||
if loaded.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let native_order: Vec<_> = by_name
|
||||
.get(name.as_str())
|
||||
.map(Vec::as_slice)
|
||||
@@ -233,32 +278,36 @@ impl StateModels {
|
||||
"model state definitions disagree with the imported block registry".into(),
|
||||
);
|
||||
}
|
||||
for ((id, _), selected) in native_order.into_iter().zip(bound) {
|
||||
if let Some(selected) = selected {
|
||||
states[id.0 as usize] = Some((choices.len(), selected));
|
||||
let (definitions, bound): (Vec<_>, Vec<_>) = loaded.into_iter().unzip();
|
||||
let first = choices.len();
|
||||
for ((id, _), picked) in native_order.into_iter().zip(topmost(bound)) {
|
||||
if let Some((layer, selected)) = picked {
|
||||
states[id.0 as usize] = Some((first + layer, selected));
|
||||
}
|
||||
}
|
||||
for choice in definition
|
||||
.variants
|
||||
.iter()
|
||||
.flatten()
|
||||
.map(|(_, choice)| choice)
|
||||
.chain(
|
||||
definition
|
||||
.multipart
|
||||
.iter()
|
||||
.flatten()
|
||||
.map(|selector| &selector.variant),
|
||||
)
|
||||
{
|
||||
match choice {
|
||||
VariantSet::Single(v) => {
|
||||
variants.insert(key(v));
|
||||
for definition in definitions {
|
||||
for choice in definition
|
||||
.variants
|
||||
.iter()
|
||||
.flatten()
|
||||
.map(|(_, choice)| choice)
|
||||
.chain(
|
||||
definition
|
||||
.multipart
|
||||
.iter()
|
||||
.flatten()
|
||||
.map(|selector| &selector.variant),
|
||||
)
|
||||
{
|
||||
match choice {
|
||||
VariantSet::Single(v) => {
|
||||
variants.insert(key(v));
|
||||
}
|
||||
VariantSet::Weighted(v) => variants.extend(v.iter().map(|(v, _)| key(v))),
|
||||
}
|
||||
VariantSet::Weighted(v) => variants.extend(v.iter().map(|(v, _)| key(v))),
|
||||
}
|
||||
choices.push(CompiledChoices::new(&definition)?);
|
||||
}
|
||||
choices.push(CompiledChoices::new(&definition)?);
|
||||
}
|
||||
let names: Vec<String> = variants.iter().map(|(name, _, _)| name.clone()).collect();
|
||||
let library = match models.block_library(root, names.clone()) {
|
||||
@@ -428,6 +477,44 @@ mod tests {
|
||||
use super::*;
|
||||
use crate::model_library::ParsedModels;
|
||||
|
||||
/// #7013: a pack's blockstate file that defines only some states (here
|
||||
/// a fence gate's `powered=true` ones) keeps vanilla's model for the
|
||||
/// rest, as vanilla binds each pack's definition and lets a higher one
|
||||
/// replace only what it binds.
|
||||
#[test]
|
||||
fn a_partial_pack_blockstate_keeps_vanilla_for_the_other_states() {
|
||||
let schema: PropertySchema = BTreeMap::from([(
|
||||
"powered".to_owned(),
|
||||
PropertyValues::Named(BTreeSet::from(["false".to_owned(), "true".to_owned()])),
|
||||
)]);
|
||||
let states: Vec<Properties> = ["false", "true"]
|
||||
.iter()
|
||||
.map(|p| BTreeMap::from([("powered".to_owned(), (*p).to_owned())]))
|
||||
.collect();
|
||||
let stacked = serde_json::json!({ crate::packs::BLOCKSTATE_LAYERS: [
|
||||
{"variants":{"":{"model":"minecraft:block/oak_fence_gate"}}},
|
||||
{"variants":{"powered=true":{"model":"minecraft:block/stone"}}}
|
||||
]})
|
||||
.to_string();
|
||||
let layers = definition_layers(&stacked).unwrap();
|
||||
assert_eq!(layers.len(), 2);
|
||||
let bound = layers
|
||||
.iter()
|
||||
.map(|text| {
|
||||
BlockStateDefinition::from_json(text)
|
||||
.unwrap()
|
||||
.bind(&schema, &states)
|
||||
.unwrap()
|
||||
})
|
||||
.collect();
|
||||
let picked = topmost(bound);
|
||||
assert_eq!(picked[0], Some((0, SelectedModels::Variant(0))));
|
||||
assert_eq!(picked[1], Some((1, SelectedModels::Variant(0))));
|
||||
// A file that isn't stacked is one definition.
|
||||
let plain = r#"{"variants":{"":{"model":"minecraft:block/stone"}}}"#;
|
||||
assert_eq!(definition_layers(plain).unwrap(), vec![plain.to_owned()]);
|
||||
}
|
||||
|
||||
struct Fixture(std::path::PathBuf);
|
||||
impl Fixture {
|
||||
fn write(&self, name: &str, json: &str) {
|
||||
|
||||
@@ -841,7 +841,14 @@ enum Merge {
|
||||
Font,
|
||||
/// `SpriteSourceList.load`: sources from the bottom pack up.
|
||||
Atlas,
|
||||
/// `BlockStateModelLoader.loadBlockStateDefinitionStack`: every pack's
|
||||
/// definition binds on its own, and a higher one replaces only the states
|
||||
/// it binds. Kept as its layers ([`BLOCKSTATE_LAYERS`]) for the catalog.
|
||||
BlockStates,
|
||||
}
|
||||
/// The key of a stacked blockstate file: its definitions, bottom first, each
|
||||
/// as the pack wrote it.
|
||||
pub const BLOCKSTATE_LAYERS: &str = "horizon:layers";
|
||||
fn merge_kind(name: &str) -> Option<Merge> {
|
||||
let (_, rest) = name.strip_prefix("assets/")?.split_once('/')?;
|
||||
if rest == "sounds.json" {
|
||||
@@ -852,6 +859,7 @@ fn merge_kind(name: &str) -> Option<Merge> {
|
||||
"lang" => Some(Merge::Lang),
|
||||
"font" => Some(Merge::Font),
|
||||
"atlases" => Some(Merge::Atlas),
|
||||
"blockstates" => Some(Merge::BlockStates),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -859,7 +867,7 @@ fn merge_kind(name: &str) -> Option<Merge> {
|
||||
/// does. Layers that do not parse are skipped, as vanilla logs and skips them.
|
||||
fn merge(kind: Merge, layers: &[Vec<u8>]) -> Option<Vec<u8>> {
|
||||
use serde_json::{Map, Value};
|
||||
let parsed: Vec<Map<String, Value>> = layers
|
||||
let mut parsed: Vec<Map<String, Value>> = layers
|
||||
.iter()
|
||||
// SoundManager skips a sounds.json Gson rejects, whole.
|
||||
.filter(|b| kind != Merge::Sounds || crate::sound_events::is_valid_file(b))
|
||||
@@ -902,6 +910,12 @@ fn merge(kind: Merge, layers: &[Vec<u8>]) -> Option<Vec<u8>> {
|
||||
}
|
||||
Merge::Font => list("providers", &mut parsed.iter().rev()),
|
||||
Merge::Atlas => list("sources", &mut parsed.iter()),
|
||||
// One definition is the file as it is.
|
||||
Merge::BlockStates if parsed.len() < 2 => Value::Object(parsed.pop()?),
|
||||
Merge::BlockStates => Value::Object(Map::from_iter([(
|
||||
BLOCKSTATE_LAYERS.to_owned(),
|
||||
Value::Array(parsed.into_iter().map(Value::Object).collect()),
|
||||
)])),
|
||||
};
|
||||
serde_json::to_vec(&out).ok()
|
||||
}
|
||||
@@ -1405,6 +1419,36 @@ mod tests {
|
||||
&[json(r#"{"sources":[1]}"#), json(r#"{"sources":[2]}"#)],
|
||||
);
|
||||
assert_eq!(parse(atlas.unwrap()), serde_json::json!({"sources":[1,2]}));
|
||||
// BlockStateModelLoader: every definition kept, bottom first, for the
|
||||
// catalog to bind one by one; one that does not parse is skipped.
|
||||
let gate = merge(
|
||||
Merge::BlockStates,
|
||||
&[
|
||||
json(r#"{"variants":{"":{"model":"v"}}}"#),
|
||||
b"not json".to_vec(),
|
||||
json(r#"{"variants":{"powered=true":{"model":"p"}}}"#),
|
||||
],
|
||||
);
|
||||
assert_eq!(
|
||||
parse(gate.unwrap()),
|
||||
serde_json::json!({BLOCKSTATE_LAYERS:[
|
||||
{"variants":{"":{"model":"v"}}},
|
||||
{"variants":{"powered=true":{"model":"p"}}}
|
||||
]})
|
||||
);
|
||||
// A single definition stays the file as it is.
|
||||
let alone = merge(
|
||||
Merge::BlockStates,
|
||||
&[json(r#"{"variants":{"":{"model":"p"}}}"#)],
|
||||
);
|
||||
assert_eq!(
|
||||
parse(alone.unwrap()),
|
||||
serde_json::json!({"variants":{"":{"model":"p"}}})
|
||||
);
|
||||
assert_eq!(
|
||||
merge_kind("assets/minecraft/blockstates/oak_fence_gate.json"),
|
||||
Some(Merge::BlockStates)
|
||||
);
|
||||
assert_eq!(
|
||||
merge_kind("assets/minecraft/lang/en_us.json"),
|
||||
Some(Merge::Lang)
|
||||
|
||||
@@ -12,7 +12,8 @@ use std::{collections::BTreeSet, fs, io, path::Path, path::PathBuf};
|
||||
// Version 3 could omit untouched vanilla directories on Windows (#1649, #1688).
|
||||
// Version 5 read every vanilla file to stamp it on Windows.
|
||||
// Version 6 stamps vanilla's files by their metadata on Windows too.
|
||||
const VERSION: u32 = 6;
|
||||
// Version 7 stacks blockstate files (every pack's definition, bottom first).
|
||||
const VERSION: u32 = 7;
|
||||
|
||||
#[derive(Default, Serialize, Deserialize)]
|
||||
pub(super) struct Dependencies {
|
||||
|
||||
@@ -439,8 +439,8 @@ pub struct Streaming {
|
||||
decoration_order: DecorationOrder,
|
||||
order_area: Option<((i32, i32), i32)>,
|
||||
/// What the queued jobs' priorities were computed from (`requeue`): the
|
||||
/// centre, the loading screen, the band, and the extra areas.
|
||||
queue_basis: (((i32, i32), bool, Option<Band>), Vec<((i32, i32), i32)>),
|
||||
/// centre, the band, and the extra areas.
|
||||
queue_basis: (((i32, i32), Option<Band>), Vec<((i32, i32), i32)>),
|
||||
finishing: FxHashSet<(i32, i32)>,
|
||||
decorates: bool,
|
||||
/// `ChunkGenerator::decorates_beside_final`.
|
||||
@@ -472,8 +472,6 @@ pub struct Streaming {
|
||||
pub forced: HashSet<(i32, i32)>,
|
||||
/// No work is scheduled while paused (title screen).
|
||||
pub paused: bool,
|
||||
/// The level loading screen is up: work goes ring by ring (`Ranges::rings`).
|
||||
pub loading: bool,
|
||||
/// Dimension flags: sky light, Nether face shading.
|
||||
pub sky_light: bool,
|
||||
pub nether_shading: bool,
|
||||
@@ -544,9 +542,9 @@ struct GenerationScan {
|
||||
plan: Vec<(i64, i32, i32)>,
|
||||
/// Entries before it are no longer missing.
|
||||
next: usize,
|
||||
/// What `plan` was made for: the ranges, the loading screen and
|
||||
/// decoration; the chunk map's revision is `chunk_revision`.
|
||||
plan_basis: Option<(bool, bool)>,
|
||||
/// What `plan` was made for: the ranges and decoration; the chunk
|
||||
/// map's revision is `chunk_revision`.
|
||||
plan_basis: Option<bool>,
|
||||
}
|
||||
impl GenerationScan {
|
||||
fn same_ranges(&self, ranges: &Ranges<'_>) -> bool {
|
||||
@@ -570,9 +568,7 @@ impl GenerationScan {
|
||||
/// Whether `plan` still holds every candidate: same ranges, and no
|
||||
/// column left the chunk map since it was made.
|
||||
fn plan_holds(&mut self, input: &GenerationInputs<'_>) -> bool {
|
||||
if self.plan_basis != Some((input.loading, input.decorates))
|
||||
|| !self.same_ranges(&input.ranges)
|
||||
{
|
||||
if self.plan_basis != Some(input.decorates) || !self.same_ranges(&input.ranges) {
|
||||
return false;
|
||||
}
|
||||
let revision = input.world.chunks.membership_revision();
|
||||
@@ -606,7 +602,6 @@ struct GenerationInputs<'a> {
|
||||
finishing: &'a FxHashSet<(i32, i32)>,
|
||||
decorates: bool,
|
||||
remote: bool,
|
||||
loading: bool,
|
||||
}
|
||||
impl GenerationScan {
|
||||
/// None means the scan was skipped; Some preserves the original candidate
|
||||
@@ -681,7 +676,7 @@ impl GenerationScan {
|
||||
{
|
||||
continue;
|
||||
}
|
||||
wanted.push((input.ranges.priority(x, z, input.loading), x, z));
|
||||
wanted.push((input.ranges.priority(x, z), x, z));
|
||||
}
|
||||
}
|
||||
// Forced chunks and their generation neighbourhood: the ring that
|
||||
@@ -703,7 +698,7 @@ impl GenerationScan {
|
||||
{
|
||||
continue;
|
||||
}
|
||||
wanted.push((input.ranges.priority(x, z, input.loading), x, z));
|
||||
wanted.push((input.ranges.priority(x, z), x, z));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -723,7 +718,7 @@ impl GenerationScan {
|
||||
// A bounded batch may leave candidates behind: the next calls
|
||||
// take them from the plan as capacity opens.
|
||||
self.exhausted = false;
|
||||
self.plan_basis = Some((input.loading, input.decorates));
|
||||
self.plan_basis = Some(input.decorates);
|
||||
}
|
||||
wanted.sort_unstable();
|
||||
wanted.dedup();
|
||||
@@ -994,7 +989,6 @@ impl Streaming {
|
||||
radius: 8,
|
||||
forced: HashSet::new(),
|
||||
paused: false,
|
||||
loading: false,
|
||||
sky_light: true,
|
||||
nether_shading: false,
|
||||
shader_pack_lighting: false,
|
||||
@@ -1171,7 +1165,7 @@ impl Streaming {
|
||||
/// render distance 32 nothing was decorated until every proto chunk had
|
||||
/// been generated.
|
||||
fn requeue(&mut self) {
|
||||
let basis = (self.center, self.loading, self.band);
|
||||
let basis = (self.center, self.band);
|
||||
if self.queue_basis.0 == basis && self.queue_basis.1 == self.extra {
|
||||
return;
|
||||
}
|
||||
@@ -1309,7 +1303,7 @@ impl Streaming {
|
||||
}
|
||||
/// The order jobs around the player run in, lowest first.
|
||||
fn distance(&self, x: i32, z: i32) -> i64 {
|
||||
self.ranges().priority(x, z, self.loading)
|
||||
self.ranges().priority(x, z)
|
||||
}
|
||||
/// The 3x3 a light job reads: the finished columns, and for a neighbour
|
||||
/// that will not finish (past the area), its decorated proto chunk, as
|
||||
@@ -1970,7 +1964,6 @@ impl Streaming {
|
||||
finishing: &self.finishing,
|
||||
decorates: self.decorates,
|
||||
remote: self.remote,
|
||||
loading: self.loading,
|
||||
};
|
||||
if let Some(wanted) = self.generation_scan.candidates(input, workers * 3) {
|
||||
for (_, x, z) in wanted {
|
||||
@@ -2457,33 +2450,14 @@ impl ExtraCover {
|
||||
}
|
||||
}
|
||||
impl Ranges<'_> {
|
||||
/// `distance`, or `rings` while the level loading screen is up.
|
||||
fn priority(&self, x: i32, z: i32, loading: bool) -> i64 {
|
||||
let own = if loading {
|
||||
self.rings(x, z)
|
||||
} else {
|
||||
self.distance(x, z)
|
||||
};
|
||||
/// `distance`, or the band's nearer priority close to it.
|
||||
fn priority(&self, x: i32, z: i32) -> i64 {
|
||||
let own = self.distance(x, z);
|
||||
match self.band {
|
||||
Some(band) if band.within(x, z, 4) => own.min(band.priority(x, z)),
|
||||
_ => own,
|
||||
}
|
||||
}
|
||||
/// Ring by ring: the Chebyshev distance to the nearest centre first, then
|
||||
/// the squared distance. Vanilla's chunk tasks run by ticket level, which
|
||||
/// spreads one level per ring of chunks (`ChunkTaskPriorityQueue`), so
|
||||
/// `LevelLoadingScreen`'s chunk map fills squares around the spawn.
|
||||
fn rings(&self, x: i32, z: i32) -> i64 {
|
||||
let d = |c: (i32, i32)| {
|
||||
let (dx, dz) = (i64::from(x - c.0), i64::from(z - c.1));
|
||||
let ring = dx.abs().max(dz.abs());
|
||||
(ring * ring) << 20 | (dx * dx + dz * dz)
|
||||
};
|
||||
self.extra
|
||||
.iter()
|
||||
.map(|(c, _)| d(*c))
|
||||
.fold(d(self.center), i64::min)
|
||||
}
|
||||
fn distance(&self, x: i32, z: i32) -> i64 {
|
||||
let d = |c: (i32, i32)| {
|
||||
let (dx, dz) = (i64::from(x - c.0), i64::from(z - c.1));
|
||||
@@ -2867,7 +2841,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn queued_jobs_follow_the_centre_and_the_loading_screen() {
|
||||
fn queued_jobs_follow_the_centre() {
|
||||
let forced = HashSet::new();
|
||||
let ranges = |center| Ranges {
|
||||
center,
|
||||
@@ -2884,13 +2858,13 @@ mod tests {
|
||||
order,
|
||||
job: Job::Generate(x, z),
|
||||
};
|
||||
let loading = ranges((0, 0));
|
||||
// Both queued around an earlier centre, where the farther job came first.
|
||||
let earlier = ranges((40, 0));
|
||||
let current = ranges((0, 0));
|
||||
let queued = || {
|
||||
BinaryHeap::from(vec![
|
||||
// Queued while the loading screen was up: ring order.
|
||||
generate(3, 1, loading.priority(3, 1, true) * 4, 1),
|
||||
// Queued after it closed: plain distances, far below any ring past the first.
|
||||
generate(20, 0, loading.priority(20, 0, false) * 4, 2),
|
||||
generate(3, 1, earlier.priority(3, 1) * 4, 1),
|
||||
generate(20, 0, earlier.priority(20, 0) * 4, 2),
|
||||
Queued {
|
||||
priority: i64::MIN,
|
||||
at: None,
|
||||
@@ -2911,12 +2885,12 @@ mod tests {
|
||||
"the stale order"
|
||||
);
|
||||
let mut queue = queued();
|
||||
rank_again(&mut queue, |x, z| loading.priority(x, z, false));
|
||||
rank_again(&mut queue, |x, z| current.priority(x, z));
|
||||
assert_eq!(pops(queue), [None, Some((3, 1)), Some((20, 0))]);
|
||||
// The player moved: the job near the new centre goes first.
|
||||
let mut queue = BinaryHeap::from(vec![generate(3, 1, 0, 1), generate(20, 0, 1, 2)]);
|
||||
let moved = ranges((19, 0));
|
||||
rank_again(&mut queue, |x, z| moved.priority(x, z, false));
|
||||
rank_again(&mut queue, |x, z| moved.priority(x, z));
|
||||
assert_eq!(queue.pop().and_then(|q| q.at), Some((20, 0)));
|
||||
}
|
||||
|
||||
@@ -3100,7 +3074,6 @@ mod tests {
|
||||
finishing: &self.finishing,
|
||||
decorates: self.decorates,
|
||||
remote: self.remote,
|
||||
loading: false,
|
||||
}
|
||||
}
|
||||
fn fill(&mut self) {
|
||||
@@ -3520,33 +3493,6 @@ mod tests {
|
||||
/// from the save) and is not unloaded right after: either way it would be
|
||||
/// asked for and loaded again on the next frame, forever (8 such columns
|
||||
/// after a teleport at render distance 16 held 8 of the in-flight slots).
|
||||
/// #770: while the loading screen is up, chunks go ring by ring (a
|
||||
/// square front, as vanilla's ticket levels), nearest first in a ring.
|
||||
#[test]
|
||||
fn loading_goes_ring_by_ring() {
|
||||
let forced = HashSet::new();
|
||||
let r = Ranges {
|
||||
center: (2, -1),
|
||||
radius: 12,
|
||||
extra: &[],
|
||||
forced: &forced,
|
||||
cover: None,
|
||||
band: None,
|
||||
};
|
||||
let at = |dx: i32, dz: i32| (2 + dx, -1 + dz);
|
||||
let key = |(x, z): (i32, i32), loading| r.priority(x, z, loading);
|
||||
// The corner of ring 3 comes before the edge of ring 4 only while loading.
|
||||
assert!(key(at(3, 3), true) < key(at(0, 4), true));
|
||||
assert!(key(at(3, 3), false) > key(at(0, 4), false));
|
||||
// Inside a ring, nearest first; rings never interleave.
|
||||
assert!(key(at(0, 3), true) < key(at(2, -3), true));
|
||||
assert!(key(at(-3, -3), true) < key(at(4, 0), true));
|
||||
for dx in -20..=20 {
|
||||
for dz in -20..=20 {
|
||||
assert_eq!(key(at(dx, dz), false), r.distance(2 + dx, -1 + dz));
|
||||
}
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn requested_columns_stay_loaded() {
|
||||
let forced = HashSet::new();
|
||||
|
||||
@@ -207,8 +207,6 @@
|
||||
"Mesh data sent to the GPU per frame. Lower is smoother while loading.": "Pro Bild an die GPU gesendete Mesh-Daten. Weniger ist flüssiger beim Laden.",
|
||||
"Frame rate and frame times over the game, with a graph.": "Bildrate und Bildzeiten über dem Spiel, mit Diagramm.",
|
||||
"horizon.multiplayer.finding_friend": "Die Welt deines Freundes wird gesucht …",
|
||||
"horizon.loading.opening_world": "Welt wird geöffnet",
|
||||
"horizon.loading.cancelling_world": "Wird abgebrochen",
|
||||
"horizon.title.not_affiliated": "Nicht mit Mojang oder Microsoft verbunden",
|
||||
"horizon.extras.header.lighting": "Beleuchtung",
|
||||
"horizon.extras.header.third_person": "Dritte Person",
|
||||
|
||||
@@ -212,8 +212,6 @@
|
||||
"Mesh data sent to the GPU per frame. Lower is smoother while loading.": "Mesh data sent to the GPU per frame. Lower is smoother while loading.",
|
||||
"Frame rate and frame times over the game, with a graph.": "Frame rate and frame times over the game, with a graph.",
|
||||
"horizon.multiplayer.finding_friend": "Finding your friend's world...",
|
||||
"horizon.loading.opening_world": "Opening world",
|
||||
"horizon.loading.cancelling_world": "Cancelling world",
|
||||
"horizon.multiplayer.not_signed_in": "This server is in online mode. Sign in with your Microsoft account in the Horizon launcher to join it.",
|
||||
"horizon.multiplayer.invalid_address": "Invalid server address: %s",
|
||||
"horizon.multiplayer.connected_friend_direct": "Connected straight to your friend...",
|
||||
|
||||
@@ -198,8 +198,6 @@
|
||||
"Mesh data sent to the GPU per frame. Lower is smoother while loading.": "Datos de malla enviados a la GPU por fotograma. Menos es más fluido durante la carga.",
|
||||
"Frame rate and frame times over the game, with a graph.": "Fotogramas por segundo y tiempos de fotograma sobre el juego, con un gráfico.",
|
||||
"horizon.multiplayer.finding_friend": "Buscando el mundo de tu amigo…",
|
||||
"horizon.loading.opening_world": "Abriendo el mundo",
|
||||
"horizon.loading.cancelling_world": "Cancelando",
|
||||
"horizon.multiplayer.not_signed_in": "Este servidor está en modo en línea. Inicia sesión con tu cuenta de Microsoft en el launcher de Horizon para unirte.",
|
||||
"horizon.multiplayer.invalid_address": "Dirección de servidor no válida: %s",
|
||||
"horizon.multiplayer.connected_friend_direct": "Conectado directamente con tu amigo…",
|
||||
|
||||
@@ -207,8 +207,6 @@
|
||||
"Mesh data sent to the GPU per frame. Lower is smoother while loading.": "Données de maillage vers le GPU par image. Moins : plus fluide.",
|
||||
"Frame rate and frame times over the game, with a graph.": "Fréquence et temps d'image par-dessus le jeu, avec un graphique.",
|
||||
"horizon.multiplayer.finding_friend": "Recherche du monde de votre ami...",
|
||||
"horizon.loading.opening_world": "Ouverture du monde",
|
||||
"horizon.loading.cancelling_world": "Annulation en cours",
|
||||
"horizon.title.not_affiliated": "Non affilié à Mojang ou à Microsoft",
|
||||
"horizon.extras.header.lighting": "Éclairage",
|
||||
"horizon.extras.header.third_person": "Troisième personne",
|
||||
|
||||
@@ -207,8 +207,6 @@
|
||||
"Mesh data sent to the GPU per frame. Lower is smoother while loading.": "Dane siatki wysyłane do GPU na klatkę. Mniej to płynniej podczas ładowania.",
|
||||
"Frame rate and frame times over the game, with a graph.": "Liczba klatek i czasy klatek nad grą, z wykresem.",
|
||||
"horizon.multiplayer.finding_friend": "Szukanie świata znajomego…",
|
||||
"horizon.loading.opening_world": "Otwieranie świata",
|
||||
"horizon.loading.cancelling_world": "Anulowanie",
|
||||
"horizon.multiplayer.not_signed_in": "Ten serwer działa w trybie online. Zaloguj się kontem Microsoft w launcherze Horizon, aby dołączyć.",
|
||||
"horizon.multiplayer.invalid_address": "Nieprawidłowy adres serwera: %s",
|
||||
"horizon.multiplayer.connected_friend_direct": "Połączono bezpośrednio ze znajomym…",
|
||||
|
||||
@@ -197,8 +197,6 @@
|
||||
"Mesh data sent to the GPU per frame. Lower is smoother while loading.": "Данных меша в видеокарту за кадр. Меньше — плавнее во время загрузки.",
|
||||
"Frame rate and frame times over the game, with a graph.": "Частота кадров и их время поверх игры, с графиком.",
|
||||
"horizon.multiplayer.finding_friend": "Ищем мир друга…",
|
||||
"horizon.loading.opening_world": "Открытие мира",
|
||||
"horizon.loading.cancelling_world": "Отмена",
|
||||
"horizon.multiplayer.not_signed_in": "Этот сервер работает в онлайн-режиме. Войдите с помощью аккаунта Microsoft в лаунчере Horizon, чтобы присоединиться.",
|
||||
"horizon.multiplayer.invalid_address": "Недействительный адрес сервера: %s",
|
||||
"horizon.multiplayer.connected_friend_direct": "Установлено прямое соединение с другом…",
|
||||
|
||||
@@ -207,8 +207,6 @@
|
||||
"Mesh data sent to the GPU per frame. Lower is smoother while loading.": "Дані сітки до GPU за кадр. Менше — плавніше завантаження.",
|
||||
"Frame rate and frame times over the game, with a graph.": "Частота та час кадрів поверх гри, з графіком.",
|
||||
"horizon.multiplayer.finding_friend": "Шукаємо світ друга…",
|
||||
"horizon.loading.opening_world": "Відкриття світу",
|
||||
"horizon.loading.cancelling_world": "Скасування",
|
||||
"horizon.multiplayer.not_signed_in": "Цей сервер працює в онлайн-режимі. Увійдіть за допомогою акаунта Microsoft у лаунчері Horizon, щоб приєднатися.",
|
||||
"horizon.multiplayer.invalid_address": "Недійсна адреса сервера: %s",
|
||||
"horizon.multiplayer.connected_friend_direct": "Пряме з'єднання з другом встановлено…",
|
||||
@@ -343,5 +341,104 @@
|
||||
"horizon.pages.open.split_setup": "Розділений екран…",
|
||||
"horizon.pages.split_settings": "Налаштування розділеного екрана",
|
||||
"horizon.pages.split_setup": "Розділений екран",
|
||||
"horizon.pages.split_setup.tooltip": "Гравці на цьому комп’ютері: геймпади можуть долучитися до вашого світу."
|
||||
"horizon.pages.split_setup.tooltip": "Гравці на цьому комп’ютері: геймпади можуть долучитися до вашого світу.",
|
||||
"horizon.split.leave": "Вийти",
|
||||
"horizon.splitscreen.setup.monitor": "Монітор %s",
|
||||
"horizon.splitscreen.setup.monitor_unknown": "монітор",
|
||||
"horizon.splitscreen.setup.window": "Вікно %s",
|
||||
"horizon.splitscreen.setup.new_window": "Нове вікно",
|
||||
"horizon.splitscreen.setup.fill_monitor": "На весь %s",
|
||||
"horizon.splitscreen.setup.done": "Готово",
|
||||
"horizon.splitscreen.setup.switch": "%s: %s",
|
||||
"horizon.splitscreen.setup.on": "Увімк.",
|
||||
"horizon.splitscreen.setup.off": "Вимк.",
|
||||
"horizon.splitscreen.setup.player": "Гравець %s",
|
||||
"horizon.splitscreen.setup.revert": "Скасувати",
|
||||
"horizon.splitscreen.setup.apply": "Застосувати",
|
||||
"horizon.splitscreen.setup.warning.empty_main": "У вікні 1 ніхто не грає: воно сховається",
|
||||
"horizon.splitscreen.setup.warning.empty_close": "У вікні %s ніхто не грає: воно закриється після «Застосувати»",
|
||||
"horizon.splitscreen.setup.warning.empty_new": "У вікні %s ніхто не грає: воно не відкриється",
|
||||
"horizon.splitscreen.setup.warning.on_main": "Вікно %s займає монітор вікна 1: його гравці грають у вікні 1",
|
||||
"horizon.splitscreen.setup.warning.unplugged": "Монітор вікна %s не під’єднано: його гравці грають у вікні 1",
|
||||
"horizon.splitscreen.setup.status.off": "Розділений екран вимкнено.",
|
||||
"horizon.splitscreen.setup.status.enable_hint": "Увімкніть його, а потім у світі натисніть кнопку на іншому контролері.",
|
||||
"horizon.splitscreen.setup.status.join_hint": "Натисніть A, щоб приєднатися, і виберіть профіль.",
|
||||
"horizon.splitscreen.setup.status.idle": "Розділений екран очікує",
|
||||
"horizon.splitscreen.setup.status.alone": "Інших локальних гравців немає.",
|
||||
"horizon.splitscreen.setup.status.solo_controls": "Керування й віддача контролера для одного гравця не змінюються.",
|
||||
"horizon.splitscreen.setup.window_empty": "Вікно %s: порожнє",
|
||||
"horizon.splitscreen.setup.note.select": "Клацніть гравця або перетягніть його на вікно чи монітор.",
|
||||
"horizon.splitscreen.setup.note.apply": "«Застосувати» — грати так, «Скасувати» — повернути як було.",
|
||||
"horizon.splitscreen.setup.note.place": "Де має грати %s?",
|
||||
"horizon.splitscreen.setup.this_player": "цей гравець",
|
||||
"horizon.splitscreen.setup.switch_disabled": "Розділений екран вимкнеться, щойно інші вийдуть (через їхнє меню паузи).",
|
||||
"horizon.splitscreen.setup.window_place": "Вікно %s (%s із %s)",
|
||||
"horizon.splitscreen.setup.names": "Імена",
|
||||
"horizon.splitscreen.setup.identify": "Позначити",
|
||||
"horizon.splitscreen.setup.layout.two": "Удвох: %s",
|
||||
"horizon.splitscreen.setup.layout.three": "Утрьох: %s",
|
||||
"horizon.splitscreen.setup.layout.two.0": "Авто",
|
||||
"horizon.splitscreen.setup.layout.two.1": "Поруч",
|
||||
"horizon.splitscreen.setup.layout.two.2": "Згори й знизу",
|
||||
"horizon.splitscreen.setup.layout.three.0": "Один широкий згори",
|
||||
"horizon.splitscreen.setup.layout.three.1": "Один високий ліворуч",
|
||||
"horizon.splitscreen.setup.layout.three.2": "Поруч",
|
||||
"horizon.splitscreen.setup.help.player": "Клацніть або перетягніть %s, щоб перемістити.",
|
||||
"horizon.splitscreen.setup.help.layout_auto": "%s (поруч, коли вікно широке). Натисніть, щоб змінити.",
|
||||
"horizon.splitscreen.setup.help.layout": "%s. Натисніть, щоб змінити.",
|
||||
"horizon.splitscreen.setup.help.switch": "Розділений екран на цьому комп’ютері: увімкнено чи вимкнено.",
|
||||
"horizon.splitscreen.setup.help.identify": "Кожен гравець бачить у своєму виді, в якому він вікні.",
|
||||
"horizon.splitscreen.setup.help.names": "Ім’я кожного гравця в кутку його виду.",
|
||||
"horizon.splitscreen.setup.help.revert": "Скасувати ще не застосовані зміни.",
|
||||
"horizon.splitscreen.setup.help.apply": "Грати так, як показує схема.",
|
||||
"horizon.splitscreen.setup.help.unchanged": "Поки нічого не змінено.",
|
||||
"horizon.splitscreen.setup.give_up_pad": "Віддати контролер",
|
||||
"horizon.splitscreen.prompt.controller_connected": "Контролер під’єднано",
|
||||
"horizon.splitscreen.prompt.controller_disconnected": "Контролер від’єднано",
|
||||
"horizon.splitscreen.prompt.controller_disconnected.hint": "Під’єднайте його знову або натисніть кнопку на іншому",
|
||||
"horizon.splitscreen.prompt.join.one": "%s хоче приєднатися",
|
||||
"horizon.splitscreen.prompt.join.many": "Контролерів, що хочуть приєднатися: %s",
|
||||
"horizon.splitscreen.prompt.join.yes": "Приєднатися",
|
||||
"horizon.splitscreen.prompt.join.as_p1": "Грати як Г1",
|
||||
"horizon.splitscreen.prompt.join.no": "Ні",
|
||||
"horizon.splitscreen.prompt.lost": "%s: контролер від’єднано",
|
||||
"horizon.splitscreen.prompt.lost.use_another": "Узяти інший",
|
||||
"horizon.splitscreen.prompt.lost.leave": "Вийти",
|
||||
"horizon.splitscreen.setup.help.give_up_pad": "Ваш контролер більше не керує вами, і його можна взяти, щоб приєднатися (A).",
|
||||
"horizon.splitscreen.setup.note.held_at": "%s → %s: A — поставити тут, B — повернути назад.",
|
||||
"horizon.splitscreen.setup.note.held": "%s узято: ліворуч/праворуч — вибрати місце, A — поставити, B — повернути назад.",
|
||||
"Block Entities": "Блокові сутності",
|
||||
"Shulker Boxes": "Шалкерові коробки",
|
||||
"Reuses settled boxes; lid movement stays animated.": "Повторно використовує нерухомі коробки; рух кришки лишається анімованим.",
|
||||
"Signs": "Таблички",
|
||||
"Reuses sign text until text, light, or font changes.": "Повторно використовує текст табличок, доки не зміняться текст, світло чи шрифт.",
|
||||
"Decorated Pots": "Оздоблені горщики",
|
||||
"Reuses still pots; break wobble and sherd changes redraw.": "Повторно використовує нерухомі горщики; хитання від удару й зміна черепків перемальовуються.",
|
||||
"Bells": "Дзвони",
|
||||
"Reuses still bells; ringing stays animated.": "Повторно використовує нерухомі дзвони; дзвін лишається анімованим.",
|
||||
"Chests": "Скрині",
|
||||
"Reuses closed chests; moving lids stay animated.": "Повторно використовує закриті скрині; рухомі кришки лишаються анімованими.",
|
||||
"Banners": "Стяги",
|
||||
"Reuses poles; flags and pattern layers still update.": "Повторно використовує держаки; полотнища й шари візерунків оновлюються.",
|
||||
"Copper Golem Statues": "Статуї мідних ґолемів",
|
||||
"Reuses statue geometry until its block state changes.": "Повторно використовує геометрію статуї, доки не зміниться стан її блока.",
|
||||
"Find BlueMap": "Шукати BlueMap",
|
||||
"On servers, finds their BlueMap web map for far terrain. Next join.": "На серверах шукає їхню вебмапу BlueMap для далекого рельєфу. З наступного входу.",
|
||||
"BlueMap Radius": "Радіус BlueMap",
|
||||
"How far around you BlueMap tiles are downloaded. Next join.": "Як далеко навколо вас завантажуються плитки BlueMap. З наступного входу.",
|
||||
"%s nits": "%s ніт",
|
||||
"Auto (%s nits)": "Авто (%s ніт)",
|
||||
"%.2f nits": "%.2f ніт",
|
||||
"Hides models behind opaque blocks. Entities update. Off with custom packs.": "Приховує моделі за непрозорими блоками. Сутності оновлюються. Вимкнено з власними наборами ресурсів.",
|
||||
"horizon.extras.repro_keys": "Клавіші запису",
|
||||
"horizon.extras.repro_keys.tooltip": "F3+R починає або зупиняє запис для відтворення помилки, F3+E зберігає його пакет. Вимкнено за замовчуванням: запис копіює світ.",
|
||||
"horizon.agent.record_session": "Почати запис сесії",
|
||||
"horizon.agent.record_stop": "Зупинити запис сесії",
|
||||
"horizon.agent.export_repro": "Експортувати пакет відтворення",
|
||||
"horizon.repro.started": "Запис почато; F3+R зупиняє його й зберігає пакет.",
|
||||
"horizon.repro.stopped": "Запис зупинено: %s",
|
||||
"horizon.repro.bundle": "Пакет відтворення: %s",
|
||||
"horizon.repro.error": "Відтворення: %s",
|
||||
"horizon.repro.no_recording": "Поки немає запису для експорту.",
|
||||
"horizon.repro.replay_started": "Відтворення почато: %s"
|
||||
}
|
||||
@@ -201,8 +201,6 @@
|
||||
"Mesh data sent to the GPU per frame. Lower is smoother while loading.": "每帧发送给 GPU 的网格数据。越低加载时越流畅。",
|
||||
"Frame rate and frame times over the game, with a graph.": "游戏运行时的帧率与帧时间,附带图表。",
|
||||
"horizon.multiplayer.finding_friend": "正在查找好友的世界…",
|
||||
"horizon.loading.opening_world": "正在打开世界",
|
||||
"horizon.loading.cancelling_world": "正在取消",
|
||||
"horizon.multiplayer.not_signed_in": "该服务器为在线模式服务器。请先在 Horizon 启动器中登录 Microsoft 账户再加入。",
|
||||
"horizon.multiplayer.invalid_address": "无效的服务器地址:%s",
|
||||
"horizon.multiplayer.connected_friend_direct": "已直连好友的世界…",
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
//!
|
||||
//! cargo run --release -p horizon-client-lib --example gen_light_bench -- \
|
||||
//! .local/horizon-client/26.3 [--seed 1234] [--center CX CZ] [--radius 12]
|
||||
//! [--threads 1,2,4,8] [--period-ms 2] [--loading] [--repeat 1] [--json]
|
||||
//! [--threads 1,2,4,8] [--period-ms 2] [--repeat 1] [--json]
|
||||
//!
|
||||
//! For each run it prints the wall time until every column within the radius
|
||||
//! (the area Streaming finishes) was lit, columns finished and lit per second, the process's CPU seconds per
|
||||
@@ -16,8 +16,7 @@
|
||||
//! covers the blocks and light of the columns within the radius: an A/B pair of
|
||||
//! builds, and every worker count, must print the same one.
|
||||
//! `--period-ms` is how long the coordinator sleeps between updates (the
|
||||
//! server thread runs one per 50 ms tick and pumps between ticks); `--loading`
|
||||
//! orders work ring by ring, as under the level loading screen.
|
||||
//! server thread runs one per 50 ms tick and pumps between ticks).
|
||||
//!
|
||||
//! `--simulate PROTO,DECORATE,FINISH` (milliseconds) swaps the generator for
|
||||
//! one whose jobs sleep that long and make empty columns: what is left is the
|
||||
@@ -117,13 +116,11 @@ fn run<G: ChunkGenerator>(
|
||||
radius: i32,
|
||||
threads: usize,
|
||||
period: Duration,
|
||||
loading: bool,
|
||||
) -> Result<Run, Error> {
|
||||
let mut streaming = Streaming::new(Arc::new(generator), context.clone(), threads);
|
||||
streaming.mesh = false;
|
||||
streaming.center = center;
|
||||
streaming.radius = radius;
|
||||
streaming.loading = loading;
|
||||
let mut world = World::new(BlockStateId(0), 0);
|
||||
let view: Vec<(i32, i32)> = (center.0 - radius - 1..=center.0 + radius + 1)
|
||||
.flat_map(|x| (center.1 - radius - 1..=center.1 + radius + 1).map(move |z| (x, z)))
|
||||
@@ -215,7 +212,6 @@ fn main() -> Result<(), Error> {
|
||||
let radius: i32 = flag(&args, "--radius", 12);
|
||||
let repeat: usize = flag(&args, "--repeat", 1);
|
||||
let period = Duration::from_millis(flag(&args, "--period-ms", 2));
|
||||
let loading = args.iter().any(|a| a == "--loading");
|
||||
let json = args.iter().any(|a| a == "--json");
|
||||
let simulate: Option<Vec<f64>> = args
|
||||
.iter()
|
||||
@@ -291,7 +287,7 @@ fn main() -> Result<(), Error> {
|
||||
render,
|
||||
});
|
||||
let terrain = || VanillaTerrain::new(&root.join("worldgen"), assets.registry, &biomes, seed);
|
||||
let go = |center, radius, n, loading| -> Result<Run, Error> {
|
||||
let go = |center, radius, n| -> Result<Run, Error> {
|
||||
match &simulate {
|
||||
Some(ms) => {
|
||||
let ms = |i: usize| Duration::from_secs_f64(ms[i] / 1e3);
|
||||
@@ -300,18 +296,18 @@ fn main() -> Result<(), Error> {
|
||||
decorate: ms(1),
|
||||
finish: ms(2),
|
||||
};
|
||||
run(generator, &context, center, radius, n, period, loading)
|
||||
run(generator, &context, center, radius, n, period)
|
||||
}
|
||||
None => run(terrain()?, &context, center, radius, n, period, loading),
|
||||
None => run(terrain()?, &context, center, radius, n, period),
|
||||
}
|
||||
};
|
||||
// Warm the shared generator data and the code away from the measured area.
|
||||
go((center.0 + 4000, center.1), 2, n, false)?;
|
||||
go((center.0 + 4000, center.1), 2, n)?;
|
||||
let pool = horizon_threads::jobs::pool().threads();
|
||||
if pool != n {
|
||||
eprintln!("the worker pool has {pool} threads, not {n} (HORIZON_JOB_THREADS?)");
|
||||
}
|
||||
let r = go(center, radius, n, loading)?;
|
||||
let r = go(center, radius, n)?;
|
||||
let busy: f64 = r.jobs.iter().map(|j| j.1 as f64).sum::<f64>() / 1e9;
|
||||
println!(
|
||||
"workers {n:2}: {:.2} s ({:.2} s to half), {} columns ({} lit), {:.1} columns/s, {:.1} lit/s, \
|
||||
|
||||
@@ -116,7 +116,7 @@ fn entity_state(mob: &mut Mob, species: Species, case: &Value) {
|
||||
for (i, flags) in ticks.iter().enumerate() {
|
||||
mob.age = i as u32 + 1;
|
||||
bat::set_resting(mob, flags.as_i64().unwrap_or(0) & 1 != 0);
|
||||
if flag("@batInFrame") {
|
||||
if flag("@batServerTick") {
|
||||
bat::tick(mob);
|
||||
} else {
|
||||
bat::setup_animation_states(mob);
|
||||
|
||||
@@ -1197,6 +1197,7 @@ pub fn main() -> horizon_client::Result<()> {
|
||||
items: Some(&items),
|
||||
lid_openers: &lid_openers,
|
||||
mod_effects: None,
|
||||
border: None,
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -311,10 +311,6 @@ pub struct FixedStep {
|
||||
/// (`ServerTickRateManager.updateStepTicks` broadcasts each one).
|
||||
step_changes: u32,
|
||||
step_requested: i32,
|
||||
/// In-frame ticks dropped since the last overload warning (`drop_ticks`).
|
||||
dropped: u32,
|
||||
/// When the last overload warning was due, on the frame clock.
|
||||
last_overload_warning: std::time::Duration,
|
||||
}
|
||||
|
||||
impl Default for FixedStep {
|
||||
@@ -332,8 +328,6 @@ impl Default for FixedStep {
|
||||
client: false,
|
||||
step_changes: 0,
|
||||
step_requested: 0,
|
||||
dropped: 0,
|
||||
last_overload_warning: std::time::Duration::ZERO,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -448,41 +442,6 @@ impl FixedStep {
|
||||
pub fn sprinting(&self) -> bool {
|
||||
self.manager.is_sprinting()
|
||||
}
|
||||
/// Whether a frame that ran its world in the frame, and whose ticks so
|
||||
/// far took `spent`, may run another one. Vanilla's world ticks on the
|
||||
/// integrated server's thread, so a slow tick costs its frames nothing:
|
||||
/// the server falls behind and says so (`MinecraftServer.runServer`'s
|
||||
/// "Can't keep up!"). In the frame, a world whose ticks take longer than
|
||||
/// their length falls behind every frame, so every frame ran the client
|
||||
/// timer's ten: at 57 ms a tick, 1.7 FPS. So a frame stops once its
|
||||
/// ticks have taken a tick's length: ticks of a few milliseconds keep the
|
||||
/// full ten, and a world that cannot keep up runs one tick a frame,
|
||||
/// slower but drawn. A sprint (`/tick sprint`) runs its own frame's worth.
|
||||
pub fn tick_fits(&self, spent: std::time::Duration) -> bool {
|
||||
self.sprinting() || spent < self.tick_duration()
|
||||
}
|
||||
/// The frame skips its remaining `ticks` (`tick_fits`): the world runs
|
||||
/// slower than its tick rate. Returns vanilla's overload warning when one
|
||||
/// is due: a second's worth of ticks behind, at most every ten seconds
|
||||
/// (`OVERLOADED_THRESHOLD_NANOS`, `OVERLOADED_WARNING_INTERVAL_NANOS`).
|
||||
pub fn drop_ticks(&mut self, ticks: u32) -> Option<String> {
|
||||
self.dropped = self.dropped.saturating_add(ticks);
|
||||
let tick = self.tick_duration();
|
||||
let behind = tick * self.dropped;
|
||||
let threshold = std::time::Duration::from_secs(1) + tick * 20;
|
||||
let interval = std::time::Duration::from_secs(10) + tick * 100;
|
||||
if behind <= threshold || self.now < self.last_overload_warning + interval {
|
||||
return None;
|
||||
}
|
||||
let warning = format!(
|
||||
"Can't keep up! Is the server overloaded? Running {}ms or {} ticks behind",
|
||||
behind.as_millis(),
|
||||
self.dropped
|
||||
);
|
||||
self.dropped = 0;
|
||||
self.last_overload_warning = self.now;
|
||||
Some(warning)
|
||||
}
|
||||
/// `requestGameToSprint`: true when a sprint was already running.
|
||||
pub fn start_sprint(&mut self, ticks: u32) -> bool {
|
||||
self.manager
|
||||
@@ -554,37 +513,6 @@ impl FixedStep {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_frame_stops_ticking_after_a_ticks_length() {
|
||||
let clock = FixedStep::default();
|
||||
let ms = std::time::Duration::from_millis;
|
||||
// Ticks of 4 ms: the ten the timer allows fit in 50 ms.
|
||||
assert!(clock.tick_fits(ms(4 * 9)));
|
||||
// Ticks of 40 ms: the second one runs, not a third.
|
||||
assert!(clock.tick_fits(ms(40)));
|
||||
assert!(!clock.tick_fits(ms(80)));
|
||||
// One tick longer than its length still runs, once a frame.
|
||||
assert!(clock.tick_fits(ms(0)));
|
||||
assert!(!clock.tick_fits(ms(120)));
|
||||
}
|
||||
#[test]
|
||||
fn dropped_ticks_warn_once_a_second_behind_and_not_again_for_ten() {
|
||||
let mut clock = FixedStep::default();
|
||||
let s = std::time::Duration::from_secs;
|
||||
clock.now = s(20);
|
||||
assert_eq!(
|
||||
clock.drop_ticks(40),
|
||||
None,
|
||||
"two seconds: not past 1 s + 20 ticks"
|
||||
);
|
||||
let warning = clock.drop_ticks(1).expect("past the threshold");
|
||||
assert!(warning.contains("2050ms or 41 ticks behind"), "{warning}");
|
||||
clock.now = s(25);
|
||||
assert_eq!(clock.drop_ticks(100), None, "within ten seconds");
|
||||
clock.now = s(36);
|
||||
assert!(clock.drop_ticks(0).unwrap().contains("100 ticks behind"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn timer_carries_fractions_and_caps_nothing_itself() {
|
||||
let mut t = Timer::new(20.0, 0);
|
||||
|
||||
@@ -1254,6 +1254,10 @@ pub struct ArmorStand {
|
||||
pub no_base_plate: bool,
|
||||
pub invisible: bool,
|
||||
pub marker: bool,
|
||||
/// `Entity.isNoGravity` (`NoGravity` in the save). `ArmorStand.hasPhysics`
|
||||
/// is false with it, as with `Marker`: `travel` does nothing, so the stand
|
||||
/// never moves, and the blocks it stands in do not touch it.
|
||||
pub no_gravity: bool,
|
||||
/// `CustomName`: the text of a hologram.
|
||||
pub custom_name: Option<crate::text::Text>,
|
||||
/// `CustomNameVisible`.
|
||||
@@ -1343,6 +1347,7 @@ impl ArmorStand {
|
||||
no_base_plate: false,
|
||||
invisible: false,
|
||||
marker: false,
|
||||
no_gravity: false,
|
||||
custom_name: None,
|
||||
name_visible: false,
|
||||
last_hit: i64::MIN / 2,
|
||||
@@ -1428,7 +1433,9 @@ impl ArmorStand {
|
||||
self.fire_ticks = self.fire_ticks.max(300);
|
||||
}
|
||||
let fire = crate::motion::environment::fire_contact(world, registry, self.bounds()) > 0.0;
|
||||
if fire && !self.invisible && !self.marker {
|
||||
// The fire block reaches a stand through `applyEffectsFromBlocks`,
|
||||
// which a NoGravity stand does not feel (measured beside the server).
|
||||
if fire && !self.invisible && !self.marker && !self.no_gravity {
|
||||
if self.fire_ticks > 0 {
|
||||
self.health -= 0.15;
|
||||
} else {
|
||||
@@ -1485,6 +1492,10 @@ impl ArmorStand {
|
||||
if v.z.abs() < 0.003 { 0.0 } else { v.z },
|
||||
);
|
||||
self.velocity = wanted;
|
||||
// ArmorStand.travel: no physics with NoGravity, the velocity stays.
|
||||
if self.no_gravity {
|
||||
return;
|
||||
}
|
||||
// `travelInFluid` reads `isFalling` before the move and any push;
|
||||
// `travelInAir` reads ground friction before the move.
|
||||
let falling = wanted.y <= 0.0;
|
||||
@@ -2644,6 +2655,36 @@ mod armor_stand_motion_tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_no_gravity_stand_stays_put() {
|
||||
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../../.local/horizon-client")
|
||||
.join(horizon_core::VANILLA_VERSION);
|
||||
if !root.join("movement-properties.nbt").exists() {
|
||||
return;
|
||||
}
|
||||
let assets = crate::assets::Assets::load(&root).unwrap();
|
||||
let render = assets.reload(&root).unwrap();
|
||||
let collision = crate::collisions::load(&root, assets.registry).unwrap();
|
||||
let facts = BlockFacts::load(&root, assets.registry).unwrap();
|
||||
let world = World::test_scene(&assets, &render, 3).unwrap();
|
||||
let start = Vec3::new(20.5, 20.0, 20.5);
|
||||
let mut stand = ArmorStand::new(1, start, 0.0);
|
||||
stand.on_ground = false;
|
||||
stand.no_gravity = true;
|
||||
// ArmorStand.travel does nothing without physics: measured on the real
|
||||
// server, Pos and Motion stay exactly as summoned for every tick.
|
||||
for _ in 0..5 {
|
||||
tick_stand(&mut stand, &world, &facts, &collision);
|
||||
assert_eq!(stand.position, start);
|
||||
assert_eq!(stand.velocity, Vec3::ZERO);
|
||||
}
|
||||
stand.no_gravity = false;
|
||||
tick_stand(&mut stand, &world, &facts, &collision);
|
||||
tick_stand(&mut stand, &world, &facts, &collision);
|
||||
assert!(stand.position.y < start.y, "gravity again once cleared");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn leather_boots_walk_on_powder_snow() {
|
||||
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
|
||||
@@ -2919,6 +2919,12 @@ pub fn armor_stand_to_nbt(a: &ArmorStand, uuid: [i32; 4], raw: Option<&Tag>) ->
|
||||
} else if let Tag::Compound(map) = &mut tag {
|
||||
map.remove(&"Marker".into());
|
||||
}
|
||||
// Entity.saveWithoutId writes NoGravity only when set.
|
||||
if a.no_gravity {
|
||||
put(&mut tag, "NoGravity", Tag::Byte(1));
|
||||
} else if let Tag::Compound(map) = &mut tag {
|
||||
map.remove(&"NoGravity".into());
|
||||
}
|
||||
if a.name_visible {
|
||||
put(&mut tag, "CustomNameVisible", Tag::Byte(1));
|
||||
} else if let Tag::Compound(map) = &mut tag {
|
||||
@@ -2984,6 +2990,7 @@ pub fn armor_stand_from_nbt(tag: &Tag, id: u32) -> Option<ArmorStand> {
|
||||
a.no_base_plate = flag(tag, "NoBasePlate");
|
||||
a.invisible = flag(tag, "Invisible");
|
||||
a.marker = flag(tag, "Marker");
|
||||
a.no_gravity = flag(tag, "NoGravity");
|
||||
a.on_ground = flag(tag, "OnGround");
|
||||
// ArmorStand.readAdditionalSaveData: each part of `Pose` that is a list of
|
||||
// three numbers, the others staying at their defaults.
|
||||
@@ -3387,6 +3394,32 @@ mod tests {
|
||||
assert!(restored.name_visible);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn armor_stand_keeps_no_gravity_through_a_save() {
|
||||
let tag = Tag::compound([
|
||||
("id", string("minecraft:armor_stand")),
|
||||
(
|
||||
"Pos",
|
||||
Tag::List(vec![
|
||||
Tag::Double(0.0f64.to_bits()),
|
||||
Tag::Double(64.0f64.to_bits()),
|
||||
Tag::Double(0.0f64.to_bits()),
|
||||
]),
|
||||
),
|
||||
("NoGravity", Tag::Byte(1)),
|
||||
]);
|
||||
let mut a = armor_stand_from_nbt(&tag, 7).unwrap();
|
||||
assert!(a.no_gravity, "summon NBT sets the flag");
|
||||
let saved = armor_stand_to_nbt(&a, [0; 4], Some(&tag));
|
||||
assert_eq!(saved.get("NoGravity"), Some(&Tag::Byte(1)));
|
||||
assert!(armor_stand_from_nbt(&saved, 7).unwrap().no_gravity);
|
||||
// `data merge entity @s {NoGravity:0b}` clears it again.
|
||||
a.no_gravity = false;
|
||||
let cleared = armor_stand_to_nbt(&a, [0; 4], Some(&saved));
|
||||
assert!(cleared.get("NoGravity").is_none());
|
||||
assert!(!armor_stand_from_nbt(&cleared, 7).unwrap().no_gravity);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn falling_block_round_trip_keeps_impact_distance_and_settings() {
|
||||
let state = Tag::compound([("Name", string("minecraft:damaged_anvil"))]);
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -3,6 +3,9 @@
|
||||
//! like a player (walk, look, break, place, use, click buttons). Actions that
|
||||
//! take time run as a task advanced every frame and reply when they finish or
|
||||
//! time out, so `horizon-ctl break 10 64 3` returns what the block dropped.
|
||||
#[path = "agent_navigation.rs"]
|
||||
mod navigation;
|
||||
|
||||
use super::event_loop::EventLoopCtl;
|
||||
use super::{Game, Screen};
|
||||
use horizon_client::{
|
||||
@@ -17,6 +20,7 @@ use horizon_client::{
|
||||
};
|
||||
use horizon_core::geometry::Vec3;
|
||||
use horizon_world::registry::BlockStateId;
|
||||
use navigation::{Cell, Node, Plan, Progress};
|
||||
use serde_json::{Value, json};
|
||||
use std::{
|
||||
collections::{BTreeMap, HashMap, HashSet},
|
||||
@@ -206,9 +210,6 @@ fn delta(before: &BTreeMap<String, i64>, after: &BTreeMap<String, i64>) -> BTree
|
||||
out
|
||||
}
|
||||
|
||||
/// A feet position the A* path walks through.
|
||||
type Node = [i32; 3];
|
||||
|
||||
enum TaskKind {
|
||||
/// Hold keys until a deadline.
|
||||
Move {
|
||||
@@ -251,9 +252,11 @@ struct Goto {
|
||||
target: [f64; 3],
|
||||
near: f64,
|
||||
sprint: bool,
|
||||
path: Vec<Node>,
|
||||
path: Plan,
|
||||
blocked: Vec<(Node, Node)>,
|
||||
progress: Progress,
|
||||
next: usize,
|
||||
/// Tick of the last waypoint reached, and how many replans so far.
|
||||
/// Tick of the last waypoint progress, and stuck recoveries so far.
|
||||
progress_tick: u64,
|
||||
replans: u32,
|
||||
jump_until: u64,
|
||||
@@ -817,105 +820,24 @@ impl Game<'_> {
|
||||
})
|
||||
}
|
||||
|
||||
/// Whether a block stops movement, for the path finder.
|
||||
fn blocks_motion(&self, pos: [i32; 3]) -> bool {
|
||||
let f = self
|
||||
.res
|
||||
.facts()
|
||||
.get(self.world.block(pos[0], pos[1], pos[2]));
|
||||
f.full_collision || f.legacy_solid
|
||||
}
|
||||
fn walkable(&self, n: Node) -> bool {
|
||||
let below = self.world.block(n[0], n[1] - 1, n[2]);
|
||||
let lava = |p: [i32; 3]| {
|
||||
&*self
|
||||
.res
|
||||
.facts()
|
||||
.get(self.world.block(p[0], p[1], p[2]))
|
||||
.name
|
||||
== "minecraft:lava"
|
||||
};
|
||||
self.world.chunks.contains_key(&(n[0] >> 4, n[2] >> 4))
|
||||
&& (self.blocks_motion([n[0], n[1] - 1, n[2]])
|
||||
|| self.res.facts().get(below).fluid != horizon_client::blocks::Fluid::None)
|
||||
&& !self.blocks_motion(n)
|
||||
&& !self.blocks_motion([n[0], n[1] + 1, n[2]])
|
||||
&& !lava(n)
|
||||
&& !lava([n[0], n[1] - 1, n[2]])
|
||||
}
|
||||
/// A* over standable feet positions: steps up one block, drops up to three.
|
||||
fn find_path(&self, from: Node, goal: [f64; 3], near: f64) -> Option<Vec<Node>> {
|
||||
use std::{cmp::Reverse, collections::BinaryHeap};
|
||||
let reached = |n: Node| {
|
||||
let c = [
|
||||
f64::from(n[0]) + 0.5,
|
||||
f64::from(n[1]),
|
||||
f64::from(n[2]) + 0.5,
|
||||
];
|
||||
((c[0] - goal[0]).powi(2) + (c[2] - goal[2]).powi(2)).sqrt() <= near.max(0.5)
|
||||
&& (c[1] - goal[1]).abs() <= near.max(1.0) + 0.5
|
||||
};
|
||||
let h = |n: Node| {
|
||||
((f64::from(n[0]) + 0.5 - goal[0]).abs()
|
||||
+ (f64::from(n[1]) - goal[1]).abs()
|
||||
+ (f64::from(n[2]) + 0.5 - goal[2]).abs())
|
||||
* 10.0
|
||||
};
|
||||
let mut open = BinaryHeap::new();
|
||||
let mut cost: HashMap<Node, u32> = HashMap::new();
|
||||
let mut came: HashMap<Node, Node> = HashMap::new();
|
||||
cost.insert(from, 0);
|
||||
open.push(Reverse((h(from) as u32, from)));
|
||||
let mut expanded = 0;
|
||||
while let Some(Reverse((_, n))) = open.pop() {
|
||||
if reached(n) {
|
||||
let mut path = vec![n];
|
||||
let mut at = n;
|
||||
while let Some(&p) = came.get(&at) {
|
||||
path.push(p);
|
||||
at = p;
|
||||
}
|
||||
path.reverse();
|
||||
return Some(path);
|
||||
}
|
||||
expanded += 1;
|
||||
if expanded > 30_000 {
|
||||
fn find_path(
|
||||
&self,
|
||||
from: Node,
|
||||
goal: [f64; 3],
|
||||
near: f64,
|
||||
blocked: &[(Node, Node)],
|
||||
) -> Option<Plan> {
|
||||
navigation::find_path(from, goal, near, blocked, |n| {
|
||||
if !self.world.chunks.contains_key(&(n[0] >> 4, n[2] >> 4)) {
|
||||
return None;
|
||||
}
|
||||
let base = cost[&n];
|
||||
for d in [[1, 0], [-1, 0], [0, 1], [0, -1]] {
|
||||
for dy in [0, 1, -1, -2, -3] {
|
||||
let m = [n[0] + d[0], n[1] + dy, n[2] + d[1]];
|
||||
// A step up needs head room above the start; a drop needs
|
||||
// the column in front clear down to the landing.
|
||||
let clear = match dy {
|
||||
1 => !self.blocks_motion([n[0], n[1] + 2, n[2]]),
|
||||
d if d < 0 => {
|
||||
(m[1]..=n[1] + 1).all(|y| !self.blocks_motion([m[0], y, m[2]]))
|
||||
}
|
||||
_ => true,
|
||||
};
|
||||
if !clear || !self.walkable(m) {
|
||||
continue;
|
||||
}
|
||||
let water = self
|
||||
.res
|
||||
.facts()
|
||||
.get(self.world.block(m[0], m[1], m[2]))
|
||||
.fluid
|
||||
!= horizon_client::blocks::Fluid::None;
|
||||
let step = 10 + if dy != 0 { 5 } else { 0 } + if water { 30 } else { 0 };
|
||||
let c = base + step;
|
||||
if cost.get(&m).is_none_or(|&old| c < old) {
|
||||
cost.insert(m, c);
|
||||
came.insert(m, n);
|
||||
open.push(Reverse((c + h(m) as u32, m)));
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
let f = self.res.facts().get(self.world.block(n[0], n[1], n[2]));
|
||||
Some(Cell {
|
||||
solid: f.full_collision || f.legacy_solid,
|
||||
fluid: f.fluid != horizon_client::blocks::Fluid::None,
|
||||
lava: &*f.name == "minecraft:lava",
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/// Point the view at `at` from the eye.
|
||||
@@ -1259,10 +1181,13 @@ impl Game<'_> {
|
||||
let timeout = a.num("timeout", 60.0)?;
|
||||
let p = self.player.as_ref().ok_or("no player")?.position;
|
||||
let from = [p.x.floor() as i32, p.y.round() as i32, p.z.floor() as i32];
|
||||
let (path, warning) = match self.find_path(from, target, near) {
|
||||
let (path, warning) = match self.find_path(from, target, near, &[]) {
|
||||
Some(path) => (path, None),
|
||||
None if a.has("direct") => (
|
||||
Vec::new(),
|
||||
Plan {
|
||||
nodes: Vec::new(),
|
||||
complete: true,
|
||||
},
|
||||
Some("no path found through loaded terrain; walking straight".to_owned()),
|
||||
),
|
||||
None => {
|
||||
@@ -1272,12 +1197,17 @@ impl Game<'_> {
|
||||
.into());
|
||||
}
|
||||
};
|
||||
let position = [p.x, p.y, p.z];
|
||||
let mut next = 1;
|
||||
let aim = path.aim(position, &mut next, target).unwrap_or(target);
|
||||
let goto = Goto {
|
||||
target,
|
||||
near,
|
||||
sprint: a.has("sprint"),
|
||||
path,
|
||||
next: 1,
|
||||
blocked: Vec::new(),
|
||||
progress: Progress::new(position, aim),
|
||||
next,
|
||||
progress_tick: self.ticks,
|
||||
replans: 0,
|
||||
jump_until: 0,
|
||||
@@ -2077,9 +2007,7 @@ impl Game<'_> {
|
||||
let on_ground = self.player.as_ref().is_some_and(|p| p.ground.on_ground);
|
||||
let horizontal =
|
||||
|a: [f64; 3], b: [f64; 3]| ((a[0] - b[0]).powi(2) + (a[2] - b[2]).powi(2)).sqrt();
|
||||
let arrived = horizontal(p, g.target) <= g.near
|
||||
&& (p[1] - g.target[1]).abs() <= g.near.max(1.0) + 1.0;
|
||||
if arrived {
|
||||
if navigation::arrived(p, g.target, g.near) {
|
||||
return Some(
|
||||
json!({ "ok": true, "arrived": true, "position": p, "target": g.target,
|
||||
"distance": dist(p, g.target), "warning": g.warning }),
|
||||
@@ -2091,48 +2019,60 @@ impl Game<'_> {
|
||||
"target": g.target, "distance": dist(p, g.target), "warning": g.warning }),
|
||||
);
|
||||
}
|
||||
// The next waypoint: path nodes, then the target itself.
|
||||
let mut aim = g.target;
|
||||
while g.next < g.path.len() {
|
||||
let n = g.path[g.next];
|
||||
let c = [
|
||||
f64::from(n[0]) + 0.5,
|
||||
f64::from(n[1]),
|
||||
f64::from(n[2]) + 0.5,
|
||||
];
|
||||
let last = g.next + 1 == g.path.len();
|
||||
if horizontal(p, c) < if last { 0.3 } else { 0.45 } && (p[1] - c[1]).abs() < 1.1 {
|
||||
g.next += 1;
|
||||
let previous_next = g.next;
|
||||
let mut aim = g.path.aim(p, &mut g.next, g.target);
|
||||
if let Some(aim) = aim {
|
||||
if g.next != previous_next {
|
||||
g.progress = Progress::new(p, aim);
|
||||
g.progress_tick = self.ticks;
|
||||
} else if g.progress.advance(p, aim) {
|
||||
g.progress_tick = self.ticks;
|
||||
continue;
|
||||
}
|
||||
aim = c;
|
||||
break;
|
||||
}
|
||||
// Stuck: jump, then replan from here.
|
||||
let stalled = self.ticks.saturating_sub(g.progress_tick);
|
||||
if stalled > 60 && on_ground {
|
||||
g.jump_until = self.ticks + 4;
|
||||
}
|
||||
if stalled > 120 {
|
||||
g.replans += 1;
|
||||
if g.replans > 3 {
|
||||
return Some(
|
||||
json!({ "error": "stuck: cannot make progress", "arrived": false, "position": p,
|
||||
"target": g.target, "distance": dist(p, g.target) }),
|
||||
);
|
||||
if aim.is_none() || stalled > 120 {
|
||||
if stalled > 120 {
|
||||
g.replans += 1;
|
||||
if g.replans > 3 {
|
||||
return Some(
|
||||
json!({ "error": "stuck: cannot make progress", "arrived": false,
|
||||
"position": p, "target": g.target, "distance": dist(p, g.target) }),
|
||||
);
|
||||
}
|
||||
if g.next > 0 && g.next < g.path.nodes.len() {
|
||||
let edge = (g.path.nodes[g.next - 1], g.path.nodes[g.next]);
|
||||
if edge.0 != edge.1 && !g.blocked.contains(&edge) {
|
||||
g.blocked.push(edge);
|
||||
}
|
||||
}
|
||||
}
|
||||
let from = [
|
||||
p[0].floor() as i32,
|
||||
p[1].round() as i32,
|
||||
p[2].floor() as i32,
|
||||
];
|
||||
if let Some(path) = self.find_path(from, g.target, g.near) {
|
||||
g.path = path;
|
||||
g.next = 1;
|
||||
}
|
||||
let Some(path) = self.find_path(from, g.target, g.near, &g.blocked) else {
|
||||
return Some(
|
||||
json!({ "error": "no further walking progress through loaded terrain",
|
||||
"arrived": false, "position": p, "target": g.target,
|
||||
"distance": dist(p, g.target), "warning": g.warning }),
|
||||
);
|
||||
};
|
||||
g.path = path;
|
||||
g.next = 1;
|
||||
g.progress_tick = self.ticks;
|
||||
aim = g.path.aim(p, &mut g.next, g.target);
|
||||
g.progress = Progress::new(p, aim.unwrap_or(g.target));
|
||||
}
|
||||
let Some(aim) = aim else {
|
||||
return Some(
|
||||
json!({ "error": "path did not provide a new walking waypoint",
|
||||
"arrived": false, "position": p, "target": g.target }),
|
||||
);
|
||||
};
|
||||
let d = [aim[0] - p[0], aim[2] - p[2]];
|
||||
if d[0].abs() + d[1].abs() > 1e-3 {
|
||||
self.yaw = (-d[0]).atan2(d[1]).to_degrees() as f32;
|
||||
|
||||
@@ -0,0 +1,439 @@
|
||||
use std::{
|
||||
cmp::Reverse,
|
||||
collections::{BinaryHeap, HashMap},
|
||||
};
|
||||
|
||||
pub(crate) type Node = [i32; 3];
|
||||
|
||||
#[derive(Clone, Copy, Default)]
|
||||
pub(crate) struct Cell {
|
||||
pub solid: bool,
|
||||
pub fluid: bool,
|
||||
pub lava: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct Plan {
|
||||
pub nodes: Vec<Node>,
|
||||
pub complete: bool,
|
||||
}
|
||||
|
||||
fn solid(query: &impl Fn(Node) -> Option<Cell>, n: Node) -> bool {
|
||||
query(n).is_none_or(|b| b.solid)
|
||||
}
|
||||
|
||||
fn walkable(query: &impl Fn(Node) -> Option<Cell>, n: Node) -> bool {
|
||||
let Some(feet) = query(n) else {
|
||||
return false;
|
||||
};
|
||||
let Some(below) = query([n[0], n[1] - 1, n[2]]) else {
|
||||
return false;
|
||||
};
|
||||
let Some(head) = query([n[0], n[1] + 1, n[2]]) else {
|
||||
return false;
|
||||
};
|
||||
(below.solid || below.fluid) && !feet.solid && !head.solid && !feet.lava && !below.lava
|
||||
}
|
||||
|
||||
pub(crate) fn find_path(
|
||||
from: Node,
|
||||
goal: [f64; 3],
|
||||
near: f64,
|
||||
blocked: &[(Node, Node)],
|
||||
query: impl Fn(Node) -> Option<Cell>,
|
||||
) -> Option<Plan> {
|
||||
let reached = |n: Node| {
|
||||
let c = [
|
||||
f64::from(n[0]) + 0.5,
|
||||
f64::from(n[1]),
|
||||
f64::from(n[2]) + 0.5,
|
||||
];
|
||||
((c[0] - goal[0]).powi(2) + (c[2] - goal[2]).powi(2)).sqrt() <= near.max(0.5)
|
||||
&& (c[1] - goal[1]).abs() <= near.max(1.0) + 0.5
|
||||
};
|
||||
let h = |n: Node| {
|
||||
((f64::from(n[0]) + 0.5 - goal[0]).abs()
|
||||
+ (f64::from(n[1]) - goal[1]).abs()
|
||||
+ (f64::from(n[2]) + 0.5 - goal[2]).abs())
|
||||
* 10.0
|
||||
};
|
||||
let mut open = BinaryHeap::new();
|
||||
let mut cost: HashMap<Node, u32> = HashMap::new();
|
||||
let mut came: HashMap<Node, Node> = HashMap::new();
|
||||
cost.insert(from, 0);
|
||||
open.push(Reverse((h(from) as u32, from)));
|
||||
let distance = |n: Node| {
|
||||
(f64::from(n[0]) + 0.5 - goal[0]).powi(2)
|
||||
+ (f64::from(n[1]) - goal[1]).powi(2)
|
||||
+ (f64::from(n[2]) + 0.5 - goal[2]).powi(2)
|
||||
};
|
||||
let mut best = from;
|
||||
let mut expanded = 0;
|
||||
while let Some(Reverse((_, n))) = open.pop() {
|
||||
if reached(n) {
|
||||
return Some(reconstruct(n, &came, true));
|
||||
}
|
||||
if distance(n) < distance(best) {
|
||||
best = n;
|
||||
}
|
||||
expanded += 1;
|
||||
if expanded >= 30_000 {
|
||||
break;
|
||||
}
|
||||
let base = cost[&n];
|
||||
for d in [[1, 0], [-1, 0], [0, 1], [0, -1]] {
|
||||
for dy in [0, 1, -1, -2, -3] {
|
||||
let m = [n[0] + d[0], n[1] + dy, n[2] + d[1]];
|
||||
// A step up needs head room above the start; a drop needs
|
||||
// the column in front clear down to the landing.
|
||||
let clear = match dy {
|
||||
1 => !solid(&query, [n[0], n[1] + 2, n[2]]),
|
||||
d if d < 0 => (m[1]..=n[1] + 1).all(|y| !solid(&query, [m[0], y, m[2]])),
|
||||
_ => true,
|
||||
};
|
||||
if !clear || !walkable(&query, m) {
|
||||
continue;
|
||||
}
|
||||
if blocked
|
||||
.iter()
|
||||
.any(|&(a, b)| (a == n && b == m) || (a == m && b == n))
|
||||
{
|
||||
break;
|
||||
}
|
||||
let water = query(m).is_some_and(|b| b.fluid);
|
||||
let step = 10 + if dy != 0 { 5 } else { 0 } + if water { 30 } else { 0 };
|
||||
let c = base + step;
|
||||
if cost.get(&m).is_none_or(|&old| c < old) {
|
||||
cost.insert(m, c);
|
||||
came.insert(m, n);
|
||||
open.push(Reverse((c + h(m) as u32, m)));
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
(best != from && distance(best) + 1e-6 < distance(from))
|
||||
.then(|| reconstruct(best, &came, false))
|
||||
}
|
||||
|
||||
fn reconstruct(end: Node, came: &HashMap<Node, Node>, complete: bool) -> Plan {
|
||||
let mut nodes = vec![end];
|
||||
let mut at = end;
|
||||
while let Some(&previous) = came.get(&at) {
|
||||
nodes.push(previous);
|
||||
at = previous;
|
||||
}
|
||||
nodes.reverse();
|
||||
Plan { nodes, complete }
|
||||
}
|
||||
|
||||
impl Plan {
|
||||
pub(crate) fn aim(&self, p: [f64; 3], next: &mut usize, target: [f64; 3]) -> Option<[f64; 3]> {
|
||||
while *next < self.nodes.len() {
|
||||
let n = self.nodes[*next];
|
||||
let c = [
|
||||
f64::from(n[0]) + 0.5,
|
||||
f64::from(n[1]),
|
||||
f64::from(n[2]) + 0.5,
|
||||
];
|
||||
let last = *next + 1 == self.nodes.len();
|
||||
if ((p[0] - c[0]).powi(2) + (p[2] - c[2]).powi(2)).sqrt()
|
||||
< if last { 0.3 } else { 0.45 }
|
||||
&& (p[1] - c[1]).abs() < 1.1
|
||||
{
|
||||
*next += 1;
|
||||
} else {
|
||||
return Some(c);
|
||||
}
|
||||
}
|
||||
self.complete.then_some(target)
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn arrived(p: [f64; 3], target: [f64; 3], near: f64) -> bool {
|
||||
((p[0] - target[0]).powi(2) + (p[2] - target[2]).powi(2)).sqrt() <= near
|
||||
&& (p[1] - target[1]).abs() <= near.max(1.0) + 1.0
|
||||
}
|
||||
|
||||
fn horizontal_distance(p: [f64; 3], aim: [f64; 3]) -> f64 {
|
||||
((p[0] - aim[0]).powi(2) + (p[2] - aim[2]).powi(2)).sqrt()
|
||||
}
|
||||
|
||||
pub(crate) struct Progress {
|
||||
best_distance: f64,
|
||||
}
|
||||
impl Progress {
|
||||
pub(crate) fn new(position: [f64; 3], aim: [f64; 3]) -> Self {
|
||||
Self {
|
||||
best_distance: horizontal_distance(position, aim),
|
||||
}
|
||||
}
|
||||
pub(crate) fn advance(&mut self, p: [f64; 3], aim: [f64; 3]) -> bool {
|
||||
let distance = horizontal_distance(p, aim);
|
||||
if self.best_distance - distance >= 0.25 {
|
||||
self.best_distance = distance;
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[derive(Default)]
|
||||
struct Terrain {
|
||||
floors: HashMap<(i32, i32), i32>,
|
||||
cells: HashMap<Node, Cell>,
|
||||
}
|
||||
impl Terrain {
|
||||
fn flat(x: std::ops::RangeInclusive<i32>, z: std::ops::RangeInclusive<i32>) -> Self {
|
||||
let mut t = Self::default();
|
||||
for x in x {
|
||||
for z in z.clone() {
|
||||
t.floors.insert((x, z), 63);
|
||||
}
|
||||
}
|
||||
t
|
||||
}
|
||||
fn query(&self, n: Node) -> Option<Cell> {
|
||||
let floor = self.floors.get(&(n[0], n[2]))?;
|
||||
Some(self.cells.get(&n).copied().unwrap_or(Cell {
|
||||
solid: n[1] <= *floor,
|
||||
..Cell::default()
|
||||
}))
|
||||
}
|
||||
fn path(&self, from: Node, goal: [f64; 3], blocked: &[(Node, Node)]) -> Option<Plan> {
|
||||
find_path(from, goal, 0.6, blocked, |n| self.query(n))
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn short_path_is_complete() {
|
||||
let p = Terrain::flat(0..=5, 0..=0)
|
||||
.path([0, 64, 0], [5.5, 64.0, 0.5], &[])
|
||||
.unwrap();
|
||||
assert!(p.complete);
|
||||
assert_eq!(p.nodes.last(), Some(&[5, 64, 0]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn loaded_frontier_continues_after_chunks_extend() {
|
||||
let mut t = Terrain::flat(0..=5, 0..=0);
|
||||
let goal = [10.5, 64.0, 0.5];
|
||||
let partial = t
|
||||
.path([0, 64, 0], goal, &[])
|
||||
.expect("loaded frontier should be usable");
|
||||
assert!(!partial.complete);
|
||||
assert_eq!(partial.nodes.last(), Some(&[5, 64, 0]));
|
||||
let endpoint = *partial.nodes.last().unwrap();
|
||||
assert!(
|
||||
t.path(endpoint, goal, &[]).is_none(),
|
||||
"unchanged frontier must stop"
|
||||
);
|
||||
for x in 6..=10 {
|
||||
t.floors.insert((x, 0), 63);
|
||||
}
|
||||
let complete = t.path(endpoint, goal, &[]).unwrap();
|
||||
assert!(complete.complete);
|
||||
assert_eq!(complete.nodes.last(), Some(&[10, 64, 0]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_closer_endpoint_fails() {
|
||||
assert!(
|
||||
Terrain::flat(-4..=0, 0..=0)
|
||||
.path([0, 64, 0], [10.5, 64.0, 0.5], &[])
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn complete_path_can_initially_move_away() {
|
||||
let mut t = Terrain::flat(-2..=0, 0..=0);
|
||||
for z in 0..=2 {
|
||||
t.floors.insert((-2, z), 63);
|
||||
}
|
||||
for x in -2..=5 {
|
||||
t.floors.insert((x, 2), 63);
|
||||
}
|
||||
for z in 0..=2 {
|
||||
t.floors.insert((5, z), 63);
|
||||
}
|
||||
let p = t.path([0, 64, 0], [5.5, 64.0, 0.5], &[]).unwrap();
|
||||
assert!(p.complete);
|
||||
assert_eq!(p.nodes[1], [-1, 64, 0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failed_edge_uses_bypass() {
|
||||
let blocked = [([0, 64, 0], [1, 64, 0])];
|
||||
let p = Terrain::flat(0..=3, 0..=1)
|
||||
.path([0, 64, 0], [3.5, 64.0, 0.5], &blocked)
|
||||
.unwrap();
|
||||
assert!(p.complete);
|
||||
assert!(
|
||||
!p.nodes
|
||||
.windows(2)
|
||||
.any(|w| w == [blocked[0].0, blocked[0].1])
|
||||
);
|
||||
assert_eq!(p.nodes[1], [0, 64, 1]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failed_edge_without_bypass_stops() {
|
||||
assert!(
|
||||
Terrain::flat(0..=1, 0..=0)
|
||||
.path([0, 64, 0], [1.5, 64.0, 0.5], &[([0, 64, 0], [1, 64, 0])])
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lava_and_unknown_terrain_are_not_walked() {
|
||||
let mut t = Terrain::flat(0..=1, 0..=0);
|
||||
t.cells.insert(
|
||||
[1, 63, 0],
|
||||
Cell {
|
||||
lava: true,
|
||||
fluid: true,
|
||||
solid: false,
|
||||
},
|
||||
);
|
||||
assert!(t.path([0, 64, 0], [1.5, 64.0, 0.5], &[]).is_none());
|
||||
t.cells.remove(&[1, 63, 0]);
|
||||
t.cells.insert(
|
||||
[1, 64, 0],
|
||||
Cell {
|
||||
lava: true,
|
||||
fluid: true,
|
||||
solid: false,
|
||||
},
|
||||
);
|
||||
assert!(t.path([0, 64, 0], [1.5, 64.0, 0.5], &[]).is_none());
|
||||
assert!(
|
||||
Terrain::flat(0..=0, 0..=0)
|
||||
.path([0, 64, 0], [5.5, 64.0, 0.5], &[])
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn one_step_up_and_three_step_drop_only_with_clearance() {
|
||||
let mut t = Terrain::flat(0..=1, 0..=0);
|
||||
t.floors.insert((1, 0), 64);
|
||||
assert!(t.path([0, 64, 0], [1.5, 65.0, 0.5], &[]).unwrap().complete);
|
||||
t.cells.insert(
|
||||
[0, 66, 0],
|
||||
Cell {
|
||||
solid: true,
|
||||
..Cell::default()
|
||||
},
|
||||
);
|
||||
assert!(t.path([0, 64, 0], [1.5, 65.0, 0.5], &[]).is_none());
|
||||
t.cells.clear();
|
||||
t.floors.insert((1, 0), 65);
|
||||
assert!(t.path([0, 64, 0], [1.5, 66.0, 0.5], &[]).is_none());
|
||||
t.floors.insert((1, 0), 60);
|
||||
assert!(t.path([0, 64, 0], [1.5, 61.0, 0.5], &[]).unwrap().complete);
|
||||
t.cells.insert(
|
||||
[1, 64, 0],
|
||||
Cell {
|
||||
solid: true,
|
||||
..Cell::default()
|
||||
},
|
||||
);
|
||||
assert!(t.path([0, 64, 0], [1.5, 61.0, 0.5], &[]).is_none());
|
||||
t.cells.clear();
|
||||
t.floors.insert((1, 0), 59);
|
||||
assert!(t.path([0, 64, 0], [1.5, 60.0, 0.5], &[]).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dry_bypass_costs_less_than_water() {
|
||||
let mut t = Terrain::flat(0..=2, 0..=1);
|
||||
t.cells.insert(
|
||||
[1, 64, 0],
|
||||
Cell {
|
||||
fluid: true,
|
||||
..Cell::default()
|
||||
},
|
||||
);
|
||||
let p = t.path([0, 64, 0], [2.5, 64.0, 0.5], &[]).unwrap();
|
||||
assert!(p.complete);
|
||||
assert!(!p.nodes.contains(&[1, 64, 0]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn search_budget_returns_progress_instead_of_discarding_it() {
|
||||
let p = Terrain::flat(0..=30_010, 0..=0)
|
||||
.path([0, 64, 0], [40_000.5, 64.0, 0.5], &[])
|
||||
.unwrap();
|
||||
assert!(!p.complete);
|
||||
assert!(p.nodes.len() > 2 && p.nodes.len() <= 30_001);
|
||||
}
|
||||
#[test]
|
||||
fn consumed_partial_never_aims_at_unknown_goal_or_arrives() {
|
||||
let plan = Plan {
|
||||
nodes: vec![[0, 64, 0], [1, 64, 0]],
|
||||
complete: false,
|
||||
};
|
||||
let p = [1.5, 64.0, 0.5];
|
||||
let goal = [10.5, 64.0, 0.5];
|
||||
let mut next = 1;
|
||||
assert_eq!(plan.aim(p, &mut next, goal), None);
|
||||
assert!(!arrived(p, goal, 0.6));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replacement_path_uses_its_new_waypoint() {
|
||||
let plan = Plan {
|
||||
nodes: vec![[0, 64, 0], [0, 64, 1], [1, 64, 1]],
|
||||
complete: false,
|
||||
};
|
||||
assert_eq!(
|
||||
plan.aim([0.5, 64.0, 0.5], &mut 1, [5.5, 64.0, 0.5]),
|
||||
Some([0.5, 64.0, 1.5])
|
||||
);
|
||||
assert!(arrived([5.5, 64.0, 0.5], [5.5, 64.0, 0.5], 0.6));
|
||||
}
|
||||
#[test]
|
||||
fn jumping_in_place_does_not_reset_stuck_timer() {
|
||||
let start = [0.5, 64.0, 0.5];
|
||||
let aim = [4.5, 64.0, 0.5];
|
||||
let mut progress = Progress::new(start, aim);
|
||||
assert!(!progress.advance([0.5, 65.0, 0.5], aim));
|
||||
assert!(!progress.advance([0.6, 64.0, 0.6], aim));
|
||||
assert!(progress.advance([0.8, 64.0, 0.5], aim));
|
||||
}
|
||||
#[test]
|
||||
fn oscillation_and_vertical_drift_do_not_count_as_progress() {
|
||||
let aim = [4.5, 64.0, 0.5];
|
||||
let mut progress = Progress::new([0.5, 64.0, 0.5], aim);
|
||||
for tick in 0..400 {
|
||||
let x = if tick % 2 == 0 { 0.2 } else { 0.5 };
|
||||
assert!(
|
||||
!progress.advance([x, 64.0, 0.5], aim),
|
||||
"oscillation reset at {tick}"
|
||||
);
|
||||
}
|
||||
assert!(!progress.advance([0.5, 66.0, 0.5], aim));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_new_best_distance_counts_and_waypoint_switch_resets_baseline() {
|
||||
let aim = [4.5, 64.0, 0.5];
|
||||
let start = [0.5, 64.0, 0.5];
|
||||
let mut progress = Progress::new(start, aim);
|
||||
assert!(!progress.advance([0.6, 64.0, 0.5], aim));
|
||||
assert!(progress.advance([0.8, 64.0, 0.5], aim));
|
||||
assert!(!progress.advance(start, aim));
|
||||
assert!(!progress.advance([0.8, 64.0, 0.5], aim));
|
||||
assert!(progress.advance([1.1, 64.0, 0.5], aim));
|
||||
let detour_aim = [1.1, 64.0, 2.5];
|
||||
let mut progress = Progress::new([1.1, 64.0, 0.5], detour_aim);
|
||||
assert!(progress.advance([1.1, 64.0, 0.8], detour_aim));
|
||||
}
|
||||
}
|
||||
@@ -97,7 +97,7 @@ impl Game<'_> {
|
||||
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.
|
||||
// A client holds no other levels (`enter_level`).
|
||||
if self.dim == dim {
|
||||
self.set_bed_occupied(head, false);
|
||||
}
|
||||
|
||||
@@ -486,23 +486,14 @@ impl Game<'_> {
|
||||
|
||||
/// The leads on boats that hang on a player or a mob, for the entity
|
||||
/// tracker: the holder's entity id (`ClientboundSetEntityLinkPacket`).
|
||||
pub(super) fn boat_leash_link(
|
||||
&self,
|
||||
b: &Boat,
|
||||
host_entity: Option<i32>,
|
||||
guests: &[(u128, i32)],
|
||||
) -> Option<i32> {
|
||||
pub(super) fn boat_leash_link(&self, b: &Boat, guests: &[(u128, i32)]) -> Option<i32> {
|
||||
match b.leash? {
|
||||
Holder::Player => {
|
||||
let uuid = self.boat_leash_player(b);
|
||||
match guests
|
||||
guests
|
||||
.iter()
|
||||
.find(|(u, _)| Some(horizon_client::lan::playerdata::uuid_ints(*u)) == uuid)
|
||||
{
|
||||
Some(&(_, id)) => Some(id),
|
||||
None if uuid == self.level.player_uuid => host_entity,
|
||||
None => None,
|
||||
}
|
||||
.map(|&(_, id)| id)
|
||||
}
|
||||
Holder::Mob(id) | Holder::Boat(id) => Some(id as i32),
|
||||
Holder::Knot(p) => self.knot_ids.get(&p).map(|&id| id as i32),
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
//! paddling controls, collisions, riding (`positionRider`, `clampRotation`,
|
||||
//! dismounting), `BoatItem` placement, `VehicleEntity.hurtServer` breaking,
|
||||
//! saving and `AbstractBoatRenderer` with animated paddles.
|
||||
use super::{Game, InteractTarget, Keys, entity::Entity, entity_culling::at, view_vector};
|
||||
use super::{Game, InteractTarget, entity::Entity, entity_culling::at, view_vector};
|
||||
use horizon_client::entity_rules::EntityRules;
|
||||
use horizon_client::{
|
||||
blocks::Fluid,
|
||||
@@ -1063,43 +1063,6 @@ impl Game<'_> {
|
||||
max_y: b.bounds().max_position().y,
|
||||
})
|
||||
}
|
||||
/// The player's tick while riding: steer, tick the boat, sit in it.
|
||||
pub(super) fn tick_riding(&mut self, keys: Keys) -> horizon_client::Result<()> {
|
||||
if self.tick_cushion_riding(keys) {
|
||||
return Ok(());
|
||||
}
|
||||
let Some(i) = self.boats.iter().position(|b| Some(b.id) == self.riding) else {
|
||||
if self.tick_cart_riding(keys) || self.tick_mount_riding(keys) {
|
||||
return Ok(());
|
||||
}
|
||||
self.riding = None;
|
||||
return Ok(());
|
||||
};
|
||||
if keys.shift || self.boats[i].out_of_control >= 60.0 {
|
||||
self.dismount(i);
|
||||
return Ok(());
|
||||
}
|
||||
// The boat steers with the input `LocalPlayer.rideTick` latched on the
|
||||
// previous tick; this tick's keys are latched after it ticked.
|
||||
let mut b = self.tick_boat(self.boats[i].clone(), true);
|
||||
b.input = [keys.left, keys.right, keys.forward, keys.backward];
|
||||
// positionRider: the seat, turned with the boat; clampRotation to 105 degrees.
|
||||
self.yaw += b.delta_rotation;
|
||||
let delta = (self.yaw - b.yaw + 180.0).rem_euclid(360.0) - 180.0;
|
||||
self.yaw += delta.clamp(-105.0, 105.0) - delta;
|
||||
self.boats[i] = b;
|
||||
let seat = self.player_boat_seat(i);
|
||||
if let Some(p) = self.player.as_mut() {
|
||||
self.previous_position = p.position;
|
||||
p.position = seat;
|
||||
p.velocity = Vec3::ZERO;
|
||||
p.fall_distance = 0.0;
|
||||
}
|
||||
if self.boat_lava_tick(i) {
|
||||
self.riding = None;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
/// `getDismountLocationForPassenger`: in front of the player if there is
|
||||
/// floor there, else on top of the boat.
|
||||
pub(super) fn dismount(&mut self, i: usize) {
|
||||
|
||||
@@ -773,8 +773,8 @@ impl Game<'_> {
|
||||
// The Chat option's Hidden level takes the server's messages too.
|
||||
self.local_chat_line(text, Some(SYSTEM_INDICATOR), ChatSource::Server);
|
||||
}
|
||||
/// A player's chat line on the window's own chat: a LAN guest's message
|
||||
/// while the host runs in-frame (`HostEvent::Chat`). `ChatListener`
|
||||
/// A player's chat line on the window's own chat: a guest's message on
|
||||
/// the server's own chat (`HostEvent::Chat`). `ChatListener`
|
||||
/// checks `canReceivePlayerMessages` for these, so Commands Only and
|
||||
/// Hidden drop them; command feedback (`/list`, `/tp`) is a system
|
||||
/// message and takes the system-message permission instead.
|
||||
@@ -957,7 +957,6 @@ impl Game<'_> {
|
||||
self.ticks,
|
||||
focused,
|
||||
);
|
||||
self.lan_host_chat(&message);
|
||||
}
|
||||
}
|
||||
pub(super) fn chat_scroll(&mut self, amount: f32) {
|
||||
@@ -1442,45 +1441,6 @@ impl Game<'_> {
|
||||
log::warn!("the world clocks were not written: {e}");
|
||||
}
|
||||
}
|
||||
/// Death message in chat (CombatTracker.getDeathMessage, as far as the
|
||||
/// cause is known to the client).
|
||||
pub(super) fn death_message(&mut self) {
|
||||
let cause = self.ui.death_cause.take();
|
||||
let player = self.native_acting_id();
|
||||
self.native_player_died(player, cause.as_deref());
|
||||
self.ui.died_at = Some(std::time::Instant::now());
|
||||
self.ui.death_text = None;
|
||||
self.ui.death_confirm = None;
|
||||
if !self.ui.rules.bool("show_death_messages") {
|
||||
return;
|
||||
}
|
||||
let Some(p) = self.player.as_ref() else {
|
||||
return;
|
||||
};
|
||||
let below_world = p.position.y < f64::from(self.world.bottom_y() - 64);
|
||||
let name = self.player_display_text();
|
||||
let message = match cause {
|
||||
Some(key) => Text::translate(&key, &[name]),
|
||||
None if below_world => Text::translate("death.attack.outOfWorld", &[name]),
|
||||
None => self.combat_death_message(),
|
||||
};
|
||||
self.ui.death_text = Some(message.clone());
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
/// F1 hides the HUD (see `hud`); the screenshot key still works.
|
||||
pub(super) fn toggle_hide_gui(&mut self) {
|
||||
self.ui.hide_gui = !self.ui.hide_gui;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
//! The window's own ambient effects of entities, which a client makes for
|
||||
//! itself (`level().isClientSide()` branches): endermen's and endermites'
|
||||
//! portal particles, blazes' smoke and burn, eyes of ender's trail. They run
|
||||
//! on the game that has the window, in-frame or following a server, never on
|
||||
//! on the game that has the window, following a server, never on
|
||||
//! a server: nothing of them goes over the network.
|
||||
use super::Game;
|
||||
use horizon_client::blocks::RenderShape;
|
||||
@@ -78,8 +78,7 @@ impl Game<'_> {
|
||||
runners.push((m.position, movement, None, f64::from(m.width())));
|
||||
}
|
||||
}
|
||||
let guests = self.lan.iter().flat_map(|host| host.models.values());
|
||||
for m in self.mobs.iter().chain(guests) {
|
||||
for m in self.mobs.iter() {
|
||||
// Another player's fluids aren't known here: none where they stand.
|
||||
let feet = [
|
||||
m.position.x.floor() as i32,
|
||||
@@ -175,8 +174,7 @@ impl Game<'_> {
|
||||
invisible,
|
||||
);
|
||||
}
|
||||
let guests = self.lan.iter().flat_map(|host| host.models.values());
|
||||
for m in self.mobs.iter().chain(guests) {
|
||||
for m in self.mobs.iter() {
|
||||
if !m.alive() {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
//! The player's `CombatTracker`: the damage taken lately, who dealt it and
|
||||
//! where the player fell from, turned into the vanilla death message.
|
||||
use super::{Game, chat::PLAYER_NAME, commands::Target};
|
||||
use super::{Game, commands::Target};
|
||||
use horizon_client::{
|
||||
health::HealthEvent,
|
||||
mobs::{Mob, Species},
|
||||
@@ -254,12 +254,6 @@ impl Game<'_> {
|
||||
.pending_direct
|
||||
.push((damage_type.to_owned(), name));
|
||||
}
|
||||
/// `LivingEntity.handleDamageEvent` for the player: the hurt sound of the
|
||||
/// source's `DamageEffects` (`Player.getHurtSound`).
|
||||
pub(super) fn player_hurt_sound(&mut self, sound: &str, at: [f64; 3]) {
|
||||
// Player.getVoicePitch: always 1.0.
|
||||
self.audio.play(sound, Some(at), 1.0, 1.0);
|
||||
}
|
||||
/// `CombatTracker.recordDamage` for the motion events of this tick.
|
||||
pub(super) fn record_combat(&mut self, events: &[MotionEvent]) {
|
||||
self.recheck_combat();
|
||||
@@ -384,57 +378,6 @@ impl Game<'_> {
|
||||
damage.in_tag(id, tag) || damage.in_tag(id, &format!("minecraft:{tag}"))
|
||||
})
|
||||
}
|
||||
/// The killing blow as advancement predicates see it (`DamageSource` of the
|
||||
/// last combat entry): its type and the causing entity. The projectile
|
||||
/// damage types have the projectile as direct entity, not the shooter.
|
||||
pub(super) fn combat_killing_blow(&self) -> Option<horizon_client::advancements::DamageView> {
|
||||
use horizon_client::advancements::{DamageView, EntityView};
|
||||
let e = self.combat.entries.last()?;
|
||||
let source = e.attacker.as_ref().and_then(|(id, _)| {
|
||||
self.mobs
|
||||
.iter()
|
||||
.find(|m| m.id == *id)
|
||||
.map(|m| self.mob_entity(m))
|
||||
});
|
||||
let projectile = match e.damage_type.as_str() {
|
||||
// The rocket is the direct entity of its `fireworks` damage.
|
||||
"minecraft:fireworks" => Some("minecraft:firework_rocket"),
|
||||
t @ ("minecraft:arrow"
|
||||
| "minecraft:trident"
|
||||
| "minecraft:mob_projectile"
|
||||
| "minecraft:fireball"
|
||||
| "minecraft:unattributed_fireball"
|
||||
| "minecraft:wither_skull"
|
||||
| "minecraft:wind_charge"
|
||||
| "minecraft:thrown"
|
||||
| "minecraft:spit") => Some(t),
|
||||
_ => None,
|
||||
};
|
||||
let direct = match projectile {
|
||||
Some(kind) => Some(EntityView {
|
||||
kind: kind.to_owned(),
|
||||
..Default::default()
|
||||
}),
|
||||
None => source.clone(),
|
||||
};
|
||||
Some(DamageView {
|
||||
kind: e.damage_type.clone(),
|
||||
direct,
|
||||
source,
|
||||
is_direct: projectile.is_none(),
|
||||
original: e.damage,
|
||||
actual: e.damage,
|
||||
blocked: false,
|
||||
})
|
||||
}
|
||||
/// The type of the mob that dealt the killing blow (`killed_by`).
|
||||
pub(super) fn combat_killer_kind(&self) -> Option<String> {
|
||||
let (id, _) = self.combat.entries.last()?.attacker.clone()?;
|
||||
self.mobs
|
||||
.iter()
|
||||
.find(|m| m.id == id)
|
||||
.map(|m| m.kind.id.clone())
|
||||
}
|
||||
/// `CombatTracker.getMostSignificantFall`.
|
||||
fn most_significant_fall(&self) -> Option<usize> {
|
||||
let (mut result, mut alternative) = (None, None);
|
||||
@@ -498,10 +441,6 @@ impl Game<'_> {
|
||||
.clone()
|
||||
.or_else(|| self.mob_main_hand_name(e.attacker.as_ref()?.0))
|
||||
}
|
||||
/// `CombatTracker.getDeathMessage`.
|
||||
pub(super) fn combat_death_message(&mut self) -> Text {
|
||||
self.combat_death_message_for(Text::literal(PLAYER_NAME))
|
||||
}
|
||||
/// `CombatTracker.getDeathMessage` for a player whose display name is
|
||||
/// `me`: a server's guest is not `PLAYER_NAME`.
|
||||
pub(super) fn combat_death_message_for(&mut self, me: Text) -> Text {
|
||||
|
||||
@@ -295,8 +295,7 @@ impl Game<'_> {
|
||||
}
|
||||
/// `CommandSourceStack.broadcastToAdmins` for a command block or a command
|
||||
/// minecart called `name`: `[name: message]`, gray and italic, for every
|
||||
/// operator when `send_command_feedback` (the world's host too: no player
|
||||
/// ran the command) and for a dedicated server's console when
|
||||
/// operator when `send_command_feedback` and for a dedicated server's console when
|
||||
/// `log_admin_commands`. Queued: `send_admin_feedback` sends it.
|
||||
pub(super) fn broadcast_source_to_admins(&mut self, name: Text, message: Text) {
|
||||
if message
|
||||
@@ -308,19 +307,10 @@ impl Game<'_> {
|
||||
let line = Text::translate("chat.type.admin", &[name, message])
|
||||
.with_color(text::GRAY)
|
||||
.italic();
|
||||
// Nobody else is in this game: its player is the only operator there is.
|
||||
if self.lan.is_none()
|
||||
&& self.lan_acting.is_none()
|
||||
&& self.serving.is_none()
|
||||
&& self.options.dedicated.is_none()
|
||||
{
|
||||
self.system_message(line);
|
||||
return;
|
||||
}
|
||||
let ops = self.ui.rules.bool("send_command_feedback");
|
||||
let console = self.options.dedicated.is_some() && self.ui.rules.bool("log_admin_commands");
|
||||
if ops || console {
|
||||
self.admin_feedback.push((line, ops, console, true));
|
||||
self.admin_feedback.push((line, ops, console));
|
||||
}
|
||||
}
|
||||
/// CommandSourceStack.broadcastToAdmins (`sendSuccess` with `broadcast`)
|
||||
@@ -364,7 +354,7 @@ impl Game<'_> {
|
||||
let line = Text::translate("chat.type.admin", &[who, message])
|
||||
.with_color(text::GRAY)
|
||||
.italic();
|
||||
self.admin_feedback.push((line, ops, console, false));
|
||||
self.admin_feedback.push((line, ops, console));
|
||||
}
|
||||
/// Player.getDisplayName: the name (team-formatted), suggesting a
|
||||
/// `/tell`, showing the player on hover, inserting the name.
|
||||
@@ -1614,43 +1604,6 @@ impl Game<'_> {
|
||||
self.native_entity_teleport(self.dim.id(), entity as u32, from, to);
|
||||
}
|
||||
}
|
||||
/// Teleport the local player into the command source's dimension. Unlike
|
||||
/// portal travel, `/tp` keeps the coordinates given by the command.
|
||||
fn teleport_player_to_dimension(
|
||||
&mut self,
|
||||
dimension: Dim,
|
||||
pos: [f64; 3],
|
||||
rotation: Option<[f32; 2]>,
|
||||
) -> R<()> {
|
||||
let Some(mut player) = self.player.take() else {
|
||||
return Ok(());
|
||||
};
|
||||
let from = self.dim;
|
||||
if self.switch_level(dimension, player.position).is_err() {
|
||||
self.player = Some(player);
|
||||
return Err(err("command.failed", &[]));
|
||||
}
|
||||
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;
|
||||
player.fall_distance = 0.0;
|
||||
player.ground.on_ground = false;
|
||||
if let Some([yaw, pitch]) = rotation {
|
||||
let pitch = pitch.clamp(-90.0, 90.0);
|
||||
self.yaw = yaw;
|
||||
self.pitch = pitch;
|
||||
player.yaw = yaw;
|
||||
player.head_yaw = yaw;
|
||||
player.pitch = pitch;
|
||||
}
|
||||
self.travelling = Some((player, target));
|
||||
self.portals.travel = None;
|
||||
self.end_travel = false;
|
||||
self.end_portal_travel = false;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A server's player teleported into another level than theirs
|
||||
/// (ServerPlayer.teleport): queued to move after the command. False
|
||||
@@ -1668,7 +1621,7 @@ impl Game<'_> {
|
||||
_ => None,
|
||||
};
|
||||
let at = self.target_level(t);
|
||||
let Some(conn) = conn.filter(|_| at != level && self.options.dedicated.is_some()) else {
|
||||
let Some(conn) = conn.filter(|_| at != level) else {
|
||||
return false;
|
||||
};
|
||||
self.level_teleports
|
||||
@@ -1693,10 +1646,7 @@ impl Game<'_> {
|
||||
pub(super) fn network_entity_id(&self, t: Target) -> Option<i32> {
|
||||
let host = self.lan.as_ref();
|
||||
match t {
|
||||
Target::Player => match self.lan_acting {
|
||||
Some(conn) => host?.hub.guest(conn).map(|g| g.entity_id),
|
||||
None => host.map(|h| h.host_entity),
|
||||
},
|
||||
Target::Player => host?.hub.guest(self.lan_acting?).map(|g| g.entity_id),
|
||||
Target::Guest(conn) => host?.hub.guest(conn).map(|g| g.entity_id),
|
||||
other => non_player_entity_id(other),
|
||||
}
|
||||
@@ -1825,12 +1775,7 @@ impl Game<'_> {
|
||||
if self.teleport_across(t, source_level, pos, rotation) {
|
||||
continue;
|
||||
}
|
||||
let dimension = self.command_source.map_or(self.dim, |s| s.dimension);
|
||||
if t == Target::Player && dimension != self.dim {
|
||||
self.teleport_player_to_dimension(dimension, pos, rotation)?;
|
||||
} else {
|
||||
self.teleport_to(t, pos, rotation);
|
||||
}
|
||||
self.teleport_to(t, pos, rotation);
|
||||
// LookAt.perform after a successful teleportTo.
|
||||
if let Some(f) = facing {
|
||||
self.send_player_look_at(t, anchored_eyes, f, facing_entity);
|
||||
@@ -3622,27 +3567,16 @@ 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.
|
||||
/// ListPlayersCommand: every online player in join order (a single-player
|
||||
/// owner is one of them), 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 => (self.player_uuid(), PLAYER_NAME.to_owned()),
|
||||
});
|
||||
let guests = self
|
||||
let players: Vec<(u128, String)> = 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();
|
||||
.map(|g| (g.uuid, g.name.clone()))
|
||||
.collect();
|
||||
let max = self
|
||||
.lan
|
||||
.as_ref()
|
||||
|
||||
@@ -306,10 +306,6 @@ impl Game<'_> {
|
||||
_ => self.set_camera(None),
|
||||
}
|
||||
}
|
||||
/// The eye height the camera follows (`Camera.tick`'s entity).
|
||||
pub(in crate::game) fn camera_eye_height(&self) -> Option<f32> {
|
||||
self.cmds.camera.map(|t| self.target_eye_height(t) as f32)
|
||||
}
|
||||
|
||||
// ---- /waypoint ------------------------------------------------------
|
||||
|
||||
@@ -968,11 +964,8 @@ impl Game<'_> {
|
||||
guest_commands: allow,
|
||||
force_game_mode: false,
|
||||
};
|
||||
let started = if self.serving() {
|
||||
self.publish(settings).is_ok()
|
||||
} else {
|
||||
self.lan_start(settings).is_ok()
|
||||
};
|
||||
// A dedicated server is open from the start (`published` above).
|
||||
let started = self.serving() && self.publish(settings).is_ok();
|
||||
if !started {
|
||||
return Err(err("commands.publish.failed", &[]));
|
||||
}
|
||||
@@ -1307,18 +1300,4 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
}
|
||||
impl Game<'_> {
|
||||
/// The player's profile id: the saved id, else the offline id of the
|
||||
/// player's name.
|
||||
pub(in crate::game) fn player_uuid(&self) -> u128 {
|
||||
self.level.player_uuid.map_or_else(
|
||||
|| horizon_protocol::crypto::offline_uuid(super::super::chat::PLAYER_NAME),
|
||||
|[a, b, c, d]| {
|
||||
(u128::from(a as u32) << 96)
|
||||
| (u128::from(b as u32) << 64)
|
||||
| (u128::from(c as u32) << 32)
|
||||
| u128::from(d as u32)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
impl Game<'_> {}
|
||||
@@ -210,8 +210,7 @@ impl Game<'_> {
|
||||
if !self.verify_test_structure(&def.structure) {
|
||||
return Ok(None);
|
||||
}
|
||||
// `server.getLevel(dimension)`: the server's levels; a client-only
|
||||
// game (deprecated in-frame mode) has the one it is in.
|
||||
// `server.getLevel(dimension)`: the server's levels.
|
||||
let dim = match def.dimension.as_str() {
|
||||
"minecraft:overworld" | "minecraft:the_nether" | "minecraft:the_end" => {
|
||||
Some(Dim::from_id(&def.dimension))
|
||||
|
||||
@@ -23,7 +23,7 @@ use horizon_client::{
|
||||
},
|
||||
mod_api::NativeLootDrops,
|
||||
particles::{ParticleOptions, ParticleType},
|
||||
projectiles::{Hit, Projectile, ProjectileKind, ProjectileWorld},
|
||||
projectiles::{Hit, Projectile, ProjectileKind, ProjectileWorld, clip_including_border},
|
||||
text::Text,
|
||||
};
|
||||
use horizon_core::{
|
||||
@@ -95,6 +95,9 @@ pub(super) struct Creatures {
|
||||
pub(super) struct ProjWorld<'a, 'w> {
|
||||
pub w: &'a MobWorld<'w>,
|
||||
pub targets: Vec<(Who, Aabb)>,
|
||||
/// The world border's [minX, minZ, maxX, maxZ], which stops the ray as a
|
||||
/// block would (`clipIncludingBorder`); None: no border.
|
||||
pub border: Option<[f64; 4]>,
|
||||
}
|
||||
impl ProjectileWorld for ProjWorld<'_, '_> {
|
||||
fn block_contacts(&self, entity: i32, kind: &str, from: Vec3, to: Vec3, bounds: Aabb) {
|
||||
@@ -121,12 +124,17 @@ impl ProjectileWorld for ProjWorld<'_, '_> {
|
||||
.ok()?;
|
||||
shape.clip(from, to, p)
|
||||
});
|
||||
let hit = r.ok().flatten()?;
|
||||
Some((
|
||||
hit.location,
|
||||
[hit.block.x, hit.block.y, hit.block.z],
|
||||
hit.direction.normal(),
|
||||
))
|
||||
let hit = r.ok().flatten().map(|hit| {
|
||||
(
|
||||
hit.location,
|
||||
[hit.block.x, hit.block.y, hit.block.z],
|
||||
hit.direction.normal(),
|
||||
)
|
||||
});
|
||||
match self.border {
|
||||
Some(bounds) => clip_including_border(hit, from, to, bounds),
|
||||
None => hit,
|
||||
}
|
||||
}
|
||||
/// Entity.updateFluidInteraction over the projectile's box, not the
|
||||
/// block at its feet (#3072).
|
||||
@@ -784,6 +792,7 @@ impl Game<'_> {
|
||||
} else {
|
||||
None
|
||||
},
|
||||
border: Some(self.border.geometry()),
|
||||
// The first player in the level: a server has no player of its own.
|
||||
difficulty: self.local_difficulty(
|
||||
self.level_players()
|
||||
@@ -1106,7 +1115,6 @@ impl Game<'_> {
|
||||
self.tick_apparition();
|
||||
self.guardian_bubbles();
|
||||
self.villager_reputation_tick();
|
||||
self.merchant_tick();
|
||||
self.villager_item_sense();
|
||||
self.villager_pickups();
|
||||
for m in dead {
|
||||
@@ -3575,10 +3583,16 @@ impl Game<'_> {
|
||||
arrow_targets_guests.extend(guests.iter().copied());
|
||||
let mut targets_guests = targets.clone();
|
||||
targets_guests.extend(guests);
|
||||
let pw = ProjWorld { w: &w, targets };
|
||||
let border = Some(self.border.bounds());
|
||||
let pw = ProjWorld {
|
||||
w: &w,
|
||||
targets,
|
||||
border,
|
||||
};
|
||||
let pw_guests = ProjWorld {
|
||||
w: &w,
|
||||
targets: targets_guests,
|
||||
border,
|
||||
};
|
||||
let guest_entities = self.lan_guest_entities();
|
||||
// ThrownEnderpearl.tick: a dead player's pearl is discarded in
|
||||
@@ -3653,6 +3667,7 @@ impl Game<'_> {
|
||||
p.tick(&ProjWorld {
|
||||
w: if pvp_shot { pw_guests.w } else { pw.w },
|
||||
targets,
|
||||
border,
|
||||
})
|
||||
}
|
||||
};
|
||||
@@ -7302,10 +7317,12 @@ mod pearl_tests {
|
||||
items: Some(&items),
|
||||
lid_openers: &lid_openers,
|
||||
mod_effects: None,
|
||||
border: None,
|
||||
};
|
||||
let pw = ProjWorld {
|
||||
w: &w,
|
||||
targets: Vec::new(),
|
||||
border: None,
|
||||
};
|
||||
let pearl = |x: f64, y: f64, velocity: Vec3| {
|
||||
let mut p = Projectile::new(ProjectileKind::EnderPearl, Vec3::new(x, y, 1.5), None);
|
||||
|
||||
@@ -953,8 +953,7 @@ impl Game<'_> {
|
||||
}
|
||||
let lerp = |a: f64, b: f64| a + f64::from(alpha) * (b - a);
|
||||
let lerpf = |a: f32, b: f32| a + alpha * (b - a);
|
||||
let guests = self.lan.iter().flat_map(|h| h.models.values());
|
||||
for m in self.mobs.iter().chain(guests) {
|
||||
for m in self.mobs.iter() {
|
||||
if m.removed || m.invisible || m.has_effect("minecraft:invisibility") {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -172,8 +172,7 @@ impl Game<'_> {
|
||||
}
|
||||
"tps" => self.debug_tps_line(d),
|
||||
// DebugEntryChunkGeneration, where the world is generated
|
||||
// here: the window's server thread publishes its generators;
|
||||
// the deprecated in-frame path is not extended.
|
||||
// here: the window's server thread publishes its generators.
|
||||
"chunk_generation_stats" => {
|
||||
if let Some(p) = self.player.as_ref() {
|
||||
let terrain = self
|
||||
@@ -521,10 +520,6 @@ impl Game<'_> {
|
||||
self.dragon_hit()
|
||||
.map(|(_, d)| (d, "minecraft:ender_dragon".to_owned())),
|
||||
);
|
||||
offer(
|
||||
self.guest_hit()
|
||||
.map(|(_, d)| (d, "minecraft:player".to_owned())),
|
||||
);
|
||||
best.map(|b| b.1)
|
||||
}
|
||||
|
||||
|
||||
@@ -933,7 +933,6 @@ impl Game<'_> {
|
||||
levels: self.level_shapes,
|
||||
seed: self.level.seed,
|
||||
max_players: props.max_players.max(0) as usize,
|
||||
dedicated: true,
|
||||
integrated: false,
|
||||
};
|
||||
let settings = LanSettings {
|
||||
@@ -944,7 +943,7 @@ impl Game<'_> {
|
||||
if let Some(sync) = sync {
|
||||
sync.adopt(&self.pack_files());
|
||||
}
|
||||
if let Err(e) = self.lan_host(settings, net, config, None) {
|
||||
if let Err(e) = self.lan_host(settings, net, config) {
|
||||
log("WARN", "**** FAILED TO BIND TO PORT!");
|
||||
log("WARN", &format!("The exception was: {e}"));
|
||||
log("WARN", "Perhaps a server is already running on that port?");
|
||||
|
||||
@@ -866,15 +866,15 @@ impl Game<'_> {
|
||||
(-2..=1).all(|cz| (-2..=1).all(|cx| self.streaming.is_lit(cx, cz)))
|
||||
}
|
||||
/// A player's entity id: the acting player's body has it, another
|
||||
/// player's comes from the LAN host (0 when it is out on a guest's turn,
|
||||
/// and for an in-frame host with none).
|
||||
/// player's comes from the hub (0 when it is out on a guest's turn, and
|
||||
/// for no player).
|
||||
fn fight_entity(&self, who: FightWho) -> i32 {
|
||||
if who == self.acting_player() {
|
||||
return self.player.as_ref().map_or(0, |p| p.entity_id);
|
||||
}
|
||||
let host = self.lan.as_ref();
|
||||
match who {
|
||||
FightWho::Host => host.map_or(0, |h| h.host_entity),
|
||||
FightWho::Host => 0,
|
||||
FightWho::Guest(conn) => host
|
||||
.and_then(|h| h.hub.guest(conn))
|
||||
.map_or(0, |g| g.entity_id),
|
||||
@@ -950,9 +950,6 @@ impl Game<'_> {
|
||||
/// `EnderDragonFight.tick` and the fight's entities, while the End is
|
||||
/// ticked (in place for its player, or on a server with players in it).
|
||||
pub(super) fn tick_fight(&mut self) {
|
||||
if self.travelling.is_some() {
|
||||
return;
|
||||
}
|
||||
// A dragon outside the End (`/summon`) has no fight: `ServerLevel.getDragonFight` is
|
||||
// null there, but `EnderDragon.aiStep` still flies it.
|
||||
if self.dim != Dim::End {
|
||||
|
||||
@@ -11,7 +11,6 @@ use horizon_client::{
|
||||
items::ItemStack,
|
||||
mesher::Vertex,
|
||||
mobs::Who,
|
||||
motion::PlayerMotion,
|
||||
particles::{ParticleOptions, ParticleType},
|
||||
projectiles::{Projectile, ProjectileKind},
|
||||
vanillagen::VanillaTerrain,
|
||||
@@ -115,7 +114,6 @@ fn exit_target(exit: [i32; 3], exact: bool, find: impl FnOnce([i32; 3]) -> [i32;
|
||||
|
||||
#[derive(Default)]
|
||||
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>,
|
||||
@@ -531,43 +529,6 @@ impl Game<'_> {
|
||||
if self.dim == Dim::End {
|
||||
self.ensure_exit_portal()?;
|
||||
}
|
||||
let Some(p) = self.player.as_ref() else {
|
||||
return Ok(());
|
||||
};
|
||||
if self.travelling.is_some() || p.health.dead_or_dying() {
|
||||
return Ok(());
|
||||
}
|
||||
let (portal, gateway) = self.inside_end_portals(
|
||||
p.position,
|
||||
f64::from(p.dimensions.width),
|
||||
f64::from(p.dimensions.height),
|
||||
);
|
||||
if portal && self.dim == Dim::End && !self.level.seen_credits {
|
||||
// EndPortalBlock.entityInside: the first exit shows the credits.
|
||||
self.show_end_credits()?;
|
||||
} else if portal {
|
||||
if self.portals.player.cooldown == 0 {
|
||||
let target = if self.dim == Dim::End {
|
||||
Dim::Overworld
|
||||
} else {
|
||||
Dim::End
|
||||
};
|
||||
self.end_portal_travel = true;
|
||||
let result = self.enter_dimension(target);
|
||||
self.end_portal_travel = false;
|
||||
result?;
|
||||
// Player.getDimensionChangingDelay.
|
||||
self.portals.player.cooldown = 10;
|
||||
} else {
|
||||
// Entity.setAsInsidePortal while cooling down restarts the cooldown.
|
||||
self.portals.player.cooldown = 10;
|
||||
}
|
||||
} else if let Some(g) = gateway
|
||||
&& self.end.gateway_cooldown == 0
|
||||
&& self.gateway_enter(g)
|
||||
{
|
||||
self.enter_gateway(g)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -629,55 +590,6 @@ impl Game<'_> {
|
||||
Some(Vec3::new(s[0].floor() + 0.5, s[1], s[2].floor() + 0.5))
|
||||
}
|
||||
|
||||
/// Arrival after End portal or gateway travel, once the destination is loaded.
|
||||
pub(super) fn place_after_end_travel(
|
||||
&mut self,
|
||||
mut player: PlayerMotion,
|
||||
target: Vec3,
|
||||
) -> Result<()> {
|
||||
self.end_travel = false;
|
||||
// Player.getDimensionChangingDelay (enter_dimension uses the Nether portal value).
|
||||
self.portals.player.cooldown = 10;
|
||||
if player.health.dead_or_dying() {
|
||||
// Respawning out of the End: a fresh player at the spawn.
|
||||
player = PlayerMotion::new(self.motion, target)?;
|
||||
self.eye = 1.62;
|
||||
self.previous_eye = 1.62;
|
||||
self.clock.reset();
|
||||
}
|
||||
let position = if let Some(job) = self.end.job.take() {
|
||||
self.resolve_gateway(job, target)?
|
||||
} else {
|
||||
let mut finder = self.player_spawn.take();
|
||||
let arrival = self.end_arrival(target, &mut finder);
|
||||
self.player_spawn = finder;
|
||||
let Some((position, yaw)) = arrival? else {
|
||||
// A spawn candidate's chunk is loading: try again next tick.
|
||||
self.travelling = Some((player, target));
|
||||
self.end_travel = true;
|
||||
return Ok(());
|
||||
};
|
||||
if let Some(yaw) = yaw {
|
||||
self.yaw = yaw;
|
||||
player.yaw = yaw;
|
||||
player.head_yaw = yaw;
|
||||
}
|
||||
position
|
||||
};
|
||||
player.position = position;
|
||||
player.velocity = Vec3::ZERO;
|
||||
player.fall_distance = 0.0;
|
||||
self.previous_position = player.position;
|
||||
self.player = Some(player);
|
||||
self.pad.player_replaced();
|
||||
self.sway = Default::default();
|
||||
self.apply_equipment();
|
||||
let pitch = self.world.random.next_float() * 0.4 + 0.8;
|
||||
self.audio
|
||||
.play("minecraft:block.portal.travel", None, 0.25, pitch);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Where an End portal's traveller lands once `target` has loaded, and
|
||||
/// the yaw it faces there: the platform below the End's spawn point,
|
||||
/// facing west, or the top of the Overworld's spawn column.
|
||||
@@ -736,27 +648,13 @@ impl Game<'_> {
|
||||
Ok(Some((Vec3::new(target.x, y, target.z), None)))
|
||||
}
|
||||
|
||||
/// Dying away from the Overworld respawns at the world spawn there.
|
||||
pub(super) fn respawn_out_of_end(&mut self) -> Result<()> {
|
||||
if self.dim == Dim::Overworld || self.player.is_none() {
|
||||
return Ok(());
|
||||
}
|
||||
self.end_portal_travel = true;
|
||||
let result = self.enter_dimension(Dim::Overworld);
|
||||
self.end_portal_travel = false;
|
||||
// The arrival places a fresh player at the spawn.
|
||||
self.end_travel = true;
|
||||
result
|
||||
}
|
||||
|
||||
/// EnderDragonFight without a dragon: once the centre of the main island
|
||||
/// is loaded, place the active exit portal (EndPodiumFeature) and the
|
||||
/// first gateway (spawnNewGateway) if the portal is not there yet. Like
|
||||
/// scanState and spawnExitPortal, only while the fight has no exit portal
|
||||
/// location: a podium that is later broken stays broken.
|
||||
fn ensure_exit_portal(&mut self) -> Result<()> {
|
||||
if self.travelling.is_some()
|
||||
|| !(-1..=0).all(|cz| (-1..=0).all(|cx| self.streaming.is_lit(cx, cz)))
|
||||
if !(-1..=0).all(|cz| (-1..=0).all(|cx| self.streaming.is_lit(cx, cz)))
|
||||
|| !self.fight_ready()
|
||||
{
|
||||
return Ok(());
|
||||
@@ -980,21 +878,6 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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<()> {
|
||||
// TheEndGatewayBlockEntity.triggerCooldown: 40 ticks.
|
||||
self.end.gateway_cooldown = 40;
|
||||
let (job, destination) = self.gateway_job(gateway);
|
||||
let Some(player) = self.player.take() else {
|
||||
return Ok(());
|
||||
};
|
||||
self.end.job = Some(job);
|
||||
self.end_travel = true;
|
||||
self.travelling = Some((player, destination));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Where `gateway` leads (TheEndGatewayBlockEntity.getPortalPosition):
|
||||
/// its exit, or the search for one, and the spot to load before either
|
||||
/// is resolved ([`Game::resolve_gateway`]).
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
//! no vtable. A new kind is one line in `entity_lists!` and one `impl Entity`.
|
||||
|
||||
use super::commands::Target;
|
||||
use super::persist::ChunkKey;
|
||||
use super::{FallingBlock, Game};
|
||||
use super::{
|
||||
boats::Boat, fireworks::Rocket, minecarts::Minecart, tnt::PrimedTnt, trident::ThrownTrident,
|
||||
@@ -18,7 +19,9 @@ use horizon_client::mobs::{Mob, Species};
|
||||
use horizon_client::projectiles::Projectile;
|
||||
use horizon_core::geometry::{Aabb, Direction, Vec3};
|
||||
use horizon_core::position::BlockPos;
|
||||
use horizon_world::nbt::Tag;
|
||||
use horizon_world::{level::data::PushReaction, piston_push};
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
/// What `Entity.thunderHit` does to a kind: it sets the entity on fire and
|
||||
/// calls `hurtServer(lightning_bolt, 5)`, so a kind whose `hurtServer` does
|
||||
@@ -58,6 +61,14 @@ pub(super) trait Entity: EntityRules {
|
||||
/// What a selector finds this entity as (`@e`), or `None` for one it cannot
|
||||
/// name yet. Required, so a new kind states which.
|
||||
fn selector_target(&self) -> Option<Target>;
|
||||
/// Saves entity `index` of this kind into its chunk's entity list: the
|
||||
/// chunk and the NBT when it lies in `keys` and is saved with its chunk.
|
||||
/// Required, so a new kind states how it saves.
|
||||
fn save_nbt(
|
||||
game: &mut Game<'_>,
|
||||
index: usize,
|
||||
keys: &HashSet<ChunkKey>,
|
||||
) -> Option<(ChunkKey, Tag)>;
|
||||
|
||||
fn on_ground(&self) -> bool {
|
||||
false
|
||||
@@ -196,6 +207,11 @@ pub(super) trait Entity: EntityRules {
|
||||
fn can_use_portal(&self, _game: &Game<'_>) -> bool {
|
||||
true
|
||||
}
|
||||
/// A boat or minecart: the kinds whose portal pass opens the tick, before
|
||||
/// they move (`AbstractMinecart.tick` handles its portal first).
|
||||
fn is_vehicle(&self) -> bool {
|
||||
false
|
||||
}
|
||||
/// Where it stood at the start of its tick, for the kinds that store it
|
||||
/// (None: `portals.before` has it).
|
||||
fn portal_from(&self) -> Option<Vec3> {
|
||||
@@ -402,7 +418,47 @@ macro_rules! with_entity_mut {
|
||||
}
|
||||
pub(super) use with_entity_mut;
|
||||
|
||||
macro_rules! save_lists {
|
||||
(($game:expr, $keys:expr, $out:expr)
|
||||
$($kind:ident($ty:ty) $field:ident: $($list:ident).+,)*) => {
|
||||
$(for i in 0..$game$(.$list)+.len() {
|
||||
if let Some((key, tag)) = <$ty as $crate::game::entity::Entity>::save_nbt($game, i, $keys) {
|
||||
$out.entry(key).or_default().push(tag);
|
||||
}
|
||||
})*
|
||||
};
|
||||
}
|
||||
pub(crate) use save_lists;
|
||||
|
||||
/// Saves every entity of every list in `entity_lists!` into `$out` by chunk,
|
||||
/// each through its own `Entity::save_nbt`: one loop per list, static dispatch,
|
||||
/// nothing allocated beyond the tags. A kind added to the lists cannot be left
|
||||
/// out, since `save_nbt` has no default.
|
||||
macro_rules! save_entities_of_every_kind {
|
||||
($game:expr, $keys:expr, $out:expr) => {
|
||||
$crate::game::entity::entity_lists!(save_lists, $game, $keys, $out)
|
||||
};
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
/// Saves every entity of every kind that lies in `keys` into `out`, by chunk.
|
||||
pub(super) fn save_every_entity(
|
||||
&mut self,
|
||||
keys: &HashSet<ChunkKey>,
|
||||
out: &mut HashMap<ChunkKey, Vec<Tag>>,
|
||||
) {
|
||||
save_entities_of_every_kind!(self, keys, out);
|
||||
}
|
||||
}
|
||||
|
||||
impl Entity for Mob {
|
||||
fn save_nbt(
|
||||
game: &mut Game<'_>,
|
||||
index: usize,
|
||||
keys: &HashSet<ChunkKey>,
|
||||
) -> Option<(ChunkKey, Tag)> {
|
||||
game.save_mob(index, keys)
|
||||
}
|
||||
stored_motion!();
|
||||
fn portal_size(&self) -> Option<(f32, f32)> {
|
||||
Some((self.width(), self.height()))
|
||||
@@ -478,6 +534,13 @@ impl Entity for Mob {
|
||||
}
|
||||
|
||||
impl Entity for ItemEntity {
|
||||
fn save_nbt(
|
||||
game: &mut Game<'_>,
|
||||
index: usize,
|
||||
keys: &HashSet<ChunkKey>,
|
||||
) -> Option<(ChunkKey, Tag)> {
|
||||
game.save_item(index, keys)
|
||||
}
|
||||
stored_motion!();
|
||||
fn portal_size(&self) -> Option<(f32, f32)> {
|
||||
Some((0.25, 0.25))
|
||||
@@ -513,6 +576,13 @@ impl Entity for ItemEntity {
|
||||
}
|
||||
|
||||
impl Entity for ExperienceOrb {
|
||||
fn save_nbt(
|
||||
game: &mut Game<'_>,
|
||||
index: usize,
|
||||
keys: &HashSet<ChunkKey>,
|
||||
) -> Option<(ChunkKey, Tag)> {
|
||||
game.save_orb(index, keys)
|
||||
}
|
||||
stored_motion!();
|
||||
fn portal_size(&self) -> Option<(f32, f32)> {
|
||||
Some((0.5, 0.5))
|
||||
@@ -545,7 +615,17 @@ impl Entity for ExperienceOrb {
|
||||
}
|
||||
|
||||
impl Entity for Boat {
|
||||
fn save_nbt(
|
||||
game: &mut Game<'_>,
|
||||
index: usize,
|
||||
keys: &HashSet<ChunkKey>,
|
||||
) -> Option<(ChunkKey, Tag)> {
|
||||
game.save_boat(index, keys)
|
||||
}
|
||||
stored_motion!();
|
||||
fn is_vehicle(&self) -> bool {
|
||||
true
|
||||
}
|
||||
/// `Boat.getEyeY`: the top of the 0.5625 box.
|
||||
fn fluid_eye_y(&self) -> f64 {
|
||||
self.position.y + super::boats::HEIGHT
|
||||
@@ -578,7 +658,17 @@ impl Entity for Boat {
|
||||
}
|
||||
|
||||
impl Entity for Minecart {
|
||||
fn save_nbt(
|
||||
game: &mut Game<'_>,
|
||||
index: usize,
|
||||
keys: &HashSet<ChunkKey>,
|
||||
) -> Option<(ChunkKey, Tag)> {
|
||||
game.save_cart(index, keys)
|
||||
}
|
||||
stored_motion!();
|
||||
fn is_vehicle(&self) -> bool {
|
||||
true
|
||||
}
|
||||
fn portal_size(&self) -> Option<(f32, f32)> {
|
||||
Some((
|
||||
super::minecarts::WIDTH as f32,
|
||||
@@ -621,6 +711,13 @@ fn armor_stand_size(stand: &ArmorStand) -> (f64, f64) {
|
||||
}
|
||||
|
||||
impl Entity for ArmorStand {
|
||||
fn save_nbt(
|
||||
game: &mut Game<'_>,
|
||||
index: usize,
|
||||
keys: &HashSet<ChunkKey>,
|
||||
) -> Option<(ChunkKey, Tag)> {
|
||||
game.save_armor_stand(index, keys)
|
||||
}
|
||||
stored_motion!();
|
||||
fn selector_target(&self) -> Option<Target> {
|
||||
Some(Target::Stand(self.id))
|
||||
@@ -666,6 +763,12 @@ impl Entity for ArmorStand {
|
||||
fn is_ignoring_block_triggers(&self) -> bool {
|
||||
self.marker
|
||||
}
|
||||
/// A NoGravity stand is not swept for the blocks it stands in (plates,
|
||||
/// fire): measured beside the real server, it never presses a plate nor
|
||||
/// catches fire, and does again once `NoGravity` is cleared.
|
||||
fn sweeps_blocks_inside(&self) -> bool {
|
||||
!self.no_gravity
|
||||
}
|
||||
fn pushed_by_explosion(&self) -> bool {
|
||||
false
|
||||
}
|
||||
@@ -680,6 +783,13 @@ impl Entity for ArmorStand {
|
||||
}
|
||||
|
||||
impl Entity for PrimedTnt {
|
||||
fn save_nbt(
|
||||
game: &mut Game<'_>,
|
||||
index: usize,
|
||||
keys: &HashSet<ChunkKey>,
|
||||
) -> Option<(ChunkKey, Tag)> {
|
||||
game.save_tnt(index, keys)
|
||||
}
|
||||
stored_motion!();
|
||||
/// `PrimedTnt` eye height: `0.98 * 0.85`.
|
||||
fn fluid_eye_y(&self) -> f64 {
|
||||
@@ -714,6 +824,13 @@ impl Entity for PrimedTnt {
|
||||
}
|
||||
|
||||
impl Entity for FallingBlock {
|
||||
fn save_nbt(
|
||||
game: &mut Game<'_>,
|
||||
index: usize,
|
||||
keys: &HashSet<ChunkKey>,
|
||||
) -> Option<(ChunkKey, Tag)> {
|
||||
game.save_falling(index, keys)
|
||||
}
|
||||
stored_motion!();
|
||||
fn selector_target(&self) -> Option<Target> {
|
||||
Some(Target::Falling(self.id))
|
||||
@@ -724,6 +841,13 @@ impl Entity for FallingBlock {
|
||||
}
|
||||
|
||||
impl Entity for Projectile {
|
||||
fn save_nbt(
|
||||
game: &mut Game<'_>,
|
||||
index: usize,
|
||||
keys: &HashSet<ChunkKey>,
|
||||
) -> Option<(ChunkKey, Tag)> {
|
||||
game.save_projectile(index, keys)
|
||||
}
|
||||
stored_motion!();
|
||||
/// Arrows and the thrown items and fireballs a chunk saves.
|
||||
fn portal_size(&self) -> Option<(f32, f32)> {
|
||||
@@ -777,6 +901,13 @@ impl Entity for Projectile {
|
||||
}
|
||||
|
||||
impl Entity for Rocket {
|
||||
fn save_nbt(
|
||||
game: &mut Game<'_>,
|
||||
index: usize,
|
||||
keys: &HashSet<ChunkKey>,
|
||||
) -> Option<(ChunkKey, Tag)> {
|
||||
game.save_rocket(index, keys)
|
||||
}
|
||||
stored_motion!();
|
||||
/// A rocket that carries its owner stays.
|
||||
fn portal_size(&self) -> Option<(f32, f32)> {
|
||||
@@ -802,6 +933,13 @@ impl Entity for Rocket {
|
||||
}
|
||||
|
||||
impl Entity for ThrownTrident {
|
||||
fn save_nbt(
|
||||
game: &mut Game<'_>,
|
||||
index: usize,
|
||||
keys: &HashSet<ChunkKey>,
|
||||
) -> Option<(ChunkKey, Tag)> {
|
||||
game.save_trident(index, keys)
|
||||
}
|
||||
stored_motion!();
|
||||
fn portal_size(&self) -> Option<(f32, f32)> {
|
||||
Some((0.5, 0.5))
|
||||
@@ -875,3 +1013,23 @@ impl EntityRules for FallingBlock {
|
||||
0.04
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod no_gravity_stand_tests {
|
||||
use super::Entity as _;
|
||||
use horizon_client::entities::ArmorStand;
|
||||
use horizon_core::geometry::Vec3;
|
||||
|
||||
#[test]
|
||||
fn a_no_gravity_stand_is_not_swept_for_the_blocks_it_is_in() {
|
||||
let mut stand = ArmorStand::new(1, Vec3::new(0.5, 100.0, 0.5), 0.0);
|
||||
// A resting stand presses a plate (measured beside the real server)...
|
||||
assert!(stand.sweeps_blocks_inside() && stand.triggers_blocks());
|
||||
// ...a NoGravity one does not, nor does a marker.
|
||||
stand.no_gravity = true;
|
||||
assert!(!stand.sweeps_blocks_inside());
|
||||
stand.no_gravity = false;
|
||||
stand.marker = true;
|
||||
assert!(!stand.triggers_blocks());
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
//! A Game's execution role is fixed at construction. Connecting or disconnecting
|
||||
//! changes `remote`, not this role. A client may simulate a world in-frame.
|
||||
//! changes `remote`, not this role. A client never simulates a world itself: single
|
||||
//! player's runs on its integrated server thread.
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(super) enum ExecutionRole {
|
||||
@@ -14,34 +15,10 @@ impl ExecutionRole {
|
||||
self == Self::Client
|
||||
}
|
||||
|
||||
/// The optional in-frame simulation remains a client execution mode.
|
||||
pub fn starts_integrated_server(self, server_thread: bool) -> bool {
|
||||
self.is_client() && server_thread
|
||||
}
|
||||
|
||||
/// Single-player worlds (in-frame or on the integrated server thread) keep
|
||||
/// the owner's state in `Level` and write `players/data/<singleplayer_uuid>.dat`
|
||||
/// with the world; a dedicated server saves each connected player separately.
|
||||
/// Single-player worlds (on the integrated server thread) keep the owner's state in
|
||||
/// `Level` and write `players/data/<singleplayer_uuid>.dat` with the world; a dedicated
|
||||
/// server saves each connected player separately.
|
||||
pub fn writes_singleplayer_player(self) -> bool {
|
||||
self != Self::DedicatedServer
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::ExecutionRole::*;
|
||||
|
||||
#[test]
|
||||
fn world_loading_keeps_in_frame_clients_and_server_roles_separate() {
|
||||
for (role, client, threaded, writes_player) in [
|
||||
(Client, true, true, true),
|
||||
(IntegratedServer, false, false, true),
|
||||
(DedicatedServer, false, false, false),
|
||||
] {
|
||||
assert_eq!(role.is_client(), client);
|
||||
assert_eq!(role.starts_integrated_server(true), threaded);
|
||||
assert!(!role.starts_integrated_server(false));
|
||||
assert_eq!(role.writes_singleplayer_player(), writes_player);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
//! Landing impacts for falling blocks, before their placement/drop decision.
|
||||
use super::{FallingBlock, Game, Landing};
|
||||
use horizon_client::{falling_block, items::ItemStack};
|
||||
use horizon_client::falling_block;
|
||||
use horizon_core::{geometry::Vec3, position::BlockPos, random::RandomSource};
|
||||
use horizon_world::level::LevelEvent;
|
||||
|
||||
@@ -81,9 +81,12 @@ impl Game<'_> {
|
||||
pub(super) fn finish_falling_block(&mut self, block: FallingBlock, pos: [i32; 3]) {
|
||||
let name = self.res.facts().get(block.state).name.clone();
|
||||
let anvil = name.ends_with("anvil");
|
||||
// Fallable.onBrokenAfterFall: the anvil's, the pointed dripstone's and
|
||||
// the sulfur spike's level event.
|
||||
let sound_event = match &*name {
|
||||
"minecraft:pointed_dripstone" => Some(1045),
|
||||
"minecraft:sulfur_spike" => Some(1052),
|
||||
_ if anvil => Some(1029),
|
||||
_ => None,
|
||||
};
|
||||
if block.damage.cancel_drop {
|
||||
@@ -93,12 +96,6 @@ impl Game<'_> {
|
||||
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]),
|
||||
data: 0,
|
||||
});
|
||||
} else if self
|
||||
.res
|
||||
.facts()
|
||||
@@ -157,18 +154,9 @@ impl Game<'_> {
|
||||
data: 0,
|
||||
});
|
||||
}
|
||||
let item = self.res.facts().get(block.state).item.clone();
|
||||
if &*item != "minecraft:air" {
|
||||
self.pop_resource(
|
||||
pos,
|
||||
ItemStack {
|
||||
item,
|
||||
count: 1,
|
||||
damage: 0,
|
||||
extra: None,
|
||||
},
|
||||
);
|
||||
}
|
||||
// FallingBlockEntity.tick: spawnAtLocation, from where the entity
|
||||
// came to rest (on top of a turtle egg, not inside its cell).
|
||||
self.drop_falling_block(block.state, block.position);
|
||||
}
|
||||
}
|
||||
fn falling_hurt_player(&mut self, position: Vec3, amount: f32, kind: &str) {
|
||||
|
||||
@@ -592,6 +592,7 @@ impl Game<'_> {
|
||||
let block = (super::creatures::ProjWorld {
|
||||
w: &w,
|
||||
targets: Vec::new(),
|
||||
border: Some(self.border.bounds()),
|
||||
})
|
||||
.clip(from, to);
|
||||
if let Some((at, ..)) = block {
|
||||
|
||||
@@ -803,17 +803,6 @@ impl Game<'_> {
|
||||
/// (`Projectile.canHitEntity`), the host and the guests in the level.
|
||||
pub(super) fn refresh_hook_targets(&mut self, host: &LanHost) {
|
||||
let mut targets = Vec::new();
|
||||
if self.options.dedicated.is_none()
|
||||
&& !self.spectator()
|
||||
&& let Some(p) = self.player.as_ref()
|
||||
&& !p.health.dead_or_dying()
|
||||
{
|
||||
targets.push(HookTarget {
|
||||
id: host.host_entity as u32,
|
||||
conn: None,
|
||||
bounds: p.bounds(),
|
||||
});
|
||||
}
|
||||
for g in &host.hub.guests {
|
||||
if host.hub.here(g) && g.loaded && !g.dead && !g.spectator() {
|
||||
targets.push(HookTarget {
|
||||
@@ -838,9 +827,7 @@ impl Game<'_> {
|
||||
p.velocity = p.velocity.add_vector(delta);
|
||||
}
|
||||
};
|
||||
if id == host.host_entity as u32 {
|
||||
push(self);
|
||||
} else if let Some(conn) = host
|
||||
if let Some(conn) = host
|
||||
.hub
|
||||
.guests
|
||||
.iter()
|
||||
@@ -1174,17 +1161,13 @@ impl Hook {
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
/// The hooks guests track: the host's and each guest's in the level.
|
||||
/// The hooks guests track: each player's in the level.
|
||||
pub(super) fn lan_hook_states(
|
||||
&self,
|
||||
host_entity: Option<i32>,
|
||||
play: &HashMap<u32, GuestPlay>,
|
||||
hub: &Hub,
|
||||
out: &mut States,
|
||||
) {
|
||||
if let (Some(h), Some(owner)) = (&self.hook, host_entity) {
|
||||
h.track(owner, out);
|
||||
}
|
||||
for g in hub.guests.iter().filter(|g| hub.here(g)) {
|
||||
if let Some(h) = play.get(&g.conn).and_then(|p| p.hook.as_ref()) {
|
||||
h.track(g.entity_id, out);
|
||||
|
||||
@@ -423,7 +423,6 @@ impl Game<'_> {
|
||||
|| before(self.interaction_hit().map(|h| h.1))
|
||||
|| before(self.dragon_hit().map(|h| h.1))
|
||||
|| before(self.mob_hit().map(|h| h.1))
|
||||
|| before(self.guest_hit().map(|h| h.1))
|
||||
|| before(self.redirectable_hit().map(|h| h.1))
|
||||
}
|
||||
/// `LocalPlayer.isHandsBusy`: paddling a boat.
|
||||
|
||||
@@ -6,10 +6,8 @@
|
||||
//! 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. That path
|
||||
//! is DEPRECATED: never use it, it gets no fixes and will be removed soon.
|
||||
//! LAN guests and dedicated servers use the same code either way.
|
||||
//! Every single-player world runs this way; LAN guests and dedicated servers
|
||||
//! use the same server code.
|
||||
use super::{
|
||||
Dim, Game, Options, PackResources, Resources, UiResources, WorldIcon,
|
||||
execution_role::ExecutionRole,
|
||||
@@ -79,6 +77,9 @@ struct Flags {
|
||||
/// What the server's `LevelLoadListener` reports, for the window's
|
||||
/// `LevelLoadingScreen` (`LevelLoadTracker.serverProgress/statusView`).
|
||||
load: Arc<horizon_client::level_loading::ServerLoadView>,
|
||||
/// What the world's upgrade has done (`UpgradeProgress`): the window shows it on
|
||||
/// `FileFixerProgressScreen` while the file fixers run, and its Cancel stops them.
|
||||
upgrade: Arc<horizon_world::datafix::progress::UpgradeProgress>,
|
||||
/// Where the window's debug entries want the server's lines for (its
|
||||
/// dimension and feet), and the lines of the last tick there.
|
||||
debug_at: Mutex<Option<(Dim, [i32; 3])>>,
|
||||
@@ -108,6 +109,10 @@ impl Integrated {
|
||||
pub fn load_view(&self) -> &Arc<horizon_client::level_loading::ServerLoadView> {
|
||||
&self.flags.load
|
||||
}
|
||||
/// The world's upgrade while the server opens it.
|
||||
pub fn upgrade(&self) -> &horizon_world::datafix::progress::UpgradeProgress {
|
||||
&self.flags.upgrade
|
||||
}
|
||||
pub fn smoothed_tick_ms(&self) -> f32 {
|
||||
f32::from_bits(self.flags.smoothed_tick_ms.load(Ordering::Relaxed))
|
||||
}
|
||||
@@ -378,7 +383,6 @@ impl Game<'_> {
|
||||
// A server without a console: no local player, no window, no control socket.
|
||||
// Compatibility for gameplay checks outside startup/world loading.
|
||||
options.dedicated = Some(Vec::new());
|
||||
options.server_thread = false;
|
||||
options.execution_role = ExecutionRole::IntegratedServer;
|
||||
options.control = None;
|
||||
options.benchmark = None;
|
||||
@@ -473,6 +477,12 @@ impl Game<'_> {
|
||||
let (pipe, world_name) = match starting.ready.try_recv() {
|
||||
Ok(Ok(ready)) => ready,
|
||||
Err(std::sync::mpsc::TryRecvError::Empty) => return Ok(()),
|
||||
// FileFixerProgressScreen's Cancel: the upgrade stopped, back to the title.
|
||||
Ok(Err(_)) if starting.handle.upgrade().is_canceled() => {
|
||||
self.integrated_starting = None;
|
||||
self.perf.cancel_world_opening();
|
||||
return Ok(());
|
||||
}
|
||||
Ok(Err(e)) => {
|
||||
self.integrated_starting = None;
|
||||
return Err(e.into());
|
||||
@@ -738,7 +748,6 @@ impl Game<'_> {
|
||||
seed: self.level.seed,
|
||||
max_players: MAX_PLAYERS,
|
||||
// No player of its own: the hub runs without a host.
|
||||
dedicated: true,
|
||||
integrated: true,
|
||||
};
|
||||
let owner = launch.owner;
|
||||
@@ -777,9 +786,7 @@ impl Game<'_> {
|
||||
guest_commands: false,
|
||||
force_game_mode: false,
|
||||
};
|
||||
startup::time("server.lan_host", || {
|
||||
self.lan_host(settings, net, config, None)
|
||||
})?;
|
||||
startup::time("server.lan_host", || self.lan_host(settings, net, config))?;
|
||||
let commands = self.allow_commands();
|
||||
let pipe = {
|
||||
let host = self
|
||||
@@ -1163,6 +1170,7 @@ fn serve(
|
||||
pack.clone(),
|
||||
options.clone(),
|
||||
settings.clone(),
|
||||
flags.upgrade.clone(),
|
||||
)
|
||||
});
|
||||
let opening = Opening {
|
||||
@@ -1203,7 +1211,6 @@ impl Game<'_> {
|
||||
let mut options = self.options.clone();
|
||||
options.execution_role = ExecutionRole::IntegratedServer;
|
||||
options.dedicated = Some(Vec::new());
|
||||
options.server_thread = false;
|
||||
let result = std::thread::Builder::new()
|
||||
.name("Server thread".into())
|
||||
.spawn(move || {
|
||||
|
||||
@@ -460,6 +460,38 @@ impl Game<'_> {
|
||||
&mut sink,
|
||||
);
|
||||
}
|
||||
/// [`Self::submit_item_lit`] of `state` under each of `poses` on the GPU path: the mark
|
||||
/// before its draws and their bounds, or `None` (nothing drawn) when the state cannot
|
||||
/// take it.
|
||||
pub(super) fn submit_item_skinned(
|
||||
&self,
|
||||
state: &ItemRenderState,
|
||||
poses: &[glam::Mat4],
|
||||
light: u32,
|
||||
lights: (glam::Vec3, glam::Vec3),
|
||||
) -> Option<((usize, usize, u32), Option<(glam::Vec3, glam::Vec3)>)> {
|
||||
let mut models = self.skin_models.borrow_mut();
|
||||
let mut frame = self.skin_frame.borrow_mut();
|
||||
let mark = frame.mark();
|
||||
let mut skin = horizon_client::mobs::skin::Skin {
|
||||
models: &mut models,
|
||||
frame: &mut frame,
|
||||
bounds: None,
|
||||
};
|
||||
let sink = (
|
||||
item_hdr_light(state, light),
|
||||
(state.hdr_light == 0).then_some(lights),
|
||||
state.hdr_tier,
|
||||
);
|
||||
for &pose in poses {
|
||||
let renderer = &self.pack.item_renderer;
|
||||
if !state.submit_skinned(renderer, &self.gui.atlas.sprites, pose, sink, &mut skin) {
|
||||
// Decided before any draw, the same for every pose.
|
||||
return None;
|
||||
}
|
||||
}
|
||||
Some((mark, skin.bounds))
|
||||
}
|
||||
/// `ItemEntityRenderer.submit` for every dropped item.
|
||||
pub(super) fn dropped_item_geometry(
|
||||
&self,
|
||||
@@ -488,6 +520,8 @@ impl Game<'_> {
|
||||
// position. A pickup particle is not an entity and keeps 64 blocks.
|
||||
let range =
|
||||
super::entity_culling::default_range(glam::DVec3::splat(0.25), self.view_scale());
|
||||
let skinning = self.skinning();
|
||||
let lights = diffuse_lights(self.dim == Dim::Nether);
|
||||
for (item, at, partial, entity) in live.chain(taken) {
|
||||
let entity_start = out.len();
|
||||
self.mark_motion(entity_start, (2 << 32) | u64::from(item.id));
|
||||
@@ -538,19 +572,27 @@ impl Game<'_> {
|
||||
);
|
||||
let seed = i64::from(self.pack.item_renderer.item_id(&item.stack.item))
|
||||
+ i64::from(item.stack.damage);
|
||||
for copy in item_render::cluster_poses(
|
||||
let copies = item_render::cluster_poses(
|
||||
&state,
|
||||
pose,
|
||||
item_render::rendered_amount(item.stack.count),
|
||||
seed,
|
||||
) {
|
||||
self.submit_item_world(&state, copy, vertex_light(packed), out);
|
||||
);
|
||||
let glowing = self.entity_glowing(item.id);
|
||||
// Posed on the GPU when every layer can be (the outline reads the CPU's vertices).
|
||||
let skinned = (skinning && !glowing)
|
||||
.then(|| self.submit_item_skinned(&state, &copies, vertex_light(packed), lights))
|
||||
.flatten();
|
||||
if skinned.is_none() {
|
||||
for © in &copies {
|
||||
self.submit_item_world(&state, copy, vertex_light(packed), out);
|
||||
}
|
||||
}
|
||||
if self.entity_glowing(item.id) {
|
||||
if glowing {
|
||||
let rgb = super::glowing::team_color(self.mob_team(item.id));
|
||||
self.mark_outline(&out[entity_start..], rgb);
|
||||
} else {
|
||||
self.cull_entity_vertices(out, entity_start);
|
||||
self.cull_skinned(out, entity_start, skinned);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,25 +2,22 @@
|
||||
//! server threads and the play-state hub, feeds it the world's entities each
|
||||
//! tick and turns what the guests do into gameplay. A world that is not open
|
||||
//! costs one `Option` check per tick.
|
||||
use super::{Game, shield::OFFHAND_SLOT, shoulders::shoulder_variants};
|
||||
use super::{Game, shield::OFFHAND_SLOT};
|
||||
use horizon_client::{
|
||||
Result,
|
||||
items::ItemStack,
|
||||
lan::{
|
||||
self, Lan, MAX_PLAYERS, NetConfig,
|
||||
self, Lan, NetConfig,
|
||||
chunks::ChunkEncoder,
|
||||
data::SyncData,
|
||||
entity::{EntityState, States},
|
||||
hub::{BodyFlags, HostEvent, HostState, Hub, HubConfig, TickCtx},
|
||||
hub::{HostEvent, Hub, HubConfig, TickCtx},
|
||||
playerdata,
|
||||
},
|
||||
mobs::{Mob, Species, Who, leash::Holder},
|
||||
text::Text,
|
||||
world::World,
|
||||
};
|
||||
use horizon_core::geometry::Vec3;
|
||||
use horizon_protocol::{
|
||||
crypto::offline_uuid,
|
||||
entity_data::{Value, serializer},
|
||||
packets::play::{self, Clientbound as C},
|
||||
};
|
||||
@@ -154,23 +151,15 @@ pub(super) struct LanHost {
|
||||
/// The mods heard that everyone still here leaves with the server.
|
||||
pub players_told_left: bool,
|
||||
pub settings: LanSettings,
|
||||
pub host_entity: i32,
|
||||
/// The host's swings the guests were told of (`Game::swings_started`).
|
||||
pub host_swings: u32,
|
||||
/// The host's crack the guests were told of: the block and its stage.
|
||||
pub host_crack: Option<([i32; 3], i32)>,
|
||||
pub host_uuid: u128,
|
||||
/// The host's profile name (the account's, or the offline name).
|
||||
pub host_name: String,
|
||||
/// The guests' models in the host's world.
|
||||
pub models: HashMap<u32, Mob>,
|
||||
host_mode: i32,
|
||||
pub play: HashMap<u32, super::lan_actions::GuestPlay>,
|
||||
/// Blocks broken this tick (their sound is a level event for the guests).
|
||||
pub broken: Vec<[f64; 3]>,
|
||||
/// Hurt time, death and swing time (while swinging) of the mobs guests
|
||||
/// see; the players' hurt time by entity id.
|
||||
mob_state: HashMap<super::Dim, HashMap<u32, (i32, bool, Option<i32>)>>,
|
||||
/// Each placed guest's pose as the mods last heard it
|
||||
/// (`native_entity_pose_change`), by connection.
|
||||
pub poses: HashMap<u32, horizon_client::pose::Pose>,
|
||||
/// The guest each mob sees as its player, when not the host (game/lan_mobs.rs).
|
||||
/// Looked up per mob each tick: a fast hasher (never iterated).
|
||||
pub mob_players: horizon_client::fast_hash::FxHashMap<u32, u32>,
|
||||
@@ -1108,7 +1097,7 @@ fn enforce_guest_game_mode(
|
||||
return;
|
||||
}
|
||||
let mode = host.hub.config.default_game_mode;
|
||||
let spares_operators = !host.hub.config.dedicated || host.hub.config.integrated;
|
||||
let spares_operators = host.hub.config.integrated;
|
||||
let stale: Vec<u32> = host
|
||||
.hub
|
||||
.guests
|
||||
@@ -1169,7 +1158,8 @@ impl Game<'_> {
|
||||
match &self.lan {
|
||||
Some(h) if !self.serving() || self.published_port().is_some() => json!({
|
||||
"open": true,
|
||||
"host": h.host_name,
|
||||
// The owner's profile name.
|
||||
"host": self.options.player_name(),
|
||||
"port": h.settings.port,
|
||||
// The server's own world: its finished chunks and how many it
|
||||
// has generated or loaded so far.
|
||||
@@ -1204,17 +1194,6 @@ impl Game<'_> {
|
||||
],
|
||||
"chunks": g.sent_chunks.len(),
|
||||
"tracked": g.tracker.len(),
|
||||
// Whether the host draws this guest lying in a bed.
|
||||
"model_lying": h.models.get(&g.conn).map(|m| m.extra.ints[1] != 0),
|
||||
// The armour the host draws on this guest, head to feet.
|
||||
"model_armor": h.models.get(&g.conn).map(|m| {
|
||||
[&m.extra.head, &m.extra.chest, &m.extra.legs, &m.extra.feet]
|
||||
.map(|s| s.as_deref())
|
||||
}),
|
||||
// The host draws this guest's arm swinging.
|
||||
"model_swinging": h.models.get(&g.conn).map(|m| m.ai.swinging),
|
||||
// For how many ticks the host draws this guest using an item.
|
||||
"model_using": h.models.get(&g.conn).map(|m| m.extra.using_item),
|
||||
})).collect::<Vec<_>>(),
|
||||
}),
|
||||
_ => json!({ "open": false }),
|
||||
@@ -1230,8 +1209,7 @@ impl Game<'_> {
|
||||
} else if self.serving() {
|
||||
return self.publish(settings).map(drop).map_err(Into::into);
|
||||
} else {
|
||||
self.lan_start(settings)?;
|
||||
self.lan.as_ref().map_or(0, |l| l.settings.port)
|
||||
return Err("no world of this game to open".into());
|
||||
};
|
||||
self.system_message(Text::translate(
|
||||
"menu.multiplayerOptions.publish.started.lan",
|
||||
@@ -1239,45 +1217,6 @@ impl Game<'_> {
|
||||
));
|
||||
Ok(())
|
||||
}
|
||||
/// IntegratedServer.publishServer without the screen's announcement.
|
||||
pub(super) fn lan_start(&mut self, settings: LanSettings) -> Result<()> {
|
||||
if self.lan.is_some() || self.remote.is_some() || self.title.is_some() {
|
||||
return Err("already open".into());
|
||||
}
|
||||
// IntegratedServer.publishServer: the MOTD names the host's profile.
|
||||
let name = self.options.player_name();
|
||||
let motd = format!("{name} - {}", self.level.name);
|
||||
let host_uuid = self.level.player_uuid.map_or_else(
|
||||
|| offline_uuid(&name),
|
||||
|u| {
|
||||
u128::from(u[0] as u32) << 96
|
||||
| u128::from(u[1] as u32) << 64
|
||||
| u128::from(u[2] as u32) << 32
|
||||
| u128::from(u[3] as u32)
|
||||
},
|
||||
);
|
||||
let config = HubConfig {
|
||||
allow_flight: true,
|
||||
default_game_mode: self.level.mode(),
|
||||
guest_commands: settings.guest_commands,
|
||||
force_game_mode: settings.force_game_mode,
|
||||
hardcore: self.level.hardcore,
|
||||
view_distance: self.settings.render_distance.clamp(2, 32),
|
||||
simulation_distance: self.settings.render_distance.clamp(2, 32),
|
||||
dimension: self.dim.id().to_owned(),
|
||||
levels: self.level_shapes,
|
||||
seed: self.level.seed,
|
||||
max_players: MAX_PLAYERS,
|
||||
dedicated: false,
|
||||
integrated: false,
|
||||
};
|
||||
let net = NetConfig {
|
||||
levels: self.level_shapes,
|
||||
online: horizon_client::lan::OnlineMode::lan(),
|
||||
..NetConfig::lan(settings.port, motd, MAX_PLAYERS)
|
||||
};
|
||||
self.lan_host(settings, net, config, Some(host_uuid))
|
||||
}
|
||||
/// Start the network side and the play-state hub: for the LAN (with the
|
||||
/// host's own profile reserved) or for a dedicated server (no host).
|
||||
pub(super) fn lan_host(
|
||||
@@ -1285,7 +1224,6 @@ impl Game<'_> {
|
||||
settings: LanSettings,
|
||||
net: NetConfig,
|
||||
config: HubConfig,
|
||||
host: Option<u128>,
|
||||
) -> Result<()> {
|
||||
let mut sync = sync_data(&self.options.resources, &self.pack_files())?;
|
||||
let features = if self.level.enabled_features.is_empty() {
|
||||
@@ -1343,12 +1281,6 @@ impl Game<'_> {
|
||||
let far = (net.lod_distance > 0)
|
||||
.then(|| horizon_client::lod_net::Service::new(net.lod_distance, biome_names.clone()));
|
||||
let lan = Lan::start_with(sync.clone(), net)?;
|
||||
if let Some(uuid) = host {
|
||||
lan.reserve(uuid);
|
||||
}
|
||||
let host_uuid = host.unwrap_or(0);
|
||||
self.next_entity += 1;
|
||||
let host_entity = self.next_entity as i32;
|
||||
let mut hub = Hub::new(lan, sync, encoder, config);
|
||||
hub.flat_world = self.world_flat;
|
||||
hub.debug_world = self.world_debug;
|
||||
@@ -1359,16 +1291,10 @@ impl Game<'_> {
|
||||
players_told_left: false,
|
||||
hub,
|
||||
settings: LanSettings { port, ..settings },
|
||||
host_entity,
|
||||
host_swings: self.swings_started,
|
||||
host_crack: None,
|
||||
host_uuid,
|
||||
host_name: self.options.player_name(),
|
||||
models: HashMap::new(),
|
||||
host_mode: self.mode_id(),
|
||||
play: HashMap::new(),
|
||||
broken: Vec::new(),
|
||||
mob_state: HashMap::new(),
|
||||
poses: HashMap::new(),
|
||||
mob_players: Default::default(),
|
||||
owner: None,
|
||||
waypoints: Default::default(),
|
||||
@@ -1746,57 +1672,10 @@ impl Game<'_> {
|
||||
self.lan.as_ref().map(|h| h.settings.port)
|
||||
}
|
||||
|
||||
/// The host as the guests see them.
|
||||
fn lan_host_state(&self, host: &LanHost) -> Option<HostState> {
|
||||
let p = self.player.as_ref()?;
|
||||
let mut equipment: [Option<ItemStack>; 6] = Default::default();
|
||||
equipment[0] = self.inventory.get(self.selected).cloned().flatten();
|
||||
equipment[1] = self.offhand.clone();
|
||||
// Feet, legs, chest, head are slots 2..=5; armor is stored head first.
|
||||
equipment[2] = self.armor[3].clone();
|
||||
equipment[3] = self.armor[2].clone();
|
||||
equipment[4] = self.armor[1].clone();
|
||||
equipment[5] = self.armor[0].clone();
|
||||
Some(HostState {
|
||||
uuid: host.host_uuid,
|
||||
name: host.host_name.clone(),
|
||||
entity_id: host.host_entity,
|
||||
position: [p.position.x, p.position.y, p.position.z],
|
||||
yaw: self.yaw,
|
||||
pitch: self.pitch,
|
||||
head_yaw: self.yaw,
|
||||
on_ground: p.ground.on_ground,
|
||||
game_mode: self.mode_id(),
|
||||
sneaking: p.pose == horizon_client::pose::Pose::Crouching,
|
||||
sprinting: p.sprinting,
|
||||
equipment,
|
||||
health: p.health.health(),
|
||||
attributes: horizon_client::lan::attributes::syncable(&p.attributes),
|
||||
// Bit 4: Riptide's spin (LivingEntity.isAutoSpinAttack).
|
||||
living_flags: living_flags(self.using)
|
||||
| if p.auto_spin_attack_ticks > 0 { 4 } else { 0 },
|
||||
stuck: (self.combat.stuck.arrows, self.combat.stuck.stingers),
|
||||
shoulders: shoulder_variants(self.level.player_extra.as_ref()),
|
||||
bed: self.sleeping,
|
||||
body: BodyFlags {
|
||||
gliding: p.fall_flying,
|
||||
swimming: p.swimming,
|
||||
crawling: p.pose == horizon_client::pose::Pose::Swimming && !p.swimming,
|
||||
crouching: p.pose == horizon_client::pose::Pose::Crouching,
|
||||
spin_attack: p.auto_spin_attack_ticks > 0,
|
||||
on_fire: p.fire_ticks > 0,
|
||||
// ServerPlayer.updateInvisibilityStatus: a spectator is always invisible.
|
||||
invisible: self.potions.effects.get("invisibility").is_some() || self.spectator(),
|
||||
glowing: self.potions.effects.get("glowing").is_some(),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/// The entities guests track: mobs, items and the rest (game/lan_entities.rs).
|
||||
/// `mob_players` is the guest each mob sees as its player (game/lan_mobs.rs).
|
||||
fn lan_entities(
|
||||
&mut self,
|
||||
host_entity: Option<i32>,
|
||||
mob_players: &horizon_client::fast_hash::FxHashMap<u32, u32>,
|
||||
hub: &mut Hub,
|
||||
out: &mut States,
|
||||
@@ -1804,13 +1683,12 @@ impl Game<'_> {
|
||||
) {
|
||||
out.clear();
|
||||
for m in self.mobs.iter().filter(|m| !m.removed) {
|
||||
// The lead's holder: the player the mob sees (a guest, else the
|
||||
// host), another mob or a fence knot (game/lan_entities.rs).
|
||||
// The lead's holder: the player the mob sees, another mob or a
|
||||
// fence knot (game/lan_entities.rs).
|
||||
let player_entity = mob_players
|
||||
.get(&m.id)
|
||||
.and_then(|conn| hub.guest(*conn))
|
||||
.map(|g| g.entity_id)
|
||||
.or(host_entity);
|
||||
.map(|g| g.entity_id);
|
||||
let leash = m.extra.leash.and_then(|h| match h {
|
||||
Holder::Player => player_entity,
|
||||
Holder::Mob(id) | Holder::Boat(id) => Some(id as i32),
|
||||
@@ -1866,7 +1744,7 @@ impl Game<'_> {
|
||||
e.meta
|
||||
.push((8, serializer::INT, Value::Int(o.value as i32)));
|
||||
}
|
||||
self.lan_object_states(host_entity, hub, out, drain_minecart_steps);
|
||||
self.lan_object_states(hub, out, drain_minecart_steps);
|
||||
}
|
||||
|
||||
/// Apply gameplay events produced while the hub handles guest packets.
|
||||
@@ -1879,7 +1757,6 @@ impl Game<'_> {
|
||||
&mut self,
|
||||
mut host: LanHost,
|
||||
events: impl IntoIterator<Item = HostEvent>,
|
||||
state: Option<&HostState>,
|
||||
entities: &[EntityState],
|
||||
spawn: [f64; 3],
|
||||
) -> Option<LanHost> {
|
||||
@@ -1935,7 +1812,7 @@ impl Game<'_> {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
self.lan_guest_joined(&mut host, conn, state, entities, spawn);
|
||||
self.lan_guest_joined(&mut host, conn, entities, spawn);
|
||||
if self.options.dedicated.is_some()
|
||||
&& let Some(g) = host.hub.guest(conn)
|
||||
{
|
||||
@@ -2012,7 +1889,6 @@ impl Game<'_> {
|
||||
self.take_parent_vehicle(&mut host, &mut guest);
|
||||
self.save_guest(&host, &mut guest);
|
||||
self.guest_attachments.remove(&guest.uuid);
|
||||
host.models.remove(&guest.conn);
|
||||
host.play.remove(&guest.conn);
|
||||
self.guest_combat.remove(&guest.conn);
|
||||
if self.options.dedicated.is_some() && guest.placed {
|
||||
@@ -2085,12 +1961,6 @@ impl Game<'_> {
|
||||
return;
|
||||
};
|
||||
let holding = lan::Holding::start();
|
||||
let state = self.lan_host_state(&host);
|
||||
if state.is_none() && !host.hub.config.dedicated {
|
||||
self.lan = Some(host);
|
||||
self.level_pass.packets_drained = true;
|
||||
return;
|
||||
}
|
||||
// Last tick's tracked entities: enough for place collision and joins
|
||||
// before this tick moves anyone.
|
||||
let entities = std::mem::take(&mut host.states);
|
||||
@@ -2099,7 +1969,6 @@ impl Game<'_> {
|
||||
let clock_rate = self.network_clock_rate();
|
||||
let mut ctx = TickCtx {
|
||||
world: &mut self.world,
|
||||
host: state.as_ref(),
|
||||
entities: entities.as_slice(),
|
||||
next_entity: &mut self.next_entity,
|
||||
spawn,
|
||||
@@ -2126,9 +1995,7 @@ impl Game<'_> {
|
||||
events.extend(host.hub.drain_packets(&mut ctx));
|
||||
events
|
||||
};
|
||||
let Some(back) =
|
||||
self.lan_process_events(host, events, state.as_ref(), entities.as_slice(), spawn)
|
||||
else {
|
||||
let Some(back) = self.lan_process_events(host, events, entities.as_slice(), spawn) else {
|
||||
return;
|
||||
};
|
||||
host = back;
|
||||
@@ -2181,7 +2048,6 @@ impl Game<'_> {
|
||||
if !self.admin_feedback.is_empty() {
|
||||
self.send_admin_feedback(&mut host, None);
|
||||
}
|
||||
let state = self.lan_host_state(&host);
|
||||
// An animal whose rider left the game (or the level) stops taking
|
||||
// its input.
|
||||
for m in &mut self.mobs {
|
||||
@@ -2200,12 +2066,10 @@ impl Game<'_> {
|
||||
if !paused {
|
||||
self.tick_guests(&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();
|
||||
let mut states = std::mem::take(&mut host.states);
|
||||
if has_guests {
|
||||
let host_entity = state.as_ref().map(|s| s.entity_id);
|
||||
self.lan_assign_ids();
|
||||
let guests: Vec<(u32, i32)> = host
|
||||
.hub
|
||||
@@ -2213,15 +2077,9 @@ impl Game<'_> {
|
||||
.iter()
|
||||
.filter_map(|g| Some((g.vehicle?, g.entity_id)))
|
||||
.collect();
|
||||
self.lan_riding(host_entity, &guests, &mut host.hub.riding);
|
||||
self.lan_entities(
|
||||
host_entity,
|
||||
&host.mob_players,
|
||||
&mut host.hub,
|
||||
&mut states,
|
||||
true,
|
||||
);
|
||||
self.lan_hook_states(host_entity, &host.play, &host.hub, &mut states);
|
||||
self.lan_riding(&guests, &mut host.hub.riding);
|
||||
self.lan_entities(&host.mob_players, &mut host.hub, &mut states, true);
|
||||
self.lan_hook_states(&host.play, &host.hub, &mut states);
|
||||
self.lan_entity_uuids(&mut states);
|
||||
} else {
|
||||
states.clear();
|
||||
@@ -2272,7 +2130,6 @@ impl Game<'_> {
|
||||
let clock_rate = self.network_clock_rate();
|
||||
let mut ctx = TickCtx {
|
||||
world: &mut self.world,
|
||||
host: state.as_ref(),
|
||||
entities,
|
||||
next_entity: &mut self.next_entity,
|
||||
spawn,
|
||||
@@ -2293,7 +2150,7 @@ impl Game<'_> {
|
||||
// IntegratedServer.getScaledTrackingDistance: the Entity Distance
|
||||
// option scales what an integrated server or a LAN host tracks (a
|
||||
// dedicated one keeps its entity-broadcast-range-percentage).
|
||||
if host.hub.config.integrated || !host.hub.config.dedicated {
|
||||
if host.hub.config.integrated {
|
||||
host.hub.entity_range_percent =
|
||||
(self.settings.entity_distance * 100.0).round() as i32;
|
||||
}
|
||||
@@ -2318,15 +2175,8 @@ impl Game<'_> {
|
||||
}
|
||||
events
|
||||
};
|
||||
if let Some(state) = &state
|
||||
&& host.host_mode != state.game_mode
|
||||
{
|
||||
host.host_mode = state.game_mode;
|
||||
host.hub.set_host_game_mode(state.uuid, state.game_mode);
|
||||
}
|
||||
self.wake_woken_guests(&mut host);
|
||||
let Some(back) = self.lan_process_events(host, events, state.as_ref(), entities, spawn)
|
||||
else {
|
||||
let Some(back) = self.lan_process_events(host, events, entities, spawn) else {
|
||||
return;
|
||||
};
|
||||
host = back;
|
||||
@@ -2385,8 +2235,8 @@ impl Game<'_> {
|
||||
self.tick_server_travel(&mut host);
|
||||
self.lan_swings(&mut host);
|
||||
self.lan_update_streaming(&host);
|
||||
self.lan_update_models(&mut host);
|
||||
self.lan_update_waypoints(&mut host, state.as_ref());
|
||||
self.lan_guest_poses(&mut host);
|
||||
self.lan_update_waypoints(&mut host);
|
||||
self.lan_sync_scoreboard(&mut host);
|
||||
self.lan_sync_border(&mut host);
|
||||
host.hub.anticheat_ping();
|
||||
@@ -2396,8 +2246,8 @@ impl Game<'_> {
|
||||
lan::flush_all(host.hub.guests.iter().map(|g| &g.sender));
|
||||
self.lan = Some(host);
|
||||
self.flush_native_sounds();
|
||||
// `tick_server` resets the pass after all dimensions. The deprecated
|
||||
// in-frame path has no outer pass, so make its packet gate per-tick.
|
||||
// `tick_server` resets the pass after all dimensions; outside a pass
|
||||
// the packet gate is per-tick.
|
||||
if self.level_pass.running.is_none() {
|
||||
self.level_pass.packets_drained = false;
|
||||
}
|
||||
@@ -2407,7 +2257,6 @@ impl Game<'_> {
|
||||
&mut self,
|
||||
host: &mut LanHost,
|
||||
conn: u32,
|
||||
state: Option<&HostState>,
|
||||
entities: &[EntityState],
|
||||
spawn: [f64; 3],
|
||||
) {
|
||||
@@ -2446,11 +2295,10 @@ impl Game<'_> {
|
||||
g.food = 20;
|
||||
g.dimension = super::Dim::Overworld.id().to_owned();
|
||||
}
|
||||
// A server places each player in their own level
|
||||
// (`PlayerList.placeNewPlayer`); the in-frame game has one.
|
||||
// A server places each player in their own level (`PlayerList.placeNewPlayer`).
|
||||
let back = self.dim;
|
||||
let level = super::Dim::from_id(&g.dimension);
|
||||
let elsewhere = self.options.dedicated.is_some() && level != back;
|
||||
let elsewhere = level != back;
|
||||
if !elsewhere {
|
||||
g.dimension = in_place;
|
||||
}
|
||||
@@ -2464,8 +2312,8 @@ impl Game<'_> {
|
||||
return;
|
||||
}
|
||||
// A dedicated server has no host player.
|
||||
self.lan_entities(None, &host.mob_players, &mut host.hub, &mut theirs, false);
|
||||
self.lan_hook_states(None, &host.play, &host.hub, &mut theirs);
|
||||
self.lan_entities(&host.mob_players, &mut host.hub, &mut theirs, false);
|
||||
self.lan_hook_states(&host.play, &host.hub, &mut theirs);
|
||||
self.lan_entity_uuids(&mut theirs);
|
||||
theirs.as_slice()
|
||||
} else {
|
||||
@@ -2474,7 +2322,6 @@ impl Game<'_> {
|
||||
let clock_rate = self.network_clock_rate();
|
||||
let mut ctx = TickCtx {
|
||||
world: &mut self.world,
|
||||
host: state,
|
||||
entities,
|
||||
next_entity: &mut self.next_entity,
|
||||
spawn,
|
||||
@@ -2613,15 +2460,10 @@ impl Game<'_> {
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let told = if self.lan_acting.is_some() {
|
||||
let told = self.lan_acting.is_some();
|
||||
if told {
|
||||
self.guest_equipment_breaks.push((event, at));
|
||||
true
|
||||
} else if let Some(host) = &self.lan {
|
||||
host.hub.entity_event(host.host_entity, event, at);
|
||||
true
|
||||
} else {
|
||||
false
|
||||
};
|
||||
}
|
||||
if told && !self.options.execution_role.is_client() {
|
||||
return;
|
||||
}
|
||||
@@ -2732,14 +2574,14 @@ impl Game<'_> {
|
||||
{
|
||||
use horizon_client::mobs::{DamageKind as K, Who};
|
||||
let kind = super::creatures::source_damage_type(source);
|
||||
// The player a mob sees is the guest acting, else the host.
|
||||
let cause = source.attacker.map(|who| match who {
|
||||
// The player a mob sees is the guest acting.
|
||||
let cause = source.attacker.and_then(|who| match who {
|
||||
Who::Player => host
|
||||
.mob_players
|
||||
.get(&m.id)
|
||||
.and_then(|conn| host.hub.guest(*conn))
|
||||
.map_or(host.host_entity, |g| g.entity_id),
|
||||
Who::Mob(id) => id as i32,
|
||||
.map(|g| g.entity_id),
|
||||
Who::Mob(id) => Some(id as i32),
|
||||
});
|
||||
// A projectile is the direct entity of its hit: not tracked here.
|
||||
let melee = matches!(
|
||||
@@ -2785,48 +2627,20 @@ impl Game<'_> {
|
||||
}
|
||||
seen.insert(id, (d.hurt_time, dead, None));
|
||||
}
|
||||
// LivingEntity.hurtServer for the in-frame LAN host's player (guests
|
||||
// broadcast directly from HealthEvent::DamageBroadcast): a hit that
|
||||
// restarted the hurt time and was not blocked by a shield broadcasts
|
||||
// the damage event, so the clients around flash it red.
|
||||
if let Some(p) = self.player.as_ref() {
|
||||
let id = host.host_entity;
|
||||
let at = [p.position.x, p.position.y, p.position.z];
|
||||
let hurt = p.health.hurt_animation_time;
|
||||
let dead = p.health.dead_or_dying();
|
||||
let old = host
|
||||
.mob_state
|
||||
.get(&self.dim)
|
||||
.and_then(|s| s.get(&(id as u32)));
|
||||
if hurt > old.map_or(0, |o| o.0) && !dead {
|
||||
let kind = p
|
||||
.health
|
||||
.hurt
|
||||
.last_source
|
||||
.and_then(|s| self.motion.health.damage.definition(s))
|
||||
.map_or("minecraft:generic", |d| d.name.as_str());
|
||||
host.hub.damage_event(id, kind, None, None, at);
|
||||
}
|
||||
seen.insert(id as u32, (hurt, dead, None));
|
||||
}
|
||||
host.mob_state.insert(self.dim, seen);
|
||||
// Level.broadcastEntityEvent(player, 2) of a spear's stab.
|
||||
for attacker in kinetic_hits {
|
||||
let hit = match attacker {
|
||||
None => self
|
||||
.player
|
||||
.as_ref()
|
||||
.map(|p| (host.host_entity, [p.position.x, p.position.y, p.position.z])),
|
||||
Some(conn) => host.hub.guest(conn).map(|g| (g.entity_id, g.position)),
|
||||
};
|
||||
let hit = attacker
|
||||
.and_then(|conn| host.hub.guest(conn))
|
||||
.map(|g| (g.entity_id, g.position));
|
||||
if let Some((id, at)) = hit {
|
||||
host.hub.entity_event(id, 2, at);
|
||||
}
|
||||
}
|
||||
for (attacker, target, magic) in critical_hits {
|
||||
let Some(attacker) = attacker.map_or(Some(host.host_entity), |conn| {
|
||||
host.hub.guest(conn).map(|g| g.entity_id)
|
||||
}) else {
|
||||
let Some(attacker) =
|
||||
attacker.and_then(|conn| host.hub.guest(conn).map(|g| g.entity_id))
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
host.hub.critical_hit(attacker, target, magic);
|
||||
@@ -2868,153 +2682,30 @@ impl Game<'_> {
|
||||
.collect();
|
||||
}
|
||||
|
||||
/// The guests' models, for the host to see them.
|
||||
fn lan_update_models(&mut self, host: &mut LanHost) {
|
||||
let Some(kind) = self.res.player_type().clone() else {
|
||||
/// The bed at `head` as a sleeping model lies in it (`ints[1]`,
|
||||
/// `villager::sleep_pose`): its `facing`.
|
||||
/// `Entity.setPose` for the server's players: the mods hear each placed
|
||||
/// guest's change of pose. Nothing is kept while no mod is loaded.
|
||||
fn lan_guest_poses(&mut self, host: &mut LanHost) {
|
||||
if horizon_client::mod_api::mods().is_empty() {
|
||||
host.poses.clear();
|
||||
return;
|
||||
};
|
||||
host.models
|
||||
}
|
||||
host.poses
|
||||
.retain(|conn, _| host.hub.guest(*conn).is_some_and(|g| g.placed));
|
||||
let mut changed = Vec::new();
|
||||
for g in host.hub.guests.iter().filter(|g| g.placed) {
|
||||
let at = Vec3::new(
|
||||
g.position[0],
|
||||
g.position[1] - if g.sneaking { 0.125 } else { 0.0 },
|
||||
g.position[2],
|
||||
);
|
||||
let m = host
|
||||
.models
|
||||
.entry(g.conn)
|
||||
.or_insert_with(|| Mob::new(g.entity_id as u32, kind.clone(), at, g.yaw));
|
||||
let (dx, dy, dz) = (
|
||||
at.x - m.position.x,
|
||||
at.y - m.position.y,
|
||||
at.z - m.position.z,
|
||||
);
|
||||
m.previous = m.position;
|
||||
m.position = at;
|
||||
if dx * dx + dy * dy + dz * dz > 100.0 {
|
||||
m.previous = at;
|
||||
let to = g.pose();
|
||||
let from = host.poses.insert(g.conn, to).unwrap_or_default();
|
||||
if from != to {
|
||||
changed.push((g.uuid, g.name.clone(), from, to));
|
||||
}
|
||||
m.previous_yaw = m.yaw;
|
||||
m.previous_body_yaw = m.body_yaw;
|
||||
m.previous_head_yaw = m.head_yaw;
|
||||
m.previous_head_pitch = m.head_pitch;
|
||||
m.yaw = g.yaw;
|
||||
m.head_yaw = g.head_yaw;
|
||||
m.head_pitch = g.pitch;
|
||||
let mut target = m.body_yaw;
|
||||
let wrap = |a: f32| (a + 180.0).rem_euclid(360.0) - 180.0;
|
||||
if dx * dx + dz * dz > 0.002_500_000_2 {
|
||||
// LivingEntity.tick: walking backwards the body faces away
|
||||
// from the walk direction, not along it.
|
||||
let walk = (dz.atan2(dx) as f32).to_degrees() - 90.0;
|
||||
let facing = (wrap(g.yaw) - walk).abs();
|
||||
target = if 95.0 < facing && facing < 265.0 {
|
||||
walk - 180.0
|
||||
} else {
|
||||
walk
|
||||
};
|
||||
}
|
||||
m.body_yaw += wrap(target - m.body_yaw) * 0.3;
|
||||
let diff = wrap(g.yaw - m.body_yaw);
|
||||
if diff.abs() > 50.0 {
|
||||
m.body_yaw += diff - diff.signum() * 50.0;
|
||||
}
|
||||
let dist = (dx * dx + dz * dz).sqrt() as f32;
|
||||
m.previous_limb_speed = m.limb_speed;
|
||||
m.limb_speed += ((dist * 4.0).min(1.0) - m.limb_speed) * 0.4;
|
||||
m.limb_position += m.limb_speed;
|
||||
m.age = m.age.wrapping_add(1);
|
||||
// The hurt red flash (LivingEntity.hurtTime).
|
||||
m.hurt_time = g
|
||||
.motion
|
||||
.as_ref()
|
||||
.map_or(0, |p| p.health.hurt_animation_time);
|
||||
m.extra
|
||||
.set_main_hand_stack(g.inventory.get(g.selected).cloned().flatten());
|
||||
m.extra.off_hand = g.offhand.as_ref().map(|s| s.item.clone());
|
||||
[m.extra.head, m.extra.chest, m.extra.legs, m.extra.feet] = g
|
||||
.armor
|
||||
.each_ref()
|
||||
.map(|s| s.as_ref().map(|s| s.item.clone()));
|
||||
m.extra.armor = g.armor.clone();
|
||||
m.extra.crouching = g.sneaking;
|
||||
let from_pose = m.extra.avatar_pose;
|
||||
let to_pose = g.pose();
|
||||
m.extra.avatar_pose = to_pose;
|
||||
if from_pose != to_pose {
|
||||
self.native_entity_pose_change(g.uuid, &g.name, from_pose.name(), to_pose.name());
|
||||
}
|
||||
m.extra.sprinting = g.sprinting;
|
||||
// Entity.DATA_POSE and the shared and living flags the others
|
||||
// get (hub::guest_body): gliding, swimming, Riptide's spin.
|
||||
let body = lan::hub::guest_body(g);
|
||||
m.extra.fall_flying = body.gliding;
|
||||
m.extra.synced_pose = if body.swimming || body.crawling { 3 } else { 0 };
|
||||
m.extra.auto_spin_attack = body.spin_attack;
|
||||
m.extra.previous_velocity = m.velocity;
|
||||
(m.extra.arrow_count, m.extra.stinger_count) = g.stuck;
|
||||
m.extra.shoulder_parrots = g.shoulders;
|
||||
m.velocity = g
|
||||
.motion
|
||||
.as_ref()
|
||||
.map_or(Vec3::new(dx, dy, dz), |motion| motion.velocity);
|
||||
m.tick_avatar_pose(
|
||||
g.motion
|
||||
.as_ref()
|
||||
.is_some_and(|motion| motion.fluids.was_touching_water),
|
||||
);
|
||||
// CrossbowItem.isCharged: the held crossbow's projectiles.
|
||||
m.extra.charged = g
|
||||
.inventory
|
||||
.get(g.selected)
|
||||
.and_then(|s| s.as_ref())
|
||||
.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;
|
||||
m.extra.spectator = g.game_mode == horizon_client::lan::guest::SPECTATOR;
|
||||
// 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()
|
||||
.filter_map(|e| super::client_ambient::effect_particle(e.id, e.ambient))
|
||||
.collect();
|
||||
m.extra.effect_ambient = visible().all(|e| e.ambient);
|
||||
// Entity.isInvisible: the invisibility effect hides the body (the
|
||||
// model pass, the name tag rules and `trader::is_invisible` read it).
|
||||
m.extra.effects.retain(|e| e.0 != "minecraft:invisibility");
|
||||
if let Some(e) = g.effects.get("invisibility") {
|
||||
m.extra
|
||||
.effects
|
||||
.push(("minecraft:invisibility", e.duration, e.amplifier));
|
||||
}
|
||||
// Lying in its bed (LivingEntity.getBedOrientation).
|
||||
m.extra.ints[1] = g.bed.map_or(0, |b| self.bed_facing(b));
|
||||
// LivingEntity.updateSwingTime: the arm swing lan_swings started.
|
||||
m.anim.attack_anim_o = m.anim.attack_anim;
|
||||
horizon_client::mobs::ai::tick_swing(m);
|
||||
// LivingEntity.onSyncedDataUpdated(DATA_LIVING_ENTITY_FLAGS): the
|
||||
// item in use and its hand (the arm poses of a bow, a shield...).
|
||||
let using = host.play.get(&g.conn).and_then(|p| p.using());
|
||||
m.extra.using_item = using.map_or(0, |(_, ticks)| ticks as i32 + 1);
|
||||
m.extra.using_off_hand = using.is_some_and(|(slot, _)| slot == OFFHAND_SLOT);
|
||||
// AvatarRenderer.getArmPose: the used item's BLOCK animation (an
|
||||
// item with `blocks_attacks`) raises the arm.
|
||||
m.extra.blocking = using
|
||||
.and_then(|(slot, _)| {
|
||||
if slot == OFFHAND_SLOT {
|
||||
m.extra.off_hand.as_ref()
|
||||
} else {
|
||||
m.extra.main_hand.as_ref()
|
||||
}
|
||||
})
|
||||
.and_then(|i| self.items().get(i))
|
||||
.is_some_and(|i| i.blocks_attacks.is_some());
|
||||
}
|
||||
for (uuid, name, from, to) in changed {
|
||||
self.native_entity_pose_change(uuid, &name, from.name(), to.name());
|
||||
}
|
||||
}
|
||||
|
||||
/// The bed at `head` as a sleeping model lies in it (`ints[1]`,
|
||||
/// `villager::sleep_pose`): its `facing`.
|
||||
pub(super) fn bed_facing(&self, head: [i32; 3]) -> i32 {
|
||||
let state = self.world.block(head[0], head[1], head[2]);
|
||||
let facing = self
|
||||
@@ -3026,85 +2717,6 @@ impl Game<'_> {
|
||||
.map(String::as_str);
|
||||
horizon_client::mobs::villager::bed_facing_code(facing)
|
||||
}
|
||||
|
||||
/// The guests' models for the entity pass.
|
||||
pub(super) fn guest_model_geometry(
|
||||
&self,
|
||||
camera: glam::DVec3,
|
||||
alpha: f64,
|
||||
out: &mut Vec<horizon_client::mesher::Vertex>,
|
||||
) {
|
||||
let Some(host) = &self.lan else {
|
||||
return;
|
||||
};
|
||||
let atlas = &self.gui.atlas;
|
||||
let lookup = |name: &str| atlas.sprites.get(name).map(|r| [r.u0, r.v0, r.u1, r.v1]);
|
||||
for (conn, m) in &host.models {
|
||||
let entity_start = out.len();
|
||||
let probe = super::perspective::entity_light_block(
|
||||
m.position,
|
||||
m.extra.crouching,
|
||||
m.eye_height(),
|
||||
);
|
||||
let light = self.world.light(probe[0], probe[1], probe[2]);
|
||||
let packed = (u32::from(light >> 4) * 16) | ((u32::from(light & 15) * 16) << 8);
|
||||
horizon_client::mobs::render::render_with(
|
||||
m,
|
||||
alpha as f32,
|
||||
camera,
|
||||
&lookup,
|
||||
packed,
|
||||
self.res.mob_data(),
|
||||
&host.hub.guest(*conn).map_or_else(
|
||||
|| self.worn_armor_layers(m),
|
||||
|g| self.stack_armor_layers(&g.armor),
|
||||
),
|
||||
out,
|
||||
);
|
||||
// AvatarRenderer.shouldRenderLayers: a spectator draws no layers.
|
||||
if horizon_client::mobs::render::layers_shown(m) {
|
||||
self.player_elytra_geometry(
|
||||
m,
|
||||
m.extra.chest.as_deref(),
|
||||
camera,
|
||||
alpha as f32,
|
||||
packed,
|
||||
out,
|
||||
);
|
||||
if let Some(g) = host.hub.guest(*conn) {
|
||||
self.player_ears_geometry(m, &g.name, camera, alpha as f32, packed, out);
|
||||
}
|
||||
self.spin_attack_geometry(m, camera, alpha as f32, packed, out);
|
||||
if let Some(g) = host.hub.guest(*conn) {
|
||||
self.stuck_in_body_geometry(m, g.entity_id, camera, alpha as f32, packed, out);
|
||||
}
|
||||
self.held_item_geometry(m, camera, alpha, packed, true, out);
|
||||
self.player_pumpkin_geometry(
|
||||
m,
|
||||
host.hub.guest(*conn).and_then(|g| g.armor[0].as_ref()),
|
||||
camera,
|
||||
alpha,
|
||||
packed,
|
||||
out,
|
||||
);
|
||||
}
|
||||
// LivingEntity.isCurrentlyGlowing: the guest's Glowing effect.
|
||||
let guest = host.hub.guest(*conn);
|
||||
if m.glowing || guest.is_some_and(|g| g.effects.get("glowing").is_some()) {
|
||||
let team = guest.and_then(|g| self.score.board.players_team(&g.name));
|
||||
self.mark_outline(&out[entity_start..], super::glowing::team_color(team));
|
||||
} else {
|
||||
self.cull_entity_vertices(out, entity_start);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A chat line the host typed, for the guests.
|
||||
pub(super) fn lan_host_chat(&mut self, message: &str) {
|
||||
if let Some(host) = &self.lan {
|
||||
host.hub.chat(&host.host_name, message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
|
||||
@@ -2693,39 +2693,23 @@ impl Game<'_> {
|
||||
/// player: the host's own, or a guest in the world.
|
||||
fn guest_spectate_teleport(&mut self, host: &mut LanHost, gi: usize, uuid: u128) {
|
||||
let own = host.hub.guests[gi].conn;
|
||||
let host_player = self
|
||||
.player
|
||||
.as_ref()
|
||||
.filter(|_| self.options.dedicated.is_none() && uuid == host.host_uuid)
|
||||
.map(|p| {
|
||||
(
|
||||
[p.position.x, p.position.y, p.position.z],
|
||||
self.yaw,
|
||||
self.pitch,
|
||||
self.dim,
|
||||
)
|
||||
});
|
||||
let target = host_player.or_else(|| {
|
||||
host.hub
|
||||
.guests
|
||||
.iter()
|
||||
.find(|g| g.placed && g.conn != own && g.uuid == uuid)
|
||||
.map(|g| (g.position, g.yaw, g.pitch, Dim::from_id(&g.dimension)))
|
||||
});
|
||||
let target = host
|
||||
.hub
|
||||
.guests
|
||||
.iter()
|
||||
.find(|g| g.placed && g.conn != own && g.uuid == uuid)
|
||||
.map(|g| (g.position, g.yaw, g.pitch, Dim::from_id(&g.dimension)));
|
||||
let Some((at, yaw, pitch, dim)) = target else {
|
||||
return;
|
||||
};
|
||||
if Dim::from_id(&host.hub.guests[gi].dimension) != dim {
|
||||
// Only a dedicated server keeps its players in different levels.
|
||||
if self.options.dedicated.is_some() {
|
||||
self.level_teleports
|
||||
.push(super::server_travel::LevelTeleport {
|
||||
conn: own,
|
||||
dim,
|
||||
at,
|
||||
rotation: Some([yaw, pitch]),
|
||||
});
|
||||
}
|
||||
self.level_teleports
|
||||
.push(super::server_travel::LevelTeleport {
|
||||
conn: own,
|
||||
dim,
|
||||
at,
|
||||
rotation: Some([yaw, pitch]),
|
||||
});
|
||||
return;
|
||||
}
|
||||
let g = &mut host.hub.guests[gi];
|
||||
@@ -2903,9 +2887,6 @@ impl Game<'_> {
|
||||
|
||||
pub(super) fn guest_target_box(&self, host: &LanHost, gi: usize, id: i32) -> Option<Aabb> {
|
||||
let conn = host.hub.guests[gi].conn;
|
||||
if id == host.host_entity {
|
||||
return self.player.as_ref().map(|p| p.bounds());
|
||||
}
|
||||
if let Some(g) = host
|
||||
.hub
|
||||
.guests
|
||||
@@ -2951,12 +2932,7 @@ impl Game<'_> {
|
||||
}
|
||||
// Native mods, where Player.attack starts: a canceled blow does
|
||||
// nothing (game/native_hooks.rs).
|
||||
let aimed = if id == host.host_entity {
|
||||
self.player.as_ref().map(|p| {
|
||||
let at = [p.position.x, p.position.y, p.position.z];
|
||||
("minecraft:player".to_owned(), at)
|
||||
})
|
||||
} else if let Some(g) = host
|
||||
let aimed = if let Some(g) = host
|
||||
.hub
|
||||
.guests
|
||||
.iter()
|
||||
@@ -2984,8 +2960,7 @@ impl Game<'_> {
|
||||
if let Some(Some(strike)) = self.as_guest(host, conn, |g| g.pvp_strike())
|
||||
&& self.pvp_land(host, victim, &strike)
|
||||
{
|
||||
let host_on_ground = self.player.as_ref().map(|p| p.ground.on_ground);
|
||||
let on_ground = Self::pvp_victim_on_ground(host, victim, host_on_ground);
|
||||
let on_ground = Self::pvp_victim_on_ground(host, victim);
|
||||
self.as_guest(host, conn, |g| {
|
||||
g.weapon_hit_player(on_ground);
|
||||
g.pvp_exhaustion();
|
||||
@@ -3117,12 +3092,7 @@ 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"]) {
|
||||
// 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);
|
||||
let content = host.hub.list_reply(words.len() == 2);
|
||||
host.hub.send_to(
|
||||
conn,
|
||||
&C::SystemChat(play::SystemChat {
|
||||
@@ -3248,14 +3218,9 @@ impl Game<'_> {
|
||||
/// operator gets them, and the console logs them as the server's own
|
||||
/// system messages (MinecraftServer.sendSystemMessage).
|
||||
pub(super) fn send_admin_feedback(&mut self, host: &mut LanHost, source: Option<u32>) {
|
||||
// PlayerList.isOp for the host of a world open to LAN: the owner when
|
||||
// the world allows commands, everyone when the LAN does.
|
||||
let host_ops =
|
||||
!host.hub.config.dedicated && (self.allow_commands() || host.hub.config.guest_commands);
|
||||
let host_op = source.is_some() && host_ops;
|
||||
// server.toml's `admin_feedback_to_ops` off: the console's log only.
|
||||
let to_ops = host.hub.admin_feedback_to_ops;
|
||||
for (line, ops, console, no_player) in std::mem::take(&mut self.admin_feedback) {
|
||||
for (line, ops, console) in std::mem::take(&mut self.admin_feedback) {
|
||||
if ops && to_ops {
|
||||
let content = horizon_client::lan::data::component_nbt(&line.component());
|
||||
for g in host
|
||||
@@ -3269,10 +3234,6 @@ impl Game<'_> {
|
||||
overlay: false,
|
||||
}));
|
||||
}
|
||||
// A line no player's command made (a command block's) reaches the host too.
|
||||
if host_op || (no_player && host_ops) {
|
||||
self.system_message(line.clone());
|
||||
}
|
||||
}
|
||||
if console {
|
||||
super::dedicated::log("INFO", &format!("System chat: {}", line.plain()));
|
||||
@@ -3495,7 +3456,6 @@ impl Game<'_> {
|
||||
let clock_rate = self.network_clock_rate();
|
||||
let ctx = TickCtx {
|
||||
world: &mut self.world,
|
||||
host: None,
|
||||
entities: &[],
|
||||
next_entity: &mut self.next_entity,
|
||||
spawn,
|
||||
@@ -4665,11 +4625,9 @@ impl Game<'_> {
|
||||
g.trigger_thrown_picked_up_by_player(&item.stack, thrower);
|
||||
});
|
||||
}
|
||||
self.guest_pickup(host, id, item);
|
||||
taken.push((item_id, before));
|
||||
}
|
||||
Some(rest) if rest.count < before => {
|
||||
self.guest_pickup(host, id, self.dropped[i].clone());
|
||||
self.dropped[i].stack = rest;
|
||||
if let Some(thrower) = thrower {
|
||||
let left = self.dropped[i].stack.clone();
|
||||
@@ -4753,13 +4711,6 @@ impl Game<'_> {
|
||||
self.native_player_pickup_arrow(player, &name, shot.id, &arrow.pickup_item.item);
|
||||
}
|
||||
host.hub.take_item(shot.id as i32, id, 1, at);
|
||||
if let Some(target) = super::pickups::guest_target(host, id as u32) {
|
||||
self.add_pickup_toward(
|
||||
super::pickups::Taken::Arrow(Box::new(shot)),
|
||||
super::pickups::Collector::Guest(id as u32),
|
||||
target,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4856,14 +4807,6 @@ impl Game<'_> {
|
||||
pitch,
|
||||
);
|
||||
let value = orb.value;
|
||||
if let Some(target) = super::pickups::guest_target(host, id as u32) {
|
||||
use super::pickups::{Collector, Taken};
|
||||
self.add_pickup_toward(
|
||||
Taken::Orb(Box::new(orb)),
|
||||
Collector::Guest(id as u32),
|
||||
target,
|
||||
);
|
||||
}
|
||||
self.as_guest(host, conn, |game| {
|
||||
let rest = game.mending(value);
|
||||
if rest > 0 {
|
||||
@@ -4879,23 +4822,6 @@ impl Game<'_> {
|
||||
});
|
||||
}
|
||||
|
||||
/// The host's own `ItemPickupParticle` toward the guest's model.
|
||||
fn guest_pickup(
|
||||
&mut self,
|
||||
host: &LanHost,
|
||||
id: i32,
|
||||
item: horizon_client::entities::ItemEntity,
|
||||
) {
|
||||
use super::pickups::{Collector, Taken, guest_target};
|
||||
if let Some(target) = guest_target(host, id as u32) {
|
||||
self.add_pickup_toward(
|
||||
Taken::Item(Box::new(item)),
|
||||
Collector::Guest(id as u32),
|
||||
target,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// `level.playSound(player, ...)` of a guest's use it predicted (`remote::hand_operated`
|
||||
/// blocks only, as the guest decides): the others hear it now, the guest already did.
|
||||
fn guest_use_sounds(&mut self, host: &mut LanHost, conn: u32, logged: usize, heard: usize) {
|
||||
@@ -5039,9 +4965,8 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// The tick's arm swings for the players around (the guests' to everyone
|
||||
/// else, the host's to the guests), and `LivingEntity.updateSwingTime`
|
||||
/// for the guests' bodies here.
|
||||
/// The tick's arm swings for the players around (each guest's to everyone
|
||||
/// else), and `LivingEntity.updateSwingTime` for the guests' bodies here.
|
||||
pub(super) fn lan_swings(&mut self, host: &mut LanHost) {
|
||||
let conns: Vec<u32> = host.play.keys().copied().collect();
|
||||
for conn in conns {
|
||||
@@ -5054,55 +4979,9 @@ impl Game<'_> {
|
||||
if let (true, true, Some(c)) = (swung, here, play.swing.current) {
|
||||
let packet = swing_packet(id, c.hand, c.animation);
|
||||
host.hub.broadcast_near(at, 128.0, &packet, Some(conn));
|
||||
if let Some(m) = host.models.get_mut(&conn) {
|
||||
// The duration already counts the guest's haste or fatigue.
|
||||
m.swing_hand(c.hand == Hand::Off, c.duration_ticks);
|
||||
}
|
||||
}
|
||||
play.swing.tick();
|
||||
}
|
||||
self.lan_host_crack(host);
|
||||
if self.swings_started != host.host_swings {
|
||||
host.host_swings = self.swings_started;
|
||||
if let (Some(p), Some(c)) = (self.player.as_ref(), self.swing.current) {
|
||||
let at = [p.position.x, p.position.y, p.position.z];
|
||||
let packet = swing_packet(host.host_entity, c.hand, c.animation);
|
||||
host.hub.broadcast_near(at, 128.0, &packet, None);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
/// The host player's crack, for the guests (`ServerLevel.destroyBlockProgress` from the
|
||||
/// host's mining): each new stage, and -1 once it stops or breaks the block.
|
||||
fn lan_host_crack(&mut self, host: &mut LanHost) {
|
||||
let now = self
|
||||
.mining
|
||||
.as_ref()
|
||||
.and_then(|m| (m.progress > 0.0).then(|| (m.pos, ((m.progress * 10.0) as i32).min(9))));
|
||||
if now == host.host_crack {
|
||||
return;
|
||||
}
|
||||
let packet = |pos: [i32; 3], progress: u8| {
|
||||
C::BlockDestruction(play::BlockDestruction {
|
||||
id: host.host_entity,
|
||||
pos,
|
||||
progress,
|
||||
})
|
||||
};
|
||||
let near = |pos: [i32; 3]| [f64::from(pos[0]), f64::from(pos[1]), f64::from(pos[2])];
|
||||
if let Some((pos, _)) = host.host_crack
|
||||
&& now.is_none_or(|(p, _)| p != pos)
|
||||
{
|
||||
host.hub
|
||||
.broadcast_near(near(pos), 64.0, &packet(pos, 255), None);
|
||||
}
|
||||
if let Some((pos, stage)) = now {
|
||||
host.hub
|
||||
.broadcast_near(near(pos), 64.0, &packet(pos, stage as u8), None);
|
||||
}
|
||||
host.host_crack = now;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -43,13 +43,6 @@ impl Game<'_> {
|
||||
{
|
||||
return (!g.dead).then_some((g.position, [g.yaw, g.pitch]));
|
||||
}
|
||||
if id == host.host_entity {
|
||||
let p = self.player.as_ref()?;
|
||||
return Some((
|
||||
[p.position.x, p.position.y, p.position.z],
|
||||
[self.yaw, self.pitch],
|
||||
));
|
||||
}
|
||||
if let Some(special) = self.camera_extra(id) {
|
||||
return Some((special.position, special.rotation));
|
||||
}
|
||||
@@ -102,9 +95,6 @@ impl Game<'_> {
|
||||
if let Some(g) = host.hub.guests.iter().find(|g| g.entity_id == id) {
|
||||
return !g.spectator() && !g.dead;
|
||||
}
|
||||
if id == host.host_entity {
|
||||
return self.player.is_some() && !self.spectator();
|
||||
}
|
||||
if self.camera_extra(id).is_some() {
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -131,14 +131,9 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Who rides what (`Entity.getPassengers`): mobs on mobs, and the
|
||||
/// host in its boat or minecart.
|
||||
pub(super) fn lan_riding(
|
||||
&self,
|
||||
host_entity: Option<i32>,
|
||||
guests: &[(u32, i32)],
|
||||
riding: &mut Riding,
|
||||
) {
|
||||
/// Who rides what (`Entity.getPassengers`): the players in their vehicles and
|
||||
/// mobs on mobs.
|
||||
pub(super) fn lan_riding(&self, guests: &[(u32, i32)], riding: &mut Riding) {
|
||||
riding.clear();
|
||||
// The server's players in their boats and minecarts.
|
||||
for &(vehicle, guest) in guests {
|
||||
@@ -149,16 +144,12 @@ impl Game<'_> {
|
||||
riding.add(vehicle as i32, m.id as i32);
|
||||
}
|
||||
}
|
||||
if let (Some(host), Some(vehicle)) = (host_entity, self.riding) {
|
||||
riding.add(vehicle as i32, host);
|
||||
}
|
||||
}
|
||||
|
||||
/// Boats, minecarts, armor stands, item frames, paintings and the
|
||||
/// entities kept unsimulated, as guests track them.
|
||||
pub(super) fn lan_object_states(
|
||||
&mut self,
|
||||
host_entity: Option<i32>,
|
||||
hub: &mut Hub,
|
||||
out: &mut States,
|
||||
drain_minecart_steps: bool,
|
||||
@@ -173,7 +164,7 @@ impl Game<'_> {
|
||||
let p = b.position;
|
||||
let mut e = state(b.id, &b.entity_type(), [p.x, p.y, p.z], b.yaw, b.on_ground);
|
||||
// Leashable.setLeashedTo / dropLeash: who holds the boat's lead.
|
||||
e.leash = self.boat_leash_link(b, host_entity, &guests);
|
||||
e.leash = self.boat_leash_link(b, &guests);
|
||||
e.velocity = [b.velocity.x, b.velocity.y, b.velocity.z];
|
||||
e.meta = vehicle_meta(b.hurt_time, b.hurt_dir, b.damage);
|
||||
// AbstractBoat.DATA_ID_PADDLE_LEFT and DATA_ID_PADDLE_RIGHT.
|
||||
@@ -410,7 +401,7 @@ impl Game<'_> {
|
||||
));
|
||||
out.push(e);
|
||||
}
|
||||
self.lan_projectile_states(host_entity, hub, out);
|
||||
self.lan_projectile_states(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);
|
||||
@@ -550,11 +541,10 @@ impl Game<'_> {
|
||||
|
||||
/// Primed TNT, falling blocks, projectiles, tridents, firework rockets and
|
||||
/// end crystals, as guests track them.
|
||||
fn lan_projectile_states(&self, host_entity: Option<i32>, hub: &mut Hub, out: &mut States) {
|
||||
fn lan_projectile_states(&self, hub: &mut Hub, out: &mut States) {
|
||||
let owner = |who: Option<horizon_client::mobs::Who>| match who {
|
||||
Some(horizon_client::mobs::Who::Mob(id)) => id as i32,
|
||||
Some(horizon_client::mobs::Who::Player) => host_entity.unwrap_or(0),
|
||||
None => 0,
|
||||
Some(horizon_client::mobs::Who::Player) | None => 0,
|
||||
};
|
||||
let at = |p: horizon_core::geometry::Vec3| [p.x, p.y, p.z];
|
||||
for f in &self.fangs.list {
|
||||
@@ -722,7 +712,7 @@ impl Game<'_> {
|
||||
.iter()
|
||||
.find(|g| g.conn == conn)
|
||||
.map(|g| g.entity_id),
|
||||
None => host_entity,
|
||||
None => None,
|
||||
};
|
||||
if r.attached
|
||||
&& let Some(player) = boosted
|
||||
|
||||
@@ -809,7 +809,6 @@ mod tests {
|
||||
allow_flight: false,
|
||||
seed: 0,
|
||||
max_players: 20,
|
||||
dedicated: false,
|
||||
integrated: false,
|
||||
levels: Default::default(),
|
||||
};
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
//! Players hitting players in an open world (`Player.attack` on a player):
|
||||
//! the host hits a guest, a guest hits the host or another guest. The hit
|
||||
//! Players hitting players on a server (`Player.attack` on a player). The hit
|
||||
//! is worked out as the attacker (weapon, attack strength, critical hit,
|
||||
//! enchantments, a mace's smash) and lands on the victim like any player
|
||||
//! 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, mace::Spared, ray_box};
|
||||
use super::{Game, lan::LanHost, lan_mobs::guest_bounds, mace::Spared};
|
||||
|
||||
use horizon_client::{
|
||||
Result,
|
||||
@@ -30,19 +29,16 @@ pub(super) fn meets_players(kind: &ProjectileKind) -> bool {
|
||||
)
|
||||
}
|
||||
|
||||
/// Who a player's attack lands on.
|
||||
/// Who a player's attack lands on: a player by connection.
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
pub(super) enum Victim {
|
||||
Host,
|
||||
Guest(u32),
|
||||
}
|
||||
|
||||
impl Victim {
|
||||
fn conn(self) -> Option<u32> {
|
||||
match self {
|
||||
Victim::Host => None,
|
||||
Victim::Guest(conn) => Some(conn),
|
||||
}
|
||||
let Victim::Guest(conn) = self;
|
||||
Some(conn)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -106,53 +102,9 @@ impl Strike {
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
/// The guest on the host's crosshair and how far, within reach.
|
||||
pub(super) fn guest_hit(&self) -> Option<(u32, f64)> {
|
||||
let host = self.lan.as_ref()?;
|
||||
let (eye, dir) = self.aim()?;
|
||||
let reach = self.entity_reach;
|
||||
host.hub
|
||||
.guests
|
||||
.iter()
|
||||
.filter(|g| host.hub.here(g) && g.loaded && !g.dead && !g.spectator())
|
||||
.filter_map(|g| Some((g.conn, ray_box(eye, dir, reach, guest_bounds(g))?)))
|
||||
.min_by(|a, b| a.1.total_cmp(&b.1))
|
||||
}
|
||||
|
||||
/// The host's attack on the guest under the crosshair, when no mob or
|
||||
/// block is nearer.
|
||||
pub(super) fn attack_guest(&mut self) -> bool {
|
||||
let Some((conn, d)) = self.guest_hit() else {
|
||||
return false;
|
||||
};
|
||||
if self.mob_hit().is_some_and(|(_, m)| m < d) || !self.before_block(d) {
|
||||
return false;
|
||||
}
|
||||
let Some(mut host) = self.lan.take() else {
|
||||
return false;
|
||||
};
|
||||
if let Some(strike) = self.pvp_strike()
|
||||
&& self.pvp_land(&mut host, Victim::Guest(conn), &strike)
|
||||
{
|
||||
self.weapon_hit_player(Self::pvp_victim_on_ground(&host, Victim::Guest(conn), None));
|
||||
self.pvp_exhaustion();
|
||||
if strike.stops_sprint() {
|
||||
self.stop_sprint_after_extra_knockback();
|
||||
}
|
||||
}
|
||||
self.lan = Some(host);
|
||||
true
|
||||
}
|
||||
|
||||
/// Whether the player a hit landed on stands on the ground (the host's
|
||||
/// `ground` is passed in as `host_on_ground` when the host is the victim).
|
||||
pub(super) fn pvp_victim_on_ground(
|
||||
host: &LanHost,
|
||||
victim: Victim,
|
||||
host_on_ground: Option<bool>,
|
||||
) -> bool {
|
||||
/// Whether the player a hit landed on stands on the ground.
|
||||
pub(super) fn pvp_victim_on_ground(host: &LanHost, victim: Victim) -> bool {
|
||||
match victim {
|
||||
Victim::Host => host_on_ground.unwrap_or(true),
|
||||
Victim::Guest(conn) => host
|
||||
.hub
|
||||
.guest(conn)
|
||||
@@ -163,9 +115,6 @@ impl Game<'_> {
|
||||
|
||||
/// The player `id` that guest `conn` attacks, if it is one.
|
||||
pub(super) fn pvp_victim(host: &LanHost, conn: u32, id: i32) -> Option<Victim> {
|
||||
if id == host.host_entity {
|
||||
return Some(Victim::Host);
|
||||
}
|
||||
host.hub
|
||||
.guests
|
||||
.iter()
|
||||
@@ -229,8 +178,7 @@ impl Game<'_> {
|
||||
})
|
||||
}
|
||||
|
||||
/// The player a player's projectile hit, when it hit one: the host for a
|
||||
/// guest's shot, or a guest.
|
||||
/// The player a player's projectile hit, when it hit one.
|
||||
pub(super) fn projectile_pvp_victim(&self, p: &Projectile, hit: Hit) -> Option<Victim> {
|
||||
if p.owner != Some(Who::Player) || !meets_players(&p.kind) {
|
||||
return None;
|
||||
@@ -239,7 +187,7 @@ impl Game<'_> {
|
||||
return None;
|
||||
};
|
||||
match who {
|
||||
Who::Player => p.guest.map(|_| Victim::Host),
|
||||
Who::Player => None,
|
||||
Who::Mob(id) => self
|
||||
.lan
|
||||
.as_ref()?
|
||||
@@ -273,7 +221,6 @@ impl Game<'_> {
|
||||
None => self.player_team().cloned(),
|
||||
};
|
||||
let target = match victim {
|
||||
Victim::Host => self.player_team().cloned(),
|
||||
Victim::Guest(conn) => host.hub.guest(conn).and_then(|g| team_of(self, &g.name)),
|
||||
};
|
||||
let refused = !(self.ui.rules.bool("pvp")
|
||||
@@ -284,16 +231,6 @@ impl Game<'_> {
|
||||
};
|
||||
let shooter = self.player_attacker(&host, p.guest);
|
||||
let result = match victim {
|
||||
Victim::Host => {
|
||||
self.lan = Some(host);
|
||||
self.combat.refuse_player_hit = refused;
|
||||
self.combat.projectile_attacker = shooter;
|
||||
let r = self.projectile_hit(p, on_player);
|
||||
self.combat.refuse_player_hit = false;
|
||||
// A refused or creative hit returns before hurt_player_from takes it.
|
||||
self.combat.projectile_attacker = None;
|
||||
return r;
|
||||
}
|
||||
Victim::Guest(conn) => self.guest_hit_by(&mut host, conn, |g| {
|
||||
g.combat.refuse_player_hit = refused;
|
||||
g.combat.projectile_attacker = shooter;
|
||||
@@ -313,10 +250,7 @@ impl Game<'_> {
|
||||
let g = host.hub.guest(conn)?;
|
||||
Some((g.entity_id as u32, self.player_text(&g.name, g.uuid)))
|
||||
}
|
||||
None => self.player.is_some().then(|| {
|
||||
let name = self.player_text(&host.host_name, host.host_uuid);
|
||||
(host.host_entity as u32, name)
|
||||
}),
|
||||
None => None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -326,7 +260,6 @@ impl Game<'_> {
|
||||
// ServerPlayer.canHarmPlayer: the pvp game rule, then the victim's
|
||||
// team (Player.canHarmPlayer: allies only with friendly fire on).
|
||||
let team = match victim {
|
||||
Victim::Host => self.player_team(),
|
||||
Victim::Guest(conn) => host
|
||||
.hub
|
||||
.guest(conn)
|
||||
@@ -346,16 +279,11 @@ impl Game<'_> {
|
||||
// CombatTracker: the attacking player is the death message's killer.
|
||||
// Without it the message is built with one argument and a template
|
||||
// that names a killer ("%1$s was slain by %2$s") shows as written.
|
||||
let attacker_note = match s.attacker {
|
||||
Some(conn) => host
|
||||
.hub
|
||||
let attacker_note = s.attacker.and_then(|conn| {
|
||||
host.hub
|
||||
.guest(conn)
|
||||
.map(|a| (a.entity_id, self.player_text(&a.name, a.uuid))),
|
||||
None => Some((
|
||||
host.host_entity,
|
||||
self.player_text(&host.host_name, host.host_uuid),
|
||||
)),
|
||||
};
|
||||
.map(|a| (a.entity_id, self.player_text(&a.name, a.uuid)))
|
||||
});
|
||||
let land = |g: &mut Self| {
|
||||
g.combat.player_attacker = Some((s.from, s.disable_blocking));
|
||||
if let Some((id, name)) = attacker_note.clone() {
|
||||
@@ -373,7 +301,6 @@ impl Game<'_> {
|
||||
};
|
||||
let (hurt, thorns) = if self.ui.rules.bool("pvp") && harm {
|
||||
match victim {
|
||||
Victim::Host => land(self),
|
||||
Victim::Guest(conn) => self.guest_struck_by(host, conn, land).unwrap_or_default(),
|
||||
}
|
||||
} else {
|
||||
@@ -384,10 +311,6 @@ impl Game<'_> {
|
||||
let died = match victim {
|
||||
// A totem that fired is applied on the next tick_totem, so the
|
||||
// victim is still at zero health but is not dying.
|
||||
Victim::Host => self
|
||||
.player
|
||||
.as_ref()
|
||||
.is_some_and(|p| p.health.dead_or_dying() && !p.health.totem_used),
|
||||
Victim::Guest(conn) => host
|
||||
.hub
|
||||
.guest(conn)
|
||||
@@ -405,7 +328,6 @@ impl Game<'_> {
|
||||
}
|
||||
if hurt && (s.crit || s.magic) {
|
||||
let target = match victim {
|
||||
Victim::Host => self.player.as_ref().map(|p| (host.host_entity, p)),
|
||||
Victim::Guest(conn) => host
|
||||
.hub
|
||||
.guest(conn)
|
||||
@@ -454,7 +376,6 @@ impl Game<'_> {
|
||||
if let Some(at) = s.sweep.filter(|_| hurt) {
|
||||
// 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))
|
||||
@@ -531,16 +452,6 @@ impl Game<'_> {
|
||||
pub(super) fn sweep_players(&mut self, host: &mut LanHost, sweep: &Sweep) {
|
||||
let from = Vec3::new(sweep.from[0], sweep.from[1], sweep.from[2]);
|
||||
let mut victims = Vec::new();
|
||||
if sweep.attacker.is_some()
|
||||
&& self.options.dedicated.is_none()
|
||||
&& !self.spectator()
|
||||
&& let Some(p) = self.player.as_ref()
|
||||
&& !p.health.dead_or_dying()
|
||||
&& p.bounds().intersects(sweep.hit_box)
|
||||
&& p.position.distance_squared(from) < 9.0
|
||||
{
|
||||
victims.push((Victim::Host, host.host_entity, self.player_team().cloned()));
|
||||
}
|
||||
for g in &host.hub.guests {
|
||||
if host.hub.here(g)
|
||||
&& g.loaded
|
||||
@@ -572,12 +483,8 @@ impl Game<'_> {
|
||||
{
|
||||
continue;
|
||||
}
|
||||
match victim {
|
||||
Victim::Host => land(self),
|
||||
Victim::Guest(conn) => {
|
||||
self.guest_struck_by(host, conn, land);
|
||||
}
|
||||
}
|
||||
let Victim::Guest(conn) = victim;
|
||||
self.guest_struck_by(host, conn, land);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,16 +3,9 @@
|
||||
use super::{Game, lan::LanHost};
|
||||
use horizon_client::{
|
||||
items::ItemStack,
|
||||
lan::{
|
||||
hub::HostState,
|
||||
waypoints::{self, HIDING_HEADS, Icon, PLAYER_RANGE},
|
||||
},
|
||||
lan::waypoints::{self, HIDING_HEADS, Icon, PLAYER_RANGE},
|
||||
};
|
||||
|
||||
/// The host player's connection id in the waypoint managers: its packets
|
||||
/// go to this client's locator bar rather than to a guest.
|
||||
const HOST_CONN: u32 = u32::MAX;
|
||||
|
||||
/// `waypoint_transmit_range`: hidden by crouching, invisibility or a head worn.
|
||||
fn transmit_range(crouching: bool, invisible: bool, head: Option<&ItemStack>) -> f64 {
|
||||
let hiding_head = head.is_some_and(|s| HIDING_HEADS.contains(&&*s.item));
|
||||
@@ -35,38 +28,8 @@ impl Game<'_> {
|
||||
/// level: a player who changes level leaves the old one's manager
|
||||
/// (`ServerLevel.removePlayerImmediately`: untracked for everyone there)
|
||||
/// and joins the new one's (`addDuringTeleport`).
|
||||
pub(super) fn lan_update_waypoints(&mut self, host: &mut LanHost, state: Option<&HostState>) {
|
||||
pub(super) fn lan_update_waypoints(&mut self, host: &mut LanHost) {
|
||||
let mut players: Vec<(&str, waypoints::Player)> = Vec::new();
|
||||
if let Some(h) = state {
|
||||
let icon = self
|
||||
.cmds
|
||||
.waypoint_icons
|
||||
.get(&super::commands::Target::Player)
|
||||
.map(|i| Icon {
|
||||
style: i.style.clone(),
|
||||
color: i.color,
|
||||
})
|
||||
.unwrap_or_default();
|
||||
players.push((
|
||||
self.dim.id(),
|
||||
waypoints::Player {
|
||||
uuid: h.uuid,
|
||||
conn: Some(HOST_CONN),
|
||||
position: h.position,
|
||||
spectator: self.spectator(),
|
||||
transmit_range: transmit_range(
|
||||
h.sneaking,
|
||||
self.potions.effects.get("invisibility").is_some(),
|
||||
h.equipment[5].as_ref(),
|
||||
),
|
||||
receive_range: PLAYER_RANGE,
|
||||
first_tick: false,
|
||||
view_distance: host.hub.config.view_distance,
|
||||
team: self.waypoint_team(&h.name),
|
||||
icon,
|
||||
},
|
||||
));
|
||||
}
|
||||
let mut respawned = Vec::new();
|
||||
for g in host.hub.guests.iter().filter(|g| g.placed) {
|
||||
// PlayerList.respawn makes a new ServerPlayer.
|
||||
@@ -127,12 +90,7 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
for (conn, packet) in out {
|
||||
if conn == HOST_CONN {
|
||||
// The host's own connection: its locator bar.
|
||||
if let horizon_protocol::packets::play::Clientbound::TrackedWaypoint(w) = &packet {
|
||||
self.handle_waypoint(w);
|
||||
}
|
||||
} else if let Some(g) = host.hub.guest(conn) {
|
||||
if let Some(g) = host.hub.guest(conn) {
|
||||
g.send(&packet);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,8 +5,7 @@
|
||||
//! A server (the dedicated server and single player's server thread) keeps
|
||||
//! the other dimensions' levels here and ticks each one with players or forced
|
||||
//! chunks by swapping it into place (`enter_level`), the way `as_guest` swaps a player
|
||||
//! in, so the tick code runs unchanged, one level at a time. The in-frame
|
||||
//! game holds one level, which follows its player (`enter_dimension`).
|
||||
//! in, so the tick code runs unchanged, one level at a time.
|
||||
//!
|
||||
//! Shared by every level, as vanilla's derived levels share the
|
||||
//! Overworld's level data: the game time, the day time and the weather, the
|
||||
@@ -309,7 +308,7 @@ impl Game<'_> {
|
||||
mut host: Option<&mut LanHost>,
|
||||
target: Dim,
|
||||
) -> Result<()> {
|
||||
// The in-frame game keeps its one level (`enter_dimension`).
|
||||
// A client holds no levels.
|
||||
if self.options.execution_role.is_client() {
|
||||
return Ok(());
|
||||
}
|
||||
@@ -425,6 +424,7 @@ impl Game<'_> {
|
||||
let observe_level =
|
||||
running.is_some() && (self.serving.is_some() || self.options.dedicated.is_some());
|
||||
let dims = self.levels_to_tick();
|
||||
let order = dims.clone();
|
||||
let n = dims.len();
|
||||
let mut result = Ok(());
|
||||
for (k, dim) in dims.into_iter().enumerate() {
|
||||
@@ -467,6 +467,22 @@ impl Game<'_> {
|
||||
self.native_level_tick_post(dim.id());
|
||||
}
|
||||
self.publish_server_debug(dim);
|
||||
// An entity that crossed into a level that already ticked this
|
||||
// server tick is in it when the tick ends (`addDuringTeleport`).
|
||||
if running == Some(true) {
|
||||
let mut moved = false;
|
||||
for &earlier in &order[..k] {
|
||||
if !self.arrival_chunks(earlier).is_empty()
|
||||
&& self.enter_level(None, earlier).is_ok()
|
||||
{
|
||||
self.land_portal_arrivals();
|
||||
moved = true;
|
||||
}
|
||||
}
|
||||
if moved {
|
||||
self.enter_level(None, dim)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
self.level_pass = Pass::default();
|
||||
if result.is_err() {
|
||||
|
||||
@@ -15,7 +15,6 @@ use horizon_core::{
|
||||
position::BlockPos,
|
||||
random::RandomSource,
|
||||
};
|
||||
use horizon_world::registry::BlockStateId;
|
||||
|
||||
/// `MaceItem.SMASH_ATTACK_FALL_THRESHOLD`.
|
||||
const FALL_THRESHOLD: f64 = 1.5;
|
||||
@@ -585,31 +584,6 @@ impl Game<'_> {
|
||||
};
|
||||
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(
|
||||
&mut self,
|
||||
state: BlockStateId,
|
||||
pos: BlockPos,
|
||||
fall_distance: f64,
|
||||
) {
|
||||
let Some((sprite, tint)) = self.particle_sprite(state, [pos.x, pos.y, pos.z]) else {
|
||||
return;
|
||||
};
|
||||
let count = (50.0 * fall_distance).clamp(0.0, 200.0) as u32;
|
||||
let spread = f64::from(0.3f32);
|
||||
self.particles.send_particles(
|
||||
&ParticleOptions::block(ParticleType::Block, sprite, tint),
|
||||
[
|
||||
f64::from(pos.x) + 0.5,
|
||||
f64::from(pos.y) + 1.0,
|
||||
f64::from(pos.z) + 0.5,
|
||||
],
|
||||
[spread; 3],
|
||||
f64::from(0.15f32),
|
||||
count,
|
||||
);
|
||||
}
|
||||
/// Level event 2013 (`ParticleUtils.spawnSmashAttackParticles`): dust
|
||||
/// pillars of the block at `pos`, a cluster over it and a ring 3.5 out.
|
||||
pub(super) fn smash_dust(&mut self, pos: BlockPos, count: i32) {
|
||||
|
||||
@@ -538,7 +538,8 @@ impl Game<'_> {
|
||||
boxes.retain(|b| b.intersects(near));
|
||||
boxes
|
||||
}
|
||||
/// Entity.move for a cart: collisions zero the blocked components.
|
||||
/// Entity.move for a cart: collisions zero the blocked components, the world border's walls
|
||||
/// among them (`CollisionGetter.borderCollision`, asked for every entity).
|
||||
fn move_cart(&self, c: &mut Minecart, delta: Vec3) {
|
||||
let scene = SceneCollisions {
|
||||
world: &self.world,
|
||||
@@ -549,7 +550,7 @@ impl Game<'_> {
|
||||
let mut scene = horizon_client::collisions::EntityCollisions {
|
||||
blocks: scene,
|
||||
entities: &entities,
|
||||
border: None,
|
||||
border: Some((self.border.geometry(), [c.position.x, c.position.z])),
|
||||
};
|
||||
if let Ok(moved) = scene.resolve(c.bounds(), delta, 0.0, c.on_ground) {
|
||||
c.position = Vec3::new(
|
||||
|
||||
@@ -74,42 +74,6 @@ fn antenna_block_matrix(head: glam::Mat4, flowerbed_shift: bool) -> glam::Mat4 {
|
||||
pub(super) const APPARITION_TICKS: i32 = 30;
|
||||
|
||||
impl Game<'_> {
|
||||
/// `CustomHeadLayer` for a pumpkin worn by a LAN guest drawn on the
|
||||
/// in-frame host, whose model has no worn stacks for `head_item_geometry`.
|
||||
pub(super) fn player_pumpkin_geometry(
|
||||
&self,
|
||||
mob: &Mob,
|
||||
stack: Option<&ItemStack>,
|
||||
camera: glam::DVec3,
|
||||
alpha: f64,
|
||||
light: u32,
|
||||
out: &mut Vec<Vertex>,
|
||||
) {
|
||||
let Some(stack) = stack.filter(|s| &*s.item == "minecraft:carved_pumpkin") else {
|
||||
return;
|
||||
};
|
||||
let Some(head) =
|
||||
render::part_transform(mob, alpha as f32, camera, self.res.mob_data(), "head")
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let pose = head
|
||||
* glam::Mat4::from_translation(glam::Vec3::new(0.0, -0.25, 0.0))
|
||||
* glam::Mat4::from_rotation_y(std::f32::consts::PI)
|
||||
* glam::Mat4::from_scale(glam::Vec3::new(0.625, -0.625, -0.625));
|
||||
let owner = super::item_draw::owner_at(
|
||||
[mob.position.x, mob.position.y, mob.position.z],
|
||||
mob.body_yaw,
|
||||
&mob.kind.id,
|
||||
);
|
||||
let state = self.resolve_item(
|
||||
stack,
|
||||
horizon_assets::item_model::DisplayContext::Head,
|
||||
Some(&owner),
|
||||
mob.id as i32 + 5,
|
||||
);
|
||||
self.submit_item_world(&state, pose, light, out);
|
||||
}
|
||||
/// Count the elder guardian's apparition down.
|
||||
pub(super) fn tick_apparition(&mut self) {
|
||||
self.creatures.elder_apparition = (self.creatures.elder_apparition - 1).max(0);
|
||||
|
||||
@@ -61,17 +61,9 @@ impl Game<'_> {
|
||||
t.push(objective.display_text());
|
||||
Some(t)
|
||||
}
|
||||
/// The other players' models and names: the guests on a LAN host, the
|
||||
/// players a server sends on a client.
|
||||
/// The other players' models and names: the players a server sends.
|
||||
fn other_players(&self) -> Vec<(&Mob, String)> {
|
||||
let mut out = Vec::new();
|
||||
if let Some(host) = &self.lan {
|
||||
for (conn, m) in &host.models {
|
||||
if let Some(g) = host.hub.guest(*conn) {
|
||||
out.push((m, g.name.clone()));
|
||||
}
|
||||
}
|
||||
}
|
||||
for (id, name) in self.remote_player_names() {
|
||||
if let Some(m) = self.mobs.iter().find(|m| m.id == id) {
|
||||
out.push((m, name));
|
||||
|
||||
@@ -1565,8 +1565,7 @@ impl Game<'_> {
|
||||
mod_api::dispatch_emissive(self, mod_api::mods(), layers);
|
||||
}
|
||||
/// The world's phases, read-only; `error_text` carries the engine's
|
||||
/// own open-failure text, empty otherwise. Server-side; the deprecated
|
||||
/// in-frame path delivers no lifecycle events. A server loop or final
|
||||
/// own open-failure text, empty otherwise. Server-side. A server loop or final
|
||||
/// save failure fires `WorldCloseFailed` with the error text.
|
||||
pub(super) fn native_world_lifecycle(
|
||||
&mut self,
|
||||
@@ -4932,12 +4931,10 @@ impl Game<'_> {
|
||||
/// 0 runs it out on its first tick, as `native_living_entity_use_item_tick`
|
||||
/// running it out, unless the item's own is 0 already), or set
|
||||
/// `canceled` and the use doesn't start. Both sides, as
|
||||
/// vanilla's: wherever the world runs (the server, single player
|
||||
/// in-frame, a LAN host) for every player of the world, and on a
|
||||
/// vanilla's: on the server for every player of the world, and on a
|
||||
/// server's client for the uses it predicts. Answer the same on both,
|
||||
/// and change the world only where this `Game` runs the world, not on a
|
||||
/// server's client: no helper tells the two apart yet (`native_window`
|
||||
/// is also true in-frame and on an in-frame LAN host). API 90.
|
||||
/// and change the world only on the server (`native_window` is false
|
||||
/// there), not on a server's client. API 90.
|
||||
pub(super) fn native_living_entity_use_item_start(
|
||||
&mut self,
|
||||
stack: &ItemStack,
|
||||
@@ -4999,9 +4996,8 @@ impl Game<'_> {
|
||||
/// the code. On a server's client a run-out use stays in use, frozen,
|
||||
/// so letting go still tells the server. Both sides, as vanilla's
|
||||
/// `updatingUsingItem`: answer the same on both, and change the world
|
||||
/// only where this `Game` runs the world, not on a server's client (no
|
||||
/// helper tells the two apart yet; `native_window` is also true
|
||||
/// in-frame and on an in-frame LAN host). Runs every tick of every use:
|
||||
/// only on the server (`native_window` is false there), not on a
|
||||
/// server's client. Runs every tick of every use:
|
||||
/// check the item first. API 131.
|
||||
pub(super) fn native_living_entity_use_item_tick(
|
||||
&mut self,
|
||||
@@ -5967,8 +5963,7 @@ impl Game<'_> {
|
||||
/// as on a refused hit, except that a canceled hit on the ender dragon
|
||||
/// still counts as landed (`EnderDragon.hurt` returns true whatever
|
||||
/// `reallyHurt` did: the arrow is spent, the melee effects run). It runs
|
||||
/// on the thread that ticks the world (the server thread; the window's
|
||||
/// in-frame): state shared with render thread hooks needs synchronising.
|
||||
/// on the thread that ticks the world (the server thread): state shared with render thread hooks needs synchronising.
|
||||
/// No `self`: the game is in the middle of the hit. API 50.
|
||||
pub(super) fn native_living_incoming_damage(
|
||||
entity: i32,
|
||||
@@ -6010,7 +6005,7 @@ impl Game<'_> {
|
||||
/// ender dragon's wings, Punch arrows, explosions, wind charges, a mace smash's area, a
|
||||
/// hoglin's throw), nor yet for the ones Horizon makes another way: a mob's melee push from
|
||||
/// its attack knockback or a Knockback weapon, an armor stand's, the ender dragon's. It runs
|
||||
/// on the thread that ticks the world (the server thread; the window's in-frame): state
|
||||
/// on the thread that ticks the world (the server thread): state
|
||||
/// shared with render thread hooks needs synchronising. No `self`: the game is in the middle
|
||||
/// of the hit. API 125.
|
||||
pub(super) fn native_living_knockback(
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
//! Read-only lifecycle observations for native mods on authoritative servers.
|
||||
//! The deprecated in-frame path delivers no lifecycle events. A paused pass
|
||||
//! A paused pass
|
||||
//! observes nothing; a frozen world reports `world_running: false` except during a step.
|
||||
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
@@ -353,37 +353,4 @@ mod tests {
|
||||
assert!(lifecycle.world_close_failed().is_none());
|
||||
assert!(lifecycle.world_close_final().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lifecycle_delivery_stays_off_the_deprecated_in_frame_path() {
|
||||
let game = include_str!("../game.rs")
|
||||
.split("#[cfg(test)]")
|
||||
.next()
|
||||
.unwrap_or_default();
|
||||
for site in [
|
||||
"native_lifecycle_open_started()",
|
||||
"native_lifecycle_world_ready()",
|
||||
"native_lifecycle_world_closed()",
|
||||
"native_lifecycle.begin_close()",
|
||||
] {
|
||||
assert!(!game.contains(site), "in-frame site remains: {site}");
|
||||
}
|
||||
let saving = include_str!("saving.rs")
|
||||
.split("#[cfg(test)]")
|
||||
.next()
|
||||
.unwrap_or_default();
|
||||
assert!(!saving.contains("native_lifecycle"));
|
||||
|
||||
let integrated = include_str!("integrated.rs")
|
||||
.split("#[cfg(test)]")
|
||||
.next()
|
||||
.unwrap_or_default();
|
||||
let open = integrated
|
||||
.find("self.native_lifecycle_open_started();")
|
||||
.unwrap();
|
||||
let load = integrated
|
||||
.find("self.finish_open_world(opening.world?.finish()?)?;")
|
||||
.unwrap();
|
||||
assert!(open < load);
|
||||
}
|
||||
}
|
||||
@@ -5,20 +5,20 @@ use super::{Game, native_hooks::mob_kind};
|
||||
|
||||
impl Game<'_> {
|
||||
/// Whether this game is a server whose mounts the mods hear: the server thread or a
|
||||
/// dedicated server, never a client nor the deprecated in-frame path.
|
||||
/// dedicated server, never a client.
|
||||
fn native_hears_mounts(&self) -> bool {
|
||||
self.serving.is_some() || self.options.dedicated.is_some()
|
||||
}
|
||||
|
||||
/// The entity id of the acting player: a guest's while their action runs (kept with the
|
||||
/// action, as the hub is often out of `lan` then), else the host's.
|
||||
/// action, as the hub is often out of `lan` then), else none (0).
|
||||
fn native_acting_entity(&self) -> u32 {
|
||||
match self.lan_acting {
|
||||
Some(conn) => self.lan_acting_entity.unwrap_or_else(|| {
|
||||
let guests = self.lan_guest_entities();
|
||||
guests.iter().find(|g| g.0 == conn).map_or(0, |g| g.1)
|
||||
}),
|
||||
None => self.lan.as_ref().map_or(0, |host| host.host_entity as u32),
|
||||
None => 0,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -46,8 +46,7 @@ fn projectile_type(p: &Projectile) -> &'static str {
|
||||
|
||||
impl Game<'_> {
|
||||
/// Whether this game is a server that flies projectiles for the mods (the
|
||||
/// server thread or a dedicated server, never the deprecated in-frame path
|
||||
/// nor a client) and a mod asked to hear their impacts
|
||||
/// server thread or a dedicated server, never a client) and a mod asked to hear their impacts
|
||||
/// (`native_hears_projectile_impacts`). Without one, the tick keeps no copy
|
||||
/// of its projectiles: it costs nothing.
|
||||
pub(super) fn native_hears_impacts(&self) -> bool {
|
||||
@@ -77,7 +76,7 @@ impl Game<'_> {
|
||||
.into_iter()
|
||||
.find(|&(c, _)| c == conn)
|
||||
.map_or(0, |(_, id)| id),
|
||||
None => self.lan.as_ref().map_or(0, |host| host.host_entity as u32),
|
||||
None => 0,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -166,15 +165,12 @@ impl Game<'_> {
|
||||
let (entity, kind) = self.native_impact_entity(id);
|
||||
(Some(entity), kind, [0; 3], [0; 3], at)
|
||||
}
|
||||
// A server's players are hit as their entities, never as `Player`.
|
||||
Hit::Entity {
|
||||
who: Who::Player,
|
||||
at,
|
||||
who: Who::Player, ..
|
||||
} => {
|
||||
let Some(host) = self.lan.as_ref().map(|h| h.host_entity as u32) else {
|
||||
canceled.push(false);
|
||||
continue;
|
||||
};
|
||||
(Some(host), "minecraft:player".into(), [0; 3], [0; 3], at)
|
||||
canceled.push(false);
|
||||
continue;
|
||||
}
|
||||
Hit::None => {
|
||||
canceled.push(false);
|
||||
@@ -286,7 +282,11 @@ impl Game<'_> {
|
||||
horizon_client::arrows::tick_through(&mut p, &scene, &targets)
|
||||
} else {
|
||||
let w = self.mob_world_base(&[]);
|
||||
p.tick_through(&ProjWorld { w: &w, targets })
|
||||
p.tick_through(&ProjWorld {
|
||||
w: &w,
|
||||
targets,
|
||||
border: Some(self.border.bounds()),
|
||||
})
|
||||
};
|
||||
if shooter.is_some() {
|
||||
p.owner = Some(Who::Player);
|
||||
|
||||
@@ -53,7 +53,7 @@ impl TickRound {
|
||||
|
||||
impl Game<'_> {
|
||||
/// A round for a family about to tick: on a server only (the server thread or a dedicated
|
||||
/// server, never a client nor the deprecated in-frame path), and only while a mod listens
|
||||
/// server, never a client), and only while a mod listens
|
||||
/// (`native_entity_tick_listened`). Its list is kept between ticks.
|
||||
pub(super) fn native_tick_round(&mut self) -> TickRound {
|
||||
let server = self.serving.is_some() || self.options.dedicated.is_some();
|
||||
|
||||
@@ -11,7 +11,6 @@
|
||||
//! saved compound until its destination chunks are loaded.
|
||||
use super::entity::{Entity as _, entity_lists_of, for_each_entity};
|
||||
use super::{Dim, Game, Screen};
|
||||
use horizon_client::Result;
|
||||
use horizon_client::fast_hash::FxHashMap;
|
||||
use horizon_client::nether_portal::{ENTITY_DELAY, PortalState};
|
||||
use horizon_core::{
|
||||
@@ -81,8 +80,6 @@ struct Arrival {
|
||||
pub(super) struct Portals {
|
||||
/// The player's.
|
||||
pub(super) player: PortalState,
|
||||
/// The player's travel, while the destination loads.
|
||||
pub(super) travel: Option<Plan>,
|
||||
/// `LocalPlayer.portalProcess.isInsidePortalThisTick`.
|
||||
client_inside: bool,
|
||||
/// Items and mobs, by entity id.
|
||||
@@ -143,6 +140,38 @@ pub(super) fn projectile_box(p: &horizon_client::projectiles::Projectile) -> (f3
|
||||
(size, size)
|
||||
}
|
||||
}
|
||||
/// `Entity.calculatePassengerTransition`: the riders under `tag`'s
|
||||
/// `Passengers`, and theirs, keep their place on the vehicle: they move by
|
||||
/// what it moved and turn by what it turned.
|
||||
fn move_passengers(tag: &mut Tag, by: Vec3, turn: f32) {
|
||||
let Tag::Compound(c) = tag else {
|
||||
return;
|
||||
};
|
||||
let Some(Tag::List(riders)) = c.get_mut(&"Passengers".into()) else {
|
||||
return;
|
||||
};
|
||||
for rider in riders {
|
||||
if let Some(at) = horizon_client::entity_nbt::position(rider)
|
||||
&& let Tag::Compound(r) = rider
|
||||
{
|
||||
r.insert(
|
||||
"Pos".into(),
|
||||
doubles([at.x + by.x, at.y + by.y, at.z + by.z]),
|
||||
);
|
||||
let [yaw, pitch] = horizon_client::entity_nbt::rotation(rider);
|
||||
if let Tag::Compound(r) = rider {
|
||||
r.insert(
|
||||
"Rotation".into(),
|
||||
Tag::List(vec![
|
||||
Tag::Float((yaw + turn).to_bits()),
|
||||
Tag::Float(pitch.to_bits()),
|
||||
]),
|
||||
);
|
||||
}
|
||||
}
|
||||
move_passengers(rider, by, turn);
|
||||
}
|
||||
}
|
||||
fn doubles(values: [f64; 3]) -> Tag {
|
||||
Tag::List(values.iter().map(|v| Tag::Double(v.to_bits())).collect())
|
||||
}
|
||||
@@ -484,25 +513,6 @@ impl Game<'_> {
|
||||
|
||||
// ---- the player ----
|
||||
|
||||
/// `NetherPortalBlock.entityInside` for the player: `canUsePortal`, then
|
||||
/// `setAsInsidePortal` (and the client's own, which drives the effect).
|
||||
pub(super) fn player_inside_nether_portal(&mut self, pos: BlockPos) {
|
||||
let alive = self
|
||||
.player
|
||||
.as_ref()
|
||||
.is_some_and(|p| !p.health.dead_or_dying());
|
||||
if !alive || self.riding.is_some() || self.sleeping.is_some() {
|
||||
return;
|
||||
}
|
||||
// The client's LocalPlayer has the same cooldown (the Respawn
|
||||
// packet's spawn info): standing in the arrival portal, its overlay
|
||||
// fades instead of building up again.
|
||||
let cooling = self.portals.player.cooldown > 0;
|
||||
self.portals.player.set_inside(pos, PLAYER_DELAY);
|
||||
if !cooling {
|
||||
self.portals.client_inside = true;
|
||||
}
|
||||
}
|
||||
/// The same on a server's client: `Entity.canUsePortal` (alive, not a
|
||||
/// passenger), then only the client's `setAsInsidePortal`, which drives
|
||||
/// the confusion effect (`client_portal_effect`). On a server's client
|
||||
@@ -521,62 +531,6 @@ impl Game<'_> {
|
||||
self.portals.client_inside = true;
|
||||
}
|
||||
}
|
||||
/// `ServerPlayer.handlePortal` (not while changing dimension: there is no
|
||||
/// player then). Players wait `players_nether_portal_default_delay`
|
||||
/// ticks, or the creative delay with invulnerable abilities.
|
||||
pub(super) fn tick_nether_portal(&mut self) -> Result<()> {
|
||||
// A server's portals are its own.
|
||||
if self.remote.is_some() {
|
||||
return Ok(());
|
||||
}
|
||||
let Some(p) = self.player.as_ref() else {
|
||||
return Ok(());
|
||||
};
|
||||
if self.travelling.is_some() {
|
||||
return Ok(());
|
||||
}
|
||||
let allowed = !p.health.dead_or_dying() && self.riding.is_none() && self.sleeping.is_none();
|
||||
let rule = if p.invulnerable {
|
||||
"players_nether_portal_creative_delay"
|
||||
} else {
|
||||
"players_nether_portal_default_delay"
|
||||
};
|
||||
let transition = self.ui.rules.int(rule).max(0);
|
||||
let Some(entry) = self
|
||||
.portals
|
||||
.player
|
||||
.handle(allowed, transition, PLAYER_DELAY)
|
||||
else {
|
||||
return Ok(());
|
||||
};
|
||||
self.player_through_nether_portal(entry)
|
||||
}
|
||||
/// `Entity.handlePortal` once the player's portal process at `entry`
|
||||
/// finished: `NetherPortalBlock.getPortalDestination`, then
|
||||
/// `teleportToPortalDestination` (`isAllowedToEnterPortal`).
|
||||
pub(super) fn player_through_nether_portal(&mut self, entry: BlockPos) -> Result<()> {
|
||||
let Some(p) = self.player.as_ref() else {
|
||||
return Ok(());
|
||||
};
|
||||
let plan = self.portal_plan(
|
||||
entry,
|
||||
p.position,
|
||||
p.dimensions.width,
|
||||
p.dimensions.height,
|
||||
true,
|
||||
);
|
||||
if !self.may_enter(plan.dim) {
|
||||
return Ok(());
|
||||
}
|
||||
self.portals.travel = Some(plan);
|
||||
self.end_portal_travel = false;
|
||||
self.enter_dimension(plan.dim)?;
|
||||
let centre = self.arrival_centre(&plan);
|
||||
if let Some((_, target)) = self.travelling.as_mut() {
|
||||
*target = centre;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
/// Where to wait for the destination of `plan` to load, once its level
|
||||
/// is open: the portal its points of interest lead to, or the
|
||||
/// approximate exit.
|
||||
@@ -600,52 +554,6 @@ impl Game<'_> {
|
||||
f64::from(centre.z) + 0.5,
|
||||
)
|
||||
}
|
||||
/// The player's arrival once the destination has loaded around the
|
||||
/// approximate exit: the waiting entities first, then the exit portal
|
||||
/// (found or built), the position in it, the turned yaw and motion, and
|
||||
/// `TeleportTransition.PLAY_PORTAL_SOUND` (level event 1032).
|
||||
pub(super) fn arrive_through_nether_portal(
|
||||
&mut self,
|
||||
mut player: horizon_client::motion::PlayerMotion,
|
||||
target: Vec3,
|
||||
) -> Result<()> {
|
||||
self.resolve_arrivals();
|
||||
let plan = self.portals.travel.take();
|
||||
let Some(plan) = plan else {
|
||||
// A command teleport can change dimensions without going through
|
||||
// a portal. It has no portal placement, rotation, or sound effect.
|
||||
player.position = target;
|
||||
player.velocity = Vec3::ZERO;
|
||||
player.fall_distance = 0.0;
|
||||
self.previous_position = target;
|
||||
self.player = Some(player);
|
||||
self.pad.player_replaced();
|
||||
self.sway = Default::default();
|
||||
self.apply_equipment();
|
||||
return Ok(());
|
||||
};
|
||||
let exit = self.nether_exit_portal(&plan);
|
||||
let (position, rotation) = match exit {
|
||||
Some(exit) => self.arrival(&plan, exit),
|
||||
None => (target, 0.0),
|
||||
};
|
||||
player.position = position;
|
||||
// Relative.ROTATION and Relative.DELTA: the yaw turns, the motion
|
||||
// turns with it.
|
||||
player.yaw += rotation;
|
||||
player.head_yaw += rotation;
|
||||
self.yaw += rotation;
|
||||
player.velocity = rotate_y(player.velocity, -rotation);
|
||||
self.previous_position = player.position;
|
||||
self.player = Some(player);
|
||||
self.pad.player_replaced();
|
||||
self.sway = Default::default();
|
||||
self.apply_equipment();
|
||||
let pitch = self.world.random.next_float() * 0.4 + 0.8;
|
||||
self.audio
|
||||
.play("minecraft:block.portal.travel", None, 0.25, pitch);
|
||||
Ok(())
|
||||
}
|
||||
/// `LocalPlayer.handlePortalTransitionEffect`: whether the confusion
|
||||
/// effect grows this tick. Entering a portal closes screens other than
|
||||
/// the chat and death screens and plays `block.portal.trigger` when the
|
||||
@@ -681,6 +589,15 @@ impl Game<'_> {
|
||||
/// at the start of their tick: an item or mob that stayed in a portal
|
||||
/// goes through (transition time 0) and waits for its dimension.
|
||||
pub(super) fn handle_entity_portals(&mut self) {
|
||||
self.handle_portals(false);
|
||||
}
|
||||
/// `Entity.handlePortal` of the boats and minecarts, which `tick_boats`
|
||||
/// and `tick_carts` move: it runs before them, from the position they
|
||||
/// ended the last tick at, as the start of a vehicle's own tick does.
|
||||
pub(super) fn handle_vehicle_portals(&mut self) {
|
||||
self.handle_portals(true);
|
||||
}
|
||||
fn handle_portals(&mut self, vehicles: bool) {
|
||||
if self.remote.is_some() {
|
||||
return;
|
||||
}
|
||||
@@ -688,13 +605,17 @@ impl Game<'_> {
|
||||
let mut leaving: Vec<(u32, BlockPos)> = Vec::new();
|
||||
// Positions before the entities move, and (only for an entity in a
|
||||
// portal, or cooling down from one, which has a state here) whether it
|
||||
// can use one: asking every entity on every tick is not worth it.
|
||||
// can use one: asking every entity on every tick is not worth it. The
|
||||
// vehicles' pass opens the tick and the other kinds' pass follows, so
|
||||
// each walk takes only its own group.
|
||||
let mut before = std::mem::take(&mut self.portals.before);
|
||||
before.clear();
|
||||
if vehicles {
|
||||
before.clear();
|
||||
}
|
||||
let mut ids: Vec<(u32, bool)> = Vec::new();
|
||||
let tracked = &self.portals.entities;
|
||||
for_each_entity!(entity_lists_of!(self), entity => {
|
||||
if entity.portal_size().is_some() {
|
||||
if entity.portal_size().is_some() && entity.is_vehicle() == vehicles {
|
||||
if entity.portal_from().is_none() {
|
||||
before.insert(entity.id(), entity.position());
|
||||
}
|
||||
@@ -705,7 +626,12 @@ impl Game<'_> {
|
||||
});
|
||||
self.portals.before = before;
|
||||
let present: std::collections::HashSet<u32> = ids.iter().map(|(id, _)| *id).collect();
|
||||
self.portals.entities.retain(|id, _| present.contains(id));
|
||||
// Each pass keeps the other group's states.
|
||||
let (boats, carts) = (&self.boats, &self.carts);
|
||||
self.portals.entities.retain(|id, _| {
|
||||
let vehicle = boats.iter().any(|b| b.id == *id) || carts.iter().any(|c| c.id == *id);
|
||||
present.contains(id) || vehicle != vehicles
|
||||
});
|
||||
for (id, allowed) in ids {
|
||||
let Some(state) = self.portals.entities.get_mut(&id) else {
|
||||
continue;
|
||||
@@ -774,20 +700,20 @@ impl Game<'_> {
|
||||
&& (!self.guest_rides(m.id) || self.carries_riders())
|
||||
&& m.species() != Species::Wither
|
||||
}
|
||||
/// `canUsePortal(false)` of a boat or minecart: empty, or ridden by
|
||||
/// server players only, whom it carries along.
|
||||
/// `Entity.canUsePortal(false)` for a primed TNT: it carries no mob.
|
||||
pub(super) fn tnt_can_use_portal(&self, id: u32) -> bool {
|
||||
// Id 0: not tracked yet (the in-frame path), nothing to key its portal state by.
|
||||
// Id 0: not tracked yet, nothing to key its portal state by.
|
||||
id != 0 && !self.mobs.iter().any(|m| m.extra.mount == Some(id))
|
||||
}
|
||||
/// `canUsePortal(false)` of a boat or minecart: it is nobody's passenger;
|
||||
/// the mobs riding it, and on a server the players, cross with it.
|
||||
pub(super) fn vehicle_can_use_portal(&self, id: u32) -> bool {
|
||||
if !self.vehicle_occupied(id) {
|
||||
return true;
|
||||
// The window's player is moved by its own trip, not by the vehicle's.
|
||||
if self.riding == Some(id) {
|
||||
return false;
|
||||
}
|
||||
self.carries_riders()
|
||||
&& self.riding != Some(id)
|
||||
&& !self.mobs.iter().any(|m| m.extra.mount == Some(id))
|
||||
// Mob riders always go along; server players only on a server.
|
||||
!self.ridden_by_guest(id) || self.carries_riders()
|
||||
}
|
||||
/// Whether a vehicle can take the server players riding it through a
|
||||
/// portal: only a server moves players between its levels. Elsewhere a
|
||||
@@ -805,27 +731,23 @@ impl Game<'_> {
|
||||
here.into_iter().map(|(_, id, entry)| (id, entry)).collect()
|
||||
}
|
||||
/// The plan of vehicle `id` through the portal at `entry`: from its own
|
||||
/// position and size (`getPortalDestination(level, vehicle)`).
|
||||
/// position and size (`getPortalDestination(level, vehicle)`). A boat or
|
||||
/// minecart, like a mob, lands in the middle of the exit portal's depth
|
||||
/// (`AbstractBoat` and `AbstractMinecart.getRelativePortalPosition`).
|
||||
pub(super) fn vehicle_plan(&self, id: u32, entry: BlockPos) -> Option<Plan> {
|
||||
if let Some(m) = self.mobs.iter().find(|m| m.id == id) {
|
||||
return Some(self.entity_plan(entry, m.position, m.width(), m.height(), true));
|
||||
}
|
||||
if let Some(b) = self.boats.iter().find(|b| b.id == id) {
|
||||
let (width, height) = (super::boats::WIDTH as f32, super::boats::HEIGHT as f32);
|
||||
return Some(self.entity_plan(entry, b.position, width, height, false));
|
||||
return Some(self.entity_plan(entry, b.position, width, height, true));
|
||||
}
|
||||
let c = self.carts.iter().find(|c| c.id == id)?;
|
||||
let (width, height) = (
|
||||
super::minecarts::WIDTH as f32,
|
||||
super::minecarts::HEIGHT as f32,
|
||||
);
|
||||
Some(self.entity_plan(entry, c.position, width, height, false))
|
||||
}
|
||||
/// Whether the player, a server player or a mob rides vehicle `id`.
|
||||
fn vehicle_occupied(&self, id: u32) -> bool {
|
||||
self.riding == Some(id)
|
||||
|| self.ridden_by_guest(id)
|
||||
|| self.mobs.iter().any(|m| m.extra.mount == Some(id))
|
||||
Some(self.entity_plan(entry, c.position, width, height, true))
|
||||
}
|
||||
/// `NetherPortalBlock.entityInside` and `EndPortalBlock.entityInside`
|
||||
/// for the items, mobs, empty boats and minecarts, and arrows that moved
|
||||
@@ -1053,18 +975,8 @@ impl Game<'_> {
|
||||
c.remove(&"leash".into());
|
||||
}
|
||||
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));
|
||||
gone.extend(self.remove_mob_riders(id));
|
||||
self.mobs.retain(|m| m.id != id);
|
||||
for g in gone {
|
||||
self.detach_entity(g);
|
||||
}
|
||||
@@ -1073,14 +985,14 @@ impl Game<'_> {
|
||||
} else if let Some(i) = self.boats.iter().position(|b| b.id == id) {
|
||||
let b = &self.boats[i];
|
||||
let (width, height) = (super::boats::WIDTH as f32, super::boats::HEIGHT as f32);
|
||||
let plan = self.entity_plan(entry, b.position, width, height, false);
|
||||
let plan = self.entity_plan(entry, b.position, width, height, true);
|
||||
if !self.may_enter(plan.dim) {
|
||||
return;
|
||||
}
|
||||
self.release_vehicle_container(id);
|
||||
let uuid = self.uuid_for(id);
|
||||
let b = &self.boats[i];
|
||||
let tag = horizon_client::entity_nbt::boat_to_nbt(
|
||||
let mut tag = horizon_client::entity_nbt::boat_to_nbt(
|
||||
&b.entity_type(),
|
||||
b.position,
|
||||
b.velocity,
|
||||
@@ -1089,6 +1001,11 @@ impl Game<'_> {
|
||||
self.persist_raw(id),
|
||||
b.chest.then_some(&b.items[..]),
|
||||
);
|
||||
// The mobs riding it cross with it and mount it again.
|
||||
self.put_mob_passengers(&mut tag, id);
|
||||
for rider in self.remove_mob_riders(id) {
|
||||
self.detach_entity(rider);
|
||||
}
|
||||
self.boats.remove(i);
|
||||
self.detach_entity(id);
|
||||
self.portals.arrivals.push(Arrival { plan, tag });
|
||||
@@ -1099,13 +1016,27 @@ impl Game<'_> {
|
||||
super::minecarts::WIDTH as f32,
|
||||
super::minecarts::HEIGHT as f32,
|
||||
);
|
||||
let plan = self.entity_plan(entry, c.position, width, height, false);
|
||||
let plan = self.entity_plan(entry, c.position, width, height, true);
|
||||
if !self.may_enter(plan.dim) {
|
||||
return;
|
||||
}
|
||||
self.release_vehicle_container(id);
|
||||
let uuid = self.uuid_for(id);
|
||||
let tag = self.carts[i].to_nbt(uuid, self.persist_raw(id));
|
||||
let mut tag = self.carts[i].to_nbt(uuid, self.persist_raw(id));
|
||||
// `PositionMoveRotation.of(this)` takes the cart's `getKnownMovement`:
|
||||
// `OldMinecartBehavior` clamps it to 0.4 either way along x and z.
|
||||
let m = horizon_client::entity_nbt::motion(&tag);
|
||||
if let Tag::Compound(c) = &mut tag {
|
||||
c.insert(
|
||||
"Motion".into(),
|
||||
doubles([m.x.clamp(-0.4, 0.4), m.y, m.z.clamp(-0.4, 0.4)]),
|
||||
);
|
||||
}
|
||||
// The mobs riding it cross with it and mount it again.
|
||||
self.put_mob_passengers(&mut tag, id);
|
||||
for rider in self.remove_mob_riders(id) {
|
||||
self.detach_entity(rider);
|
||||
}
|
||||
self.carts.remove(i);
|
||||
self.detach_entity(id);
|
||||
self.portals.arrivals.push(Arrival { plan, tag });
|
||||
@@ -1150,6 +1081,26 @@ impl Game<'_> {
|
||||
self.native_entity_portal(self.dim.id(), id, [entry.x, entry.y, entry.z]);
|
||||
}
|
||||
}
|
||||
/// Removes the mobs riding `vehicle`, and those riding them, from the
|
||||
/// level: `Entity.teleportCrossDimension` takes the whole tree with its
|
||||
/// root. Their ids.
|
||||
fn remove_mob_riders(&mut self, vehicle: u32) -> Vec<u32> {
|
||||
let mut tree = vec![vehicle];
|
||||
let mut k = 0;
|
||||
while k < tree.len() {
|
||||
let v = tree[k];
|
||||
tree.extend(
|
||||
self.mobs
|
||||
.iter()
|
||||
.filter(|m| m.extra.mount == Some(v))
|
||||
.map(|m| m.id),
|
||||
);
|
||||
k += 1;
|
||||
}
|
||||
tree.remove(0);
|
||||
self.mobs.retain(|m| !tree.contains(&m.id));
|
||||
tree
|
||||
}
|
||||
/// The chunks of the arrivals still waiting to land in `dim`, for the
|
||||
/// streaming `extra` areas that light them: vanilla's portal ticket
|
||||
/// (`Entity.teleport` adds `TicketType.PORTAL` at the destination) keeps
|
||||
@@ -1162,6 +1113,20 @@ impl Game<'_> {
|
||||
.map(|a| (a.plan.approximate.x >> 4, a.plan.approximate.z >> 4))
|
||||
.collect()
|
||||
}
|
||||
/// `ServerLevel.addDuringTeleport`: the entities that arrive in the level in
|
||||
/// place join it now, before its entities tick, so a vehicle that crossed
|
||||
/// from an earlier level this server tick moves in its new level the same
|
||||
/// tick, and one that crossed from a later level stands there until the
|
||||
/// next. Only the portal arrivals join; the chunks' entities wait.
|
||||
pub(super) fn land_portal_arrivals(&mut self) {
|
||||
if self.remote.is_some() || self.portals.arrivals.is_empty() {
|
||||
return;
|
||||
}
|
||||
let waiting = std::mem::take(&mut self.world.loaded_entities);
|
||||
self.resolve_arrivals();
|
||||
self.spawn_loaded_entities();
|
||||
self.world.loaded_entities.extend(waiting);
|
||||
}
|
||||
/// Entities waiting for the loaded dimension whose exit area is loaded:
|
||||
/// their exit portal (found or built) or End destination, position,
|
||||
/// turned yaw and motion, and portal cooldown; they join the world with the chunk entities.
|
||||
@@ -1183,6 +1148,7 @@ impl Game<'_> {
|
||||
self.portals.dirty = true;
|
||||
let motion = horizon_client::entity_nbt::motion(&tag);
|
||||
let [yaw, pitch] = horizon_client::entity_nbt::rotation(&tag);
|
||||
let was_yaw = yaw;
|
||||
let (at, motion, [yaw, pitch]) = if plan.end {
|
||||
// Relative.DELTA: the motion is kept.
|
||||
let at = self.end_portal_exit(&plan);
|
||||
@@ -1199,6 +1165,10 @@ impl Game<'_> {
|
||||
let (at, rotation) = self.arrival(&plan, exit);
|
||||
(at, rotate_y(motion, -rotation), [yaw + rotation, pitch])
|
||||
};
|
||||
let yaw_turn = yaw - was_yaw;
|
||||
if let Some(was) = horizon_client::entity_nbt::position(&tag) {
|
||||
move_passengers(&mut tag, at.subtract(was), yaw_turn);
|
||||
}
|
||||
if let Tag::Compound(c) = &mut tag {
|
||||
c.insert("Pos".into(), doubles([at.x, at.y, at.z]));
|
||||
c.insert("Motion".into(), doubles([motion.x, motion.y, motion.z]));
|
||||
|
||||
@@ -188,10 +188,7 @@ impl Game<'_> {
|
||||
// until those resources exist, then apply the latest selection once.
|
||||
// A reload already under its overlay finishes first (vanilla's
|
||||
// `pendingReload`, which `runTick` starts once the overlay is gone).
|
||||
if reload_waits(
|
||||
self.rest_loader.is_some() || self.world_loader.is_some(),
|
||||
self.pack_reload.is_some(),
|
||||
) {
|
||||
if reload_waits(self.rest_loader.is_some(), self.pack_reload.is_some()) {
|
||||
self.pending_pack_reload = true;
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -771,7 +771,7 @@ impl Game<'_> {
|
||||
}
|
||||
/// The mobs riding entity `id` under `tag`'s `Passengers`, after the
|
||||
/// passengers it kept from loading.
|
||||
fn put_mob_passengers(&mut self, tag: &mut Tag, id: u32) {
|
||||
pub(super) fn put_mob_passengers(&mut self, tag: &mut Tag, id: u32) {
|
||||
let riders: Vec<usize> = (0..self.mobs.len())
|
||||
.filter(|&j| self.mobs[j].extra.mount == Some(id) && self.mobs[j].alive())
|
||||
.collect();
|
||||
@@ -1154,167 +1154,19 @@ impl Game<'_> {
|
||||
) -> Vec<((i32, i32), Vec<Tag>)> {
|
||||
let mut out: HashMap<(i32, i32), Vec<Tag>> =
|
||||
keys.iter().map(|k| (*k, Vec::new())).collect();
|
||||
// A rider is saved inside its root vehicle, in the root's chunk; a
|
||||
// boat's passengers inside the boat.
|
||||
let mobs: Vec<usize> = (0..self.mobs.len())
|
||||
.filter(|&i| {
|
||||
self.mob_root(i) == i
|
||||
&& self.mobs[i].extra.mount.is_none_or(|v| {
|
||||
self.boats.iter().all(|b| b.id != v) && self.carts.iter().all(|c| c.id != v)
|
||||
})
|
||||
&& keys.contains(&chunk_of(self.mobs[i].position))
|
||||
&& self.mobs[i].alive()
|
||||
&& !self.mobs[i].removed
|
||||
})
|
||||
.collect();
|
||||
for i in mobs {
|
||||
let tag = self.mob_tree_nbt(i);
|
||||
out.entry(chunk_of(self.mobs[i].position))
|
||||
.or_default()
|
||||
.push(tag);
|
||||
}
|
||||
let items: Vec<usize> = (0..self.dropped.len())
|
||||
.filter(|&i| keys.contains(&chunk_of(self.dropped[i].position)))
|
||||
.collect();
|
||||
for i in items {
|
||||
let id = self.dropped[i].id;
|
||||
let uuid = self.uuid_for(id);
|
||||
let tag =
|
||||
entity_nbt::item_entity_to_nbt(&self.dropped[i], uuid, self.persist.raw.get(&id));
|
||||
out.entry(chunk_of(self.dropped[i].position))
|
||||
.or_default()
|
||||
.push(tag);
|
||||
}
|
||||
let anon = |persist: &EntityMemory, kind: &str, at: Vec3| {
|
||||
persist
|
||||
.anon
|
||||
.get(&anon_key(kind, at))
|
||||
.map(|(_, uuid, raw)| (uuid.unwrap_or_else(random_uuid), Some(raw.clone())))
|
||||
.unwrap_or_else(|| (random_uuid(), None))
|
||||
};
|
||||
for o in &self.orbs {
|
||||
if keys.contains(&chunk_of(o.position)) {
|
||||
let (uuid, raw) = anon(&self.persist, "minecraft:experience_orb", o.position);
|
||||
out.entry(chunk_of(o.position)).or_default().push(with_raw(
|
||||
with_raw(entity_nbt::orb_to_nbt(o, uuid), raw.as_ref()),
|
||||
self.persist.raw.get(&o.id),
|
||||
));
|
||||
}
|
||||
}
|
||||
for p in &self.creatures.projectiles {
|
||||
if !keys.contains(&chunk_of(p.position)) || !entity_nbt::saved_with_chunk(p) {
|
||||
continue;
|
||||
}
|
||||
let owner = p.owner_uuid;
|
||||
let (uuid, raw) = anon(&self.persist, p.kind.id(), p.position);
|
||||
let in_block = p
|
||||
.arrow
|
||||
.as_ref()
|
||||
.and_then(|a| a.last_state)
|
||||
.map(|s| self.state_to_nbt(s));
|
||||
let tag = entity_nbt::arrow_to_nbt(p, uuid, owner, in_block, raw.as_ref())
|
||||
.or_else(|| entity_nbt::fireball_to_nbt(p, uuid, owner, raw.as_ref()))
|
||||
.or_else(|| entity_nbt::throwable_to_nbt(p, uuid, owner, raw.as_ref()));
|
||||
if let Some(tag) = tag {
|
||||
let tag = with_raw(tag, self.persist.raw.get(&p.id));
|
||||
out.entry(chunk_of(p.position)).or_default().push(tag);
|
||||
}
|
||||
}
|
||||
for f in &self.falling {
|
||||
if keys.contains(&chunk_of(f.position)) {
|
||||
let (uuid, raw) = match &f.saved {
|
||||
Some(saved) => (saved.0, Some(saved.1.clone())),
|
||||
None => anon(&self.persist, "minecraft:falling_block", f.position),
|
||||
};
|
||||
let tag = entity_nbt::falling_block_to_nbt(
|
||||
self.state_to_nbt(f.state),
|
||||
f.position,
|
||||
f.velocity,
|
||||
f.age,
|
||||
f.drop_item,
|
||||
f.damage,
|
||||
f.fall_distance,
|
||||
uuid,
|
||||
);
|
||||
let mut tag = with_raw(tag, raw.as_ref());
|
||||
if let Tag::Compound(fields) = &mut tag {
|
||||
fields.insert("PortalCooldown".into(), Tag::Int(f.portal.cooldown));
|
||||
}
|
||||
out.entry(chunk_of(f.position)).or_default().push(tag);
|
||||
}
|
||||
}
|
||||
for i in 0..self.rockets.len() {
|
||||
if keys.contains(&chunk_of(self.rockets[i].position)) {
|
||||
let (id, at) = (self.rockets[i].id, self.rockets[i].position);
|
||||
let uuid = self.uuid_for(id);
|
||||
let tag =
|
||||
self.rockets[i].to_nbt(uuid, self.level.player_uuid, self.persist.raw.get(&id));
|
||||
out.entry(chunk_of(at)).or_default().push(tag);
|
||||
}
|
||||
}
|
||||
for t in &self.tridents {
|
||||
if keys.contains(&chunk_of(t.position)) {
|
||||
let (uuid, raw) = anon(&self.persist, "minecraft:trident", t.position);
|
||||
out.entry(chunk_of(t.position))
|
||||
.or_default()
|
||||
.push(t.to_nbt(uuid, raw.as_ref()));
|
||||
}
|
||||
}
|
||||
for i in 0..self.tnts.len() {
|
||||
let t = &self.tnts[i];
|
||||
if keys.contains(&chunk_of(t.position)) {
|
||||
let (id, at) = (t.id, t.position);
|
||||
let uuid = self.uuid_for(id);
|
||||
let t = &self.tnts[i];
|
||||
let tag = entity_nbt::tnt_to_nbt(
|
||||
t.position,
|
||||
t.velocity,
|
||||
t.on_ground,
|
||||
t.fuse,
|
||||
t.power,
|
||||
uuid,
|
||||
self.persist.raw.get(&id),
|
||||
);
|
||||
out.entry(chunk_of(at)).or_default().push(tag);
|
||||
}
|
||||
}
|
||||
let boats: Vec<usize> = (0..self.boats.len())
|
||||
.filter(|&i| keys.contains(&chunk_of(self.boats[i].position)))
|
||||
.collect();
|
||||
for i in boats {
|
||||
let b = &self.boats[i];
|
||||
let (id, at) = (b.id, b.position);
|
||||
let uuid = self
|
||||
.persist
|
||||
.uuids
|
||||
.get(&id)
|
||||
.copied()
|
||||
.unwrap_or_else(random_uuid);
|
||||
let mut tag = entity_nbt::boat_to_nbt(
|
||||
&b.entity_type(),
|
||||
b.position,
|
||||
b.velocity,
|
||||
b.yaw,
|
||||
uuid,
|
||||
self.persist.raw.get(&id),
|
||||
b.chest.then_some(&b.items[..]),
|
||||
);
|
||||
self.put_mob_passengers(&mut tag, id);
|
||||
// AbstractBoat.addAdditionalSaveData: the lead's holder.
|
||||
self.write_boat_leash(&mut tag, i);
|
||||
out.entry(chunk_of(at)).or_default().push(tag);
|
||||
}
|
||||
// Every kind in `entity_lists!` saves itself (`Entity::save_nbt`).
|
||||
self.save_every_entity(keys, &mut out);
|
||||
if let Some(d) = &self.dragon
|
||||
&& keys.contains(&chunk_of(d.position))
|
||||
{
|
||||
let (uuid, raw) = anon(&self.persist, "minecraft:ender_dragon", d.position);
|
||||
let (uuid, raw) = anon_uuid_raw(&self.persist, "minecraft:ender_dragon", d.position);
|
||||
out.entry(chunk_of(d.position))
|
||||
.or_default()
|
||||
.push(d.to_nbt(uuid, raw.as_ref()));
|
||||
}
|
||||
for c in &self.crystals {
|
||||
if keys.contains(&chunk_of(c.position)) {
|
||||
let (uuid, raw) = anon(&self.persist, "minecraft:end_crystal", c.position);
|
||||
let (uuid, raw) = anon_uuid_raw(&self.persist, "minecraft:end_crystal", c.position);
|
||||
out.entry(chunk_of(c.position))
|
||||
.or_default()
|
||||
.push(c.to_nbt(uuid, raw.as_ref()));
|
||||
@@ -1333,34 +1185,6 @@ impl Game<'_> {
|
||||
out.entry(chunk_of(at)).or_default().push(tag);
|
||||
}
|
||||
}
|
||||
let carts: Vec<usize> = (0..self.carts.len())
|
||||
.filter(|&i| keys.contains(&chunk_of(self.carts[i].position)))
|
||||
.collect();
|
||||
for i in carts {
|
||||
let id = self.carts[i].id;
|
||||
let at = self.carts[i].position;
|
||||
let uuid = self
|
||||
.persist
|
||||
.uuids
|
||||
.get(&id)
|
||||
.copied()
|
||||
.unwrap_or_else(random_uuid);
|
||||
let mut tag = self.carts[i].to_nbt(uuid, self.persist.raw.get(&id));
|
||||
self.put_mob_passengers(&mut tag, id);
|
||||
out.entry(chunk_of(at)).or_default().push(tag);
|
||||
}
|
||||
for a in &self.armor_stands {
|
||||
if keys.contains(&chunk_of(a.position)) {
|
||||
let uuid = self
|
||||
.persist
|
||||
.uuids
|
||||
.get(&a.id)
|
||||
.copied()
|
||||
.unwrap_or_else(random_uuid);
|
||||
let tag = entity_nbt::armor_stand_to_nbt(a, uuid, self.persist.raw.get(&a.id));
|
||||
out.entry(chunk_of(a.position)).or_default().push(tag);
|
||||
}
|
||||
}
|
||||
for f in &self.item_frames {
|
||||
let (lo, hi) = f.bounds();
|
||||
let at = Vec3::new(
|
||||
@@ -1627,15 +1451,227 @@ impl Game<'_> {
|
||||
self.native_entities_unload(self.dim.id(), [chunk.0, chunk.1], count);
|
||||
}
|
||||
}
|
||||
/// Leaving a dimension: its entities are saved with it.
|
||||
pub(super) fn save_dimension_entities(&mut self) -> Result<()> {
|
||||
self.spawn_loaded_entities();
|
||||
let keys: HashSet<(i32, i32)> = self.world.chunks.keys().copied().collect();
|
||||
let chunks = self.entity_chunks(&keys, true);
|
||||
self.persist = EntityMemory::default();
|
||||
self.save.save_entities(chunks)
|
||||
}
|
||||
impl Game<'_> {
|
||||
/// `Entity::save_nbt` for a mob: a rider is saved inside its root vehicle,
|
||||
/// in the root's chunk; a boat's or cart's passengers inside the vehicle.
|
||||
pub(super) fn save_mob(
|
||||
&mut self,
|
||||
i: usize,
|
||||
keys: &HashSet<ChunkKey>,
|
||||
) -> Option<(ChunkKey, Tag)> {
|
||||
let m = &self.mobs[i];
|
||||
let saves = self.mob_root(i) == i
|
||||
&& m.extra.mount.is_none_or(|v| {
|
||||
self.boats.iter().all(|b| b.id != v) && self.carts.iter().all(|c| c.id != v)
|
||||
})
|
||||
&& keys.contains(&chunk_of(m.position))
|
||||
&& m.alive()
|
||||
&& !m.removed;
|
||||
saves.then(|| (chunk_of(self.mobs[i].position), self.mob_tree_nbt(i)))
|
||||
}
|
||||
pub(super) fn save_item(
|
||||
&mut self,
|
||||
i: usize,
|
||||
keys: &HashSet<ChunkKey>,
|
||||
) -> Option<(ChunkKey, Tag)> {
|
||||
let at = self.dropped[i].position;
|
||||
keys.contains(&chunk_of(at)).then(|| {
|
||||
let id = self.dropped[i].id;
|
||||
let uuid = self.uuid_for(id);
|
||||
let tag =
|
||||
entity_nbt::item_entity_to_nbt(&self.dropped[i], uuid, self.persist.raw.get(&id));
|
||||
(chunk_of(at), tag)
|
||||
})
|
||||
}
|
||||
pub(super) fn save_orb(
|
||||
&mut self,
|
||||
i: usize,
|
||||
keys: &HashSet<ChunkKey>,
|
||||
) -> Option<(ChunkKey, Tag)> {
|
||||
let o = &self.orbs[i];
|
||||
keys.contains(&chunk_of(o.position)).then(|| {
|
||||
let (uuid, raw) = anon_uuid_raw(&self.persist, "minecraft:experience_orb", o.position);
|
||||
let tag = with_raw(
|
||||
with_raw(entity_nbt::orb_to_nbt(o, uuid), raw.as_ref()),
|
||||
self.persist.raw.get(&o.id),
|
||||
);
|
||||
(chunk_of(o.position), tag)
|
||||
})
|
||||
}
|
||||
pub(super) fn save_projectile(
|
||||
&mut self,
|
||||
i: usize,
|
||||
keys: &HashSet<ChunkKey>,
|
||||
) -> Option<(ChunkKey, Tag)> {
|
||||
let p = &self.creatures.projectiles[i];
|
||||
if !keys.contains(&chunk_of(p.position)) || !entity_nbt::saved_with_chunk(p) {
|
||||
return None;
|
||||
}
|
||||
let owner = p.owner_uuid;
|
||||
let (uuid, raw) = anon_uuid_raw(&self.persist, p.kind.id(), p.position);
|
||||
let in_block = p
|
||||
.arrow
|
||||
.as_ref()
|
||||
.and_then(|a| a.last_state)
|
||||
.map(|s| self.state_to_nbt(s));
|
||||
let tag = entity_nbt::arrow_to_nbt(p, uuid, owner, in_block, raw.as_ref())
|
||||
.or_else(|| entity_nbt::fireball_to_nbt(p, uuid, owner, raw.as_ref()))
|
||||
.or_else(|| entity_nbt::throwable_to_nbt(p, uuid, owner, raw.as_ref()))?;
|
||||
Some((
|
||||
chunk_of(p.position),
|
||||
with_raw(tag, self.persist.raw.get(&p.id)),
|
||||
))
|
||||
}
|
||||
pub(super) fn save_falling(
|
||||
&mut self,
|
||||
i: usize,
|
||||
keys: &HashSet<ChunkKey>,
|
||||
) -> Option<(ChunkKey, Tag)> {
|
||||
let f = &self.falling[i];
|
||||
if !keys.contains(&chunk_of(f.position)) {
|
||||
return None;
|
||||
}
|
||||
let (uuid, raw) = match &f.saved {
|
||||
Some(saved) => (saved.0, Some(saved.1.clone())),
|
||||
None => anon_uuid_raw(&self.persist, "minecraft:falling_block", f.position),
|
||||
};
|
||||
let tag = entity_nbt::falling_block_to_nbt(
|
||||
self.state_to_nbt(f.state),
|
||||
f.position,
|
||||
f.velocity,
|
||||
f.age,
|
||||
f.drop_item,
|
||||
f.damage,
|
||||
f.fall_distance,
|
||||
uuid,
|
||||
);
|
||||
let mut tag = with_raw(tag, raw.as_ref());
|
||||
if let Tag::Compound(fields) = &mut tag {
|
||||
fields.insert("PortalCooldown".into(), Tag::Int(f.portal.cooldown));
|
||||
}
|
||||
Some((chunk_of(f.position), tag))
|
||||
}
|
||||
pub(super) fn save_rocket(
|
||||
&mut self,
|
||||
i: usize,
|
||||
keys: &HashSet<ChunkKey>,
|
||||
) -> Option<(ChunkKey, Tag)> {
|
||||
let (id, at) = (self.rockets[i].id, self.rockets[i].position);
|
||||
keys.contains(&chunk_of(at)).then(|| {
|
||||
let uuid = self.uuid_for(id);
|
||||
let tag =
|
||||
self.rockets[i].to_nbt(uuid, self.level.player_uuid, self.persist.raw.get(&id));
|
||||
(chunk_of(at), tag)
|
||||
})
|
||||
}
|
||||
pub(super) fn save_trident(
|
||||
&mut self,
|
||||
i: usize,
|
||||
keys: &HashSet<ChunkKey>,
|
||||
) -> Option<(ChunkKey, Tag)> {
|
||||
let t = &self.tridents[i];
|
||||
keys.contains(&chunk_of(t.position)).then(|| {
|
||||
let (uuid, raw) = anon_uuid_raw(&self.persist, "minecraft:trident", t.position);
|
||||
(chunk_of(t.position), t.to_nbt(uuid, raw.as_ref()))
|
||||
})
|
||||
}
|
||||
pub(super) fn save_tnt(
|
||||
&mut self,
|
||||
i: usize,
|
||||
keys: &HashSet<ChunkKey>,
|
||||
) -> Option<(ChunkKey, Tag)> {
|
||||
let (id, at) = (self.tnts[i].id, self.tnts[i].position);
|
||||
keys.contains(&chunk_of(at)).then(|| {
|
||||
let uuid = self.uuid_for(id);
|
||||
let t = &self.tnts[i];
|
||||
let tag = entity_nbt::tnt_to_nbt(
|
||||
t.position,
|
||||
t.velocity,
|
||||
t.on_ground,
|
||||
t.fuse,
|
||||
t.power,
|
||||
uuid,
|
||||
self.persist.raw.get(&id),
|
||||
);
|
||||
(chunk_of(at), tag)
|
||||
})
|
||||
}
|
||||
pub(super) fn save_boat(
|
||||
&mut self,
|
||||
i: usize,
|
||||
keys: &HashSet<ChunkKey>,
|
||||
) -> Option<(ChunkKey, Tag)> {
|
||||
let b = &self.boats[i];
|
||||
let (id, at) = (b.id, b.position);
|
||||
if !keys.contains(&chunk_of(at)) {
|
||||
return None;
|
||||
}
|
||||
let uuid = self.saved_uuid_or_random(id);
|
||||
let mut tag = entity_nbt::boat_to_nbt(
|
||||
&b.entity_type(),
|
||||
b.position,
|
||||
b.velocity,
|
||||
b.yaw,
|
||||
uuid,
|
||||
self.persist.raw.get(&id),
|
||||
b.chest.then_some(&b.items[..]),
|
||||
);
|
||||
self.put_mob_passengers(&mut tag, id);
|
||||
// AbstractBoat.addAdditionalSaveData: the lead's holder.
|
||||
self.write_boat_leash(&mut tag, i);
|
||||
Some((chunk_of(at), tag))
|
||||
}
|
||||
pub(super) fn save_cart(
|
||||
&mut self,
|
||||
i: usize,
|
||||
keys: &HashSet<ChunkKey>,
|
||||
) -> Option<(ChunkKey, Tag)> {
|
||||
let (id, at) = (self.carts[i].id, self.carts[i].position);
|
||||
if !keys.contains(&chunk_of(at)) {
|
||||
return None;
|
||||
}
|
||||
let uuid = self.saved_uuid_or_random(id);
|
||||
let mut tag = self.carts[i].to_nbt(uuid, self.persist.raw.get(&id));
|
||||
self.put_mob_passengers(&mut tag, id);
|
||||
Some((chunk_of(at), tag))
|
||||
}
|
||||
pub(super) fn save_armor_stand(
|
||||
&mut self,
|
||||
i: usize,
|
||||
keys: &HashSet<ChunkKey>,
|
||||
) -> Option<(ChunkKey, Tag)> {
|
||||
let a = &self.armor_stands[i];
|
||||
keys.contains(&chunk_of(a.position)).then(|| {
|
||||
let uuid = self.saved_uuid_or_random(a.id);
|
||||
let tag = entity_nbt::armor_stand_to_nbt(a, uuid, self.persist.raw.get(&a.id));
|
||||
(chunk_of(a.position), tag)
|
||||
})
|
||||
}
|
||||
/// The UUID kept for `id`, or a new one that is not remembered (what a
|
||||
/// saved vehicle or stand gets when it never had one).
|
||||
fn saved_uuid_or_random(&self, id: u32) -> [i32; 4] {
|
||||
self.persist
|
||||
.uuids
|
||||
.get(&id)
|
||||
.copied()
|
||||
.unwrap_or_else(random_uuid)
|
||||
}
|
||||
}
|
||||
|
||||
/// A chunk's key, as `chunk_of` gives it.
|
||||
pub(super) type ChunkKey = (i32, i32);
|
||||
|
||||
/// The saved UUID and raw compound kept for an entity without an id, found by
|
||||
/// kind and position; a new UUID when none was kept.
|
||||
fn anon_uuid_raw(persist: &EntityMemory, kind: &str, at: Vec3) -> ([i32; 4], Option<Tag>) {
|
||||
persist
|
||||
.anon
|
||||
.get(&anon_key(kind, at))
|
||||
.map(|(_, uuid, raw)| (uuid.unwrap_or_else(random_uuid), Some(raw.clone())))
|
||||
.unwrap_or_else(|| (random_uuid(), None))
|
||||
}
|
||||
|
||||
/// Only a mob is finalized; another entity drops the request.
|
||||
fn strip_finalize(tag: &mut Tag) {
|
||||
if let Tag::Compound(c) = tag {
|
||||
|
||||
@@ -795,7 +795,12 @@ impl Game<'_> {
|
||||
240 | 240 << 8,
|
||||
&mut verts,
|
||||
);
|
||||
// `WingsLayer` draws through `RenderTypes.armorCutoutNoCull`: the
|
||||
// wings' faces show from both sides (the elytra texture is empty on
|
||||
// the face that looks at the body).
|
||||
let wings_start = verts.len();
|
||||
self.player_elytra_geometry(&m, chest, camera, 1.0, 240 | 240 << 8, &mut verts);
|
||||
let wings = wings_start..verts.len();
|
||||
self.shoulder_parrots_geometry(&m, camera, 1.0, 240 | 240 << 8, &mut verts);
|
||||
self.held_item_geometry(&m, camera, 1.0, 240 | 240 << 8, false, &mut verts);
|
||||
self.head_item_geometry(&m, camera, 1.0, 240 | 240 << 8, false, &mut verts);
|
||||
@@ -812,7 +817,7 @@ impl Game<'_> {
|
||||
.as_ref()
|
||||
.filter(|p| p.fire_ticks > 0 && !p.spectator)
|
||||
.map(|p| (p.dimensions.width, p.dimensions.height));
|
||||
let quads = player_preview_quads(verts, gui, fire, &atlas.sprites);
|
||||
let quads = player_preview_quads_no_cull(verts, gui, fire, &atlas.sprites, wings);
|
||||
let start = batch.vertices.len();
|
||||
for (_, p, uv, color) in quads {
|
||||
batch.quad_3d(p, uv, color);
|
||||
@@ -1046,13 +1051,9 @@ impl Game<'_> {
|
||||
out,
|
||||
);
|
||||
}
|
||||
/// The entity id the local player has: the server's, else the LAN host's.
|
||||
/// The entity id the local player has on its server.
|
||||
fn own_entity_id(&self) -> i32 {
|
||||
match (&self.remote, &self.lan) {
|
||||
(Some(r), _) => r.entity_id,
|
||||
(None, Some(host)) => host.host_entity,
|
||||
_ => 0,
|
||||
}
|
||||
self.remote.as_ref().map_or(0, |r| r.entity_id)
|
||||
}
|
||||
/// `ArrowLayer` and `BeeStingerLayer` (`StuckInBodyLayer`) for a player:
|
||||
/// the arrows and bee stingers stuck in the body, placed from the entity
|
||||
@@ -1180,10 +1181,23 @@ fn entity_in_ui_shade(normal: glam::Vec3) -> f32 {
|
||||
}
|
||||
|
||||
pub(super) fn player_preview_quads(
|
||||
verts: Vec<horizon_client::mesher::Vertex>,
|
||||
gui: glam::Mat4,
|
||||
fire: Option<(f32, f32)>,
|
||||
sprites: &std::collections::HashMap<String, horizon_client::hud::Rect>,
|
||||
) -> Vec<(f32, [[f32; 3]; 4], [[f32; 2]; 4], u32)> {
|
||||
player_preview_quads_no_cull(verts, gui, fire, sprites, 0..0)
|
||||
}
|
||||
|
||||
/// `player_preview_quads` where the faces of the vertices in `no_cull` are
|
||||
/// kept when they look away (a `NoCull` render type); their light still comes
|
||||
/// from the face's own outward normal, as the vertex normal gives it.
|
||||
pub(super) fn player_preview_quads_no_cull(
|
||||
mut verts: Vec<horizon_client::mesher::Vertex>,
|
||||
gui: glam::Mat4,
|
||||
fire: Option<(f32, f32)>,
|
||||
sprites: &std::collections::HashMap<String, horizon_client::hud::Rect>,
|
||||
no_cull: std::ops::Range<usize>,
|
||||
) -> Vec<(f32, [[f32; 3]; 4], [[f32; 2]; 4], u32)> {
|
||||
let flame_start = verts.len();
|
||||
if let Some((width, height)) = fire {
|
||||
@@ -1204,7 +1218,8 @@ pub(super) fn player_preview_quads(
|
||||
std::array::from_fn(|i| gui.transform_point3(glam::Vec3::from_array(q[i].pos)));
|
||||
let n = (p[1] - p[0]).cross(p[2] - p[0]);
|
||||
// The winding gives inward normals: faces with n.z >= 0 point away.
|
||||
if n.z >= 0.0 {
|
||||
let back = n.z >= 0.0;
|
||||
if back && !no_cull.contains(&(index * 4)) {
|
||||
continue;
|
||||
}
|
||||
let n = n.normalize_or_zero();
|
||||
@@ -1217,12 +1232,13 @@ pub(super) fn player_preview_quads(
|
||||
let ch = |s: u32| ((((c >> s) & 255) as f32 * shade) as u32) << s;
|
||||
let color = (c & 0xFF00_0000) | ch(0) | ch(8) | ch(16);
|
||||
let depth = p.iter().map(|v| v.z).sum::<f32>() / 4.0;
|
||||
quads.push((
|
||||
depth,
|
||||
p.map(|v| v.to_array()),
|
||||
std::array::from_fn(|i| q[i].uv),
|
||||
color,
|
||||
));
|
||||
let (mut pos, mut uv) = (p.map(|v| v.to_array()), std::array::from_fn(|i| q[i].uv));
|
||||
if back {
|
||||
// Seen from behind: the same quad in the opposite winding.
|
||||
pos.reverse();
|
||||
uv.reverse();
|
||||
}
|
||||
quads.push((depth, pos, uv, color));
|
||||
}
|
||||
// Keep far-to-near order for translucent faces; opaque overlap uses depth.
|
||||
quads.sort_by(|a, b| a.0.total_cmp(&b.0));
|
||||
@@ -1398,6 +1414,37 @@ mod preview_tests {
|
||||
assert_eq!(batch.runs(), vec![(0..12, Blend::Depth([1, 0, 1, 1]))]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_cull_faces_stay_in_the_preview_when_they_look_away() {
|
||||
use horizon_client::mesher::Vertex;
|
||||
// The wings' `armorCutoutNoCull`: a face turned from the viewer is
|
||||
// dropped by the preview's culling unless its vertices are no-cull.
|
||||
let face = |z: f32| {
|
||||
[[-1.0, -1.0], [-1.0, 1.0], [1.0, 1.0], [1.0, -1.0]].map(|[x, y]| Vertex {
|
||||
pos: [x, y, z],
|
||||
uv: [x, y],
|
||||
layer: 0,
|
||||
color: u32::MAX,
|
||||
light: 0,
|
||||
normal: [0; 4],
|
||||
glint_uv: [0.0, 0.0],
|
||||
})
|
||||
};
|
||||
// Wound one way a face looks at the viewer, the other way it looks away.
|
||||
let mut away = face(0.0);
|
||||
away.reverse();
|
||||
let verts: Vec<_> = face(0.0).into_iter().chain(away).collect();
|
||||
let sprites = Default::default();
|
||||
let gui = glam::Mat4::IDENTITY;
|
||||
let culled = player_preview_quads(verts.clone(), gui, None, &sprites);
|
||||
assert_eq!(culled.len(), 1);
|
||||
let kept = player_preview_quads_no_cull(verts.clone(), gui, None, &sprites, 4..8);
|
||||
assert_eq!(kept.len(), 2);
|
||||
// The first face is outside the range and still culled when it looks away.
|
||||
let first_away = player_preview_quads_no_cull(verts, gui, None, &sprites, 0..0);
|
||||
assert_eq!(first_away.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_inventory_preview_is_lit_as_entity_in_ui() {
|
||||
// A face looking at the viewer, up, down, to the right and to the left.
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
//! `ServerGamePacketListenerImpl.handlePickItemFromEntity`): the client aims,
|
||||
//! the server answers with `Entity.getPickResult`.
|
||||
use super::Game;
|
||||
use horizon_client::{items::ItemStack, lan::playerdata::uuid_ints, save::uuid_text};
|
||||
use horizon_client::{items::ItemStack, save::uuid_text};
|
||||
|
||||
impl Game<'_> {
|
||||
/// The entity the crosshair hits before any block, as `Entity.getId`; an
|
||||
@@ -23,9 +23,6 @@ impl Game<'_> {
|
||||
if let Some((i, d)) = self.mob_hit() {
|
||||
offer(d, Some(self.mobs[i].id as i32));
|
||||
}
|
||||
if let Some((_, d)) = self.guest_hit() {
|
||||
offer(d, None);
|
||||
}
|
||||
if let Some((_, d)) = self.redirectable_hit() {
|
||||
offer(d, None);
|
||||
}
|
||||
@@ -54,10 +51,6 @@ impl Game<'_> {
|
||||
if let Some((i, d)) = self.mob_hit() {
|
||||
offer(d, uuid_of(self.mobs[i].id));
|
||||
}
|
||||
if let Some((conn, d)) = self.guest_hit() {
|
||||
let guest = self.lan.as_ref().and_then(|h| h.hub.guest(conn));
|
||||
offer(d, guest.map(|g| uuid_ints(g.uuid)));
|
||||
}
|
||||
if let Some((i, d)) = self.redirectable_hit() {
|
||||
offer(d, uuid_of(self.creatures.projectiles[i].id));
|
||||
}
|
||||
|
||||
@@ -37,8 +37,6 @@ pub(super) enum Collector {
|
||||
Player,
|
||||
/// A mob, or another player on a server (both kept as mobs here).
|
||||
Mob(u32),
|
||||
/// A LAN guest, by entity id (the host draws it as a model).
|
||||
Guest(u32),
|
||||
}
|
||||
|
||||
pub(super) struct Pickup {
|
||||
@@ -80,13 +78,6 @@ pub(super) fn position(p: &Pickup, partial: f64) -> [f64; 3] {
|
||||
})
|
||||
}
|
||||
|
||||
/// A LAN guest's target, from the model the host draws for it.
|
||||
pub(super) fn guest_target(host: &super::lan::LanHost, id: u32) -> Option<[f64; 3]> {
|
||||
let m = host.models.values().find(|m| m.id == id)?;
|
||||
let (p, eye) = (m.position, m.eye_height());
|
||||
Some([p.x, (p.y + (p.y + eye)) / 2.0, p.z])
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
/// `ItemPickupParticle.updatePosition`: the collector's x and z, halfway up to its eyes.
|
||||
fn collector_target(&self, collector: Collector) -> Option<[f64; 3]> {
|
||||
@@ -99,9 +90,6 @@ impl Game<'_> {
|
||||
let m = self.mobs.iter().find(|m| m.id == id)?;
|
||||
(m.position, m.eye_height())
|
||||
}
|
||||
Collector::Guest(id) => {
|
||||
return guest_target(self.lan.as_ref()?, id);
|
||||
}
|
||||
};
|
||||
Some([p.x, (p.y + (p.y + eye)) / 2.0, p.z])
|
||||
}
|
||||
|
||||
@@ -3,59 +3,10 @@
|
||||
//! sinking, slowing, climbing and freezing are `PlayerMotion`'s
|
||||
//! (`crate::motion`); placing it is the powder snow bucket's `BlockItem.place`.
|
||||
use super::Game;
|
||||
use horizon_client::{
|
||||
Result,
|
||||
items::ItemStack,
|
||||
mobs::Who,
|
||||
particles::{ParticleOptions, ParticleType},
|
||||
};
|
||||
use horizon_core::{geometry::Vec3, position::BlockPos, random::RandomSource};
|
||||
use horizon_client::{Result, items::ItemStack, mobs::Who};
|
||||
use horizon_core::{position::BlockPos, random::RandomSource};
|
||||
|
||||
impl Game<'_> {
|
||||
/// `ClientPacketListener.handleParticleEvent` for the one ALTERNATIVE
|
||||
/// snowflake `PowderSnowBlock.entityInside` sends: no spread, its speed.
|
||||
pub(super) fn snowflake(&mut self, position: Vec3, velocity: Vec3) {
|
||||
let r = self.particles.random();
|
||||
// nextDouble() * dist for each axis, the distances being 0.
|
||||
let offset = [
|
||||
r.next_double() * 0.0,
|
||||
r.next_double() * 0.0,
|
||||
r.next_double() * 0.0,
|
||||
];
|
||||
let options = ParticleOptions::simple(ParticleType::Snowflake);
|
||||
self.particles.add(
|
||||
&options,
|
||||
[
|
||||
position.x + offset[0],
|
||||
position.y + offset[1],
|
||||
position.z + offset[2],
|
||||
],
|
||||
[velocity.x, velocity.y, velocity.z],
|
||||
);
|
||||
}
|
||||
/// The EXTINGUISH effect of the powder snow the player went into this
|
||||
/// tick: burning, it destroys those blocks (a player always may), then
|
||||
/// its fire goes out (`Entity.clearFire`) with the extinguish sound.
|
||||
pub(super) fn powder_snow_fire(&mut self) -> Result<()> {
|
||||
let Some(player) = self.player.as_mut() else {
|
||||
return Ok(());
|
||||
};
|
||||
let entered = std::mem::take(&mut player.powder_snow_entered);
|
||||
let at = [player.position.x, player.position.y, player.position.z];
|
||||
if entered.is_empty() || player.fire_ticks <= 0 {
|
||||
return Ok(());
|
||||
}
|
||||
for pos in entered {
|
||||
self.with_level(|b, level, ticks| b.destroy_block(level, ticks, pos, false));
|
||||
self.world.modified.insert((pos.x >> 4, pos.z >> 4));
|
||||
}
|
||||
self.process_level_events()?;
|
||||
if let Some(player) = self.player.as_mut() {
|
||||
player.fire_ticks = 0;
|
||||
}
|
||||
self.extinguish_sound(at);
|
||||
Ok(())
|
||||
}
|
||||
/// Entity.playEntityOnFireExtinguishedSound
|
||||
pub(super) fn extinguish_sound(&mut self, at: [f64; 3]) {
|
||||
let r = self.particles.random();
|
||||
@@ -83,13 +34,6 @@ impl Game<'_> {
|
||||
}
|
||||
self.extinguish_sound([at.x, at.y, at.z]);
|
||||
}
|
||||
/// The in-window player's rain extinguish (a server's player has its
|
||||
/// fire put out by the server).
|
||||
pub(super) fn rain_extinguish_in_frame(&mut self) {
|
||||
if self.remote.is_none() {
|
||||
self.rain_extinguish_player();
|
||||
}
|
||||
}
|
||||
/// Whether the block at `pos` is powder snow.
|
||||
pub(super) fn is_powder_snow(&self, pos: [i32; 3]) -> bool {
|
||||
&*self
|
||||
|
||||
@@ -921,7 +921,6 @@ impl Game<'_> {
|
||||
self.pickups.clear();
|
||||
self.orbs.clear();
|
||||
self.falling.clear();
|
||||
self.travelling = None;
|
||||
self.mining = None;
|
||||
self.container = None;
|
||||
self.carried = None;
|
||||
@@ -8674,8 +8673,7 @@ impl Game<'_> {
|
||||
/// dialogs release on the first tick after closing instead, as vanilla
|
||||
/// does.
|
||||
pub(super) fn release_use_for_keybind_screen(&mut self) {
|
||||
// In-frame (deprecated) has no server client; `tick_used_item` releases
|
||||
// the use on its own.
|
||||
// Only a server's client holds a use to release.
|
||||
if self.remote.is_none() {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -644,6 +644,7 @@ impl Game<'_> {
|
||||
let world = super::creatures::ProjWorld {
|
||||
w: &w,
|
||||
targets: Vec::new(),
|
||||
border: Some(self.border.bounds()),
|
||||
};
|
||||
for p in &mut projectiles {
|
||||
match p.kind {
|
||||
|
||||
@@ -1077,7 +1077,7 @@ impl Game<'_> {
|
||||
pending.opened = true;
|
||||
}
|
||||
}
|
||||
if !self.loaded() || self.pending_entry.is_some() || self.world_loader.is_some() {
|
||||
if !self.loaded() || self.pending_entry.is_some() {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,13 +11,11 @@ use horizon_client::{
|
||||
collisions::{SceneCollisions, no_collision_empty},
|
||||
items::ItemStack,
|
||||
mobs::Who,
|
||||
motion::PlayerMotion,
|
||||
text::Text,
|
||||
};
|
||||
use horizon_core::{
|
||||
geometry::{Aabb, Axis, Vec3, VoxelShape},
|
||||
position::BlockPos,
|
||||
random::RandomSource,
|
||||
};
|
||||
use horizon_world::{collision_context::CollisionContext, registry::BlockStateId};
|
||||
|
||||
@@ -641,93 +639,6 @@ impl Game<'_> {
|
||||
}
|
||||
Ok(stand.map(|p| (p, look_at_yaw(p, c.pos))))
|
||||
}
|
||||
/// The death screen's respawn with an anchor as the respawn point: travel
|
||||
/// to its dimension and wait for its chunks (`travelling`), then resolve
|
||||
/// it in [`Game::place_respawned`].
|
||||
pub(super) fn begin_anchor_respawn(&mut self) -> Result<bool> {
|
||||
let Some(config) = self.respawn_config else {
|
||||
return Ok(false);
|
||||
};
|
||||
if self.player.is_none() {
|
||||
return Ok(false);
|
||||
}
|
||||
if self.dim != config.dim {
|
||||
self.end_portal_travel = false;
|
||||
self.enter_dimension(config.dim)?;
|
||||
} else {
|
||||
let player = self.player.take().ok_or("player")?;
|
||||
self.travelling = Some((player, Vec3::ZERO));
|
||||
}
|
||||
let target = Vec3::new(
|
||||
f64::from(config.pos[0]) + 0.5,
|
||||
f64::from(config.pos[1]),
|
||||
f64::from(config.pos[2]) + 0.5,
|
||||
);
|
||||
if let Some((_, t)) = self.travelling.as_mut() {
|
||||
*t = target;
|
||||
}
|
||||
self.end_travel = false;
|
||||
self.respawn_travel = Some(config);
|
||||
Ok(true)
|
||||
}
|
||||
/// `PlayerList.respawn` at an anchor once its dimension is loaded around
|
||||
/// it: a fresh player at the stand-up position facing the anchor and the
|
||||
/// deplete sound, or, with no anchor left, the world spawn and
|
||||
/// `block.minecraft.spawn.not_valid`.
|
||||
pub(super) fn place_respawned(&mut self, config: RespawnConfig) -> Result<()> {
|
||||
match self.find_respawn_and_use(config, true)? {
|
||||
Some((position, yaw)) => {
|
||||
let mut player = PlayerMotion::new(self.motion, position)?;
|
||||
player.yaw = yaw;
|
||||
player.head_yaw = yaw;
|
||||
self.yaw = yaw;
|
||||
self.pitch = 0.0;
|
||||
player.pitch = 0.0;
|
||||
self.eye = 1.62;
|
||||
self.previous_eye = 1.62;
|
||||
self.clock.reset();
|
||||
self.previous_position = position;
|
||||
self.player = Some(player);
|
||||
self.sway = Default::default();
|
||||
self.apply_equipment();
|
||||
// ClientboundSoundPacket at the block's corner, seeded from the level random.
|
||||
let state = self
|
||||
.world
|
||||
.block(config.pos[0], config.pos[1], config.pos[2]);
|
||||
if self.is_anchor(state) {
|
||||
let _seed = self.world.random.next_long();
|
||||
let at = [
|
||||
f64::from(config.pos[0]),
|
||||
f64::from(config.pos[1]),
|
||||
f64::from(config.pos[2]),
|
||||
];
|
||||
self.audio
|
||||
.play("minecraft:block.respawn_anchor.deplete", Some(at), 1.0, 1.0);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
None => {
|
||||
// TeleportTransition.missingRespawnBlock: the world spawn, and
|
||||
// the point is forgotten.
|
||||
self.respawn_config = None;
|
||||
self.system_message(Text::translate("block.minecraft.spawn.not_valid", &[]));
|
||||
let dead = PlayerMotion::new(self.motion, Vec3::ZERO)?;
|
||||
if self.dim != Dim::Overworld {
|
||||
// A dead stand-in travels; the arrival makes a fresh player.
|
||||
let mut dead = dead;
|
||||
dead.set_health(0.0, false)?;
|
||||
self.player = Some(dead);
|
||||
self.end_portal_travel = true;
|
||||
let result = self.enter_dimension(Dim::Overworld);
|
||||
self.end_portal_travel = false;
|
||||
self.end_travel = true;
|
||||
return result;
|
||||
}
|
||||
self.player = None;
|
||||
self.spawn_player()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `RespawnPosAngle.calculateLookAtYaw`: the yaw from the position to the
|
||||
|
||||
@@ -453,17 +453,6 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
}
|
||||
/// `ServerPlayer.die`: the death count.
|
||||
pub(super) fn score_player_died(&mut self) {
|
||||
let display = self.player_display_comp();
|
||||
let me = self.acting_player_name().to_owned();
|
||||
let board = &mut self.score.board;
|
||||
board.for_all_objectives_with(sb::CRITERIA_DEATH_COUNT, &me, Some(&display), |v| {
|
||||
v.wrapping_add(1)
|
||||
});
|
||||
// A respawned player has everything to record again.
|
||||
self.score.recorded = Recorded::default();
|
||||
}
|
||||
/// `ServerPlayer.awardKillScore`: total kills and the team kill counts.
|
||||
pub(super) fn score_player_killed(&mut self, victim: Target) {
|
||||
let victim_name = self.holder_name(victim);
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
use super::{Game, Screen};
|
||||
use horizon_client::{
|
||||
Result,
|
||||
command::args::GameMode,
|
||||
confirm_screen::Confirm,
|
||||
dialog::Dialog,
|
||||
hud::{HudBatch, rgba},
|
||||
@@ -678,56 +677,10 @@ impl Game<'_> {
|
||||
/// button, or at once with `immediate_respawn`).
|
||||
pub(super) fn respawn_player(&mut self) -> Result<()> {
|
||||
self.death_focus = None;
|
||||
if self.remote.is_none() {
|
||||
// PlayerList.respawn removes the dead player, and Player.remove's
|
||||
// inventoryMenu.removed drops what the cursor held, whatever
|
||||
// keep_inventory says (the 20-tick branch handles a later respawn).
|
||||
if let Some(stack) = self.carried.take()
|
||||
&& let Some(eye) = self.eye_position()
|
||||
{
|
||||
self.throw_from_eyes(stack, eye, false);
|
||||
}
|
||||
let keep = self.ui.rules.bool("keep_inventory") || self.spectator();
|
||||
self.experience.respawn(keep);
|
||||
// The guests are sent the respawned host as a new entity, not the dead body.
|
||||
if let Some(host) = self.lan.as_mut() {
|
||||
let id = host.host_entity;
|
||||
host.hub.respawned(id);
|
||||
}
|
||||
// Autosaves keep a login position; death must resolve the respawn point instead.
|
||||
self.level.position = None;
|
||||
// The dead player's Entity.setRemoved stopped its riding: the
|
||||
// new one is not put back into the vehicle.
|
||||
self.riding = None;
|
||||
}
|
||||
if self.remote.is_some() {
|
||||
self.ui.death_text = None;
|
||||
self.toggles.respawn();
|
||||
self.remote_respawn();
|
||||
} else if self.respawn_config.is_some() {
|
||||
// LocalPlayer.respawn: KeyMapping.resetToggleKeys.
|
||||
self.toggles.respawn();
|
||||
// PlayerList.respawn at the respawn anchor, once it is loaded.
|
||||
self.begin_anchor_respawn()?;
|
||||
self.screen = Screen::Playing;
|
||||
self.ui.death_text = None;
|
||||
self.spawn_player()?;
|
||||
self.capture(true);
|
||||
} else {
|
||||
// LocalPlayer.respawn: KeyMapping.resetToggleKeys.
|
||||
self.toggles.respawn();
|
||||
self.respawn_out_of_end()?;
|
||||
self.player = None;
|
||||
self.screen = Screen::Playing;
|
||||
self.ui.death_text = None;
|
||||
self.spawn_player()?;
|
||||
self.capture(true);
|
||||
}
|
||||
// ServerGamePacketListenerImpl.handleClientCommand: a hardcore world's
|
||||
// respawned player only watches.
|
||||
if self.remote.is_none() && self.level.hardcore {
|
||||
self.set_mode(GameMode::Spectator);
|
||||
}
|
||||
self.ui.death_text = None;
|
||||
// LocalPlayer.respawn: KeyMapping.resetToggleKeys.
|
||||
self.toggles.respawn();
|
||||
self.remote_respawn();
|
||||
Ok(())
|
||||
}
|
||||
pub(super) fn death_click(&mut self) -> Result<()> {
|
||||
|
||||
@@ -346,7 +346,7 @@ impl Game<'_> {
|
||||
pub(super) fn tick_server_packs(&mut self) {
|
||||
self.server_packs.notifiers.retain_mut(PackNotifier::poll);
|
||||
// A reload waits for the world's resources (`reload_packs`).
|
||||
if self.rest_loader.is_some() || self.world_loader.is_some() {
|
||||
if self.rest_loader.is_some() {
|
||||
return;
|
||||
}
|
||||
self.drive_pack_reload();
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
//!
|
||||
//! A server holds a level per dimension (game/levels.rs). A player who steps
|
||||
//! into a Nether or End portal moves to the destination's level, which opens
|
||||
//! if nobody was there: the swap is the in-frame path's portal plans and
|
||||
//! arrival search, and the player hears it as `ServerPlayer.teleport` into
|
||||
//! if nobody was there: the portal plans and arrival search pick the exit,
|
||||
//! and the player hears it as `ServerPlayer.teleport` into
|
||||
//! another level: a Respawn packet keeping all data, then a teleport to the
|
||||
//! exit once the destination has loaded around them.
|
||||
|
||||
@@ -896,7 +896,6 @@ impl Game<'_> {
|
||||
let clock_rate = self.network_clock_rate();
|
||||
TickCtx {
|
||||
world: &mut self.world,
|
||||
host: None,
|
||||
entities: &[],
|
||||
next_entity: &mut self.next_entity,
|
||||
spawn,
|
||||
|
||||
@@ -259,6 +259,21 @@ impl StartupMenu {
|
||||
}
|
||||
return Action::None;
|
||||
}
|
||||
// `TabNavigationBar.keyPressed` comes before the open tab's own
|
||||
// widgets: Ctrl plus a digit or Tab picks a tab instead of reaching
|
||||
// them.
|
||||
let ctrl = if cfg!(target_os = "macos") {
|
||||
self.modifiers.super_key()
|
||||
} else {
|
||||
self.modifiers.control_key()
|
||||
};
|
||||
if let PhysicalKey::Code(key) = event.physical_key
|
||||
&& title
|
||||
.tab_bar_key(key, ctrl, self.modifiers.shift_key())
|
||||
.is_some()
|
||||
{
|
||||
return Action::None;
|
||||
}
|
||||
match event.physical_key {
|
||||
PhysicalKey::Code(KeyCode::Escape) => title.escape(),
|
||||
PhysicalKey::Code(KeyCode::F5) => title.refresh_servers(),
|
||||
|
||||
@@ -86,10 +86,6 @@ impl Game<'_> {
|
||||
let Some(body) = self.apply_totem() else {
|
||||
return;
|
||||
};
|
||||
if let Some(host) = &self.lan {
|
||||
host.hub
|
||||
.entity_event(host.host_entity, TOTEM_EVENT, body.at);
|
||||
}
|
||||
self.totem_event(body, true);
|
||||
}
|
||||
/// Entity event 35 from a server, for this client's own player or an
|
||||
|
||||
@@ -483,55 +483,6 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
}
|
||||
/// `Villager.customServerAiStep`: the TRADE reputation event of the last
|
||||
/// trade, a tick later; the prices follow the new reputation.
|
||||
pub(super) fn merchant_tick(&mut self) {
|
||||
// `AbstractVillager.stillValid`: the trade screen closes once the trader
|
||||
// is gone or dead, or the player is more than 4 blocks past their reach.
|
||||
if self.remote.is_none()
|
||||
&& self.screen == super::Screen::Inventory
|
||||
&& let Some(id) = self
|
||||
.station
|
||||
.as_ref()
|
||||
.and_then(|s| s.merchant.as_ref())
|
||||
.and_then(|m| m.mob)
|
||||
&& let Some(eye) = self.eye_position()
|
||||
{
|
||||
let valid = self.mobs.iter().find(|m| m.id == id).is_some_and(|m| {
|
||||
let (lo, hi) = (m.bounds().min_position(), m.bounds().max_position());
|
||||
let d = [
|
||||
eye[0] - eye[0].clamp(lo.x, hi.x),
|
||||
eye[1] - eye[1].clamp(lo.y, hi.y),
|
||||
eye[2] - eye[2].clamp(lo.z, hi.z),
|
||||
];
|
||||
let range = self.entity_reach + 4.0;
|
||||
m.alive() && d[0] * d[0] + d[1] * d[1] + d[2] * d[2] < range * range
|
||||
});
|
||||
if !valid {
|
||||
self.close_container();
|
||||
self.screen = super::Screen::Playing;
|
||||
self.capture(true);
|
||||
return;
|
||||
}
|
||||
}
|
||||
let Some(m) = self.station.as_mut().and_then(|s| s.merchant.as_mut()) else {
|
||||
return;
|
||||
};
|
||||
if !m.session.villager || !m.session.last_traded {
|
||||
return;
|
||||
}
|
||||
m.session.last_traded = false;
|
||||
let Some(id) = m.mob else { return };
|
||||
self.villager_traded(id);
|
||||
let reputation = self.villager_reputation(id);
|
||||
self.menu_session(|menu, env, _| {
|
||||
let s = menu.merchant.as_mut()?;
|
||||
s.reputation = reputation;
|
||||
s.update_special_prices();
|
||||
menu.merchant_update(env);
|
||||
Some(true)
|
||||
});
|
||||
}
|
||||
/// `MerchantMenu.tryMoveItems` for the selected offer (the select trade packet).
|
||||
fn try_move_items(&mut self, index: usize) {
|
||||
if self.remote.is_some() {
|
||||
|
||||
@@ -237,38 +237,6 @@ impl Game<'_> {
|
||||
let (at, top) = (p.position, p.bounds().max_position().y);
|
||||
water || self.in_rain(at, top)
|
||||
}
|
||||
/// The living entities a spinning player can touch this tick, for
|
||||
/// `LivingEntity.checkAutoSpinAttack` (none when not spinning).
|
||||
pub(super) fn spin_targets(&self) -> Vec<(u32, Aabb)> {
|
||||
let Some(p) = self
|
||||
.player
|
||||
.as_ref()
|
||||
.filter(|p| p.auto_spin_attack_ticks > 0)
|
||||
else {
|
||||
return Vec::new();
|
||||
};
|
||||
// A spin moves a few blocks a tick at most.
|
||||
let near = p.bounds().inflate(Vec3::new(16.0, 16.0, 16.0));
|
||||
self.mobs
|
||||
.iter()
|
||||
.filter(|m| m.alive() && m.bounds().intersects(near))
|
||||
.map(|m| (m.id, m.bounds()))
|
||||
.collect()
|
||||
}
|
||||
/// `Player.doAutoAttackOnTouch`: the touched entity takes `Player.attack`
|
||||
/// with the spin's damage; once the spin is over its damage is cleared.
|
||||
pub(super) fn end_spin_attack(&mut self, hit: Option<u32>) {
|
||||
if let Some(id) = hit
|
||||
&& let Some(index) = self.mobs.iter().position(|m| m.id == id)
|
||||
{
|
||||
self.attack_mob(index);
|
||||
}
|
||||
if let Some(p) = self.player.as_mut()
|
||||
&& p.auto_spin_attack_ticks <= 0
|
||||
{
|
||||
p.auto_spin_attack_dmg = 0.0;
|
||||
}
|
||||
}
|
||||
/// The `summon_entity` and `play_sound` effects of `channeling.json`: in a
|
||||
/// thunderstorm (`weather_check`) a spot that sees the sky (`can_see_sky`)
|
||||
/// gets a lightning bolt and the thunder sound.
|
||||
|
||||
@@ -204,9 +204,8 @@ impl super::Game<'_> {
|
||||
}
|
||||
|
||||
pub(super) fn tutorial_get_item(&mut self, stack: Option<&horizon_client::items::ItemStack>) {
|
||||
// The server thread's settings are a stale copy of the window's, and an
|
||||
// in-frame host acting for a guest is not its own player: a guest's
|
||||
// tutorial is their own client's to record.
|
||||
// The server thread's settings are a stale copy of the window's: a
|
||||
// guest's tutorial is their own client's to record.
|
||||
if self.options.dedicated.is_some()
|
||||
|| self.serving.is_some()
|
||||
|| self.lan_acting.is_some()
|
||||
@@ -230,9 +229,8 @@ impl super::Game<'_> {
|
||||
state: horizon_world::registry::BlockStateId,
|
||||
progress: f32,
|
||||
) {
|
||||
// The server thread's settings are a stale copy of the window's, and an
|
||||
// in-frame host acting for a guest is not its own player: a guest's
|
||||
// tutorial is their own client's to record.
|
||||
// The server thread's settings are a stale copy of the window's: a
|
||||
// guest's tutorial is their own client's to record.
|
||||
if self.options.dedicated.is_some()
|
||||
|| self.serving.is_some()
|
||||
|| self.lan_acting.is_some()
|
||||
|
||||
@@ -17,9 +17,6 @@ use winit::{
|
||||
/// What closing the screen does (`WinScreen.onFinished`).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(super) enum Finish {
|
||||
/// `PERFORM_RESPAWN` in single player: the player, out of the End since
|
||||
/// the credits began, arrives in the Overworld.
|
||||
Respawn,
|
||||
/// `PERFORM_RESPAWN` sent to the server.
|
||||
RemoteRespawn,
|
||||
/// Back to Credits & Attribution.
|
||||
@@ -50,16 +47,6 @@ impl Game<'_> {
|
||||
self.screen = Screen::Win;
|
||||
self.capture(false);
|
||||
}
|
||||
/// `ServerPlayer.showEndCredits`: the player leaves the End now (no
|
||||
/// longer in any level until the credits end) and has seen the credits.
|
||||
pub(super) fn show_end_credits(&mut self) -> Result<()> {
|
||||
self.level.seen_credits = true;
|
||||
self.open_win_screen(true, Finish::Respawn);
|
||||
self.end_portal_travel = true;
|
||||
let result = self.enter_dimension(super::Dim::Overworld);
|
||||
self.end_portal_travel = false;
|
||||
result
|
||||
}
|
||||
/// Stop the scroll `scroll` pixels in (`horizon-ctl credits ... SCROLL`).
|
||||
pub(super) fn hold_win_screen(&mut self, scroll: f32) {
|
||||
if let Some(win) = self.win.as_mut() {
|
||||
@@ -70,12 +57,6 @@ impl Game<'_> {
|
||||
pub(super) fn win_screen_pauses(&self) -> bool {
|
||||
self.win.as_ref().is_some_and(|w| !w.screen.poem)
|
||||
}
|
||||
/// The player waits out of the world while the poem rolls.
|
||||
pub(super) fn win_screen_holds_arrival(&self) -> bool {
|
||||
self.win
|
||||
.as_ref()
|
||||
.is_some_and(|w| w.finish == Finish::Respawn)
|
||||
}
|
||||
pub(super) fn draw_win_screen(&mut self, batch: &mut HudBatch) {
|
||||
let Some(win) = self.win.as_mut() else {
|
||||
return;
|
||||
@@ -138,7 +119,7 @@ impl Game<'_> {
|
||||
match win.finish {
|
||||
Finish::Options => self.screen = Screen::Options,
|
||||
Finish::Closed if self.title.is_some() => self.screen = Screen::Playing,
|
||||
Finish::Respawn | Finish::RemoteRespawn | Finish::Closed => {
|
||||
Finish::RemoteRespawn | Finish::Closed => {
|
||||
if win.finish == Finish::RemoteRespawn {
|
||||
self.remote_respawn();
|
||||
}
|
||||
|
||||
@@ -52,7 +52,7 @@ impl WorldRequest {
|
||||
let _prepare_step = startup::step("world.prepare");
|
||||
// Before any chunk is read: a loaded container with a loot table is rolled as its chunk
|
||||
// is decoded (`save::roll_loaded_loot`), and the spawn chunks are read while the world
|
||||
// opens. Every world opens here: in-frame, on the server thread, on a dedicated server.
|
||||
// opens. Every world opens here: on the server thread, on a dedicated server.
|
||||
let loot = || {
|
||||
horizon_client::structure_blocks::loot_data(
|
||||
&options.resources,
|
||||
@@ -279,52 +279,29 @@ impl WorldRequest {
|
||||
}
|
||||
|
||||
pub(super) struct WorldLoader {
|
||||
pub cancelled: bool,
|
||||
/// What the world's upgrade has done (`UpgradeProgress`), which
|
||||
/// `FileFixerProgressScreen` shows while the file fixers run.
|
||||
pub upgrade: Arc<horizon_world::datafix::progress::UpgradeProgress>,
|
||||
handle: JoinHandle<std::result::Result<PreparedWorld, String>>,
|
||||
}
|
||||
|
||||
impl WorldLoader {
|
||||
/// Prepare the world on its own thread. `upgrade` is what its upgrade has done
|
||||
/// (`UpgradeProgress`), which the window's `FileFixerProgressScreen` shows while the
|
||||
/// file fixers run, and where its Cancel stops them.
|
||||
pub fn start(
|
||||
request: WorldRequest,
|
||||
res: Arc<Resources>,
|
||||
pack: PackResources,
|
||||
options: Options,
|
||||
settings: Settings,
|
||||
upgrade: Arc<horizon_world::datafix::progress::UpgradeProgress>,
|
||||
) -> Result<Self> {
|
||||
let upgrade = Arc::new(horizon_world::datafix::progress::UpgradeProgress::new());
|
||||
let worker_upgrade = upgrade.clone();
|
||||
let handle = std::thread::Builder::new()
|
||||
.name("World initialization".into())
|
||||
.spawn(move || {
|
||||
request
|
||||
.prepare(&res, &pack, &options, &settings, &worker_upgrade)
|
||||
.prepare(&res, &pack, &options, &settings, &upgrade)
|
||||
.map_err(|e| e.to_string())
|
||||
})?;
|
||||
Ok(Self {
|
||||
cancelled: false,
|
||||
upgrade,
|
||||
handle,
|
||||
})
|
||||
}
|
||||
|
||||
// Wait for construction and disposal off the window thread. Keeping this
|
||||
// handle prevents a queued reopen racing the old session lock.
|
||||
pub fn cancel(self) -> Self {
|
||||
Self {
|
||||
cancelled: true,
|
||||
upgrade: self.upgrade.clone(),
|
||||
handle: std::thread::spawn(move || {
|
||||
drop(self.finish());
|
||||
Err("world initialization cancelled".into())
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn ready(&self) -> bool {
|
||||
self.handle.is_finished()
|
||||
Ok(Self { handle })
|
||||
}
|
||||
|
||||
pub fn finish(self) -> Result<PreparedWorld> {
|
||||
@@ -882,51 +859,9 @@ pub(super) fn make_streaming(
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn polling_does_not_wait_for_world_initialization() {
|
||||
let (release, wait) = std::sync::mpsc::channel();
|
||||
let loader = WorldLoader {
|
||||
cancelled: false,
|
||||
upgrade: Default::default(),
|
||||
handle: std::thread::spawn(move || {
|
||||
wait.recv().unwrap();
|
||||
Err("test world failure".into())
|
||||
}),
|
||||
};
|
||||
assert!(!loader.ready());
|
||||
release.send(()).unwrap();
|
||||
assert_eq!(
|
||||
loader.finish().err().unwrap().to_string(),
|
||||
"test world failure"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cancellation_keeps_initialization_tracked_until_it_has_stopped() {
|
||||
let (release, wait) = std::sync::mpsc::channel();
|
||||
let loader = WorldLoader {
|
||||
cancelled: false,
|
||||
upgrade: Default::default(),
|
||||
handle: std::thread::spawn(move || {
|
||||
wait.recv().unwrap();
|
||||
Err("cancelled test world".into())
|
||||
}),
|
||||
}
|
||||
.cancel();
|
||||
assert!(loader.cancelled);
|
||||
assert!(!loader.ready());
|
||||
release.send(()).unwrap();
|
||||
assert_eq!(
|
||||
loader.finish().err().unwrap().to_string(),
|
||||
"world initialization cancelled"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn worker_panics_reach_the_client() {
|
||||
let loader = WorldLoader {
|
||||
cancelled: false,
|
||||
upgrade: Default::default(),
|
||||
handle: std::thread::spawn(|| panic!("broken world initialization")),
|
||||
};
|
||||
assert_eq!(
|
||||
|
||||
@@ -264,6 +264,96 @@ impl ItemRenderState {
|
||||
}
|
||||
}
|
||||
}
|
||||
/// [`Self::submit`] into a [`WorldSink`] of `light`, `lights` and `tier`,
|
||||
/// posed on the GPU (`gfx::entity_skin`): the same vertices in the same
|
||||
/// order. False, with nothing drawn, when a layer cannot take that path: a
|
||||
/// special model, a glint, or a quad that glows or is translucent (another
|
||||
/// material).
|
||||
pub fn submit_skinned(
|
||||
&self,
|
||||
renderer: &ItemRenderer,
|
||||
sprites: &HashMap<String, Rect>,
|
||||
pose: Mat4,
|
||||
(light, lights, tier): (u32, Option<(Vec3, Vec3)>, u8),
|
||||
skin: &mut crate::mobs::skin::Skin,
|
||||
) -> bool {
|
||||
use crate::gfx::entity_skin::{SkinJob, SkinPart};
|
||||
// The axes' order in a stored vertex (`SkinVertex::axis`).
|
||||
const AXES: [Direction; 6] = [
|
||||
Direction::East,
|
||||
Direction::Up,
|
||||
Direction::South,
|
||||
Direction::West,
|
||||
Direction::Down,
|
||||
Direction::North,
|
||||
];
|
||||
for layer in &self.layers {
|
||||
let LayerGeometry::Quads(baked) = &layer.geometry else {
|
||||
return false;
|
||||
};
|
||||
if layer.foil != FoilType::None {
|
||||
return false;
|
||||
}
|
||||
let Some(stored) = skin.models.item(baked, skin.frame) else {
|
||||
return false;
|
||||
};
|
||||
let translucent = |run: &crate::mobs::skin::ItemRun| {
|
||||
let sprite = &baked.quads[run.quad].material.sprite;
|
||||
renderer.sprites.translucent(sprite_name(sprite))
|
||||
};
|
||||
if stored.runs.iter().any(translucent) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
for layer in &self.layers {
|
||||
let LayerGeometry::Quads(baked) = &layer.geometry else {
|
||||
continue;
|
||||
};
|
||||
let m = pose * layer.matrix;
|
||||
let mut normals = FaceNormals::new(m);
|
||||
let Some(stored) = skin.models.item(baked, skin.frame) else {
|
||||
continue;
|
||||
};
|
||||
for run in &stored.runs {
|
||||
// `submit_quads`' sprite and tint, once for the run's quads.
|
||||
let q = &baked.quads[run.quad];
|
||||
let Some(r) = sprites
|
||||
.get(sprite_name(&q.material.sprite))
|
||||
.or_else(|| sprites.get("misc/missingno"))
|
||||
.or_else(|| sprites.get("horizon/missingno"))
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let color = if run.tint_index >= 0 {
|
||||
layer
|
||||
.tints
|
||||
.get(run.tint_index as usize)
|
||||
.copied()
|
||||
.unwrap_or(-1)
|
||||
} else {
|
||||
-1
|
||||
};
|
||||
let colors = AXES.map(|d| shaded(argb(color), normals.face(d), lights));
|
||||
let frame = &mut *skin.frame;
|
||||
frame.jobs.push(SkinJob {
|
||||
first: run.first,
|
||||
count: run.count,
|
||||
part: frame.parts.len() as u32,
|
||||
dst: frame.vertices,
|
||||
rect: [r.u0, r.v0, r.u1, r.v1],
|
||||
light,
|
||||
overlay: u32::from(tier) << 16,
|
||||
item: 1,
|
||||
..Default::default()
|
||||
});
|
||||
frame.parts.push(SkinPart::item(m, colors));
|
||||
frame.vertices += run.count;
|
||||
}
|
||||
let bounds = stored.bounds;
|
||||
skin.grow_bounds(m, bounds);
|
||||
}
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
/// The atlas name of a sprite identifier (`minecraft:item/apple` -> `item/apple`).
|
||||
@@ -913,12 +1003,17 @@ pub struct WorldSink<'a> {
|
||||
/// HDR output's tier (`ItemRenderState::hdr_tier`): rides in the layer's high bits.
|
||||
pub tier: u8,
|
||||
}
|
||||
/// The vertex colour [`WorldSink`] gives a quad of `color` (ARGB) facing `normal`:
|
||||
/// shaded by `lights` (`None`: unshaded), as ABGR bytes.
|
||||
fn shaded(color: u32, normal: Vec3, lights: Option<(Vec3, Vec3)>) -> u32 {
|
||||
let brightness = lights.map_or(1.0, |l| diffuse(normal, l));
|
||||
let [a, r, g, b] = color.to_be_bytes();
|
||||
let c = |v: u8| (f32::from(v) * brightness) as u32;
|
||||
u32::from(a) << 24 | c(b) << 16 | c(g) << 8 | c(r)
|
||||
}
|
||||
impl ItemSink for WorldSink<'_> {
|
||||
fn quad(&mut self, q: ItemQuad) {
|
||||
let brightness = self.lights.map_or(1.0, |l| diffuse(q.normal, l));
|
||||
let [a, r, g, b] = q.color.to_be_bytes();
|
||||
let c = |v: u8| (f32::from(v) * brightness) as u32;
|
||||
let color = u32::from(a) << 24 | c(b) << 16 | c(g) << 8 | c(r);
|
||||
let color = shaded(q.color, q.normal, self.lights);
|
||||
// `LightCoordsUtil.lightCoordsWithEmission`: block light at least the emission.
|
||||
let emission = u32::from(q.light_emission) * 16;
|
||||
let light = if (self.light >> 8 & 0xFF) < emission {
|
||||
@@ -1165,6 +1260,137 @@ mod tests {
|
||||
assert!((transform_normal(r, Vec3::Z) - Vec3::X).length() < 1e-6);
|
||||
}
|
||||
|
||||
/// Every vanilla item on the ground, turned, shaded and unshaded: the GPU path's
|
||||
/// vertices (its jobs expanded as `entity_skin.wgsl` does) are the CPU path's, to
|
||||
/// the bit and in the same order, for each state it takes. Needs the prepared
|
||||
/// resources.
|
||||
#[test]
|
||||
fn items_posed_on_the_gpu_draw_what_the_cpu_draws() {
|
||||
use crate::gfx::entity_skin::{SkinFrame, expand};
|
||||
use crate::mobs::skin::{Skin, SkinModels};
|
||||
use horizon_assets::item_model::{NoEnv, StackView};
|
||||
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../../.local/horizon-client")
|
||||
.join(horizon_core::VANILLA_VERSION);
|
||||
if !root.join("entity-models.json").exists() {
|
||||
eprintln!("no prepared resources: skipped");
|
||||
return;
|
||||
}
|
||||
let r = ItemRenderer::load(&root).unwrap();
|
||||
let resolve = |id: &str| {
|
||||
let view = StackView::new(id, 1);
|
||||
r.resolve(&view, DisplayContext::Ground, None, None, 7, &NoEnv)
|
||||
};
|
||||
let states: Vec<_> = r
|
||||
.models
|
||||
.definitions
|
||||
.keys()
|
||||
.map(|id| (id, resolve(id)))
|
||||
.collect();
|
||||
// Each sprite its own rect, so a run drawn with another's shows.
|
||||
let mut sprites: HashMap<String, Rect> = HashMap::new();
|
||||
for (_, state) in &states {
|
||||
for layer in &state.layers {
|
||||
if let LayerGeometry::Quads(baked) = &layer.geometry {
|
||||
for q in &baked.quads {
|
||||
let n = sprites.len() as f32;
|
||||
sprites
|
||||
.entry(sprite_name(&q.material.sprite).to_owned())
|
||||
.or_insert(Rect {
|
||||
u0: n / 4096.0,
|
||||
v0: 0.5 - n / 8192.0,
|
||||
u1: n / 4096.0 + 0.003,
|
||||
v1: 0.5 - n / 8192.0 + 0.007,
|
||||
w: 16,
|
||||
h: 16,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let pose = Mat4::from_translation(Vec3::new(-2.3, 0.7, 5.1))
|
||||
* Mat4::from_rotation_y(2.1)
|
||||
* Mat4::from_scale(Vec3::splat(0.5));
|
||||
let mut models = SkinModels::default();
|
||||
let mut frame = SkinFrame::default();
|
||||
let (mut taken, mut left) = (0, 0);
|
||||
for lights in [
|
||||
Some(crate::block_entity_render::diffuse_lights(false)),
|
||||
None,
|
||||
] {
|
||||
for (id, state) in &states {
|
||||
let mut cpu = Vec::new();
|
||||
let mut sink = WorldSink {
|
||||
out: &mut cpu,
|
||||
light: 0x50_a0,
|
||||
lights,
|
||||
glint: None,
|
||||
tier: 1,
|
||||
};
|
||||
state.submit(&r, &sprites, &NoHost, pose, &mut sink);
|
||||
frame.clear_draws();
|
||||
let mut skin = Skin {
|
||||
models: &mut models,
|
||||
frame: &mut frame,
|
||||
bounds: None,
|
||||
};
|
||||
if !state.submit_skinned(&r, &sprites, pose, (0x50_a0, lights, 1), &mut skin) {
|
||||
assert!(frame.jobs.is_empty(), "{id}: left to the CPU after drawing");
|
||||
left += 1;
|
||||
continue;
|
||||
}
|
||||
taken += 1;
|
||||
let bounds = skin.bounds;
|
||||
let gpu = expand(&frame.new_model, &frame);
|
||||
assert_eq!(gpu.len(), cpu.len(), "{id}");
|
||||
for (i, (g, c)) in gpu.iter().zip(&cpu).enumerate() {
|
||||
assert_eq!(
|
||||
bytemuck::bytes_of(g),
|
||||
bytemuck::bytes_of(c),
|
||||
"{id} vertex {i}: {g:?} != {c:?}"
|
||||
);
|
||||
}
|
||||
if let (Some((lo, hi)), false) = (bounds, cpu.is_empty()) {
|
||||
for v in &cpu {
|
||||
let p = Vec3::from_array(v.pos);
|
||||
assert!(
|
||||
p.cmpge(lo - 1e-4).all() && p.cmple(hi + 1e-4).all(),
|
||||
"{id}: {p} outside"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// The 8 kinds of the `stress_items` scene all take it, and resolved again (as
|
||||
// every frame does) they draw the models already stored.
|
||||
let stored = frame.new_model.len();
|
||||
for id in [
|
||||
"stone",
|
||||
"oak_log",
|
||||
"diamond",
|
||||
"apple",
|
||||
"iron_sword",
|
||||
"bread",
|
||||
"redstone",
|
||||
"torch",
|
||||
] {
|
||||
frame.clear_draws();
|
||||
let mut skin = Skin {
|
||||
models: &mut models,
|
||||
frame: &mut frame,
|
||||
bounds: None,
|
||||
};
|
||||
let state = resolve(&format!("minecraft:{id}"));
|
||||
assert!(
|
||||
state.submit_skinned(&r, &sprites, pose, (0, None, 0), &mut skin),
|
||||
"{id}"
|
||||
);
|
||||
}
|
||||
assert_eq!(frame.new_model.len(), stored, "models stored again");
|
||||
eprintln!("{taken} item states on the GPU path, {left} on the CPU");
|
||||
assert!(taken > left, "{taken} taken, {left} left");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn face_normals_of_a_pose_are_its_inverse_transpose() {
|
||||
// A dropped item's pose: spun, squashed and moved. Each face's normal is the
|
||||
|
||||
@@ -67,45 +67,14 @@ pub struct HubConfig {
|
||||
pub allow_flight: bool,
|
||||
pub seed: i64,
|
||||
pub max_players: usize,
|
||||
/// A dedicated server: operators from `ops`, the dedicated-only commands.
|
||||
pub dedicated: bool,
|
||||
/// An integrated server (`dedicated` too, since it has no host player):
|
||||
/// the owner's level from `ops`, the others' from Allow Commands and
|
||||
/// the guests' command access, and the integrated-only commands.
|
||||
/// An integrated server: the owner's level from `ops`, the others' from Allow Commands
|
||||
/// and the guests' command access, and the integrated-only commands. Otherwise a
|
||||
/// dedicated server: operators from `ops`, the dedicated-only commands.
|
||||
pub integrated: bool,
|
||||
/// The shapes of the world's levels (their dimension types).
|
||||
pub levels: crate::chunk::LevelShapes,
|
||||
}
|
||||
|
||||
/// The host player as the other players see them.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct HostState {
|
||||
pub uuid: u128,
|
||||
pub name: String,
|
||||
pub entity_id: i32,
|
||||
pub position: [f64; 3],
|
||||
pub yaw: f32,
|
||||
pub pitch: f32,
|
||||
pub head_yaw: f32,
|
||||
pub on_ground: bool,
|
||||
pub game_mode: i32,
|
||||
pub sneaking: bool,
|
||||
pub sprinting: bool,
|
||||
pub equipment: [Option<ItemStack>; 6],
|
||||
pub health: f32,
|
||||
/// The host player's client-syncable attribute instances.
|
||||
pub attributes: Vec<play::AttributeSnapshot>,
|
||||
/// `LivingEntity.DATA_LIVING_ENTITY_FLAGS` (the item in use).
|
||||
pub living_flags: i8,
|
||||
/// `LivingEntity.DATA_ARROW_COUNT_ID` and `DATA_STINGER_COUNT_ID`.
|
||||
pub stuck: (i32, i32),
|
||||
/// `Player.DATA_SHOULDER_PARROT_LEFT` and `_RIGHT`: the parrots' variants.
|
||||
pub shoulders: [Option<i32>; 2],
|
||||
/// The bed the host sleeps in (`LivingEntity.SLEEPING_POS`).
|
||||
pub bed: Option<[i32; 3]>,
|
||||
pub body: BodyFlags,
|
||||
}
|
||||
|
||||
/// What the other players see of a player's body besides sneaking and
|
||||
/// sprinting: the pose and the rest of `Entity.DATA_SHARED_FLAGS_ID`.
|
||||
#[derive(Clone, Copy, Debug, Default)]
|
||||
@@ -131,8 +100,6 @@ pub struct BodyFlags {
|
||||
/// Everything the hub needs from the game for one tick.
|
||||
pub struct TickCtx<'a> {
|
||||
pub world: &'a mut World,
|
||||
/// The player playing on the host (none on a dedicated server).
|
||||
pub host: Option<&'a HostState>,
|
||||
pub entities: &'a [EntityState],
|
||||
pub next_entity: &'a mut u32,
|
||||
/// Where new players appear.
|
||||
@@ -769,15 +736,12 @@ impl Hub {
|
||||
/// ListPlayersCommand's reply: the players in join order (the host of a
|
||||
/// LAN world first), as display names or names with ids, joined by
|
||||
/// gray commas (ComponentUtils.formatList).
|
||||
pub fn list_reply(&self, host: Option<(u128, &str)>, uuids: bool) -> Tag {
|
||||
let players: Vec<(u128, &str)> = host
|
||||
.into_iter()
|
||||
.chain(
|
||||
self.guests
|
||||
.iter()
|
||||
.filter(|g| g.placed)
|
||||
.map(|g| (g.uuid, g.name.as_str())),
|
||||
)
|
||||
pub fn list_reply(&self, uuids: bool) -> Tag {
|
||||
let players: Vec<(u128, &str)> = self
|
||||
.guests
|
||||
.iter()
|
||||
.filter(|g| g.placed)
|
||||
.map(|g| (g.uuid, g.name.as_str()))
|
||||
.collect();
|
||||
let format = |(uuid, name): (u128, &str)| {
|
||||
if uuids {
|
||||
@@ -996,12 +960,8 @@ impl Hub {
|
||||
// IntegratedServer.getProfilePermissions / getCustomPermissionLevel.
|
||||
let guests = if self.config.guest_commands { 2 } else { 0 };
|
||||
self.ops.get(&uuid).copied().unwrap_or(guests)
|
||||
} else if self.config.dedicated {
|
||||
self.ops.get(&uuid).copied().unwrap_or(0)
|
||||
} else if self.config.guest_commands {
|
||||
2
|
||||
} else {
|
||||
0
|
||||
self.ops.get(&uuid).copied().unwrap_or(0)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1080,9 +1040,6 @@ impl Hub {
|
||||
}));
|
||||
// The player list: everyone already here, then the newcomer to all.
|
||||
let mut entries = Vec::new();
|
||||
if let Some(host) = ctx.host {
|
||||
entries.push(self.player_info(host.uuid, &host.name, host.game_mode, 0));
|
||||
}
|
||||
// Players a native mod hid from the newcomer stay out of its list,
|
||||
// and a hidden newcomer out of theirs.
|
||||
let permission = self.guests[i].permission;
|
||||
@@ -1342,7 +1299,7 @@ impl Hub {
|
||||
fn commands_packet(&self, permission: u8) -> play::Commands {
|
||||
match &self.sync.commands {
|
||||
Some(tree) => {
|
||||
let dedicated = self.config.dedicated && !self.config.integrated;
|
||||
let dedicated = !self.config.integrated;
|
||||
tree.packet_with_features(
|
||||
permission,
|
||||
dedicated,
|
||||
@@ -2462,36 +2419,9 @@ impl Hub {
|
||||
out
|
||||
}
|
||||
|
||||
/// All entities as one client sees them: the world's, the host, the other guests.
|
||||
/// All entities as one client sees them: the world's and the other players.
|
||||
fn track_entities(&mut self, ctx: &mut TickCtx) {
|
||||
let mut all: Vec<EntityState> =
|
||||
Vec::with_capacity(ctx.entities.len() + self.guests.len() + 1);
|
||||
if let Some(h) = ctx.host {
|
||||
let equipment = {
|
||||
let e = &h.equipment;
|
||||
self.equipment_of(&e[0], &e[1], [&e[5], &e[4], &e[3], &e[2]])
|
||||
};
|
||||
let mut state = self.player_state(
|
||||
h.entity_id,
|
||||
h.uuid,
|
||||
h.position,
|
||||
(h.yaw, h.pitch, h.head_yaw),
|
||||
h.on_ground,
|
||||
(h.sneaking, h.sprinting, h.bed.is_some()),
|
||||
h.body,
|
||||
h.living_flags,
|
||||
h.bed,
|
||||
0x7F,
|
||||
h.health,
|
||||
equipment,
|
||||
h.attributes.clone(),
|
||||
&[],
|
||||
);
|
||||
state.spectator = h.game_mode == 3;
|
||||
stuck_meta(&mut state, h.stuck);
|
||||
shoulder_meta(&mut state, h.shoulders);
|
||||
all.push(state);
|
||||
}
|
||||
let mut all: Vec<EntityState> = Vec::with_capacity(ctx.entities.len() + self.guests.len());
|
||||
let placed: Vec<usize> = (0..self.guests.len())
|
||||
.filter(|&i| self.here(&self.guests[i]))
|
||||
.collect();
|
||||
@@ -3629,18 +3559,6 @@ impl Hub {
|
||||
g.send(&g.abilities());
|
||||
}
|
||||
|
||||
/// The host's game mode changed (PlayerInfo UPDATE_GAME_MODE for everyone).
|
||||
pub fn set_host_game_mode(&self, uuid: u128, mode: i32) {
|
||||
self.broadcast(&C::PlayerInfoUpdate(play::PlayerInfoUpdate {
|
||||
actions: 4,
|
||||
entries: vec![play::PlayerInfo {
|
||||
uuid,
|
||||
game_mode: Some(mode),
|
||||
..Default::default()
|
||||
}],
|
||||
}));
|
||||
}
|
||||
|
||||
pub fn disconnect_all(&mut self) {
|
||||
let reason = translatable("multiplayer.disconnect.server_shutdown", &[]);
|
||||
for g in &self.guests {
|
||||
|
||||
@@ -38,7 +38,7 @@ use horizon_protocol::{
|
||||
text::translatable,
|
||||
};
|
||||
use std::{
|
||||
collections::{HashMap, HashSet},
|
||||
collections::HashMap,
|
||||
io,
|
||||
net::{IpAddr, SocketAddr, TcpListener, TcpStream},
|
||||
sync::{
|
||||
@@ -338,8 +338,6 @@ pub struct Shared {
|
||||
max_players: AtomicUsize,
|
||||
/// PlayerList.players in join order: profile id and name.
|
||||
online: Mutex<Vec<(u128, String)>>,
|
||||
/// Profiles held by the host (the LAN host's own name).
|
||||
reserved: Mutex<HashSet<u128>>,
|
||||
next: AtomicU32,
|
||||
stop: AtomicBool,
|
||||
random: Mutex<u64>,
|
||||
@@ -381,13 +379,10 @@ impl Shared {
|
||||
{
|
||||
return Some(reason);
|
||||
}
|
||||
if self.reserved.lock().unwrap().contains(&uuid) {
|
||||
return Some(translatable("multiplayer.disconnect.duplicate_login", &[]));
|
||||
}
|
||||
{
|
||||
let online = self.online.lock().unwrap();
|
||||
let bypass = self.config.bypasses_limit.as_ref().is_some_and(|b| b(uuid));
|
||||
let taken = online.len() + self.reserved.lock().unwrap().len();
|
||||
let taken = online.len();
|
||||
if taken >= self.max_players() && !bypass {
|
||||
return Some(translatable("multiplayer.disconnect.server_full", &[]));
|
||||
}
|
||||
@@ -421,7 +416,7 @@ impl Shared {
|
||||
self.online.lock().unwrap().retain(|(u, _)| *u != uuid);
|
||||
}
|
||||
pub fn online(&self) -> usize {
|
||||
self.online.lock().unwrap().len() + self.reserved.lock().unwrap().len()
|
||||
self.online.lock().unwrap().len()
|
||||
}
|
||||
fn next_random(&self, bound: usize) -> usize {
|
||||
let mut r = self.random.lock().unwrap();
|
||||
@@ -553,7 +548,6 @@ impl Lan {
|
||||
motd: Mutex::new(config.motd.clone()),
|
||||
max_players: AtomicUsize::new(config.max_players),
|
||||
online: Mutex::new(Vec::new()),
|
||||
reserved: Mutex::new(HashSet::new()),
|
||||
next: AtomicU32::new(1),
|
||||
stop: AtomicBool::new(false),
|
||||
connections: AtomicUsize::new(0),
|
||||
@@ -657,10 +651,6 @@ impl Lan {
|
||||
pub fn poll(&self) -> Vec<NetEvent> {
|
||||
self.events.try_iter().collect()
|
||||
}
|
||||
/// Reserve a uuid for the host so a guest cannot take the name.
|
||||
pub fn reserve(&self, uuid: u128) {
|
||||
self.shared.reserved.lock().unwrap().insert(uuid);
|
||||
}
|
||||
pub fn stop(&mut self) {
|
||||
self.shared.stop.store(true, Ordering::Relaxed);
|
||||
self.stop_listening();
|
||||
|
||||
@@ -2502,6 +2502,9 @@ pub struct MobWorld<'a> {
|
||||
pub lid_openers: &'a HashMap<(i32, i32, i32), i32>,
|
||||
/// What a content mod's effects do, on a server (None on a client).
|
||||
pub mod_effects: Option<&'a dyn ModEffectTicks>,
|
||||
/// The level's world border, whose walls stop a mob's moves
|
||||
/// (`Entity.collectColliders`); None: nothing stops them there.
|
||||
pub border: Option<&'a horizon_core::geometry::border::BorderGeometry>,
|
||||
}
|
||||
/// `MobEffectInstance.tickServer` for a content mod's effect: its
|
||||
/// `shouldApplyEffectTickThisTick` and `applyEffectTick`, which the game's
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
//! What a client's copy of a mob makes on its own, in the
|
||||
//! `level().isClientSide()` branches of `aiStep`: nothing of it reaches the
|
||||
//! server or goes over the network, so the window's game runs it, whether it
|
||||
//! simulates the world (in-frame) or follows a server.
|
||||
//! server or goes over the network, so the window's game runs it as it follows
|
||||
//! a server.
|
||||
use super::{Mob, MobEvent, Species, bee};
|
||||
use horizon_core::geometry::trig::Mth;
|
||||
use horizon_core::random::RandomSource;
|
||||
|
||||
@@ -556,3 +556,38 @@ mod item_tests {
|
||||
assert!(m.extra.main_hand.is_none(), "the mouth is empty");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod target_tests {
|
||||
use super::*;
|
||||
use crate::mobs::world::with_test_world;
|
||||
use crate::mobs::{Species, sensor_scan_tests::kind};
|
||||
use horizon_core::geometry::Vec3;
|
||||
|
||||
fn dolphin(baby: bool) -> Mob {
|
||||
let mut kind = kind();
|
||||
let k = std::sync::Arc::make_mut(&mut kind);
|
||||
k.id = "minecraft:dolphin".into();
|
||||
k.species = Species::Dolphin;
|
||||
let mut m = Mob::new(1, kind, Vec3::ZERO, 0.0);
|
||||
m.extra.age = if baby { -24000 } else { 0 };
|
||||
m
|
||||
}
|
||||
|
||||
/// `Dolphin.canAttack`: a baby takes nobody as its target, so a hit on it
|
||||
/// is not answered; an adult does take the one that hurt it.
|
||||
#[test]
|
||||
fn a_baby_dolphin_spares_whatever_hurt_it() {
|
||||
let ran = with_test_world(&[], |w, _| {
|
||||
assert!(
|
||||
dolphin(true).spares_target(w, Who::Mob(7)),
|
||||
"a baby dolphin fights nobody"
|
||||
);
|
||||
assert!(
|
||||
!dolphin(false).spares_target(w, Who::Mob(7)),
|
||||
"an adult dolphin takes its attacker"
|
||||
);
|
||||
});
|
||||
let _ = ran;
|
||||
}
|
||||
}
|
||||
@@ -84,6 +84,19 @@ pub fn teleport_to(
|
||||
if m.extra.passenger {
|
||||
return false;
|
||||
}
|
||||
// LivingEntity.randomTeleport: WorldBorder.clampVec3ToBound keeps the
|
||||
// target inside the border (Mth.clamp, 1.0E-5F inside the far edges).
|
||||
let (x, z) = match w.border {
|
||||
Some(border) => {
|
||||
let [min_x, min_z, max_x, max_z] = border.bounds(0.0);
|
||||
let clamp = |v: f64, low: f64, high: f64| if v < low { low } else { v.min(high) };
|
||||
(
|
||||
clamp(x, min_x, max_x - f64::from(1.0e-5_f32)),
|
||||
clamp(z, min_z, max_z - f64::from(1.0e-5_f32)),
|
||||
)
|
||||
}
|
||||
None => (x, z),
|
||||
};
|
||||
let (bx, bz) = (x.floor() as i32, z.floor() as i32);
|
||||
if !w.world.loaded(bx, bz) {
|
||||
return false;
|
||||
@@ -583,6 +596,53 @@ mod tests {
|
||||
});
|
||||
}
|
||||
|
||||
/// LivingEntity.randomTeleport clamps the target into the world border
|
||||
/// (`WorldBorder.clampVec3ToBound`), so an enderman never lands outside
|
||||
/// it (#7080). A border of size 10 around the origin ends at x = 5.
|
||||
#[test]
|
||||
fn an_enderman_teleports_inside_the_world_border() {
|
||||
let build = |world: &mut crate::world::World, registry: &BlockRegistry| {
|
||||
let stone = registry.default_state("minecraft:stone").unwrap();
|
||||
for cx in -2..2 {
|
||||
for cz in -2..2 {
|
||||
world
|
||||
.chunks
|
||||
.insert((cx, cz), Arc::new(crate::chunk::Chunk::empty(cx, cz)));
|
||||
}
|
||||
}
|
||||
for x in -32..32 {
|
||||
for z in -32..32 {
|
||||
world.set(x, -1, z, stone).unwrap();
|
||||
}
|
||||
}
|
||||
};
|
||||
let border =
|
||||
horizon_core::geometry::border::BorderGeometry::new([0.0, 0.0], 10.0, 29_999_984)
|
||||
.unwrap();
|
||||
crate::mobs::world::with_built_test_world(&[], build, |w, _| {
|
||||
let mut free = enderman();
|
||||
assert!(teleport_to(&mut free, w, 20.5, 0.0, 0.5, &mut Vec::new()));
|
||||
assert_eq!(free.position, Vec3::new(20.5, 0.0, 0.5), "no border");
|
||||
|
||||
let w = MobWorld {
|
||||
border: Some(&border),
|
||||
..w.clone()
|
||||
};
|
||||
let mut bounded = enderman();
|
||||
assert!(teleport_to(
|
||||
&mut bounded,
|
||||
&w,
|
||||
20.5,
|
||||
0.0,
|
||||
-20.5,
|
||||
&mut Vec::new()
|
||||
));
|
||||
// Mth.clamp to [min, max - 1.0E-5F] on x and z.
|
||||
let edge = 5.0 - f64::from(1.0e-5_f32);
|
||||
assert_eq!(bounded.position, Vec3::new(edge, 0.0, -5.0));
|
||||
});
|
||||
}
|
||||
|
||||
/// Enderman.dropCustomDeathLoot (#3513): a killed enderman drops the
|
||||
/// block it carries, mined with a silk touch diamond axe, so a grass
|
||||
/// block comes back as a grass block (not dirt) and a nylium as itself.
|
||||
|
||||
@@ -689,6 +689,11 @@ impl Mob {
|
||||
/// `IronGolem.canAttack`'s refusal of creepers. The callers check that
|
||||
/// the target is alive and can be seen as an enemy.
|
||||
pub fn spares_target(&self, w: &MobWorld, who: Who) -> bool {
|
||||
// Dolphin.canAttack: a baby dolphin attacks nobody, so whatever hurt it
|
||||
// is never taken as its target.
|
||||
if self.species() == Species::Dolphin && self.is_baby() {
|
||||
return true;
|
||||
}
|
||||
match who {
|
||||
Who::Player => self.spares_players(w),
|
||||
Who::Mob(id) => {
|
||||
|
||||
Loaded 100 of 178 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user