Merge main into #7153

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Ludovic MarieandClaude Opus 5.5 committed 2026-11-02 13:05:29 +01:00
commit 4dcbdd27fc
178 files changed
+4027 -3953

No files matched your search

+1 -1
View File
@@ -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.
+10 -14
View File
@@ -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
View File
@@ -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() {
+24 -20
View File
@@ -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);
+10 -1
View File
@@ -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) {
+45 -1
View File
@@ -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 {
+22 -76
View File
@@ -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,
}
};
}
-72
View File
@@ -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);
+42 -1
View File
@@ -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"))
+33
View File
@@ -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
+75 -135
View File
@@ -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));
}
}
+1 -1
View File
@@ -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);
}
+3 -12
View File
@@ -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),
+1 -38
View File
@@ -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) {
+2 -42
View File
@@ -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 -62
View File
@@ -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 {
+11 -77
View File
@@ -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))
+26 -9
View File
@@ -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);
+1 -2
View File
@@ -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)
}
+1 -2
View File
@@ -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?");
+3 -6
View File
@@ -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 {
+1 -118
View File
@@ -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`]).
+158
View File
@@ -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 {
+2 -19
View File
@@ -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);
-1
View File
@@ -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.
+17 -10
View File
@@ -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 || {
+47 -5
View File
@@ -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 &copy 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);
}
}
}
+61 -449
View File
@@ -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<'_> {
+19 -140
View File
@@ -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;
}
+8 -18
View File
@@ -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(),
};
+16 -109
View File
@@ -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);
}
}
+19 -3
View File
@@ -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() {
-26
View File
@@ -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) {
+3 -2
View File
@@ -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);
+1 -9
View File
@@ -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));
+8 -13
View File
@@ -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();
+131 -161
View File
@@ -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]));
+1 -4
View File
@@ -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;
}
+224 -188
View File
@@ -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 {
+61 -14
View File
@@ -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));
}
-12
View File
@@ -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])
}
+2 -58
View File
@@ -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
+1 -3
View File
@@ -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 {
+1 -1
View File
@@ -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);
+4 -51
View File
@@ -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(),
-4
View File
@@ -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
-49
View File
@@ -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() {
-32
View File
@@ -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.
+4 -6
View File
@@ -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()
+1 -20
View File
@@ -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!(
+230 -4
View File
@@ -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
+13 -95
View File
@@ -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 {
+3 -13
View File
@@ -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();
+3
View File
@@ -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
+2 -2
View File
@@ -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;
+35
View File
@@ -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.
+5
View File
@@ -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