mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-07 11:06:11 -10:00
Merge main into #6822
Main's #6821 added its own test beside this PR's; both are kept. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
commit
e7226b798f
395 files changed
+24209
-1550
No files matched your search
@@ -174,7 +174,7 @@ jobs:
|
||||
run: |
|
||||
if ! xcrun metal --version > /dev/null 2>&1; then
|
||||
echo "::notice::No Metal toolchain on this runner: GPUI compiles its shaders at run time"
|
||||
echo "LAUNCHER_FEATURES=gpui/runtime_shaders" >> "$GITHUB_ENV"
|
||||
echo "LAUNCHER_FEATURES=gpui_platform/runtime_shaders" >> "$GITHUB_ENV"
|
||||
fi
|
||||
# Every test target (the library, the binary, tests/), on each system the launcher ships for.
|
||||
- run: cargo test --release --locked ${{ matrix.target && format('--target {0}', matrix.target) || '' }} ${LAUNCHER_FEATURES:+--features "$LAUNCHER_FEATURES"}
|
||||
|
||||
@@ -103,6 +103,10 @@ python3 tools/agent_control_e2e.py # horizon-ctl / MCP end to end
|
||||
+ a Rust probe: `crates/<crate>/examples/<name>_{probe,fixture}.rs` (horizon-world 175, horizon-client 104),
|
||||
or the game binary with a fixture env var (`game/dispense_fixture.rs`, `game/sculk_fixture.rs`,
|
||||
`game/command_harness.rs`, `game/net_harness.rs`). Copy a nearby harness for a new one.
|
||||
- Developing a native mod? Run `horizon-launcher settings native-profile dev` once. `dev` builds the game crate
|
||||
unoptimized: a mod edit rebuilds about 6× faster than with `performance`, while game logic runs up to about twice as
|
||||
slow. Use it while developing, not for playing; `performance` stays the default for players and releases
|
||||
([docs/native-mods.md](docs/native-mods.md)).
|
||||
- Probes build into `.local/parity-target/`; harnesses find binaries via `lab.cargo_target()`, never `target/`.
|
||||
- Harness selection uses Cargo dep-info and reachable-code narrowing (`tools/parity_reach.py`).
|
||||
`horizon-client-lib` builds the control tool and client probes without gameplay; their dep-info
|
||||
|
||||
Generated
+2
@@ -1461,6 +1461,7 @@ dependencies = [
|
||||
"horizon-threads",
|
||||
"horizon-world",
|
||||
"log",
|
||||
"lz4_flex",
|
||||
"miniz_oxide 0.8.9",
|
||||
"naga",
|
||||
"png",
|
||||
@@ -1733,6 +1734,7 @@ dependencies = [
|
||||
"flate2",
|
||||
"horizon-core",
|
||||
"libm",
|
||||
"log",
|
||||
"lz4_flex",
|
||||
"postcard",
|
||||
"serde",
|
||||
|
||||
@@ -239,8 +239,19 @@ impl Action {
|
||||
pub struct Mute {
|
||||
pub uuid: String,
|
||||
pub name: String,
|
||||
/// Epoch milliseconds.
|
||||
pub until: i64,
|
||||
/// Epoch milliseconds; `None`: until `/unmute`.
|
||||
pub until: Option<i64>,
|
||||
/// The operator who muted them (`/mute`, `/tempmute`); `None`: the filter.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub by: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub reason: Option<String>,
|
||||
}
|
||||
|
||||
impl Mute {
|
||||
pub fn active(&self, now: i64) -> bool {
|
||||
self.until.is_none_or(|until| until > now)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default, Serialize, Deserialize)]
|
||||
@@ -312,12 +323,38 @@ impl Guard {
|
||||
)
|
||||
}
|
||||
|
||||
/// When the player's mute ends, if they are muted at `now`.
|
||||
pub fn muted_until(&self, uuid: u128, now: i64) -> Option<i64> {
|
||||
self.mutes
|
||||
.get(&uuid)
|
||||
.map(|m| m.until)
|
||||
.filter(|&until| until > now)
|
||||
/// The player's mute, if they are muted at `now`.
|
||||
pub fn muted(&self, uuid: u128, now: i64) -> Option<&Mute> {
|
||||
self.mutes.get(&uuid).filter(|m| m.active(now))
|
||||
}
|
||||
|
||||
/// The mutes in force at `now`, by name.
|
||||
pub fn mutes(&self, now: i64) -> Vec<&Mute> {
|
||||
let mut mutes: Vec<&Mute> = self.mutes.values().filter(|m| m.active(now)).collect();
|
||||
mutes.sort_by(|a, b| a.name.cmp(&b.name));
|
||||
mutes
|
||||
}
|
||||
|
||||
/// An operator's `/mute` (`until` `None`) or `/tempmute`: replaces the player's mute.
|
||||
pub fn mute(
|
||||
&mut self,
|
||||
uuid: u128,
|
||||
name: &str,
|
||||
until: Option<i64>,
|
||||
by: &str,
|
||||
reason: Option<String>,
|
||||
) {
|
||||
self.mutes.insert(
|
||||
uuid,
|
||||
Mute {
|
||||
uuid: uuid_string(uuid),
|
||||
name: name.to_owned(),
|
||||
until,
|
||||
by: Some(by.to_owned()),
|
||||
reason,
|
||||
},
|
||||
);
|
||||
self.save();
|
||||
}
|
||||
|
||||
/// A hit by the player at `now`: the hit's number in the window, and what it leads to.
|
||||
@@ -343,15 +380,24 @@ impl Guard {
|
||||
let until = now + i64::from(c.mute_minutes) * MINUTE;
|
||||
// The window starts again when the mute ends.
|
||||
s.counts_until = until + window;
|
||||
self.mutes.insert(
|
||||
uuid,
|
||||
Mute {
|
||||
uuid: uuid_string(uuid),
|
||||
name: name.to_owned(),
|
||||
until,
|
||||
},
|
||||
);
|
||||
self.save();
|
||||
// An operator's longer mute stays.
|
||||
if !self
|
||||
.mutes
|
||||
.get(&uuid)
|
||||
.is_some_and(|m| m.until.is_none_or(|u| u >= until))
|
||||
{
|
||||
self.mutes.insert(
|
||||
uuid,
|
||||
Mute {
|
||||
uuid: uuid_string(uuid),
|
||||
name: name.to_owned(),
|
||||
until: Some(until),
|
||||
by: None,
|
||||
reason: None,
|
||||
},
|
||||
);
|
||||
self.save();
|
||||
}
|
||||
Action::Muted { until }
|
||||
} else {
|
||||
Action::Refused
|
||||
@@ -377,7 +423,7 @@ impl Guard {
|
||||
return;
|
||||
};
|
||||
let now = now_millis();
|
||||
self.mutes.retain(|_, m| m.until > now);
|
||||
self.mutes.retain(|_, m| m.active(now));
|
||||
let mut mutes: Vec<Mute> = self.mutes.values().cloned().collect();
|
||||
mutes.sort_by(|a, b| a.name.cmp(&b.name));
|
||||
if let Ok(text) = serde_json::to_string_pretty(&MutesFile { mutes }) {
|
||||
@@ -424,6 +470,34 @@ pub fn append_log(path: &Path, line: &str) -> std::io::Result<()> {
|
||||
writeln!(f, "{line}")
|
||||
}
|
||||
|
||||
/// `/tempmute`'s duration in milliseconds: a positive whole number and a unit, `s`, `m`, `h`, `d` or
|
||||
/// `w` (`10m`, `2h`, `1d`).
|
||||
pub fn parse_duration(text: &str) -> Option<i64> {
|
||||
let unit = match text.chars().last()? {
|
||||
's' => 1_000,
|
||||
'm' => MINUTE,
|
||||
'h' => 60 * MINUTE,
|
||||
'd' => 24 * 60 * MINUTE,
|
||||
'w' => 7 * 24 * 60 * MINUTE,
|
||||
_ => return None,
|
||||
};
|
||||
let count: i64 = text[..text.len() - 1].parse().ok()?;
|
||||
(count > 0).then(|| count.checked_mul(unit)).flatten()
|
||||
}
|
||||
|
||||
/// `millis` for players, rounded up: minutes up to an hour, hours up to two days, then days.
|
||||
pub fn duration_text(millis: i64) -> String {
|
||||
let up = |unit: i64| (millis + unit - 1) / unit;
|
||||
let (count, unit) = if millis <= 60 * MINUTE {
|
||||
(up(MINUTE), "minute")
|
||||
} else if millis <= 48 * 60 * MINUTE {
|
||||
(up(60 * MINUTE), "hour")
|
||||
} else {
|
||||
(up(24 * 60 * MINUTE), "day")
|
||||
};
|
||||
format!("{count} {unit}{}", if count == 1 { "" } else { "s" })
|
||||
}
|
||||
|
||||
pub fn now_millis() -> i64 {
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
|
||||
@@ -120,14 +120,14 @@ fn the_second_hit_mutes_and_the_third_bans() {
|
||||
let mut g = guard(Config::default());
|
||||
let id = 7;
|
||||
assert_eq!(g.strike(id, "Ann", NOW), (1, Action::Refused));
|
||||
assert_eq!(g.muted_until(id, NOW), None);
|
||||
assert_eq!(g.muted(id, NOW), None);
|
||||
let until = NOW + 3 * MIN + 10 * MIN;
|
||||
assert_eq!(
|
||||
g.strike(id, "Ann", NOW + 3 * MIN),
|
||||
(2, Action::Muted { until })
|
||||
);
|
||||
assert_eq!(g.muted_until(id, NOW + 4 * MIN), Some(until));
|
||||
assert_eq!(g.muted_until(id, until), None);
|
||||
assert_eq!(g.muted(id, NOW + 4 * MIN).unwrap().until, Some(until));
|
||||
assert_eq!(g.muted(id, until), None);
|
||||
// The time spent muted doesn't count: 8 minutes after the mute ended is still within the window.
|
||||
assert_eq!(
|
||||
g.strike(id, "Ann", until + 8 * MIN),
|
||||
@@ -190,15 +190,83 @@ fn mutes_survive_a_restart_and_unmute_lifts_them() {
|
||||
let (_, action) = g.strike(5, "Dee", now);
|
||||
assert!(matches!(action, Action::Muted { .. }));
|
||||
let (mut again, _) = Guard::load(Config::default(), &dir);
|
||||
assert!(again.muted_until(5, now).is_some());
|
||||
assert!(again.muted(5, now).is_some());
|
||||
assert_eq!(again.unmute("dee").as_deref(), Some("Dee"));
|
||||
assert_eq!(again.unmute("dee"), None);
|
||||
assert_eq!(again.muted_until(5, now), None);
|
||||
assert_eq!(again.muted(5, now), None);
|
||||
// An operator's mutes are kept too, with who and why.
|
||||
again.mute(6, "Eve", None, "Op", Some("spam".into()));
|
||||
again.mute(7, "Fay", Some(now + MIN), "Op", None);
|
||||
let (reloaded, _) = Guard::load(Config::default(), &dir);
|
||||
assert_eq!(reloaded.muted_until(5, now), None);
|
||||
assert_eq!(reloaded.muted(5, now), None);
|
||||
let names: Vec<_> = reloaded
|
||||
.mutes(now)
|
||||
.iter()
|
||||
.map(|m| m.name.as_str())
|
||||
.collect();
|
||||
assert_eq!(names, ["Eve", "Fay"]);
|
||||
let eve = reloaded.muted(6, now).unwrap();
|
||||
assert_eq!(
|
||||
(eve.until, eve.by.as_deref(), eve.reason.as_deref()),
|
||||
(None, Some("Op"), Some("spam"))
|
||||
);
|
||||
assert_eq!(reloaded.mutes(now + MIN).len(), 1);
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_mute_file_from_before_manual_mutes_still_loads() {
|
||||
let saved: MutesFile = serde_json::from_str(
|
||||
r#"{"mutes":[{"uuid":"00000000-0000-0000-0000-000000000005","name":"Dee","until":5}]}"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(saved.mutes[0].until, Some(5));
|
||||
assert_eq!(saved.mutes[0].by, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_filter_never_shortens_an_operators_mute() {
|
||||
let mut g = guard(Config::default());
|
||||
g.mute(1, "Bo", None, "Op", None);
|
||||
g.strike(1, "Bo", NOW);
|
||||
g.strike(1, "Bo", NOW + MIN);
|
||||
assert_eq!(
|
||||
g.muted(1, NOW + 60 * MIN).unwrap().by.as_deref(),
|
||||
Some("Op")
|
||||
);
|
||||
// A shorter one is replaced by the filter's.
|
||||
g.mute(2, "Cy", Some(NOW + MIN), "Op", None);
|
||||
g.strike(2, "Cy", NOW);
|
||||
g.strike(2, "Cy", NOW);
|
||||
assert_eq!(g.muted(2, NOW).unwrap().until, Some(NOW + 10 * MIN));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn durations_read_and_print() {
|
||||
assert_eq!(parse_duration("10m"), Some(10 * MIN));
|
||||
assert_eq!(parse_duration("2h"), Some(120 * MIN));
|
||||
assert_eq!(parse_duration("1d"), Some(24 * 60 * MIN));
|
||||
assert_eq!(parse_duration("1w"), Some(7 * 24 * 60 * MIN));
|
||||
assert_eq!(parse_duration("30s"), Some(30_000));
|
||||
for bad in [
|
||||
"",
|
||||
"m",
|
||||
"0m",
|
||||
"-1m",
|
||||
"1.5h",
|
||||
"10",
|
||||
"10x",
|
||||
"99999999999999999w",
|
||||
] {
|
||||
assert_eq!(parse_duration(bad), None, "{bad}");
|
||||
}
|
||||
assert_eq!(duration_text(61_000), "2 minutes");
|
||||
assert_eq!(duration_text(MIN), "1 minute");
|
||||
assert_eq!(duration_text(2 * 60 * MIN), "2 hours");
|
||||
assert_eq!(duration_text(24 * 60 * MIN), "24 hours");
|
||||
assert_eq!(duration_text(3 * 24 * 60 * MIN), "3 days");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_missing_words_file_falls_back_to_the_built_in_list() {
|
||||
let config = Config {
|
||||
|
||||
@@ -132,6 +132,10 @@ path = "../horizon-client/src/bin/horizon-ctl/main.rs"
|
||||
name = "chunk_load_bench"
|
||||
path = "../horizon-client/examples/chunk_load_bench.rs"
|
||||
|
||||
[[example]]
|
||||
name = "gen_light_bench"
|
||||
path = "../horizon-client/examples/gen_light_bench.rs"
|
||||
|
||||
[[example]]
|
||||
name = "gameplay_decode_profile"
|
||||
path = "../horizon-client/examples/gameplay_decode_profile.rs"
|
||||
|
||||
@@ -7,6 +7,8 @@ rust-version = "1.90"
|
||||
description = "The client's world data, rendering and screens: chunks, meshing, light, streaming, the renderer, the HUD, menus and settings; no gameplay"
|
||||
|
||||
[dependencies]
|
||||
# The GUI atlas's pixels, kept compressed once uploaded (hud.rs `pack_pixels`).
|
||||
lz4_flex = { version = "0.14", default-features = false, features = ["std", "safe-encode", "safe-decode", "checked-decode"] }
|
||||
horizon-assets = { path = "../horizon-assets" }
|
||||
horizon-bench = { path = "../horizon-bench" }
|
||||
horizon-client-core = { path = "../horizon-client-core" }
|
||||
|
||||
@@ -187,6 +187,7 @@ pub(crate) fn discovery() -> Result<Arc<serde_json::Value>, JoinError> {
|
||||
let doc: serde_json::Value = agent()
|
||||
.get(&discovery_url())
|
||||
.call()
|
||||
.inspect_err(|e| log::warn!("The services discovery document: {e}"))
|
||||
.ok()
|
||||
.and_then(|r| r.into_string().ok())
|
||||
.and_then(|t| serde_json::from_str(&t).ok())
|
||||
@@ -253,9 +254,33 @@ pub fn join_request(session: &AuthSession, server_hash: &str) -> String {
|
||||
.to_string()
|
||||
}
|
||||
|
||||
/// A join that got no answer is worth one more try, as leaving and joining
|
||||
/// again would be: `join` is safe to repeat, it only records the server's
|
||||
/// hash. Any other answer is the service's own and stands.
|
||||
fn with_one_retry(
|
||||
mut join: impl FnMut() -> Result<(), JoinError>,
|
||||
pause: Duration,
|
||||
) -> Result<(), JoinError> {
|
||||
match join() {
|
||||
Err(JoinError::Unavailable) => {
|
||||
std::thread::sleep(pause);
|
||||
join()
|
||||
}
|
||||
result => result,
|
||||
}
|
||||
}
|
||||
|
||||
/// MinecraftServicesSessionService.joinServer: tell the session service this
|
||||
/// account is joining the server whose hash is `server_hash`.
|
||||
/// account is joining the server whose hash is `server_hash`. Horizon tries
|
||||
/// twice when the service doesn't answer; vanilla asks the player to.
|
||||
pub fn join_server(session: &AuthSession, server_hash: &str) -> Result<(), JoinError> {
|
||||
with_one_retry(
|
||||
|| join_server_once(session, server_hash),
|
||||
Duration::from_millis(500),
|
||||
)
|
||||
}
|
||||
|
||||
fn join_server_once(session: &AuthSession, server_hash: &str) -> Result<(), JoinError> {
|
||||
let doc = discovery()?;
|
||||
let url = endpoint(&doc, "session", "join").ok_or(JoinError::Unavailable)?;
|
||||
match agent()
|
||||
@@ -264,11 +289,19 @@ pub fn join_server(session: &AuthSession, server_hash: &str) -> Result<(), JoinE
|
||||
.send_string(&join_request(session, server_hash))
|
||||
{
|
||||
Ok(_) => Ok(()),
|
||||
Err(ureq::Error::Status(status, response)) => Err(JoinError::from_response(
|
||||
status,
|
||||
&response.into_string().unwrap_or_default(),
|
||||
)),
|
||||
Err(ureq::Error::Transport(_)) => Err(JoinError::Unavailable),
|
||||
Err(ureq::Error::Status(status, response)) => {
|
||||
if status >= 500 {
|
||||
log::warn!("The session service's join answered {status}");
|
||||
}
|
||||
Err(JoinError::from_response(
|
||||
status,
|
||||
&response.into_string().unwrap_or_default(),
|
||||
))
|
||||
}
|
||||
Err(ureq::Error::Transport(e)) => {
|
||||
log::warn!("The session service's join: {e}");
|
||||
Err(JoinError::Unavailable)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -564,6 +597,41 @@ mod tests {
|
||||
|
||||
const UUID: u128 = 0x069a79f444e94726a5befca90e38aaf5;
|
||||
|
||||
/// Runs `with_one_retry` over these answers, in order: the result and how
|
||||
/// many it asked for.
|
||||
fn joined(answers: &[Result<(), JoinError>]) -> (Result<(), JoinError>, usize) {
|
||||
let mut asked = 0;
|
||||
let result = with_one_retry(
|
||||
|| {
|
||||
asked += 1;
|
||||
answers[asked - 1].clone()
|
||||
},
|
||||
Duration::ZERO,
|
||||
);
|
||||
(result, asked)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_join_with_no_answer_is_tried_once_more() {
|
||||
let none = Err(JoinError::Unavailable);
|
||||
assert_eq!(joined(&[none.clone(), Ok(())]), (Ok(()), 2));
|
||||
// Twice without an answer: the message the player knows.
|
||||
assert_eq!(joined(&[none.clone(), none.clone()]), (none, 2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_join_the_service_answered_is_not_tried_again() {
|
||||
assert_eq!(joined(&[Ok(())]), (Ok(()), 1));
|
||||
for refused in [
|
||||
JoinError::InvalidSession,
|
||||
JoinError::Banned,
|
||||
JoinError::InsufficientPrivileges,
|
||||
JoinError::Other("Status: 429".into()),
|
||||
] {
|
||||
assert_eq!(joined(&[Err(refused.clone())]), (Err(refused), 1));
|
||||
}
|
||||
}
|
||||
|
||||
fn write(dir: &Path, text: &str, mode: u32) -> std::path::PathBuf {
|
||||
let path = dir.join("session.json");
|
||||
std::fs::write(&path, text).unwrap();
|
||||
|
||||
@@ -1792,6 +1792,15 @@ impl BlockEntityRenderer {
|
||||
portal_cube(base, &faces, END_GATEWAY_LAYER, out);
|
||||
}
|
||||
Kind::Conduit => {
|
||||
// HDR output: the conduit shines, not its crumbling overlay.
|
||||
let start = out.len();
|
||||
let shine = |out: &mut Vec<Vertex>| {
|
||||
if !crumbling {
|
||||
for v in &mut out[start..] {
|
||||
v.layer |= u32::from(crate::mesher::BRIGHT) << 16;
|
||||
}
|
||||
}
|
||||
};
|
||||
let (ticks, active, hunting, raw_rotation) = (scene.anim)(pos).conduit;
|
||||
// ConduitRenderer.extractRenderState.
|
||||
let rotation = (raw_rotation + if active { scene.partial } else { 0.0 }) * -0.0375;
|
||||
@@ -1809,6 +1818,7 @@ impl BlockEntityRenderer {
|
||||
* Mat4::from_translation(Vec3::splat(0.5))
|
||||
* Mat4::from_rotation_y(rotation.to_radians());
|
||||
draw_part(model, "root", m, &none, paint, out);
|
||||
shine(out);
|
||||
return;
|
||||
}
|
||||
let anim_time = ticks as f32 + scene.partial;
|
||||
@@ -1867,6 +1877,7 @@ impl BlockEntityRenderer {
|
||||
* Mat4::from_scale(Vec3::splat(1.333_333_4));
|
||||
draw_part(model, "root", m, &none, paint, out);
|
||||
}
|
||||
shine(out);
|
||||
}
|
||||
Kind::EnchantingTable => {
|
||||
let book = "entity/enchantment/enchanting_table_book";
|
||||
@@ -2141,11 +2152,13 @@ const PORTAL_FACES: [[[f32; 3]; 4]; 6] = [
|
||||
];
|
||||
/// `AbstractEndPortalRenderer.submitCube`: position-only quads (the portal
|
||||
/// shader projects its textures from the screen; uv, colour and light are unused).
|
||||
/// `rendertype_end_portal` culls back faces, as `entitySolid` does, so the cube's
|
||||
/// two Y faces are one-sided: only the one facing the camera draws.
|
||||
fn portal_cube(m: Mat4, faces: &[Direction], layer: u32, out: &mut Vec<Vertex>) {
|
||||
for &d in faces {
|
||||
for c in PORTAL_FACES[d as usize] {
|
||||
out.push(Vertex {
|
||||
normal: [0; 4],
|
||||
normal: CULL_BACK,
|
||||
pos: m.transform_point3(Vec3::from(c)).to_array(),
|
||||
uv: [0.0; 2],
|
||||
layer,
|
||||
@@ -2716,6 +2729,35 @@ mod tests {
|
||||
assert_eq!(first_line("\u{6771}", 3.0, &advance), "\u{6771}");
|
||||
assert_eq!(first_line("ab\ncd", 100.0, &advance), "ab");
|
||||
}
|
||||
/// `rendertype_end_portal` culls back faces, and `AbstractEndPortalRenderer`
|
||||
/// submits the unit cube's two Y faces. Both are wound outward, so of each
|
||||
/// face only the side looking at the camera draws: the portal shows one
|
||||
/// surface from above and one from below, never both at once.
|
||||
#[test]
|
||||
fn the_end_portal_cube_is_one_sided() {
|
||||
let mut out = Vec::new();
|
||||
portal_cube(
|
||||
Mat4::IDENTITY,
|
||||
&[Direction::Down, Direction::Up],
|
||||
END_PORTAL_LAYER,
|
||||
&mut out,
|
||||
);
|
||||
// `TheEndPortalBlockEntity.shouldRenderFace` keeps the Y faces only.
|
||||
assert_eq!(out.len(), 8, "the cube submits both Y faces");
|
||||
assert!(
|
||||
out.iter().all(|v| v.normal == CULL_BACK),
|
||||
"rendertype_end_portal culls back faces"
|
||||
);
|
||||
// Each face is wound outward, so the shader's `!front` discard keeps the
|
||||
// camera-facing side of it and drops the other.
|
||||
let facing = |quad: &[Vertex]| {
|
||||
let p = |i: usize| Vec3::from(quad[i].pos);
|
||||
(p(1) - p(0)).cross(p(2) - p(0))
|
||||
};
|
||||
assert!(facing(&out[..4]).y < 0.0, "the Down face looks down");
|
||||
assert!(facing(&out[4..]).y > 0.0, "the Up face looks up");
|
||||
}
|
||||
|
||||
/// `DecoratedPotRenderer` submits every part with `RenderTypes.entitySolid`,
|
||||
/// whose pipeline culls back faces. The pot's `top` and `bottom` are flat
|
||||
/// boxes, so without that cull both sides of each plane are drawn and they
|
||||
|
||||
@@ -285,20 +285,122 @@ fn forget_shared() {
|
||||
*shared = None;
|
||||
}
|
||||
}
|
||||
/// Packed light per block: sky light in the high nibble, block light in the low one.
|
||||
/// Packed light per block: sky light in the high nibble, block light in the
|
||||
/// low one. A section keeps only the kind of light that varies in it
|
||||
/// (`Light::packed`): most sections have no block light and only sky light
|
||||
/// that changes, or the reverse below ground.
|
||||
#[derive(Clone)]
|
||||
pub enum Light {
|
||||
Uniform(u8),
|
||||
/// Sky light per block, as nibbles (block `i` in byte `i / 2`, the even
|
||||
/// one in the low nibble, as a vanilla DataLayer), and one block light.
|
||||
Sky(Box<[u8; 2048]>, u8),
|
||||
/// Block light per block, as nibbles, and one sky light.
|
||||
Block(Box<[u8; 2048]>, u8),
|
||||
Full(Box<[u8; 4096]>),
|
||||
}
|
||||
#[inline]
|
||||
fn nibble(layer: &[u8; 2048], i: usize) -> u8 {
|
||||
layer[i >> 1] >> ((i & 1) << 2) & 15
|
||||
}
|
||||
impl Light {
|
||||
#[inline]
|
||||
pub fn get(&self, i: usize) -> u8 {
|
||||
match self {
|
||||
Self::Uniform(v) => *v,
|
||||
Self::Sky(layer, block) => nibble(layer, i) << 4 | block,
|
||||
Self::Block(layer, sky) => sky << 4 | nibble(layer, i),
|
||||
Self::Full(a) => a[i],
|
||||
}
|
||||
}
|
||||
/// The smallest form of packed light per block.
|
||||
pub fn packed(light: Box<[u8; 4096]>) -> Self {
|
||||
let first = light[0];
|
||||
let (mut sky_varies, mut block_varies) = (false, false);
|
||||
for &v in light.iter() {
|
||||
let d = v ^ first;
|
||||
sky_varies |= d & 0xF0 != 0;
|
||||
block_varies |= d & 15 != 0;
|
||||
}
|
||||
let layer = |shift: u32| {
|
||||
let mut layer = Box::new([0u8; 2048]);
|
||||
for (out, pair) in layer.iter_mut().zip(light.as_chunks::<2>().0) {
|
||||
*out = (pair[0] >> shift & 15) | (pair[1] >> shift & 15) << 4;
|
||||
}
|
||||
layer
|
||||
};
|
||||
match (sky_varies, block_varies) {
|
||||
(false, false) => Self::Uniform(first),
|
||||
(true, false) => Self::Sky(layer(4), first & 15),
|
||||
(false, true) => Self::Block(layer(0), first >> 4),
|
||||
(true, true) => Self::Full(light),
|
||||
}
|
||||
}
|
||||
/// The packed light of blocks `at..at + out.len()`, into `out`.
|
||||
#[inline]
|
||||
pub fn copy_run(&self, at: usize, out: &mut [u8]) {
|
||||
match self {
|
||||
Self::Uniform(v) => out.fill(*v),
|
||||
Self::Full(a) => out.copy_from_slice(&a[at..at + out.len()]),
|
||||
Self::Sky(layer, block) => {
|
||||
for (k, o) in out.iter_mut().enumerate() {
|
||||
*o = nibble(layer, at + k) << 4 | block;
|
||||
}
|
||||
}
|
||||
Self::Block(layer, sky) => {
|
||||
for (k, o) in out.iter_mut().enumerate() {
|
||||
*o = sky << 4 | nibble(layer, at + k);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
/// The packed light of every block, in index order.
|
||||
pub fn to_array(&self) -> Box<[u8; 4096]> {
|
||||
match self {
|
||||
Self::Full(a) => a.clone(),
|
||||
Self::Uniform(v) => Box::new([*v; 4096]),
|
||||
_ => Box::new(std::array::from_fn(|i| self.get(i))),
|
||||
}
|
||||
}
|
||||
/// Whether any block's packed light has a bit of `mask` set.
|
||||
pub fn any(&self, mask: u8) -> bool {
|
||||
let layer_any = |layer: &[u8; 2048]| layer.iter().any(|&v| v != 0);
|
||||
match self {
|
||||
Self::Uniform(v) => v & mask != 0,
|
||||
Self::Sky(layer, block) => block & mask != 0 || (mask & 0xF0 != 0 && layer_any(layer)),
|
||||
Self::Block(layer, sky) => {
|
||||
(sky << 4) & mask != 0 || (mask & 15 != 0 && layer_any(layer))
|
||||
}
|
||||
Self::Full(a) => a.iter().any(|v| v & mask != 0),
|
||||
}
|
||||
}
|
||||
/// Whether any block's packed light differs from `v` in the bits of `mask`.
|
||||
pub fn any_not(&self, mask: u8, v: u8) -> bool {
|
||||
match self {
|
||||
Self::Uniform(u) => (u ^ v) & mask != 0,
|
||||
Self::Full(a) => a.iter().any(|u| (u ^ v) & mask != 0),
|
||||
_ => (0..4096).any(|i| (self.get(i) ^ v) & mask != 0),
|
||||
}
|
||||
}
|
||||
/// Heap bytes it holds.
|
||||
pub fn heap_bytes(&self) -> usize {
|
||||
match self {
|
||||
Self::Uniform(_) => 0,
|
||||
Self::Sky(..) | Self::Block(..) => 2048,
|
||||
Self::Full(_) => 4096,
|
||||
}
|
||||
}
|
||||
/// Whether every block's light is the same as in `other`.
|
||||
pub fn same_as(&self, other: &Self) -> bool {
|
||||
match (self, other) {
|
||||
(Self::Uniform(a), Self::Uniform(b)) => a == b,
|
||||
(Self::Full(a), Self::Full(b)) => a == b,
|
||||
(Self::Sky(a, x), Self::Sky(b, y)) | (Self::Block(a, x), Self::Block(b, y)) => {
|
||||
x == y && a == b
|
||||
}
|
||||
_ => (0..4096).all(|i| self.get(i) == other.get(i)),
|
||||
}
|
||||
}
|
||||
}
|
||||
#[inline]
|
||||
fn column(x: i32, z: i32) -> usize {
|
||||
@@ -347,14 +449,15 @@ impl Section {
|
||||
pub fn is_uniform(&self, v: u32) -> bool {
|
||||
self.blocks.is_uniform(v)
|
||||
}
|
||||
/// Whether every block, biome and light value is the same as `other`'s.
|
||||
pub fn same_as(&self, other: &Self) -> bool {
|
||||
self.blocks == other.blocks
|
||||
&& self.biomes == other.biomes
|
||||
&& self.light.same_as(&other.light)
|
||||
}
|
||||
/// Heap bytes held by the block, biome and light storage (benchmark accounting).
|
||||
pub fn heap_bytes(&self) -> usize {
|
||||
self.blocks.heap_bytes()
|
||||
+ self.biomes.heap_bytes()
|
||||
+ match &self.light {
|
||||
Light::Uniform(_) => 0,
|
||||
Light::Full(_) => 4096,
|
||||
}
|
||||
self.blocks.heap_bytes() + self.biomes.heap_bytes() + self.light.heap_bytes()
|
||||
}
|
||||
/// Collapse storage that became uniform, and drop palette entries no
|
||||
/// block uses any more.
|
||||
@@ -366,13 +469,47 @@ impl Section {
|
||||
*container = PalettedContainer::new(container.strategy(), v);
|
||||
}
|
||||
}
|
||||
if let Light::Full(a) = &self.light
|
||||
&& a.iter().all(|&b| b == a[0])
|
||||
{
|
||||
self.light = Light::Uniform(a[0]);
|
||||
// Only the kind of light that varies is kept.
|
||||
if matches!(self.light, Light::Full(_)) {
|
||||
let Light::Full(a) = std::mem::replace(&mut self.light, Light::Uniform(0)) else {
|
||||
unreachable!()
|
||||
};
|
||||
self.light = Light::packed(a);
|
||||
}
|
||||
}
|
||||
}
|
||||
/// The heap bytes of a set of columns by what holds them (`Chunk::census`,
|
||||
/// `horizon-ctl memory`).
|
||||
#[derive(Clone, Copy, Debug, Default)]
|
||||
pub struct ColumnCensus {
|
||||
pub columns: usize,
|
||||
/// Section slots of every column, shared ones included.
|
||||
pub sections: usize,
|
||||
/// Distinct section allocations.
|
||||
pub unique_sections: usize,
|
||||
/// Distinct sections of a single block state (no data array).
|
||||
pub uniform_block_sections: usize,
|
||||
/// Distinct sections with light per block (not one value).
|
||||
pub light_arrays: usize,
|
||||
pub block_bytes: usize,
|
||||
pub biome_bytes: usize,
|
||||
pub light_bytes: usize,
|
||||
pub heightmap_bytes: usize,
|
||||
/// The section records themselves (`Arc<Section>`).
|
||||
pub section_record_bytes: usize,
|
||||
/// The column records and their lists of sections.
|
||||
pub column_bytes: usize,
|
||||
}
|
||||
impl ColumnCensus {
|
||||
pub fn total_bytes(&self) -> usize {
|
||||
self.block_bytes
|
||||
+ self.biome_bytes
|
||||
+ self.light_bytes
|
||||
+ self.heightmap_bytes
|
||||
+ self.section_record_bytes
|
||||
+ self.column_bytes
|
||||
}
|
||||
}
|
||||
#[derive(Clone)]
|
||||
pub struct Chunk {
|
||||
pub x: i32,
|
||||
@@ -564,19 +701,61 @@ impl Chunk {
|
||||
}
|
||||
#[inline]
|
||||
pub fn light(&self, x: i32, y: i32, z: i32) -> u8 {
|
||||
let s = (y - self.min_y) >> 4;
|
||||
if s < 0 {
|
||||
return 0;
|
||||
match self.section_at(y) {
|
||||
Ok(section) => section.light(index(x, y, z)),
|
||||
Err(light) => light,
|
||||
}
|
||||
if s >= self.sections.len() as i32 {
|
||||
return 0xF0;
|
||||
}
|
||||
/// The section holding block height `y`, or the light of the air outside the
|
||||
/// column's sections there: none below them, full sky above.
|
||||
#[inline]
|
||||
pub fn section_at(&self, y: i32) -> Result<&Section, u8> {
|
||||
match self.section_index(y) {
|
||||
Some(s) => Ok(&self.sections[s]),
|
||||
None if y < self.min_y => Err(0),
|
||||
None => Err(0xF0),
|
||||
}
|
||||
self.sections[s as usize].light(index(x, y, z))
|
||||
}
|
||||
/// Heap bytes held by the sections (benchmark accounting).
|
||||
pub fn heap_bytes(&self) -> usize {
|
||||
self.sections.iter().map(|s| s.heap_bytes()).sum::<usize>()
|
||||
}
|
||||
/// Adds this column's heap bytes to `census` by what holds them. A
|
||||
/// section shared with another column (`share_uniform`, or a copy not yet
|
||||
/// written to) counts once: `seen` holds the sections counted so far.
|
||||
pub fn census(
|
||||
&self,
|
||||
census: &mut ColumnCensus,
|
||||
seen: &mut crate::fast_hash::FxHashSet<*const Section>,
|
||||
) {
|
||||
census.columns += 1;
|
||||
census.sections += self.sections.len();
|
||||
census.column_bytes += std::mem::size_of::<Self>()
|
||||
+ self.sections.capacity() * std::mem::size_of::<Arc<Section>>();
|
||||
census.heightmap_bytes += [&self.surface, &self.motion_blocking]
|
||||
.iter()
|
||||
.filter(|map| map.get().is_some())
|
||||
.count()
|
||||
* std::mem::size_of::<[i16; 256]>();
|
||||
for section in &self.sections {
|
||||
if !seen.insert(Arc::as_ptr(section)) {
|
||||
continue;
|
||||
}
|
||||
census.unique_sections += 1;
|
||||
// The `Arc`'s two counts and the section record.
|
||||
census.section_record_bytes +=
|
||||
2 * std::mem::size_of::<usize>() + std::mem::size_of::<Section>();
|
||||
if section.blocks.bits() == 0 {
|
||||
census.uniform_block_sections += 1;
|
||||
}
|
||||
census.block_bytes += section.blocks.heap_bytes();
|
||||
census.biome_bytes += section.biomes.heap_bytes();
|
||||
if section.light.heap_bytes() != 0 {
|
||||
census.light_arrays += 1;
|
||||
census.light_bytes += section.light.heap_bytes();
|
||||
}
|
||||
}
|
||||
}
|
||||
/// Biome table index at a block position (`ChunkAccess.getNoiseBiome` with
|
||||
/// the quart y clamped to the column).
|
||||
#[inline]
|
||||
@@ -674,6 +853,33 @@ pub struct ChunkExtras {
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn each_light_form_keeps_every_value_and_run() {
|
||||
// Sky varies alone, block varies alone, both, neither.
|
||||
let cases: [(fn(usize) -> u8, &str); 4] = [
|
||||
(|i| ((i % 16) as u8) << 4 | 3, "sky"),
|
||||
(|i| 0xA0 | (i / 256) as u8, "block"),
|
||||
(|i| (i % 251) as u8, "full"),
|
||||
(|_| 0x7C, "uniform"),
|
||||
];
|
||||
for (value, name) in cases {
|
||||
let light = Light::packed(Box::new(std::array::from_fn(value)));
|
||||
let form = match light {
|
||||
Light::Sky(..) => "sky",
|
||||
Light::Block(..) => "block",
|
||||
Light::Full(_) => "full",
|
||||
Light::Uniform(_) => "uniform",
|
||||
};
|
||||
assert_eq!(form, name);
|
||||
for at in [0, 17, 1000, 4078] {
|
||||
let mut run = [0u8; 18];
|
||||
light.copy_run(at, &mut run);
|
||||
let expected: Vec<u8> = (at..at + 18).map(value).collect();
|
||||
assert_eq!(run.as_slice(), expected, "{name} at {at}");
|
||||
assert_eq!(light.get(at + 5), value(at + 5), "{name}");
|
||||
}
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn highest_skips_only_sections_that_cannot_match() {
|
||||
let (air, cave_air, stone) = (0, 7, 1);
|
||||
let mut chunk = Chunk::empty(0, 0);
|
||||
|
||||
@@ -351,7 +351,9 @@ fn shadow_color(in: Out) -> vec4<f32> {
|
||||
if in.tier == 0u {
|
||||
return vec4(0.0, 0.0, 0.0, hdr_scene_alpha(mix(in.scene, globals.hdr_light.y, fogged)));
|
||||
}
|
||||
return vec4(0.0, 0.0, 0.0, hdr_mark(in.tier, brightness(texel.rgb * in.color.rgb) * (1.0 - fogged)));
|
||||
// A layer that fades (the warden's) shines by what's left of it.
|
||||
let y = brightness(texel.rgb * in.color.rgb) * in.color.a * (1.0 - fogged);
|
||||
return vec4(0.0, 0.0, 0.0, hdr_mark(in.tier, y));
|
||||
}
|
||||
fn brightness(c: vec3<f32>) -> f32 {
|
||||
let l = pow(max(c, vec3(0.0)), vec3(2.2));
|
||||
|
||||
@@ -124,6 +124,14 @@ pub struct Extras {
|
||||
pub discord_presence: bool,
|
||||
/// Another controller can join P1's world (`splitscreen`, off by default).
|
||||
pub splitscreen: bool,
|
||||
/// Two players in one window: 0 auto (side by side when wider than tall), 1 side by side,
|
||||
/// 2 top and bottom (`splitscreenTwo`).
|
||||
pub splitscreen_two: u8,
|
||||
/// Three players on a screen: 0 one wide on top, 1 one tall on the left, 2 side by side
|
||||
/// (`splitscreenThree`).
|
||||
pub splitscreen_three: u8,
|
||||
/// Splitscreen: each player's name in the corner of their view (`splitscreenNames`).
|
||||
pub splitscreen_names: bool,
|
||||
}
|
||||
impl Default for Extras {
|
||||
fn default() -> Self {
|
||||
@@ -162,6 +170,9 @@ impl Default for Extras {
|
||||
agent_control: false,
|
||||
discord_presence: true,
|
||||
splitscreen: false,
|
||||
splitscreen_two: 0,
|
||||
splitscreen_three: 0,
|
||||
splitscreen_names: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -242,6 +253,9 @@ impl Extras {
|
||||
"agentControl" => e.agent_control = flag(e.agent_control),
|
||||
"discordPresence" => e.discord_presence = flag(e.discord_presence),
|
||||
"splitscreen" => e.splitscreen = flag(e.splitscreen),
|
||||
"splitscreenTwo" => e.splitscreen_two = value.parse().unwrap_or(0).min(2),
|
||||
"splitscreenThree" => e.splitscreen_three = value.parse().unwrap_or(0).min(2),
|
||||
"splitscreenNames" => e.splitscreen_names = flag(e.splitscreen_names),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
@@ -249,7 +263,7 @@ impl Extras {
|
||||
}
|
||||
pub fn serialize(&self) -> String {
|
||||
format!(
|
||||
"fullbright:{}\ndynamicLights:{}\nzoom:{}\nzoomKey:{}\nzoomLevel:{}\nzoomSmooth:{}\nzoomSensitivity:{}\nfps:{}\nfpsCorner:{}\nfpsLows:{}\nkeystrokes:{}\nkeystrokesCorner:{}\nkeystrokesCps:{}\nmovedToMods:{}\nminimap:{}\nminimapCorner:{}\nminimapSize:{}\nminimapShape:{}\nminimapZoom:{}\nminimapRotate:{}\nautoReconnect:{}\nuiSoundReserve:{}\nownNameTag:{}\nhideBlockParticles:{}\nmouseTweaks:{}\nmouseTweaksWheel:{}\nsort:{}\nsortKey:{}\nchatAnimation:{}\nchatIndicator:{}\n{}:{}\nagentControl:{}\ndiscordPresence:{}\nsplitscreen:{}\n",
|
||||
"fullbright:{}\ndynamicLights:{}\nzoom:{}\nzoomKey:{}\nzoomLevel:{}\nzoomSmooth:{}\nzoomSensitivity:{}\nfps:{}\nfpsCorner:{}\nfpsLows:{}\nkeystrokes:{}\nkeystrokesCorner:{}\nkeystrokesCps:{}\nmovedToMods:{}\nminimap:{}\nminimapCorner:{}\nminimapSize:{}\nminimapShape:{}\nminimapZoom:{}\nminimapRotate:{}\nautoReconnect:{}\nuiSoundReserve:{}\nownNameTag:{}\nhideBlockParticles:{}\nmouseTweaks:{}\nmouseTweaksWheel:{}\nsort:{}\nsortKey:{}\nchatAnimation:{}\nchatIndicator:{}\n{}:{}\nagentControl:{}\ndiscordPresence:{}\nsplitscreen:{}\nsplitscreenTwo:{}\nsplitscreenThree:{}\nsplitscreenNames:{}\n",
|
||||
bool_text(self.fullbright),
|
||||
bool_text(self.dynamic_lights),
|
||||
bool_text(self.zoom),
|
||||
@@ -289,6 +303,9 @@ impl Extras {
|
||||
bool_text(self.agent_control),
|
||||
bool_text(self.discord_presence),
|
||||
bool_text(self.splitscreen),
|
||||
self.splitscreen_two,
|
||||
self.splitscreen_three,
|
||||
bool_text(self.splitscreen_names),
|
||||
)
|
||||
}
|
||||
/// Binds the extra key `name` (`SORT_KEY`); false for
|
||||
@@ -402,6 +419,10 @@ impl Cps {
|
||||
self.clicks.len()
|
||||
}
|
||||
}
|
||||
/// Two players sharing a window (`splitscreen_two`): halves this way.
|
||||
pub const SPLIT_TWO: [&str; 3] = ["Auto", "Side by side", "Top and bottom"];
|
||||
/// Three players sharing a window (`splitscreen_three`).
|
||||
pub const SPLIT_THREE: [&str; 3] = ["One wide on top", "One tall on the left", "Side by side"];
|
||||
|
||||
/// The settings the Extras page shows, one per row.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
|
||||
@@ -777,6 +777,7 @@ pub struct Renderer {
|
||||
solid_opaque: wgpu::RenderPipeline,
|
||||
solid_cutout: wgpu::RenderPipeline,
|
||||
translucent: wgpu::RenderPipeline,
|
||||
translucent_mark: wgpu::RenderPipeline,
|
||||
/// Flat section draws (`flat`), where multi-draw is emulated.
|
||||
flat: Option<flat::Flat>,
|
||||
/// `flat` draws this frame's sections (off: the per-section draws).
|
||||
@@ -1841,6 +1842,53 @@ impl Renderer {
|
||||
let arenas = self.arena.bytes() + translucent.map_or(0, arena::Arena::bytes);
|
||||
[arenas, vertices * stride]
|
||||
}
|
||||
/// Device memory the backend's allocator holds, by the label of what holds
|
||||
/// it, largest first, with its allocated and reserved totals (Vulkan and
|
||||
/// DirectX 12; none on Metal and OpenGL) (`horizon-ctl memory`).
|
||||
pub fn gpu_memory_by_label(&self) -> Option<(u64, u64, Vec<(String, u64)>)> {
|
||||
let report = self.device.generate_allocator_report()?;
|
||||
let mut by_label: HashMap<String, u64> = HashMap::new();
|
||||
for a in report.allocations {
|
||||
*by_label.entry(a.name).or_default() += a.size;
|
||||
}
|
||||
let mut labels: Vec<_> = by_label.into_iter().collect();
|
||||
labels.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
|
||||
Some((
|
||||
report.total_allocated_bytes,
|
||||
report.total_reserved_bytes,
|
||||
labels,
|
||||
))
|
||||
}
|
||||
/// Heap bytes of the renderer's per-section bookkeeping (`horizon-ctl memory`):
|
||||
/// resident sections, far tiles, block entities and light sources by section.
|
||||
pub fn cpu_census(&self) -> [(&'static str, usize); 4] {
|
||||
use std::mem::size_of;
|
||||
let per_section = |map_len: usize, lists: usize| {
|
||||
map_len * (size_of::<SectionKey>() + size_of::<Vec<([i32; 3], u32)>>() + 1)
|
||||
+ lists * size_of::<([i32; 3], u32)>()
|
||||
};
|
||||
let lists = |map: &crate::fast_hash::FxHashMap<SectionKey, Vec<([i32; 3], u32)>>| {
|
||||
per_section(
|
||||
map.capacity(),
|
||||
map.values().map(Vec::capacity).sum::<usize>(),
|
||||
)
|
||||
};
|
||||
let animated: usize = self
|
||||
.sections
|
||||
.values()
|
||||
.map(|s| s.animated_layers.len() * size_of::<u16>())
|
||||
.sum();
|
||||
[
|
||||
("resident_sections", self.sections.heap_bytes() + animated),
|
||||
(
|
||||
"far_tiles",
|
||||
self.lods.capacity()
|
||||
* (size_of::<crate::lod::TileKey>() + size_of::<lod::Tile>() + 1),
|
||||
),
|
||||
("block_entities", lists(&self.block_entities)),
|
||||
("light_sources", lists(&self.light_blocks)),
|
||||
]
|
||||
}
|
||||
}
|
||||
impl Renderer {
|
||||
pub async fn new(
|
||||
@@ -2233,6 +2281,17 @@ impl Renderer {
|
||||
true,
|
||||
Some(alpha_blend),
|
||||
);
|
||||
let translucent_mark = pipeline(
|
||||
"Terrain translucent HDR mark",
|
||||
"vs_terrain",
|
||||
"fs_translucent_mark",
|
||||
&terrain_buffers,
|
||||
tri,
|
||||
Some(wgpu::Face::Back),
|
||||
false,
|
||||
true,
|
||||
Some(flat::ALPHA_ONLY),
|
||||
);
|
||||
let sky = pipeline(
|
||||
"Sky",
|
||||
"vs_sky",
|
||||
@@ -3647,6 +3706,7 @@ impl Renderer {
|
||||
solid_opaque,
|
||||
solid_cutout,
|
||||
translucent,
|
||||
translucent_mark,
|
||||
flat_on: flat.is_some(),
|
||||
cull_on: cull.is_some(),
|
||||
sides_on: true,
|
||||
@@ -3835,6 +3895,31 @@ impl Renderer {
|
||||
pass.draw_indexed(0..quads * 6, (first * 4) as i32, 0..1);
|
||||
}
|
||||
}
|
||||
/// HDR lighting on translucent terrain, over the draws that just blended it.
|
||||
fn mark_translucent_terrain(
|
||||
&self,
|
||||
pass: &mut wgpu::RenderPass<'_>,
|
||||
flat: Option<&flat::Flat>,
|
||||
solid_draws: u32,
|
||||
translucent_draws: u32,
|
||||
) {
|
||||
if self.hdr.as_ref().is_none_or(|h| !h.lighting) {
|
||||
return;
|
||||
}
|
||||
match flat {
|
||||
Some(flat) => {
|
||||
pass.set_pipeline(&flat.translucent_mark);
|
||||
flat.draw(pass, flat.translucent_calls(), |p| self.arena.buffer(p));
|
||||
pass.set_bind_group(0, &self.group, &[]);
|
||||
}
|
||||
None => {
|
||||
pass.set_pipeline(&self.translucent_mark);
|
||||
pass.set_vertex_buffer(1, self.instances.slice(..));
|
||||
self.draw_sections(pass, &self.indirect, 0, solid_draws, translucent_draws);
|
||||
}
|
||||
}
|
||||
pass.set_index_buffer(self.indices.slice(..), wgpu::IndexFormat::Uint32);
|
||||
}
|
||||
/// LevelRenderer.submitBlockOutline: the buffer holds `edges` per stroke, the black
|
||||
/// secondary stroke first when high contrast is on.
|
||||
fn draw_outline(&self, pass: &mut wgpu::RenderPass<'_>, edges: u32, high_contrast: bool) {
|
||||
@@ -7837,6 +7922,7 @@ impl Renderer {
|
||||
}
|
||||
pass.set_bind_group(0, &self.group, &[]);
|
||||
}
|
||||
self.mark_translucent_terrain(&mut pass, flat, solid_draws, translucent_draws);
|
||||
}
|
||||
Some(flat) => {
|
||||
pass.set_pipeline(pack_flat.map_or(&flat.translucent, |p| &p.1));
|
||||
@@ -7845,6 +7931,12 @@ impl Renderer {
|
||||
});
|
||||
pass.set_bind_group(0, &self.group, &[]);
|
||||
pass.set_index_buffer(self.indices.slice(..), wgpu::IndexFormat::Uint32);
|
||||
self.mark_translucent_terrain(
|
||||
&mut pass,
|
||||
Some(flat),
|
||||
solid_draws,
|
||||
translucent_draws,
|
||||
);
|
||||
}
|
||||
None => {
|
||||
pass.set_pipeline(translucent_pipeline);
|
||||
@@ -7857,6 +7949,7 @@ impl Renderer {
|
||||
solid_draws,
|
||||
translucent_draws,
|
||||
);
|
||||
self.mark_translucent_terrain(&mut pass, None, solid_draws, translucent_draws);
|
||||
}
|
||||
}
|
||||
// LevelRenderer.executeTranslucentAfterTerrain: vanilla's
|
||||
|
||||
@@ -55,6 +55,15 @@ struct PageTable {
|
||||
group: Option<wgpu::BindGroup>,
|
||||
}
|
||||
|
||||
/// Writes the fragment's alpha over the frame's and leaves its colour.
|
||||
pub(super) const ALPHA_ONLY: wgpu::BlendState = wgpu::BlendState {
|
||||
color: wgpu::BlendComponent {
|
||||
src_factor: wgpu::BlendFactor::Zero,
|
||||
dst_factor: wgpu::BlendFactor::One,
|
||||
operation: wgpu::BlendOperation::Add,
|
||||
},
|
||||
alpha: wgpu::BlendComponent::REPLACE,
|
||||
};
|
||||
/// A run of the frame's index list drawn from one arena page.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct Call {
|
||||
@@ -74,6 +83,7 @@ pub struct Flat {
|
||||
/// The solid draws as the water mirror's camera draws them (`fs_solid_mirror`).
|
||||
pub solid_mirror: wgpu::RenderPipeline,
|
||||
pub translucent: wgpu::RenderPipeline,
|
||||
pub translucent_mark: wgpu::RenderPipeline,
|
||||
flatten: wgpu::ComputePipeline,
|
||||
compute_layout: wgpu::BindGroupLayout,
|
||||
flatten_seen: wgpu::ComputePipeline,
|
||||
@@ -418,6 +428,14 @@ impl Flat {
|
||||
Some(wgpu::BlendState::ALPHA_BLENDING),
|
||||
back,
|
||||
),
|
||||
translucent_mark: pipeline(
|
||||
&layout,
|
||||
"Terrain translucent HDR mark (flat)",
|
||||
"vs_terrain_flat",
|
||||
"fs_translucent_mark",
|
||||
Some(ALPHA_ONLY),
|
||||
back,
|
||||
),
|
||||
flatten,
|
||||
compute_layout,
|
||||
flatten_seen,
|
||||
|
||||
@@ -55,6 +55,22 @@ impl<T> Resident<T> {
|
||||
pub fn len(&self) -> usize {
|
||||
self.items.len()
|
||||
}
|
||||
/// Heap bytes of its own lists and maps, not counting what a `T` points
|
||||
/// to (`horizon-ctl memory`). A hash map entry costs its pair and a
|
||||
/// control byte.
|
||||
pub fn heap_bytes(&self) -> usize {
|
||||
use std::mem::size_of;
|
||||
self.items.capacity() * size_of::<(SectionKey, T)>()
|
||||
+ self.bounds.capacity() * size_of::<Option<[Vec3; 2]>>()
|
||||
+ self.slots.capacity() * (size_of::<(SectionKey, u32)>() + 1)
|
||||
+ self.columns.capacity() * size_of::<Column>()
|
||||
+ self
|
||||
.columns
|
||||
.iter()
|
||||
.map(|c| c.sections.capacity() * size_of::<(i32, u32)>())
|
||||
.sum::<usize>()
|
||||
+ self.column_index.capacity() * (size_of::<((i32, i32), u32)>() + 1)
|
||||
}
|
||||
pub fn contains(&self, key: SectionKey) -> bool {
|
||||
self.slots.contains_key(&key)
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ use std::{
|
||||
collections::{HashMap, HashSet},
|
||||
fs,
|
||||
path::{Path, PathBuf},
|
||||
sync::{Arc, OnceLock},
|
||||
};
|
||||
use wgpu::util::DeviceExt;
|
||||
|
||||
@@ -58,7 +59,15 @@ impl TextureFlags {
|
||||
}
|
||||
}
|
||||
pub struct HudAtlas {
|
||||
/// RGBA8, `width` by `height`, as built: empty once `pack_pixels` has
|
||||
/// compressed them (read them through `pixels()`).
|
||||
pub pixels: Vec<u8>,
|
||||
/// `pixels` compressed (lz4) on a thread of its own once the renderer
|
||||
/// has uploaded them: only rebuilding a renderer reads them again.
|
||||
packed: Option<Arc<OnceLock<Vec<u8>>>>,
|
||||
/// The width each Standard Galactic letter uses in `font/ascii_sga`'s
|
||||
/// cell, by ASCII code (the enchanting table's text), read when built.
|
||||
pub galactic_widths: [u16; 128],
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
pub sprites: HashMap<String, Rect>,
|
||||
@@ -483,7 +492,76 @@ fn atlas_sprites(root: &Path, packed: &[PackedImage]) -> (Vec<PackedImage>, Vec<
|
||||
/// `MissingTextureAtlasSprite`: what a sprite the atlas lacks draws as.
|
||||
pub const MISSING_SPRITE: &str = "horizon/missing";
|
||||
|
||||
/// The used width of each Standard Galactic letter (A-Z, a-z) in the
|
||||
/// `font/ascii_sga` sheet: one past its last column with a texel that isn't
|
||||
/// transparent.
|
||||
fn galactic_widths(
|
||||
sprites: &HashMap<String, Rect>,
|
||||
pixels: &[u8],
|
||||
width: u32,
|
||||
height: u32,
|
||||
) -> [u16; 128] {
|
||||
let mut widths = [0u16; 128];
|
||||
let Some(sheet) = sprites.get("font/ascii_sga") else {
|
||||
return widths;
|
||||
};
|
||||
let cell = sheet.w / 16;
|
||||
let (px, py) = (
|
||||
(sheet.u0 * width as f32) as u32,
|
||||
(sheet.v0 * height as f32) as u32,
|
||||
);
|
||||
for code in (0x41..=0x5Au32).chain(0x61..=0x7A) {
|
||||
let (cx, cy) = (px + code % 16 * cell, py + code / 16 * cell);
|
||||
let alpha = |x: u32, y: u32| {
|
||||
pixels
|
||||
.get(((y * width + x) * 4 + 3) as usize)
|
||||
.copied()
|
||||
.unwrap_or(0)
|
||||
};
|
||||
widths[code as usize] = (0..cell)
|
||||
.rev()
|
||||
.find(|&dx| (0..cell).any(|dy| alpha(cx + dx, cy + dy) != 0))
|
||||
.map_or(0, |dx| (dx + 1).min(u32::from(u16::MAX)) as u16);
|
||||
}
|
||||
widths
|
||||
}
|
||||
impl HudAtlas {
|
||||
/// The atlas's pixels: as built, or uncompressed again after
|
||||
/// `pack_pixels` (a renderer being rebuilt).
|
||||
pub fn pixels(&self) -> std::borrow::Cow<'_, [u8]> {
|
||||
let Some(packed) = self.packed.as_ref().filter(|_| self.pixels.is_empty()) else {
|
||||
return std::borrow::Cow::Borrowed(&self.pixels);
|
||||
};
|
||||
let size = (self.width * self.height * 4) as usize;
|
||||
std::borrow::Cow::Owned(
|
||||
lz4_flex::decompress_size_prepended(packed.wait())
|
||||
.ok()
|
||||
.filter(|p| p.len() == size)
|
||||
.unwrap_or_else(|| vec![0; size]),
|
||||
)
|
||||
}
|
||||
/// Keep the pixels compressed from now on, once a renderer has them: the
|
||||
/// window's GPU holds the atlas, and a copy as built only serves a
|
||||
/// renderer made again (another window, a lost device, another graphics
|
||||
/// API). They are compressed on a thread of their own; `pixels()` waits
|
||||
/// for it.
|
||||
pub fn pack_pixels(&mut self) {
|
||||
if self.pixels.is_empty() || self.packed.is_some() {
|
||||
return;
|
||||
}
|
||||
let raw = Arc::new(std::mem::take(&mut self.pixels));
|
||||
let slot = Arc::new(OnceLock::new());
|
||||
let (job_raw, job_slot) = (raw.clone(), slot.clone());
|
||||
let spawned = std::thread::Builder::new()
|
||||
.name("Atlas packing".into())
|
||||
.spawn(move || {
|
||||
let _ = job_slot.set(lz4_flex::compress_prepend_size(&job_raw));
|
||||
});
|
||||
if spawned.is_err() {
|
||||
let _ = slot.set(lz4_flex::compress_prepend_size(&raw));
|
||||
}
|
||||
self.packed = Some(slot);
|
||||
}
|
||||
/// A sprite, or the missing one when the atlas has no such sprite (an
|
||||
/// effect without its icon), as a stitched atlas answers.
|
||||
pub fn sprite_or_missing(&self, name: &str) -> Option<Rect> {
|
||||
@@ -630,7 +708,9 @@ impl HudAtlas {
|
||||
(glyph_top, height - glyph_top),
|
||||
white,
|
||||
),
|
||||
galactic_widths: galactic_widths(&sprites, &pixels, width, height),
|
||||
pixels,
|
||||
packed: None,
|
||||
width,
|
||||
height,
|
||||
sprites,
|
||||
@@ -1063,7 +1143,9 @@ impl HudAtlas {
|
||||
white,
|
||||
);
|
||||
Ok(Self {
|
||||
galactic_widths: galactic_widths(&sprites, &pixels, width, height),
|
||||
pixels,
|
||||
packed: None,
|
||||
width,
|
||||
height,
|
||||
sprites,
|
||||
@@ -1956,6 +2038,12 @@ impl HudRenderer {
|
||||
label: Some("HUD"),
|
||||
..Default::default()
|
||||
});
|
||||
let linear_sampler = device.create_sampler(&wgpu::SamplerDescriptor {
|
||||
label: Some("HUD linear"),
|
||||
mag_filter: wgpu::FilterMode::Linear,
|
||||
min_filter: wgpu::FilterMode::Linear,
|
||||
..Default::default()
|
||||
});
|
||||
let uniform = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
|
||||
label: Some("HUD size"),
|
||||
contents: bytemuck::bytes_of(&HudUniform::new([1.0; 4], EndPortalFill::default())),
|
||||
@@ -2006,6 +2094,12 @@ impl HudRenderer {
|
||||
ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
|
||||
count: None,
|
||||
},
|
||||
wgpu::BindGroupLayoutEntry {
|
||||
binding: 5,
|
||||
visibility: wgpu::ShaderStages::FRAGMENT,
|
||||
ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
|
||||
count: None,
|
||||
},
|
||||
],
|
||||
});
|
||||
let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
@@ -2032,6 +2126,10 @@ impl HudRenderer {
|
||||
binding: 4,
|
||||
resource: wgpu::BindingResource::Sampler(glint_sampler),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 5,
|
||||
resource: wgpu::BindingResource::Sampler(&linear_sampler),
|
||||
},
|
||||
],
|
||||
});
|
||||
let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
|
||||
@@ -2134,7 +2232,7 @@ impl HudRenderer {
|
||||
operation: wgpu::BlendOperation::Add,
|
||||
},
|
||||
},
|
||||
"fs_main",
|
||||
"fs_vignette",
|
||||
false,
|
||||
);
|
||||
// RenderPipelines.END_PORTAL: opaque.
|
||||
@@ -2781,6 +2879,18 @@ mod tests {
|
||||
assert_eq!(texel_at(&atlas, *rect, 15, 15), [0, 0, 255, 255]);
|
||||
}
|
||||
#[test]
|
||||
fn packed_pixels_come_back_the_same() {
|
||||
let root = animated_root();
|
||||
let mut atlas = HudAtlas::build(&root).unwrap();
|
||||
let built = atlas.pixels.clone();
|
||||
atlas.pack_pixels();
|
||||
assert!(atlas.pixels.is_empty());
|
||||
assert!(atlas.pixels() == built.as_slice());
|
||||
// Packing again keeps the same image.
|
||||
atlas.pack_pixels();
|
||||
assert!(atlas.pixels() == built.as_slice());
|
||||
}
|
||||
#[test]
|
||||
fn a_strip_frames_size_comes_from_its_mcmeta() {
|
||||
let root = animated_root();
|
||||
let block = root.join("assets/minecraft/textures/block");
|
||||
|
||||
@@ -10,6 +10,7 @@ struct Hud {
|
||||
// enchanted_glint_item on its own repeating texture (vanilla's GlintSampler).
|
||||
@group(0) @binding(3) var glint_tex: texture_2d<f32>;
|
||||
@group(0) @binding(4) var glint_sampler: sampler;
|
||||
@group(0) @binding(5) var linear_sampler: sampler;
|
||||
struct Out {
|
||||
@builtin(position) clip: vec4<f32>,
|
||||
@location(0) uv: vec2<f32>,
|
||||
@@ -75,6 +76,12 @@ struct CitSlots {
|
||||
if c.a == 0.0 { discard; }
|
||||
return c;
|
||||
}
|
||||
// RenderPipelines.VIGNETTE (WinScreen's credits vignette): bilinear sample of
|
||||
// credits_vignette.png (which has blur: true in its .mcmeta and a wrapped border in the atlas).
|
||||
@fragment fn fs_vignette(in: Out) -> @location(0) vec4<f32> {
|
||||
let c = textureSample(atlas, linear_sampler, in.uv) * in.color;
|
||||
return vec4(c.rgb, 1.0);
|
||||
}
|
||||
// rendertype_end_portal.fsh (entity.wgsl has the same for portal blocks).
|
||||
const PORTAL_COLORS = array<vec3<f32>, 16>(
|
||||
vec3(0.022087, 0.098399, 0.110818),
|
||||
|
||||
@@ -49,7 +49,7 @@ pub mod hud_overlays;
|
||||
pub mod inventory_effects;
|
||||
pub mod java_text;
|
||||
pub mod jigsaw_block;
|
||||
pub mod json_folder;
|
||||
pub use horizon_world::json_folder;
|
||||
pub mod level_loading;
|
||||
pub mod light;
|
||||
pub mod lod;
|
||||
@@ -91,7 +91,9 @@ pub mod sound_events;
|
||||
pub mod sound_preview;
|
||||
pub mod spans;
|
||||
pub mod split_layout;
|
||||
pub mod split_map;
|
||||
pub mod split_profiles;
|
||||
pub mod split_ui;
|
||||
mod sprite_cache;
|
||||
pub mod sprites;
|
||||
pub mod streaming;
|
||||
|
||||
@@ -562,11 +562,7 @@ fn pack_lighting(
|
||||
}
|
||||
}
|
||||
}
|
||||
if data.iter().all(|&v| v == data[0]) {
|
||||
Light::Uniform(data[0])
|
||||
} else {
|
||||
Light::Full(data)
|
||||
}
|
||||
Light::packed(data)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
@@ -810,10 +806,9 @@ impl<'w> Region<'w> {
|
||||
return;
|
||||
};
|
||||
let slot = &mut self.slots[slot];
|
||||
let light = slot.light.get_or_insert_with(|| match &slot.section.light {
|
||||
Light::Uniform(v) => Box::new([*v; 4096]),
|
||||
Light::Full(a) => a.clone(),
|
||||
});
|
||||
let light = slot
|
||||
.light
|
||||
.get_or_insert_with(|| slot.section.light.to_array());
|
||||
let i = index(x, y, z);
|
||||
light[i] = f(light[i]);
|
||||
}
|
||||
@@ -830,7 +825,7 @@ impl<'w> Region<'w> {
|
||||
drop(slot.section);
|
||||
let (cx, s, cz) = slot.key;
|
||||
self.world
|
||||
.set_section_light(cx, s as usize, cz, Light::Full(light));
|
||||
.set_section_light(cx, s as usize, cz, Light::packed(light));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1624,21 +1619,20 @@ mod tests {
|
||||
current.set(8, MIN_Y, 8, TORCH);
|
||||
let region = [[None; 3], [None, Some(¤t), None], [None; 3]];
|
||||
let lights = light_chunk(®ion, &tables(), true);
|
||||
let Light::Full(data) = &lights[0] else {
|
||||
panic!("torch light varies");
|
||||
let storage = |light: &Light| match light {
|
||||
Light::Full(data) => data.as_ptr(),
|
||||
Light::Sky(layer, _) | Light::Block(layer, _) => layer.as_ptr(),
|
||||
Light::Uniform(_) => panic!("torch light varies"),
|
||||
};
|
||||
let result_storage = data.as_ptr();
|
||||
let result_storage = storage(&lights[0]);
|
||||
let snapshot = current.clone();
|
||||
current.set(8, MIN_Y, 8, STONE);
|
||||
Arc::make_mut(&mut current.sections[0]).biomes.set(0, 3);
|
||||
let edited_snapshot = current.clone();
|
||||
apply_lighting(&mut current, lights);
|
||||
let Light::Full(data) = ¤t.sections[0].light else {
|
||||
panic!("torch light varies");
|
||||
};
|
||||
assert_eq!(
|
||||
result_storage,
|
||||
data.as_ptr(),
|
||||
storage(¤t.sections[0].light),
|
||||
"returned light storage moves without cloning"
|
||||
);
|
||||
current.lit = true;
|
||||
|
||||
@@ -214,7 +214,12 @@ impl Mesher {
|
||||
Paint {
|
||||
layer: face.layer,
|
||||
light: span.light,
|
||||
color: channel(16) | channel(8) << 8 | channel(0) << 16 | 0xff000000,
|
||||
// The HDR tier, as the section mesher writes it, so a light source
|
||||
// keeps shining across the LOD boundary.
|
||||
color: crate::mesher::emitting(
|
||||
channel(16) | channel(8) << 8 | channel(0) << 16 | 0xff000000,
|
||||
context.tables.hdr_tier(span.state),
|
||||
),
|
||||
translucent: face.translucent,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -12,7 +12,7 @@ use crate::{
|
||||
assets::{Assets, RenderAssets},
|
||||
biome::{Biomes, GrassModifier},
|
||||
blocks::{BlockFacts, RenderShape},
|
||||
chunk::{Chunk, Light, MIN_Y},
|
||||
chunk::{Chunk, MIN_Y},
|
||||
graphics::LeafCullingMode,
|
||||
models::catalog::BakedFace,
|
||||
};
|
||||
@@ -368,15 +368,15 @@ pub const ALL_VISIBLE: u64 = (1 << 36) - 1;
|
||||
fn visibility(pad: &Padded, info: &[Info]) -> u64 {
|
||||
let solid: Vec<bool> = info.iter().map(|i| i.solid).collect();
|
||||
let mut rows = Vec::new();
|
||||
opaque_rows(pad, &solid, &mut rows);
|
||||
solid_rows(pad, &solid, &mut rows);
|
||||
visibility_rows(&open_rows(&rows))
|
||||
}
|
||||
/// The section's non-opaque cells by row (`y << 4 | z`, bit x) from the padded
|
||||
/// opacity rows (`MeshScratch::opaque`).
|
||||
fn open_rows(opaque: &[u32]) -> [u16; 256] {
|
||||
/// The section's cells that are not `isSolidRender`, by row (`y << 4 | z`, bit
|
||||
/// x), from the padded rows of `MeshScratch::solid_rows`.
|
||||
fn open_rows(solid: &[u32]) -> [u16; 256] {
|
||||
std::array::from_fn(|r| {
|
||||
let (y, z) = ((r >> 4) as i32, (r & 15) as i32);
|
||||
!(opaque[((y + B) as usize) * P + (z + B) as usize] >> B) as u16
|
||||
!(solid[((y + B) as usize) * P + (z + B) as usize] >> B) as u16
|
||||
})
|
||||
}
|
||||
/// The cells of `open` connected to `seeds` (a subset) within the row: an
|
||||
@@ -753,6 +753,10 @@ impl MeshTables {
|
||||
.copied()
|
||||
.unwrap_or(false)
|
||||
}
|
||||
/// HDR output's tier of a state (`hdr_tier`), for what meshes it elsewhere (the LOD tiles).
|
||||
pub fn hdr_tier(&self, state: BlockStateId) -> u8 {
|
||||
self.info.get(state.0 as usize).map_or(0, |i| i.tier)
|
||||
}
|
||||
pub fn facts(&self, state: BlockStateId) -> Option<MeshFacts> {
|
||||
let i = self.info.get(state.0 as usize)?;
|
||||
Some(MeshFacts {
|
||||
@@ -922,14 +926,10 @@ impl Padded {
|
||||
let sources: [[Source; 3]; 3] = std::array::from_fn(|rz| {
|
||||
std::array::from_fn(|rx| match region[rz][rx] {
|
||||
None => Source::Missing,
|
||||
// `Chunk::get` and `Chunk::light` outside the column's sections.
|
||||
Some(c) => {
|
||||
let s = (wy - c.min_y()) >> 4;
|
||||
match c.sections.get(s as usize).filter(|_| s >= 0) {
|
||||
Some(section) => Source::Section(section),
|
||||
None => Source::Outside(if s < 0 { 0 } else { 0xF0 }),
|
||||
}
|
||||
}
|
||||
Some(c) => match c.section_at(wy) {
|
||||
Ok(section) => Source::Section(section),
|
||||
Err(light) => Source::Outside(light),
|
||||
},
|
||||
})
|
||||
});
|
||||
for z in -B..16 + B {
|
||||
@@ -977,10 +977,7 @@ fn fill_run(
|
||||
Some(own) => blocks.copy_from_slice(own),
|
||||
None => section.blocks.get_run(at, blocks),
|
||||
}
|
||||
match §ion.light {
|
||||
Light::Uniform(v) => light.fill(*v),
|
||||
Light::Full(all) => light.copy_from_slice(&all[at..at + light.len()]),
|
||||
}
|
||||
section.light.copy_run(at, light);
|
||||
}
|
||||
Source::Outside(v) => {
|
||||
blocks.fill(0);
|
||||
@@ -1015,11 +1012,11 @@ pub struct MeshScratch {
|
||||
own: Option<Box<[u32; 4096]>>,
|
||||
leaf: leaf_culling::LeafMask,
|
||||
/// Per padded row (`pi(-B, y, z) / P`), bit `x + B` set where the block
|
||||
/// is `isSolidRender`.
|
||||
opaque: Vec<u32>,
|
||||
/// is `isSolidRender` (`MeshTables::solid`).
|
||||
solid_rows: Vec<u32>,
|
||||
}
|
||||
/// Fills `rows` with the padded section's opacity, one bit per block (`MeshScratch::opaque`).
|
||||
fn opaque_rows(pad: &Padded, solid: &[bool], rows: &mut Vec<u32>) {
|
||||
/// Fills `rows` with the padded section's `isSolidRender` blocks, one bit each (`MeshScratch::solid_rows`).
|
||||
fn solid_rows(pad: &Padded, solid: &[bool], rows: &mut Vec<u32>) {
|
||||
rows.clear();
|
||||
rows.extend(pad.blocks.as_chunks::<P>().0.iter().map(|row| {
|
||||
row.iter().enumerate().fold(0u32, |bits, (x, &id)| {
|
||||
@@ -1396,8 +1393,8 @@ impl Mesher<'_> {
|
||||
let own = scratch.own.get_or_insert_with(|| Box::new([0; 4096]));
|
||||
scratch.padded.fill(region, sy, own);
|
||||
let pad = &scratch.padded;
|
||||
opaque_rows(pad, &self.tables.solid, &mut scratch.opaque);
|
||||
let opaque = &scratch.opaque;
|
||||
solid_rows(pad, &self.tables.solid, &mut scratch.solid_rows);
|
||||
let opaque = &scratch.solid_rows;
|
||||
out.visibility = visibility_rows(&open_rows(opaque));
|
||||
let base_y = center.min_y() + sy * 16;
|
||||
if self.leaf_culling.is_interior() {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
use super::*;
|
||||
use crate::chunk::Light;
|
||||
|
||||
#[test]
|
||||
fn padded_reuse_resets_missing_neighbors_and_matches_chunk_reads_across_layouts() {
|
||||
@@ -45,6 +46,65 @@ fn padded_reuse_resets_missing_neighbors_and_matches_chunk_reads_across_layouts(
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn padded_fill_reads_every_neighbour_and_its_light_like_chunk_reads() {
|
||||
let mut state = 0x2545_F491_4F6C_DD1Du64;
|
||||
let mut next = move || {
|
||||
state ^= state << 13;
|
||||
state ^= state >> 7;
|
||||
state ^= state << 17;
|
||||
state
|
||||
};
|
||||
let mut pad = Padded::default();
|
||||
let mut own = Box::new([0; 4096]);
|
||||
for min_y in [MIN_Y, 0] {
|
||||
let layout = crate::chunk::ColumnLayout::new(min_y, 32 * 16).unwrap();
|
||||
// Nine chunks with their own blocks and per-block light, near the
|
||||
// bottom and top sections too, where the padding leaves the column.
|
||||
let chunks: Vec<Chunk> = (0..9)
|
||||
.map(|i| {
|
||||
let mut c = Chunk::with_layout(i % 3 - 1, i / 3 - 1, layout);
|
||||
for s in [0, 1, 30, 31] {
|
||||
for _ in 0..300 {
|
||||
let (x, y, z) = (next() % 16, next() % 16, next() % 16);
|
||||
let y = min_y + s * 16 + y as i32;
|
||||
c.set(x as i32, y, z as i32, 1 + (next() % 40) as u32);
|
||||
}
|
||||
let mut light = Box::new([0u8; 4096]);
|
||||
light.iter_mut().for_each(|l| *l = next() as u8);
|
||||
std::sync::Arc::make_mut(&mut c.sections[s as usize]).light =
|
||||
Light::Full(light);
|
||||
}
|
||||
c
|
||||
})
|
||||
.collect();
|
||||
let region: [[Option<&Chunk>; 3]; 3] =
|
||||
std::array::from_fn(|rz| std::array::from_fn(|rx| Some(&chunks[rz * 3 + rx])));
|
||||
for sy in [0, 1, 30, 31] {
|
||||
pad.fill(®ion, sy, &mut own);
|
||||
let base_y = min_y + sy * 16;
|
||||
for y in -B..16 + B {
|
||||
for z in -B..16 + B {
|
||||
for x in -B..16 + B {
|
||||
let c = region[((z + 16) >> 4) as usize][((x + 16) >> 4) as usize].unwrap();
|
||||
let (cx, cy, cz) = (x & 15, base_y + y, z & 15);
|
||||
assert_eq!(
|
||||
pad.blocks[pi(x, y, z)],
|
||||
c.get(cx, cy, cz),
|
||||
"{min_y} {sy} {x} {y} {z}"
|
||||
);
|
||||
assert_eq!(
|
||||
pad.light[pi(x, y, z)],
|
||||
c.light(cx, cy, cz),
|
||||
"{min_y} {sy} {x} {y} {z}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Run explicitly with prepared assets; never silently pass without exercising geometry.
|
||||
/// With a shader pack lighting the faces, every face of a lone stone block has one colour; the
|
||||
/// game's face shading (up 1.0, down 0.5, north/south 0.8, east/west 0.6) is left to the pack.
|
||||
@@ -181,7 +241,7 @@ fn reused_mesh_matches_fresh_geometry_across_sections_and_resource_reload() {
|
||||
assert!(scratch.own.is_none());
|
||||
assert_eq!(scratch.padded.blocks.capacity(), 0);
|
||||
assert_eq!(scratch.padded.light.capacity(), 0);
|
||||
assert_eq!(scratch.opaque.capacity(), 0);
|
||||
assert_eq!(scratch.solid_rows.capacity(), 0);
|
||||
}
|
||||
fresh.sort_solid();
|
||||
recycled.sort_solid();
|
||||
|
||||
@@ -203,7 +203,8 @@ fn bucket(quad: &[TerrainVertex; 4]) -> usize {
|
||||
|
||||
/// Reorders `solid`'s quads by bucket, opaque ones first in each. Solid
|
||||
/// cutout quads are also ordered front to back within each axis-facing
|
||||
/// bucket; stable sorting preserves the mesher's order for coplanar overlays.
|
||||
/// bucket; quads that tie keep the mesher's order (the quad's index ends its
|
||||
/// sort key), so coplanar overlays still draw over their base.
|
||||
pub fn sort(solid: &mut Vec<TerrainVertex>) -> Sides {
|
||||
sort_parts(solid, GROUPED.load(Ordering::Relaxed))
|
||||
}
|
||||
@@ -213,9 +214,9 @@ fn sort_parts(solid: &mut Vec<TerrainVertex>, grouped: bool) -> Sides {
|
||||
SORT_SCRATCH.with_borrow_mut(|scratch| sort_with(solid, grouped, scratch))
|
||||
}
|
||||
|
||||
/// `sort`'s buffers, kept by each meshing thread: a section's quads went
|
||||
/// through fresh vectors, and the sorted copy replaced the mesh's own
|
||||
/// (recycled) buffer.
|
||||
/// `sort`'s buffers, kept by each meshing thread so sorting a mesh allocates
|
||||
/// nothing: each quad's part, its sort key, and the sorted quads, which are
|
||||
/// copied back into the mesh's own (recycled) buffer.
|
||||
#[derive(Default)]
|
||||
struct SortScratch {
|
||||
slots: Vec<u8>,
|
||||
|
||||
@@ -1264,7 +1264,7 @@ pub enum Page {
|
||||
Online,
|
||||
/// Splitscreen: the profile another player joins as (`split_profiles`).
|
||||
SplitProfiles,
|
||||
/// Splitscreen: enable joining on this computer.
|
||||
/// Splitscreen: the monitor map and draft window arrangement.
|
||||
SplitSetup,
|
||||
}
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
@@ -1512,10 +1512,15 @@ pub struct OptionsUi {
|
||||
pub calibration: Option<crate::hdr_calibration::Calibration>,
|
||||
/// Whether the window was fullscreen before calibration made it so (the game keeps it).
|
||||
pub calibration_fullscreen: Option<bool>,
|
||||
pub split_setup: crate::split_ui::SplitUi,
|
||||
/// The AI Agent page's status lines (set by the game while it shows).
|
||||
pub agent_status: Vec<String>,
|
||||
/// Keyboard focus: the interactive target currently focused.
|
||||
focus: Option<FocusId>,
|
||||
/// The focus came from keyboard or pad navigation, so the mouse wheel steps it
|
||||
/// from anywhere (`wheel`). A click focuses its widget too, for the highlight,
|
||||
/// but vanilla's wheel ignores that: only the hovered widget then reacts.
|
||||
focus_from_keyboard: bool,
|
||||
/// Whether a focused slider is in value-editing mode (`AbstractSliderButton.canChangeValue`).
|
||||
pub(crate) can_change_value: bool,
|
||||
/// The current GUI size (w, h).
|
||||
@@ -1528,8 +1533,6 @@ pub struct OptionsUi {
|
||||
/// The list's rows as last laid out: each row's top and bottom (GUI
|
||||
/// pixels), for the narration's row under the mouse.
|
||||
row_bands: Vec<[f32; 2]>,
|
||||
/// Other local players are joined: leave before turning splitscreen off.
|
||||
pub split_joined: bool,
|
||||
}
|
||||
impl Default for OptionsUi {
|
||||
fn default() -> Self {
|
||||
@@ -1568,14 +1571,15 @@ impl Default for OptionsUi {
|
||||
split_profiles: Default::default(),
|
||||
calibration: None,
|
||||
calibration_fullscreen: None,
|
||||
split_setup: Default::default(),
|
||||
agent_status: Vec::new(),
|
||||
focus: None,
|
||||
focus_from_keyboard: false,
|
||||
can_change_value: false,
|
||||
size: (427.0, 240.0),
|
||||
framebuffer: [1280, 720],
|
||||
split_guest: false,
|
||||
row_bands: Vec::new(),
|
||||
split_joined: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2093,6 +2097,7 @@ impl OptionsUi {
|
||||
self.stack.clear();
|
||||
self.data_pack_dir = None;
|
||||
self.focus = None;
|
||||
self.focus_from_keyboard = false;
|
||||
self.can_change_value = false;
|
||||
self.push(page);
|
||||
self.in_world = in_world;
|
||||
@@ -2103,6 +2108,7 @@ impl OptionsUi {
|
||||
self.scroll = 0.0;
|
||||
self.scrollbar_drag_y = None;
|
||||
self.focus = None;
|
||||
self.focus_from_keyboard = false;
|
||||
self.can_change_value = false;
|
||||
if matches!(page, Page::Video | Page::Controls | Page::KeyBinds) {
|
||||
self.settings_search.set_value("");
|
||||
@@ -2122,6 +2128,9 @@ impl OptionsUi {
|
||||
) {
|
||||
self.graphics.reset();
|
||||
}
|
||||
if page == Page::SplitSetup {
|
||||
self.split_setup.reset();
|
||||
}
|
||||
if page == Page::SplitProfiles {
|
||||
self.split_profiles.reset();
|
||||
}
|
||||
@@ -2262,14 +2271,7 @@ impl OptionsUi {
|
||||
|
||||
fn list_rows(&self, s: &Settings) -> Vec<Row> {
|
||||
match self.page() {
|
||||
Page::SplitSetup => vec![Row::Buttons(
|
||||
if self.split_guest || self.split_joined {
|
||||
inactive(self.extra_button(s, crate::extras::Item::Splitscreen))
|
||||
} else {
|
||||
self.extra_button(s, crate::extras::Item::Splitscreen)
|
||||
},
|
||||
None,
|
||||
)],
|
||||
Page::SplitSetup => Vec::new(),
|
||||
Page::Extras => {
|
||||
// Each section's switches two to a row, as vanilla's
|
||||
// OptionsList.addSmall lays out an options screen.
|
||||
@@ -3346,6 +3348,11 @@ impl OptionsUi {
|
||||
.draw(level, s, batch, atlas, cursor, self.in_world);
|
||||
return;
|
||||
}
|
||||
if self.page() == Page::SplitSetup {
|
||||
self.split_setup.on = s.extras.splitscreen;
|
||||
self.split_setup.draw(batch, atlas, cursor, self.in_world);
|
||||
return;
|
||||
}
|
||||
if self.page() == Page::SplitProfiles {
|
||||
self.split_profiles
|
||||
.draw(batch, atlas, cursor, self.in_world, self.pad_style);
|
||||
@@ -3646,6 +3653,26 @@ impl OptionsUi {
|
||||
}
|
||||
return Vec::new();
|
||||
}
|
||||
if self.page() == Page::SplitSetup {
|
||||
use crate::split_ui::Click;
|
||||
if right {
|
||||
return Vec::new();
|
||||
}
|
||||
self.split_setup.on = s.extras.splitscreen;
|
||||
return match self.split_setup.click((x, y), size, drag) {
|
||||
Click::Nothing => Vec::new(),
|
||||
Click::Pressed => vec![Event::ButtonClick],
|
||||
Click::Switch => {
|
||||
crate::extras::Item::Splitscreen.cycle(&mut s.extras, false);
|
||||
vec![Event::ButtonClick, Event::Changed]
|
||||
}
|
||||
Click::Done => {
|
||||
let mut events = vec![Event::ButtonClick];
|
||||
events.extend(self.back(s));
|
||||
events
|
||||
}
|
||||
};
|
||||
}
|
||||
if self.page() == Page::SplitProfiles {
|
||||
use crate::split_profiles::Click;
|
||||
if right || drag {
|
||||
@@ -3750,6 +3777,7 @@ impl OptionsUi {
|
||||
search.focused = ui::hovered((x, y), search.x, search.y, search.width, search.height);
|
||||
if search.focused {
|
||||
self.focus = Some(FocusId::Search);
|
||||
self.focus_from_keyboard = false;
|
||||
self.can_change_value = false;
|
||||
search.click(atlas, x, false, false);
|
||||
self.scroll = 0.0;
|
||||
@@ -3762,6 +3790,7 @@ impl OptionsUi {
|
||||
search.focused = ui::hovered((x, y), search.x, search.y, search.width, search.height);
|
||||
if search.focused {
|
||||
self.focus = Some(FocusId::Search);
|
||||
self.focus_from_keyboard = false;
|
||||
self.can_change_value = false;
|
||||
search.click(atlas, x, false, false);
|
||||
self.scroll = 0.0;
|
||||
@@ -3875,7 +3904,10 @@ impl OptionsUi {
|
||||
if wd.list && (y < top || y >= bottom) {
|
||||
continue;
|
||||
}
|
||||
// A click focuses the widget for its highlight only: vanilla's wheel
|
||||
// steps a keyboard-focused widget from anywhere, not a clicked one.
|
||||
self.focus = Some(focus_id(wd_idx, &wd));
|
||||
self.focus_from_keyboard = false;
|
||||
match wd.kind {
|
||||
WKind::Button {
|
||||
active: true, act, ..
|
||||
@@ -4049,8 +4081,11 @@ impl OptionsUi {
|
||||
let slider = self.dragging.take().is_some() | self.shader_dragging.take().is_some();
|
||||
let graphics_slider = self.graphics.release();
|
||||
let calibration_slider = self.calibration.as_mut().is_some_and(|c| c.release());
|
||||
let split_drop = self.split_setup.release();
|
||||
if slider || graphics_slider || calibration_slider {
|
||||
vec![Event::ButtonClick, Event::Changed]
|
||||
} else if split_drop {
|
||||
vec![Event::ButtonClick]
|
||||
} else {
|
||||
Vec::new()
|
||||
}
|
||||
@@ -4095,15 +4130,19 @@ impl OptionsUi {
|
||||
}
|
||||
events
|
||||
}
|
||||
/// The mouse wheel at the cursor. Over a cycle button it takes the step
|
||||
/// (`CycleButton.mouseScrolled`: up goes back) and the list stays put;
|
||||
/// over a slider it nudges the slider up or down.
|
||||
/// The mouse wheel at the cursor. Vanilla scrolls the list
|
||||
/// (`AbstractScrollArea.mouseScrolled`), except that a hovered cycle button
|
||||
/// takes the step and keeps the wheel (`CycleButton.mouseScrolled`: up goes
|
||||
/// back); `AbstractSliderButton` does not override it, so a slider under the
|
||||
/// cursor lets the list scroll.
|
||||
///
|
||||
/// **Horizon improvement (diverges from Vanilla 26.3):** when a slider or
|
||||
/// cycle button is keyboard-focused (highlighted), the wheel steps or
|
||||
/// cycles that widget regardless of where the cursor is. Vanilla 26.3
|
||||
/// does not intercept the wheel for sliders at all; this makes keyboard
|
||||
/// navigation far more usable.
|
||||
/// **Horizon improvement (diverges from Vanilla 26.3):** a slider or cycle
|
||||
/// button focused by the keyboard (Tab, the arrows, a pad) steps or cycles on
|
||||
/// the wheel wherever the cursor is. Vanilla 26.3 does not intercept the wheel
|
||||
/// for sliders at all; this makes keyboard navigation far more usable. Focus
|
||||
/// left by a click does not count (`focus_from_keyboard`): vanilla's wheel
|
||||
/// ignores it, and honouring it toggled the clicked option on every later wheel
|
||||
/// event, from anywhere on the screen, while the list never scrolled.
|
||||
pub fn wheel(
|
||||
&mut self,
|
||||
s: &mut Settings,
|
||||
@@ -4155,7 +4194,10 @@ impl OptionsUi {
|
||||
}
|
||||
// Horizon improvement: a keyboard-focused slider or cycle button
|
||||
// intercepts the wheel before hover detection (see doc-comment above).
|
||||
if amount != 0.0 {
|
||||
// A click focuses its widget for the highlight too, and vanilla's wheel
|
||||
// must not act on that: it would toggle the clicked option from anywhere
|
||||
// on the screen and the list would never scroll.
|
||||
if amount != 0.0 && self.focus_from_keyboard {
|
||||
let (widgets, _, _, _) = self.layout(s, size.0, size.1);
|
||||
match &self.focus {
|
||||
Some(FocusId::Slider(opt)) => {
|
||||
@@ -4341,6 +4383,7 @@ impl OptionsUi {
|
||||
self.selected_key = None;
|
||||
self.selected_pad_action = None;
|
||||
self.focus = None;
|
||||
self.focus_from_keyboard = false;
|
||||
self.settings_search.focused = false;
|
||||
self.language_search.focused = false;
|
||||
self.can_change_value = false;
|
||||
@@ -4684,6 +4727,7 @@ impl OptionsUi {
|
||||
};
|
||||
let (next_id, rect, in_list) = &focusables[next_idx];
|
||||
self.focus = Some(next_id.clone());
|
||||
self.focus_from_keyboard = true;
|
||||
self.can_change_value = matches!(next_id, FocusId::Slider(_));
|
||||
if *in_list {
|
||||
if rect[1] < top {
|
||||
@@ -4722,6 +4766,7 @@ impl OptionsUi {
|
||||
if let Some(next_idx) = gamepad::focus_step_rect(from, dir, &rects) {
|
||||
let (next_id, rect, in_list) = &focusables[next_idx];
|
||||
self.focus = Some(next_id.clone());
|
||||
self.focus_from_keyboard = true;
|
||||
self.can_change_value = false;
|
||||
if *in_list {
|
||||
if rect[1] < top {
|
||||
@@ -4735,6 +4780,7 @@ impl OptionsUi {
|
||||
}
|
||||
} else {
|
||||
self.focus = Some(focusables[0].0.clone());
|
||||
self.focus_from_keyboard = true;
|
||||
self.can_change_value = false;
|
||||
}
|
||||
return Vec::new();
|
||||
@@ -4757,6 +4803,7 @@ impl OptionsUi {
|
||||
if let Some(next_idx) = step {
|
||||
let (next_id, rect, in_list) = &focusables[next_idx];
|
||||
self.focus = Some(next_id.clone());
|
||||
self.focus_from_keyboard = true;
|
||||
self.can_change_value = false;
|
||||
if *in_list {
|
||||
if rect[1] < top {
|
||||
@@ -4774,6 +4821,7 @@ impl OptionsUi {
|
||||
}
|
||||
} else {
|
||||
self.focus = Some(focusables[0].0.clone());
|
||||
self.focus_from_keyboard = true;
|
||||
self.can_change_value = false;
|
||||
}
|
||||
return Vec::new();
|
||||
@@ -4797,6 +4845,7 @@ impl OptionsUi {
|
||||
match act {
|
||||
Act::Push(p) => {
|
||||
self.focus = None;
|
||||
self.focus_from_keyboard = false;
|
||||
self.can_change_value = false;
|
||||
if p == Page::ShaderPacks {
|
||||
events.push(Event::ListShaderPacks);
|
||||
@@ -4805,6 +4854,7 @@ impl OptionsUi {
|
||||
}
|
||||
Act::Done => {
|
||||
self.focus = None;
|
||||
self.focus_from_keyboard = false;
|
||||
self.can_change_value = false;
|
||||
events.extend(self.back(s));
|
||||
}
|
||||
@@ -4844,6 +4894,7 @@ impl OptionsUi {
|
||||
}
|
||||
Act::ModSettings(i) => {
|
||||
self.focus = None;
|
||||
self.focus_from_keyboard = false;
|
||||
self.can_change_value = false;
|
||||
self.push(Page::NativeMod(i));
|
||||
events.push(Event::NativeSettings(i));
|
||||
@@ -4868,6 +4919,7 @@ impl OptionsUi {
|
||||
// The shader options page's buttons, as a click does them.
|
||||
Act::ShaderOptions => {
|
||||
self.focus = None;
|
||||
self.focus_from_keyboard = false;
|
||||
self.can_change_value = false;
|
||||
s.shader_options = None;
|
||||
self.push(Page::ShaderOptions);
|
||||
@@ -4885,6 +4937,7 @@ impl OptionsUi {
|
||||
Act::ShaderScreen(screen) => {
|
||||
if let Some(options) = &mut s.shader_options {
|
||||
self.focus = None;
|
||||
self.focus_from_keyboard = false;
|
||||
options.path.push(screen);
|
||||
self.scroll = 0.0;
|
||||
}
|
||||
@@ -7147,6 +7200,80 @@ mod tests {
|
||||
assert_eq!(s.horizon.chunk_updates, 11);
|
||||
}
|
||||
|
||||
/// Vanilla's wheel scrolls the list (`AbstractScrollArea.mouseScrolled`); only a
|
||||
/// widget focused by the keyboard takes the step from anywhere on the screen.
|
||||
/// A click focuses its widget for the highlight, and honouring that focus made a
|
||||
/// clicked option toggle on every later wheel event while the list never scrolled.
|
||||
#[test]
|
||||
fn a_clicked_option_lets_the_wheel_scroll_the_list() {
|
||||
let atlas = atlas();
|
||||
let mut s = Settings::default();
|
||||
let mut ui = OptionsUi::default();
|
||||
ui.show(Page::Video);
|
||||
let framebuffer = [854, 480];
|
||||
let click_size = (427.0, 400.0);
|
||||
let (widgets, click_top, click_bottom, _) = ui.layout(&s, click_size.0, click_size.1);
|
||||
|
||||
// A click leaves the widget focused for its highlight, not for the wheel.
|
||||
let wd = widgets
|
||||
.iter()
|
||||
.find(|wd| {
|
||||
matches!(
|
||||
wd.kind,
|
||||
WKind::Button {
|
||||
act: Act::Cycle(_),
|
||||
..
|
||||
}
|
||||
) && (!wd.list || (wd.y >= click_top && wd.y + wd.h <= click_bottom))
|
||||
})
|
||||
.expect("a visible cycle button");
|
||||
ui.click(
|
||||
&mut s,
|
||||
&atlas,
|
||||
wd.x + 5.0,
|
||||
wd.y + 5.0,
|
||||
click_size,
|
||||
framebuffer,
|
||||
false,
|
||||
false,
|
||||
);
|
||||
assert!(
|
||||
matches!(ui.focus, Some(FocusId::Cycle(_))),
|
||||
"the click focuses the cycle button it hit"
|
||||
);
|
||||
assert!(
|
||||
!ui.focus_from_keyboard,
|
||||
"a click does not leave keyboard focus"
|
||||
);
|
||||
|
||||
// The wheel over the list's empty margin scrolls it at a size where the
|
||||
// page overflows, and the clicked cycle option stays put: no ButtonClick,
|
||||
// no Changed.
|
||||
let size = (427.0, 240.0);
|
||||
let (_, top, bottom, content) = ui.layout(&s, size.0, size.1);
|
||||
assert!(
|
||||
content > bottom - top,
|
||||
"the video page scrolls at this size"
|
||||
);
|
||||
let before = s.view_bobbing;
|
||||
let start = ui.scroll;
|
||||
let cursor = (2.0, (top + bottom) / 2.0);
|
||||
let events = ui.wheel(&mut s, size, cursor, framebuffer, -1.0);
|
||||
assert_eq!(s.view_bobbing, before, "the clicked option does not toggle");
|
||||
assert!(!events.contains(&Event::ButtonClick), "no click reaches it");
|
||||
assert!(!events.contains(&Event::Changed));
|
||||
assert!(ui.scroll > start, "the list scrolls down instead");
|
||||
|
||||
// Keyboard focus keeps the documented divergence: the wheel steps it
|
||||
// wherever the cursor is, even off the widget.
|
||||
ui.focus = Some(FocusId::Cycle(Opt::ViewBobbing));
|
||||
ui.focus_from_keyboard = true;
|
||||
let events = ui.wheel(&mut s, size, cursor, framebuffer, -1.0);
|
||||
assert_ne!(s.view_bobbing, before, "a keyboard-focused option cycles");
|
||||
assert!(events.contains(&Event::ButtonClick));
|
||||
assert!(events.contains(&Event::Changed));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn options_step_slider_matches_set_slider_for_all_stepped_options() {
|
||||
let stepped_opts = [
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
//! Splitscreen's layout, pure data tested without a window: where each player's view goes in
|
||||
//! its window (`place`).
|
||||
//! its window (`place`), and the setup screen's draft (`Draft`): the windows, where each is, who
|
||||
//! plays in each, before any of it is applied.
|
||||
use winit::dpi::PhysicalSize;
|
||||
|
||||
/// Players at most: P1 and fifteen more.
|
||||
@@ -25,30 +26,6 @@ const ROWS: [&[u32]; MAX_PLAYERS - 4] = [
|
||||
&[4, 4, 4, 4],
|
||||
];
|
||||
|
||||
/// Where a window is.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum Placement {
|
||||
/// A normal window: its corner on the desktop and its size in pixels (`None`: a new one,
|
||||
/// where the system opens it).
|
||||
Free(Option<([i32; 2], [u32; 2])>),
|
||||
/// Borderless over the monitor of this name.
|
||||
Fill(String),
|
||||
}
|
||||
|
||||
/// How far window `number` opens right and down of the centre, in logical pixels: Window 2
|
||||
/// centred, each next one a step further, as systems cascade new windows (back to the centre
|
||||
/// after six), so new windows never open exactly on one another.
|
||||
pub fn cascade(number: u8) -> f64 {
|
||||
f64::from(number.saturating_sub(2) % 6) * 32.0
|
||||
}
|
||||
|
||||
/// A window: its number (1 is the game's own) and where it is.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Window {
|
||||
pub number: u8,
|
||||
pub placement: Placement,
|
||||
}
|
||||
|
||||
/// `order` made a whole order of `n` players: those it names (once, if there are), then the
|
||||
/// others, P1 first.
|
||||
pub fn ordered(order: &[usize], n: usize) -> Vec<usize> {
|
||||
@@ -152,10 +129,310 @@ fn rows(n: usize, slot: u32, w: u32, h: u32) -> [u32; 4] {
|
||||
}
|
||||
}
|
||||
|
||||
/// A screen as the system reports it: its name, where it is on the desktop and its size (both
|
||||
/// physical pixels), and its scale.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Monitor {
|
||||
pub name: String,
|
||||
pub at: [i32; 2],
|
||||
pub size: [u32; 2],
|
||||
pub scale: f64,
|
||||
}
|
||||
|
||||
impl Monitor {
|
||||
/// Where it is on the desktop and its size, as windows are placed: macOS places them in
|
||||
/// points, each monitor at its own scale; Windows and X11 in desktop pixels.
|
||||
pub fn desktop(&self) -> [f64; 4] {
|
||||
let k = if cfg!(target_os = "macos") {
|
||||
self.scale
|
||||
} else {
|
||||
1.0
|
||||
};
|
||||
[
|
||||
f64::from(self.at[0]) / k,
|
||||
f64::from(self.at[1]) / k,
|
||||
f64::from(self.size[0]) / k,
|
||||
f64::from(self.size[1]) / k,
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
/// Where a window is.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum Placement {
|
||||
/// A normal window: its corner on the desktop (`Monitor::desktop`'s units) and its size in
|
||||
/// pixels (`None`: a new one, where the system opens it, `NEW_WINDOW` big).
|
||||
Free(Option<([i32; 2], [u32; 2])>),
|
||||
/// Borderless over the monitor of this name.
|
||||
Fill(String),
|
||||
}
|
||||
|
||||
/// A new free window's size, in logical pixels.
|
||||
pub const NEW_WINDOW: [f64; 2] = [854.0, 480.0];
|
||||
|
||||
/// How far window `number` opens right and down of the centre, in logical pixels: Window 2
|
||||
/// centred, each next one a step further, as systems cascade new windows (back to the centre
|
||||
/// after six), so new windows never open exactly on one another.
|
||||
pub fn cascade(number: u8) -> f64 {
|
||||
f64::from(number.saturating_sub(2) % 6) * 32.0
|
||||
}
|
||||
|
||||
/// A window: its number (1 is the game's own) and where it is.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Window {
|
||||
pub number: u8,
|
||||
pub placement: Placement,
|
||||
}
|
||||
|
||||
/// Where a player can go: a window there is (by number), a new free one, or a new one filling a
|
||||
/// monitor (by name).
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum Spot {
|
||||
Window(u8),
|
||||
New,
|
||||
Fill(String),
|
||||
}
|
||||
|
||||
/// Something to tell before Apply.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum Warning {
|
||||
/// Nobody plays in this window: it won't open (Window 1 stays, blank).
|
||||
Empty(u8),
|
||||
/// This window fills a monitor that isn't there: its players play in Window 1.
|
||||
Unplugged(u8, String),
|
||||
/// This window fills the monitor Window 1 is on: its players play in Window 1.
|
||||
OnWindow1(u8),
|
||||
}
|
||||
|
||||
/// The setup screen's arrangement until Apply: the windows, each player's window (by number, P1
|
||||
/// first) and the shapes two or three players sharing a window take (`place`'s `two`, `three`).
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Draft {
|
||||
pub windows: Vec<Window>,
|
||||
pub players: Vec<u8>,
|
||||
pub two: u8,
|
||||
pub three: u8,
|
||||
/// The players (indices) in the order their views fill a window shared with others: first
|
||||
/// left or on top. Empty, or missing some: P1, P2... (`ordered`).
|
||||
pub order: Vec<usize>,
|
||||
}
|
||||
|
||||
impl Draft {
|
||||
/// Everyone in Window 1 (where it is now).
|
||||
pub fn everyone_here(players: usize, window1: Placement, two: u8, three: u8) -> Self {
|
||||
Self {
|
||||
windows: vec![Window {
|
||||
number: 1,
|
||||
placement: window1,
|
||||
}],
|
||||
players: vec![1; players],
|
||||
two,
|
||||
three,
|
||||
order: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Players `i` and `j` trade places: their windows and their places in them.
|
||||
pub fn swap(&mut self, i: usize, j: usize) {
|
||||
let n = self.players.len();
|
||||
if i >= n || j >= n || i == j {
|
||||
return;
|
||||
}
|
||||
self.players.swap(i, j);
|
||||
let mut order = ordered(&self.order, n);
|
||||
let (a, b) = (
|
||||
order.iter().position(|&p| p == i),
|
||||
order.iter().position(|&p| p == j),
|
||||
);
|
||||
if let (Some(a), Some(b)) = (a, b) {
|
||||
order.swap(a, b);
|
||||
}
|
||||
self.order = order;
|
||||
}
|
||||
|
||||
/// Player `i` to place `k` among the others in its window (0: before them all; past the
|
||||
/// last: after them), the others keeping their order: the bumpers' "Window 1 (2 of 3)".
|
||||
pub fn put_at(&mut self, i: usize, k: usize) {
|
||||
let n = self.players.len();
|
||||
if i >= n {
|
||||
return;
|
||||
}
|
||||
let mine = self.players[i];
|
||||
let mut order: Vec<usize> = ordered(&self.order, n)
|
||||
.into_iter()
|
||||
.filter(|&p| p != i)
|
||||
.collect();
|
||||
let others: Vec<usize> = order
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|&p| self.players[p] == mine)
|
||||
.collect();
|
||||
let at = match others.get(k) {
|
||||
Some(j) => order.iter().position(|p| p == j).unwrap_or(order.len()),
|
||||
None => others
|
||||
.last()
|
||||
.and_then(|j| order.iter().position(|p| p == j))
|
||||
.map_or(order.len(), |p| p + 1),
|
||||
};
|
||||
order.insert(at, i);
|
||||
self.order = order;
|
||||
}
|
||||
|
||||
/// The players but `i` in window `number`, in their order.
|
||||
pub fn sharing(&self, i: usize, number: u8) -> Vec<usize> {
|
||||
ordered(&self.order, self.players.len())
|
||||
.into_iter()
|
||||
.filter(|&p| p != i && self.players[p] == number)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// A new free window, numbered with the lowest number free; its number.
|
||||
pub fn add_window(&mut self) -> u8 {
|
||||
self.open(Placement::Free(None))
|
||||
}
|
||||
|
||||
fn open(&mut self, placement: Placement) -> u8 {
|
||||
let number = (2..=u8::MAX)
|
||||
.find(|n| self.windows.iter().all(|w| w.number != *n))
|
||||
.unwrap_or(u8::MAX);
|
||||
self.windows.push(Window { number, placement });
|
||||
number
|
||||
}
|
||||
|
||||
/// Player `i` to `spot`: a new window opens for it. A window it leaves empty stays in the
|
||||
/// draft until `drop_empty` (at Apply), so numbers hold still while players move. A window
|
||||
/// number the draft doesn't have is ignored.
|
||||
pub fn place_player(&mut self, i: usize, spot: Spot) {
|
||||
if i >= self.players.len() {
|
||||
return;
|
||||
}
|
||||
let number = match spot {
|
||||
Spot::Window(n) if self.windows.iter().any(|w| w.number == n) => n,
|
||||
Spot::Window(_) => return,
|
||||
Spot::New => self.add_window(),
|
||||
Spot::Fill(name) => self.open(Placement::Fill(name)),
|
||||
};
|
||||
self.players[i] = number;
|
||||
}
|
||||
|
||||
/// The windows nobody plays in, gone, but Window 1 and those in `keep`. At Apply, all of
|
||||
/// them; while a draft is edited, the ones it added (the windows open now stay until Apply,
|
||||
/// so their numbers hold still as players move).
|
||||
pub fn drop_empty(&mut self, keep: &[u8]) {
|
||||
let players = &self.players;
|
||||
self.windows
|
||||
.retain(|w| w.number == 1 || players.contains(&w.number) || keep.contains(&w.number));
|
||||
}
|
||||
|
||||
/// Whether nobody plays in window `number`.
|
||||
pub fn empty(&self, number: u8) -> bool {
|
||||
!self.players.contains(&number)
|
||||
}
|
||||
|
||||
/// What to tell before Apply, in window order.
|
||||
pub fn warnings(&self, monitors: &[Monitor], main: &str) -> Vec<Warning> {
|
||||
let mut out = Vec::new();
|
||||
for w in &self.windows {
|
||||
if let Placement::Fill(name) = &w.placement
|
||||
&& !monitors.iter().any(|m| &m.name == name)
|
||||
{
|
||||
out.push(Warning::Unplugged(w.number, name.clone()));
|
||||
} else if w.number != 1 && w.placement == Placement::Fill(main.to_owned()) {
|
||||
out.push(Warning::OnWindow1(w.number));
|
||||
} else if !self.players.contains(&w.number) {
|
||||
out.push(Warning::Empty(w.number));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Where each window would be: its corner on the desktop (`Monitor::desktop`'s units) and its
|
||||
/// size in pixels, in `windows` order: free ones where they were (new ones at `main`'s
|
||||
/// corner), the others over their monitor; a window over a missing monitor or Window 1's
|
||||
/// (its players play in Window 1, as the game does): `None`. Window 1 always has one: give
|
||||
/// it its real rect (`Free(Some)`); else it is taken as a new window would open.
|
||||
pub fn rects(&self, monitors: &[Monitor], main: &str) -> Vec<Option<([i32; 2], [u32; 2])>> {
|
||||
let home = monitors
|
||||
.iter()
|
||||
.find(|m| m.name == main)
|
||||
.or(monitors.first());
|
||||
// Centred on Window 1's monitor, as the game opens a new window.
|
||||
let new = |number: u8| {
|
||||
let (at, scale, k) = home.map_or(([0.0; 4], 1.0, 1.0), |m| {
|
||||
let at = m.desktop();
|
||||
(at, m.scale, at[2] / f64::from(m.size[0].max(1)))
|
||||
});
|
||||
let size = NEW_WINDOW.map(|v| (v * scale).round() as u32);
|
||||
let step = cascade(number) * scale * k;
|
||||
let x = at[0] + (at[2] - f64::from(size[0]) * k) / 2.0 + step;
|
||||
let y = at[1] + (at[3] - f64::from(size[1]) * k) / 2.0 + step;
|
||||
([x as i32, y as i32], size)
|
||||
};
|
||||
self.windows
|
||||
.iter()
|
||||
.map(|w| {
|
||||
let rect = match &w.placement {
|
||||
Placement::Free(Some(rect)) => Some(*rect),
|
||||
Placement::Free(None) => Some(new(w.number)),
|
||||
Placement::Fill(name) if w.number != 1 && name == main => None,
|
||||
Placement::Fill(name) => monitors.iter().find(|m| &m.name == name).map(|m| {
|
||||
let at = m.desktop();
|
||||
([at[0] as i32, at[1] as i32], m.size)
|
||||
}),
|
||||
};
|
||||
// Everyone with nowhere else plays in Window 1: it never has no size.
|
||||
if w.number == 1 {
|
||||
rect.or_else(|| Some(new(1)))
|
||||
} else {
|
||||
rect
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Every player's view as Apply would make it (`place`, as the game tiles them), with
|
||||
/// `View`'s window an index into `windows`; players of a window over a missing monitor in
|
||||
/// Window 1's.
|
||||
pub fn preview(&self, monitors: &[Monitor], main: &str) -> Vec<View> {
|
||||
let rects = self.rects(monitors, main);
|
||||
let index = |number: u8| self.windows.iter().position(|w| w.number == number);
|
||||
let window1 = index(1).unwrap_or(0);
|
||||
let windows: Vec<usize> = self
|
||||
.players
|
||||
.iter()
|
||||
.map(|&n| index(n).filter(|&i| rects[i].is_some()).unwrap_or(window1))
|
||||
.collect();
|
||||
let sizes: Vec<PhysicalSize<u32>> = rects
|
||||
.iter()
|
||||
.map(|r| {
|
||||
r.map_or(PhysicalSize::new(1, 1), |(_, [w, h])| {
|
||||
PhysicalSize::new(w, h)
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
place_ordered(&windows, &self.order, &sizes, self.two, self.three)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn window_1_always_has_a_size_and_unknown_windows_take_nobody() {
|
||||
let monitors = [Monitor {
|
||||
name: "Main".into(),
|
||||
at: [0, 0],
|
||||
size: [1920, 1080],
|
||||
scale: 1.0,
|
||||
}];
|
||||
let mut d = Draft::everyone_here(2, Placement::Fill("Gone".into()), 0, 0);
|
||||
assert!(d.rects(&monitors, "Main")[0].is_some());
|
||||
assert!(d.preview(&monitors, "Main").iter().all(|v| v.2.width > 1));
|
||||
d.place_player(1, Spot::Window(9));
|
||||
assert_eq!(d.players, [1, 1], "no Window 9 in the draft");
|
||||
}
|
||||
|
||||
fn size(w: u32, h: u32) -> PhysicalSize<u32> {
|
||||
PhysicalSize::new(w, h)
|
||||
}
|
||||
@@ -272,6 +549,97 @@ mod tests {
|
||||
)
|
||||
);
|
||||
}
|
||||
let mut draft = here(16);
|
||||
draft.place_player(15, Spot::New);
|
||||
let views = draft.preview(&monitors(), "Built-in");
|
||||
assert_eq!(views.len(), 16);
|
||||
assert_ne!(views[0].0, views[15].0);
|
||||
}
|
||||
|
||||
fn monitors() -> Vec<Monitor> {
|
||||
vec![
|
||||
Monitor {
|
||||
name: "Built-in".into(),
|
||||
at: [0, 0],
|
||||
size: [3024, 1964],
|
||||
scale: 2.0,
|
||||
},
|
||||
Monitor {
|
||||
name: "TV".into(),
|
||||
at: [3024, 0],
|
||||
size: [3840, 2160],
|
||||
scale: 1.0,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
/// P1 in Window 1, everyone else in Window 2 filling the TV.
|
||||
trait DeskAndTv {
|
||||
fn desk_and_tv(self, monitors: &[Monitor], main: &str) -> Option<Draft>;
|
||||
}
|
||||
impl DeskAndTv for Draft {
|
||||
fn desk_and_tv(mut self, monitors: &[Monitor], main: &str) -> Option<Draft> {
|
||||
let tv = monitors.iter().find(|m| m.name != main)?;
|
||||
self.windows.push(Window {
|
||||
number: 2,
|
||||
placement: Placement::Fill(tv.name.clone()),
|
||||
});
|
||||
for p in self.players.iter_mut().skip(1) {
|
||||
*p = 2;
|
||||
}
|
||||
Some(self)
|
||||
}
|
||||
}
|
||||
|
||||
fn here(players: usize) -> Draft {
|
||||
Draft::everyone_here(
|
||||
players,
|
||||
Placement::Free(Some(([100, 100], [2000, 1200]))),
|
||||
0,
|
||||
0,
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn presets() {
|
||||
let m = monitors();
|
||||
// Everyone here: four quarters of Window 1.
|
||||
let views = here(4).preview(&m, "Built-in");
|
||||
assert_eq!(views[3], (0, [1000, 600], size(1000, 600)));
|
||||
// P1 alone, the others share the TV in rows.
|
||||
let tv = here(4).desk_and_tv(&m, "Built-in").unwrap();
|
||||
assert_eq!(tv.players, [1, 2, 2, 2]);
|
||||
assert_eq!(tv.windows[1].placement, Placement::Fill("TV".into()));
|
||||
let views = tv.preview(&m, "Built-in");
|
||||
assert_eq!(views[0], (0, [0, 0], size(2000, 1200)));
|
||||
assert_eq!(views[1], (1, [0, 0], size(3840, 1080)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_windows_cascade_instead_of_stacking() {
|
||||
let m = monitors();
|
||||
let mut d = here(3);
|
||||
d.place_player(1, Spot::New);
|
||||
d.place_player(2, Spot::New);
|
||||
let rects = d.rects(&m, "Built-in");
|
||||
let ((a, _), (b, _)) = (rects[1].unwrap(), rects[2].unwrap());
|
||||
// Window 3 a step right and down of Window 2, on the same diagonal.
|
||||
assert!(b[0] > a[0] && b[0] - a[0] == b[1] - a[1]);
|
||||
assert_eq!((cascade(2), cascade(3), cascade(8)), (0.0, 32.0, 0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn put_at_a_place_among_a_window_s_players() {
|
||||
let mut d = here(3);
|
||||
d.put_at(2, 0);
|
||||
assert_eq!(d.order, [2, 0, 1]);
|
||||
d.put_at(2, 5); // past the last: after them
|
||||
assert_eq!(d.order, [0, 1, 2]);
|
||||
// Players in another window keep their places.
|
||||
d.players = vec![1, 2, 1];
|
||||
d.put_at(2, 0);
|
||||
assert_eq!(d.sharing(2, 1), [0]);
|
||||
assert_eq!(d.order, [2, 0, 1]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -293,4 +661,98 @@ mod tests {
|
||||
let views = place_ordered(&[0, 0, 0], &[2], &wide, 0, 0);
|
||||
assert_eq!(views[2].2, size(2000, 500));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn two_players_swap_in_a_window_and_between_windows() {
|
||||
let m = monitors();
|
||||
// In one window: P1 and P2 trade sides.
|
||||
let mut d = here(2);
|
||||
d.swap(0, 1);
|
||||
assert_eq!(d.order, [1, 0]);
|
||||
let views = d.preview(&m, "Built-in");
|
||||
assert!(views[1].1[0] < views[0].1[0], "P2 left of P1");
|
||||
// Between windows: P2 on the TV, P3 in Window 1; they trade windows.
|
||||
let mut d = here(3);
|
||||
d.place_player(1, Spot::Fill("TV".into()));
|
||||
d.swap(1, 2);
|
||||
assert_eq!(d.players, [1, 1, 2]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn players_placed() {
|
||||
let m = monitors();
|
||||
let mut d = here(3);
|
||||
d.place_player(1, Spot::Fill("TV".into()));
|
||||
d.place_player(2, Spot::Window(2));
|
||||
assert_eq!(d.players, [1, 2, 2]);
|
||||
d.place_player(2, Spot::New);
|
||||
assert_eq!(d.players, [1, 2, 3]);
|
||||
assert!(d.warnings(&m, "Built-in").is_empty());
|
||||
// The window left behind stays while the draft is edited (numbers hold still), and
|
||||
// closes at Apply; Window 1 never.
|
||||
d.place_player(1, Spot::Window(1));
|
||||
let numbers = |d: &Draft| d.windows.iter().map(|w| w.number).collect::<Vec<_>>();
|
||||
assert_eq!(numbers(&d), [1, 2, 3]);
|
||||
assert!(d.empty(2));
|
||||
d.drop_empty(&[]);
|
||||
assert_eq!(numbers(&d), [1, 3]);
|
||||
assert!(d.warnings(&m, "Built-in").is_empty());
|
||||
d.place_player(0, Spot::Window(3));
|
||||
d.place_player(1, Spot::Window(3));
|
||||
assert_eq!(d.warnings(&m, "Built-in"), [Warning::Empty(1)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn windows_numbered_lowest_free() {
|
||||
let mut d = here(3);
|
||||
assert_eq!(d.add_window(), 2);
|
||||
assert_eq!(d.add_window(), 3);
|
||||
d.windows.retain(|w| w.number != 2);
|
||||
assert_eq!(d.add_window(), 2);
|
||||
// Nobody to place: no window opens for it.
|
||||
d.place_player(9, Spot::New);
|
||||
assert_eq!(d.windows.len(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_window_over_window_1s_monitor() {
|
||||
let m = monitors();
|
||||
let mut d = here(2);
|
||||
d.place_player(1, Spot::Fill("Built-in".into()));
|
||||
assert_eq!(d.warnings(&m, "Built-in"), [Warning::OnWindow1(2)]);
|
||||
// As the game does: P2 beside P1 in Window 1.
|
||||
assert_eq!(d.preview(&m, "Built-in")[1].0, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn warnings_and_an_unplugged_monitor() {
|
||||
let m = monitors();
|
||||
let mut d = here(2).desk_and_tv(&m, "Built-in").unwrap();
|
||||
let three = d.add_window();
|
||||
assert_eq!(d.warnings(&m, "Built-in"), [Warning::Empty(three)]);
|
||||
// The TV unplugged: its player plays in Window 1 beside P1.
|
||||
assert_eq!(
|
||||
d.warnings(&m[..1], "Built-in"),
|
||||
[Warning::Unplugged(2, "TV".into()), Warning::Empty(three)]
|
||||
);
|
||||
let views = d.preview(&m[..1], "Built-in");
|
||||
assert_eq!(views[1], (0, [1000, 0], size(1000, 1200)));
|
||||
// P1 elsewhere: Window 1 is blank.
|
||||
d.players = vec![2, 2];
|
||||
assert_eq!(d.warnings(&m, "Built-in")[0], Warning::Empty(1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn eight_players_two_windows() {
|
||||
let m = monitors();
|
||||
let d = here(8).desk_and_tv(&m, "Built-in").unwrap();
|
||||
let views = d.preview(&m, "Built-in");
|
||||
// Seven on the TV: rows of 3, 2 and 2, the whole TV covered.
|
||||
let area: u64 = views[1..]
|
||||
.iter()
|
||||
.map(|v| u64::from(v.2.width) * u64::from(v.2.height))
|
||||
.sum();
|
||||
assert_eq!(area, 3840 * 2160);
|
||||
assert!(views[1..].iter().all(|v| v.0 == 1));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
//! Project a draft arrangement onto the Setup screen, without opening a window.
|
||||
use crate::split_layout::{Draft, Monitor};
|
||||
|
||||
/// Map area: below the title and above the move row and footer.
|
||||
pub const TOP: f32 = 30.0;
|
||||
pub const BOTTOM: f32 = 80.0;
|
||||
pub type Rect = [f32; 4];
|
||||
pub type Map = (Vec<Rect>, Vec<(u8, Rect)>, Vec<(u8, Rect)>);
|
||||
|
||||
/// Monitor, window and player rectangles in UI coordinates, in their drawing order.
|
||||
pub fn project(draft: &Draft, monitors: &[Monitor], main: &str, w: f32, h: f32) -> Map {
|
||||
let desk: Vec<[f64; 4]> = monitors.iter().map(Monitor::desktop).collect();
|
||||
// A window's size on the desktop: its pixels at the scale of the monitor it is on.
|
||||
let rects = draft.rects(monitors, main);
|
||||
let on_desk: Vec<(u8, [f64; 4])> = draft
|
||||
.windows
|
||||
.iter()
|
||||
.zip(&rects)
|
||||
.filter_map(|(win, rect)| {
|
||||
let ([x, y], [pw, ph]) = (*rect)?;
|
||||
let (x, y) = (f64::from(x), f64::from(y));
|
||||
let scale = monitors
|
||||
.iter()
|
||||
.find(|m| {
|
||||
let [mx, my, mw, mh] = m.desktop();
|
||||
x >= mx && x < mx + mw && y >= my && y < my + mh
|
||||
})
|
||||
.or_else(|| monitors.iter().find(|m| m.name == main))
|
||||
.map_or(1.0, |m| m.desktop()[2] / f64::from(m.size[0]));
|
||||
Some((
|
||||
win.number,
|
||||
[x, y, f64::from(pw) * scale, f64::from(ph) * scale],
|
||||
))
|
||||
})
|
||||
.collect();
|
||||
// The monitors, and windows past them (Wayland's, whose place is unknown) still shown.
|
||||
let all = || desk.iter().chain(on_desk.iter().map(|(_, d)| d));
|
||||
let x0 = all().map(|d| d[0]).fold(f64::MAX, f64::min);
|
||||
let y0 = all().map(|d| d[1]).fold(f64::MAX, f64::min);
|
||||
let x1 = all().map(|d| d[0] + d[2]).fold(f64::MIN, f64::max);
|
||||
let y1 = all().map(|d| d[1] + d[3]).fold(f64::MIN, f64::max);
|
||||
if desk.is_empty() || x1 <= x0 || y1 <= y0 {
|
||||
return Default::default();
|
||||
}
|
||||
let (aw, ah) = (
|
||||
f64::from(w) - 40.0,
|
||||
// Room under the monitors for their names, and always for the move row: the map
|
||||
// doesn't jump when a player is picked (a drop back to where its tile was misses).
|
||||
(f64::from(h - TOP - BOTTOM) - 18.0).max(10.0),
|
||||
);
|
||||
let k = (aw / (x1 - x0)).min(ah / (y1 - y0));
|
||||
let (ox, oy) = (
|
||||
(f64::from(w) - (x1 - x0) * k) / 2.0,
|
||||
f64::from(TOP) + 4.0 + (ah - (y1 - y0) * k) / 2.0,
|
||||
);
|
||||
let to_map = |[x, y, dw, dh]: [f64; 4]| {
|
||||
[
|
||||
(ox + (x - x0) * k) as f32,
|
||||
(oy + (y - y0) * k) as f32,
|
||||
(dw * k) as f32,
|
||||
(dh * k) as f32,
|
||||
]
|
||||
};
|
||||
let mapped_monitors = desk.iter().copied().map(to_map).collect();
|
||||
let windows: Vec<(u8, [f32; 4])> = on_desk.iter().map(|&(n, d)| (n, to_map(d))).collect();
|
||||
// In their own order, emptied ones too: a window a player was just dragged out of stays
|
||||
// where it was until Apply closes it, so the player can be dragged back into it.
|
||||
let tiles = draft
|
||||
.preview(monitors, main)
|
||||
.iter()
|
||||
.map(|&(i, [vx, vy], size)| {
|
||||
let number = draft.windows.get(i).map_or(1, |w| w.number);
|
||||
let Some((_, [wx, wy, ww, wh])) = windows.iter().find(|(n, _)| *n == number) else {
|
||||
return (number, [0.0; 4]);
|
||||
};
|
||||
let [pw, ph] = rects[i].map_or([1, 1], |r| r.1).map(|v| v.max(1) as f32);
|
||||
(
|
||||
number,
|
||||
[
|
||||
wx + vx as f32 / pw * ww,
|
||||
wy + vy as f32 / ph * wh,
|
||||
size.width as f32 / pw * ww,
|
||||
size.height as f32 / ph * wh,
|
||||
],
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
(mapped_monitors, windows, tiles)
|
||||
}
|
||||
|
||||
/// The largest visible side of a tile after later windows cover it.
|
||||
pub fn uncovered(mut r: [f32; 4], covers: impl Iterator<Item = [f32; 4]>) -> [f32; 4] {
|
||||
for [cx, cy, cw, ch] in covers {
|
||||
let [x, y, w, h] = r;
|
||||
if cx >= x + w || cx + cw <= x || cy >= y + h || cy + ch <= y {
|
||||
continue;
|
||||
}
|
||||
let sides = [
|
||||
[x, y, cx - x, h],
|
||||
[cx + cw, y, x + w - (cx + cw), h],
|
||||
[x, y, w, cy - y],
|
||||
[x, cy + ch, w, y + h - (cy + ch)],
|
||||
];
|
||||
r = sides
|
||||
.into_iter()
|
||||
.map(|[sx, sy, sw, sh]| [sx, sy, sw.max(0.0), sh.max(0.0)])
|
||||
.max_by(|a, b| (a[2] * a[3]).total_cmp(&(b[2] * b[3])))
|
||||
.unwrap_or([x, y, 0.0, 0.0]);
|
||||
}
|
||||
r
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::split_layout::{Placement, Spot};
|
||||
|
||||
#[test]
|
||||
fn preview_tiles_share_a_window_and_fallback_when_a_monitor_disappears() {
|
||||
let monitors = vec![
|
||||
Monitor {
|
||||
name: "Desk".into(),
|
||||
at: [0, 0],
|
||||
size: [2000, 1200],
|
||||
scale: 1.0,
|
||||
},
|
||||
Monitor {
|
||||
name: "TV".into(),
|
||||
at: [-1920, 0],
|
||||
size: [1920, 1080],
|
||||
scale: 1.0,
|
||||
},
|
||||
];
|
||||
let mut draft =
|
||||
Draft::everyone_here(2, Placement::Free(Some(([0, 0], [2000, 1200]))), 0, 0);
|
||||
let (_, windows, tiles) = project(&draft, &monitors, "Desk", 427.0, 300.0);
|
||||
assert_eq!(windows.len(), 1);
|
||||
let (a, b) = (tiles[0].1, tiles[1].1);
|
||||
assert!((a[2] - b[2]).abs() < 0.01);
|
||||
assert!((a[0] + a[2] - b[0]).abs() < 0.01);
|
||||
draft.place_player(1, Spot::Fill("TV".into()));
|
||||
let (screens, windows, tiles) = project(&draft, &monitors, "Desk", 427.0, 300.0);
|
||||
assert!(screens[1][0] < screens[0][0]);
|
||||
assert_eq!(windows.len(), 2);
|
||||
assert_eq!((tiles[0].0, tiles[1].0), (1, 2));
|
||||
let (_, windows, tiles) = project(&draft, &monitors[..1], "Desk", 427.0, 300.0);
|
||||
assert_eq!(windows.len(), 1);
|
||||
assert_eq!((tiles[0].0, tiles[1].0), (1, 1));
|
||||
assert!(project(&draft, &[], "Desk", 427.0, 300.0).0.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn covered_tiles_only_expose_a_visible_side_for_clicks() {
|
||||
let tile = [0.0, 0.0, 100.0, 100.0];
|
||||
assert_eq!(
|
||||
uncovered(tile, [[0.0, 0.0, 60.0, 100.0]].into_iter()),
|
||||
[60.0, 0.0, 40.0, 100.0]
|
||||
);
|
||||
let hidden = uncovered(tile, [tile].into_iter());
|
||||
assert_eq!(hidden[2] * hidden[3], 0.0);
|
||||
assert_eq!(
|
||||
uncovered(tile, [[110.0, 0.0, 50.0, 50.0]].into_iter()),
|
||||
tile
|
||||
);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -25,6 +25,40 @@ use std::{
|
||||
/// generator's scratch buffers ([`ChunkGenerator::idle`]).
|
||||
pub const IDLE_RELEASE: std::time::Duration = std::time::Duration::from_secs(5);
|
||||
|
||||
/// Worker CPU time and job counts by kind, for benchmarks
|
||||
/// (`examples/gen_light_bench.rs`). Off unless enabled: a relaxed load per
|
||||
/// job then.
|
||||
pub mod job_stats {
|
||||
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
||||
pub const KINDS: [&str; 7] = [
|
||||
"load", "generate", "decorate", "finish", "light", "mesh", "other",
|
||||
];
|
||||
static ENABLED: AtomicBool = AtomicBool::new(false);
|
||||
static NANOS: [AtomicU64; 7] = [const { AtomicU64::new(0) }; 7];
|
||||
static COUNT: [AtomicU64; 7] = [const { AtomicU64::new(0) }; 7];
|
||||
pub fn enable(on: bool) {
|
||||
ENABLED.store(on, Ordering::Relaxed);
|
||||
}
|
||||
pub(super) fn enabled() -> bool {
|
||||
ENABLED.load(Ordering::Relaxed)
|
||||
}
|
||||
pub(super) fn add(kind: usize, nanos: u64) {
|
||||
NANOS[kind].fetch_add(nanos, Ordering::Relaxed);
|
||||
COUNT[kind].fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
/// (kind, nanoseconds, count) since the last call; resets them.
|
||||
pub fn take() -> Vec<(&'static str, u64, u64)> {
|
||||
(0..KINDS.len())
|
||||
.map(|i| {
|
||||
(
|
||||
KINDS[i],
|
||||
NANOS[i].swap(0, Ordering::Relaxed),
|
||||
COUNT[i].swap(0, Ordering::Relaxed),
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
/// A chunk under generation, opaque to the streamer.
|
||||
pub type Proto = Box<dyn std::any::Any + Send>;
|
||||
/// Chunk generation in vanilla's stages: a neighbour-independent proto chunk
|
||||
@@ -716,6 +750,17 @@ impl<G: ChunkGenerator> Source for Jobs<G> {
|
||||
let Some(job) = self.shared.queue.lock().unwrap().pop() else {
|
||||
return false;
|
||||
};
|
||||
let started = job_stats::enabled().then(|| {
|
||||
let kind = match &job.job {
|
||||
Job::Generate(..) => 1,
|
||||
Job::Decorate(..) => 2,
|
||||
Job::Finish(..) => 3,
|
||||
Job::Light(..) => 4,
|
||||
Job::Mesh(..) => 5,
|
||||
_ => 6,
|
||||
};
|
||||
(kind, crate::spans::thread_cpu().unwrap_or_default())
|
||||
});
|
||||
let done = self.locals.with(
|
||||
worker,
|
||||
|| WorkerLocal {
|
||||
@@ -725,6 +770,16 @@ impl<G: ChunkGenerator> Source for Jobs<G> {
|
||||
},
|
||||
|local| self.run_job(job.job, local),
|
||||
);
|
||||
if let Some((kind, started)) = started {
|
||||
// A generation job that found the column saved loaded it.
|
||||
let kind = if kind == 1 && matches!(done, Some(Done::Generated(_))) {
|
||||
0
|
||||
} else {
|
||||
kind
|
||||
};
|
||||
let cpu = crate::spans::thread_cpu().unwrap_or_default();
|
||||
job_stats::add(kind, cpu.saturating_sub(started).as_nanos() as u64);
|
||||
}
|
||||
if let Some(done) = done {
|
||||
self.shared.done.send(done);
|
||||
}
|
||||
@@ -2627,14 +2682,7 @@ fn keep_unchanged_sections(
|
||||
}
|
||||
/// Whether two sections' packed light (4,096 cells) holds the same values.
|
||||
fn same_light(a: &crate::chunk::Light, b: &crate::chunk::Light) -> bool {
|
||||
use crate::chunk::Light;
|
||||
match (a, b) {
|
||||
(Light::Uniform(x), Light::Uniform(y)) => x == y,
|
||||
(Light::Full(x), Light::Full(y)) => x == y,
|
||||
(Light::Uniform(v), Light::Full(f)) | (Light::Full(f), Light::Uniform(v)) => {
|
||||
f.iter().all(|c| c == v)
|
||||
}
|
||||
}
|
||||
a.same_as(b)
|
||||
}
|
||||
/// Whether two biome containers (64 cells) hold the same values.
|
||||
fn same_values(
|
||||
|
||||
@@ -510,6 +510,19 @@ fn solid_finish(in: TerrainOut, texel: vec4<f32>, color: vec4<f32>) -> vec4<f32>
|
||||
}
|
||||
return apply_fog(vec4(color.rgb, emissive), in.spherical, in.cylindrical);
|
||||
}
|
||||
// HDR lighting on what's mostly opaque among translucent faces (water), drawn again after
|
||||
// them with only alpha written: `fs_translucent` blends its alpha as coverage, so a lake's
|
||||
// value would be diluted. Clear glass leaves what's behind it, light sources included, alone.
|
||||
@fragment fn fs_translucent_mark(in: TerrainOut) -> @location(0) vec4<f32> {
|
||||
let dx = dpdx(in.local);
|
||||
let dy = dpdy(in.local);
|
||||
hdr_normal = normalize(cross(dy, dx));
|
||||
if sprite(in.uv, in.layer & 0x7fffu).a < 0.6 { discard; }
|
||||
let fogged = max(linear_fog(in.spherical, globals.fog.x, globals.fog.y),
|
||||
linear_fog(in.cylindrical, globals.fog.z, globals.fog.w)) * globals.fog_color.a;
|
||||
let scene = mix(hdr_scene(in.scene.x, in.scene.y, hdr_facing()), globals.hdr_light.y, fogged);
|
||||
return vec4(0.0, 0.0, 0.0, hdr_scene_alpha(scene));
|
||||
}
|
||||
// Water, glass and other see-through faces draw whole as they fade in, the
|
||||
// layer drawn first in front (gfx.rs's water tiers): dithered, the faded-out
|
||||
// pixels showed the layer behind through the one in front, twice the water.
|
||||
|
||||
@@ -114,6 +114,9 @@ audio = ["horizon-client-render/audio", "horizon-client-lib/audio"]
|
||||
controllers = ["horizon-client-lib/controllers", "dep:gilrs", "dep:horizon-dualsense", "horizon-client-input/controllers"]
|
||||
# Records the call sites of mob random draws (`mob_ai_probe` traces).
|
||||
rng-trace = ["horizon-core/rng-trace", "horizon-client-lib/rng-trace"]
|
||||
# Rust's allocations go to the system heap instead of mimalloc, where heap profilers that replace
|
||||
# malloc (heaptrack, valgrind's massif) see them. For profiling only: mimalloc is faster.
|
||||
system-allocator = []
|
||||
# Compatibility with earlier experiment commands: main already uses mimalloc.
|
||||
windows-mimalloc = []
|
||||
# Opt-in Windows heap experiment; see docs/windows-allocators.md.
|
||||
|
||||
@@ -35,16 +35,16 @@
|
||||
"horizon.pages.settings": "Horizon Settings",
|
||||
"horizon.pages.agent": "AI Agent",
|
||||
"horizon.pages.split_settings": "Splitscreen Settings",
|
||||
"horizon.pages.split_setup": "Splitscreen",
|
||||
"horizon.pages.split_profiles": "Who's playing?",
|
||||
"horizon.pages.split_setup": "Splitscreen Setup",
|
||||
"horizon.pages.settings.tooltip": "Horizon's own settings: graphics, controllers, extras and mods.",
|
||||
"horizon.pages.graphics.tooltip": "Graphics, simply: each setting says what it costs.",
|
||||
"horizon.pages.graphics_advanced.tooltip": "Every graphics setting, Horizon's included, with what each costs.",
|
||||
"horizon.pages.controller.tooltip": "Controllers: their buttons, sticks, vibration and menu navigation.",
|
||||
"horizon.pages.extras.tooltip": "Optional additions, all off by default: the game stays vanilla until you turn one on.",
|
||||
"horizon.pages.agent.tooltip": "Let your own AI agent play: horizon-ctl and MCP clients on this computer.",
|
||||
"horizon.pages.split_setup.tooltip": "Players on this computer: controllers can join your world.",
|
||||
"horizon.pages.split_profiles.tooltip": "Who plays: a profile kept in this game folder.",
|
||||
"horizon.pages.split_setup.tooltip": "The windows and monitors, and who plays where.",
|
||||
"horizon.pages.shader_packs.tooltip": "Shader packs from the shaderpacks folder: Horizon's own and those in the shaders/ format. One runs at a time.",
|
||||
"horizon.pages.mods.tooltip": "Mods made for Horizon, from the horizon-mods folder. Each runs in a sandbox and stays off until you turn it on.",
|
||||
"horizon.pages.open.graphics": "Graphics...",
|
||||
@@ -306,6 +306,55 @@
|
||||
"horizon.extras.chat_animation.tooltip": "A new chat line slides in from the left while it fades in, instead of appearing whole.",
|
||||
"horizon.extras.chat_indicator.tooltip": "The coloured bar at the left of a chat line.",
|
||||
"horizon.extras.splitscreen.tooltip": "In a world, another controller can join as a new player: press A when it asks, then pick a profile and where to play.",
|
||||
"horizon.splitscreen.setup.monitor": "Monitor %s",
|
||||
"horizon.splitscreen.setup.monitor_unknown": "a monitor",
|
||||
"horizon.splitscreen.setup.window": "Window %s",
|
||||
"horizon.splitscreen.setup.new_window": "New window",
|
||||
"horizon.splitscreen.setup.fill_monitor": "Fill %s",
|
||||
"horizon.splitscreen.setup.done": "Done",
|
||||
"horizon.splitscreen.setup.switch": "%s: %s",
|
||||
"horizon.splitscreen.setup.on": "On",
|
||||
"horizon.splitscreen.setup.off": "Off",
|
||||
"horizon.splitscreen.setup.player": "Player %s",
|
||||
"horizon.splitscreen.setup.revert": "Revert",
|
||||
"horizon.splitscreen.setup.apply": "Apply",
|
||||
"horizon.splitscreen.setup.warning.empty_main": "Nobody plays in Window 1: it hides",
|
||||
"horizon.splitscreen.setup.warning.empty_close": "Nobody plays in Window %s: it closes on Apply",
|
||||
"horizon.splitscreen.setup.warning.empty_new": "Nobody plays in Window %s: it won't open",
|
||||
"horizon.splitscreen.setup.warning.on_main": "Window %s fills Window 1's monitor: its players play in Window 1",
|
||||
"horizon.splitscreen.setup.warning.unplugged": "Window %s's monitor is not connected: its players play in Window 1",
|
||||
"horizon.splitscreen.setup.status.off": "Splitscreen is off.",
|
||||
"horizon.splitscreen.setup.status.enable_hint": "Turn it on, then press a button on another controller in a world.",
|
||||
"horizon.splitscreen.setup.status.join_hint": "Press A to join and pick a profile.",
|
||||
"horizon.splitscreen.setup.status.idle": "Splitscreen idle",
|
||||
"horizon.splitscreen.setup.status.alone": "No other local players.",
|
||||
"horizon.splitscreen.setup.status.solo_controls": "Solo controls and controller feedback are unchanged.",
|
||||
"horizon.splitscreen.setup.window_empty": "Window %s: empty",
|
||||
"horizon.splitscreen.setup.note.select": "Click a player, or drag it to a window or a monitor.",
|
||||
"horizon.splitscreen.setup.note.apply": "Apply to play this way, Revert to undo.",
|
||||
"horizon.splitscreen.setup.note.place": "Where should %s play?",
|
||||
"horizon.splitscreen.setup.this_player": "this player",
|
||||
"horizon.splitscreen.setup.switch_disabled": "Splitscreen turns off once the others have left (their pause menu).",
|
||||
"horizon.splitscreen.setup.window_place": "Window %s (%s of %s)",
|
||||
"horizon.splitscreen.setup.names": "Names",
|
||||
"horizon.splitscreen.setup.identify": "Identify",
|
||||
"horizon.splitscreen.setup.layout.two": "2 sharing: %s",
|
||||
"horizon.splitscreen.setup.layout.three": "3 sharing: %s",
|
||||
"horizon.splitscreen.setup.layout.two.0": "Auto",
|
||||
"horizon.splitscreen.setup.layout.two.1": "Side by side",
|
||||
"horizon.splitscreen.setup.layout.two.2": "Top and bottom",
|
||||
"horizon.splitscreen.setup.layout.three.0": "One wide on top",
|
||||
"horizon.splitscreen.setup.layout.three.1": "One tall on the left",
|
||||
"horizon.splitscreen.setup.layout.three.2": "Side by side",
|
||||
"horizon.splitscreen.setup.help.player": "Click or drag %s to move them.",
|
||||
"horizon.splitscreen.setup.help.layout_auto": "%s (side by side when the window is wide). Press to change.",
|
||||
"horizon.splitscreen.setup.help.layout": "%s. Press to change.",
|
||||
"horizon.splitscreen.setup.help.switch": "Splitscreen for this computer, on or off.",
|
||||
"horizon.splitscreen.setup.help.identify": "Each player's view shows which window it is in.",
|
||||
"horizon.splitscreen.setup.help.names": "Each player's name in the corner of their view.",
|
||||
"horizon.splitscreen.setup.help.revert": "Undo the changes not applied yet.",
|
||||
"horizon.splitscreen.setup.help.apply": "Play the way the map shows.",
|
||||
"horizon.splitscreen.setup.help.unchanged": "Nothing changed yet.",
|
||||
"Culling": "Culling",
|
||||
"Hides models behind opaque blocks. Async checks off-thread; entities tick.": "Hides models behind opaque blocks. Async checks off-thread; entities tick.",
|
||||
"Leaf Culling": "Leaf Culling",
|
||||
|
||||
@@ -289,6 +289,60 @@
|
||||
"horizon.multiplayer.invalid_address": "Adresse de serveur invalide : %s",
|
||||
"horizon.multiplayer.connected_friend_direct": "Connecté directement à votre ami...",
|
||||
"horizon.multiplayer.connected_friend_relay": "Connecté à votre ami via le relais...",
|
||||
"horizon.splitscreen.setup.monitor": "Écran %s",
|
||||
"horizon.splitscreen.setup.monitor_unknown": "un écran",
|
||||
"horizon.splitscreen.setup.window": "Fenêtre %s",
|
||||
"horizon.splitscreen.setup.new_window": "Nouvelle fenêtre",
|
||||
"horizon.splitscreen.setup.fill_monitor": "Remplir %s",
|
||||
"horizon.splitscreen.setup.done": "Terminé",
|
||||
"horizon.splitscreen.setup.switch": "%s : %s",
|
||||
"horizon.splitscreen.setup.on": "Activé",
|
||||
"horizon.splitscreen.setup.off": "Désactivé",
|
||||
"horizon.splitscreen.setup.player": "Joueur %s",
|
||||
"horizon.splitscreen.setup.revert": "Rétablir",
|
||||
"horizon.splitscreen.setup.apply": "Appliquer",
|
||||
"horizon.splitscreen.setup.warning.empty_main": "La fenêtre 1 se masque si personne n'y joue.",
|
||||
"horizon.splitscreen.setup.warning.empty_close": "Personne ne joue dans la fenêtre %s : elle se fermera après application.",
|
||||
"horizon.splitscreen.setup.warning.empty_new": "Personne ne joue dans la fenêtre %s : elle ne s'ouvrira pas.",
|
||||
"horizon.splitscreen.setup.warning.on_main": "La fenêtre %s occupe l'écran de la fenêtre 1 : ses joueurs restent dans la fenêtre 1.",
|
||||
"horizon.splitscreen.setup.warning.unplugged": "L'écran de la fenêtre %s est débranché : ses joueurs restent dans la fenêtre 1.",
|
||||
"horizon.splitscreen.setup.status.off": "L'écran partagé est désactivé.",
|
||||
"horizon.splitscreen.setup.status.enable_hint": "Activez-le, puis appuyez sur un bouton d'une autre manette dans un monde.",
|
||||
"horizon.splitscreen.setup.status.join_hint": "Appuyez sur A pour rejoindre et choisir un profil.",
|
||||
"horizon.splitscreen.setup.status.idle": "Écran partagé en attente",
|
||||
"horizon.splitscreen.setup.status.alone": "Aucun autre joueur local.",
|
||||
"horizon.splitscreen.setup.status.solo_controls": "Les commandes et les vibrations en solo restent inchangées.",
|
||||
"horizon.splitscreen.setup.window_empty": "Fenêtre %s : vide",
|
||||
"horizon.splitscreen.setup.note.select": "Cliquez sur un joueur, ou faites-le glisser vers une fenêtre ou un écran.",
|
||||
"horizon.splitscreen.setup.note.apply": "Appliquez les modifications ou annulez-les.",
|
||||
"horizon.splitscreen.setup.note.place": "Où %s doit-il jouer ?",
|
||||
"horizon.splitscreen.setup.this_player": "ce joueur",
|
||||
"horizon.splitscreen.setup.switch_disabled": "L'écran partagé peut être désactivé après le départ des autres joueurs depuis leur menu pause.",
|
||||
"horizon.pages.split_setup": "Configuration de l'écran partagé",
|
||||
"horizon.pages.split_setup.tooltip": "Les fenêtres, les écrans et la place de chaque joueur.",
|
||||
"horizon.pages.open.split_setup": "Écran partagé...",
|
||||
"horizon.pages.split_settings": "Options de l'écran partagé",
|
||||
"horizon.extras.splitscreen": "Écran partagé",
|
||||
"horizon.splitscreen.setup.window_place": "Fenêtre %s (%s sur %s)",
|
||||
"horizon.splitscreen.setup.names": "Noms",
|
||||
"horizon.splitscreen.setup.identify": "Identifier",
|
||||
"horizon.splitscreen.setup.layout.two": "2 par fenêtre : %s",
|
||||
"horizon.splitscreen.setup.layout.three": "3 par fenêtre : %s",
|
||||
"horizon.splitscreen.setup.layout.two.0": "Auto",
|
||||
"horizon.splitscreen.setup.layout.two.1": "Côte à côte",
|
||||
"horizon.splitscreen.setup.layout.two.2": "Haut et bas",
|
||||
"horizon.splitscreen.setup.layout.three.0": "Un large en haut",
|
||||
"horizon.splitscreen.setup.layout.three.1": "Un haut à gauche",
|
||||
"horizon.splitscreen.setup.layout.three.2": "Côte à côte",
|
||||
"horizon.splitscreen.setup.help.player": "Cliquez sur %s ou faites-le glisser pour le déplacer.",
|
||||
"horizon.splitscreen.setup.help.layout_auto": "%s (côte à côte si la fenêtre est large). Appuyez pour changer.",
|
||||
"horizon.splitscreen.setup.help.layout": "%s. Appuyez pour changer.",
|
||||
"horizon.splitscreen.setup.help.switch": "L'écran partagé sur cet ordinateur, activé ou désactivé.",
|
||||
"horizon.splitscreen.setup.help.identify": "La vue de chaque joueur indique sa fenêtre.",
|
||||
"horizon.splitscreen.setup.help.names": "Le nom de chaque joueur dans le coin de sa vue.",
|
||||
"horizon.splitscreen.setup.help.revert": "Annuler les modifications pas encore appliquées.",
|
||||
"horizon.splitscreen.setup.help.apply": "Jouer comme le montre la carte.",
|
||||
"horizon.splitscreen.setup.help.unchanged": "Rien n'a encore changé.",
|
||||
"Culling": "Occlusion",
|
||||
"Hides models behind opaque blocks. Async checks off-thread; entities tick.": "Masque les modèles derrière les blocs opaques. Vérification en arrière-plan ; les entités continuent.",
|
||||
"Leaf Culling": "Occlusion feuillage",
|
||||
@@ -329,12 +383,7 @@
|
||||
"horizon.extras.header.sound": "Son",
|
||||
"horizon.extras.hide_block_particles": "Masquer particules",
|
||||
"horizon.extras.hide_block_particles.tooltip": "Masque les débris qui jaillissent d'un bloc cassé et s'effritent de celui que vous minez. Les autres particules restent.",
|
||||
"horizon.extras.splitscreen": "Écran partagé",
|
||||
"horizon.extras.splitscreen.tooltip": "Dans un monde, une autre manette peut rejoindre en tant que nouveau joueur : appuyez sur A quand c'est demandé, puis choisissez un profil et où jouer.",
|
||||
"horizon.extras.ui_sound_reserve": "Canaux pour sons UI",
|
||||
"horizon.extras.ui_sound_reserve.tooltip": "Les sons du monde laissent 16 des 247 canaux sonores libres, pour qu'un déluge de sons (une grosse explosion) ne couvre pas les clics de menu.",
|
||||
"horizon.pages.open.split_setup": "Écran partagé...",
|
||||
"horizon.pages.split_settings": "Options de l'écran partagé",
|
||||
"horizon.pages.split_setup": "Écran partagé",
|
||||
"horizon.pages.split_setup.tooltip": "Joueurs sur cet ordinateur : des manettes peuvent rejoindre votre monde."
|
||||
"horizon.extras.ui_sound_reserve.tooltip": "Les sons du monde laissent 16 des 247 canaux sonores libres, pour qu'un déluge de sons (une grosse explosion) ne couvre pas les clics de menu."
|
||||
}
|
||||
@@ -24,7 +24,15 @@ fn main() {
|
||||
resource.set_manifest_file(&manifest);
|
||||
}
|
||||
if let Err(e) = resource.compile() {
|
||||
panic!("embedding Windows resources: {e}");
|
||||
// The GNU toolchain's `windres` (the launcher's mod builds use it) can't take a path
|
||||
// with a space, and a Windows user name often has one: such a build goes on without
|
||||
// the icon. Release builds run in CI, where the icon stays required.
|
||||
if std::env::var_os("CI").is_some() {
|
||||
panic!("embedding Windows resources: {e}");
|
||||
}
|
||||
println!(
|
||||
"cargo:warning=building without Horizon's icon: embedding Windows resources failed: {e}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,356 @@
|
||||
//! Generation and light throughput of the game's worker pool, and how it
|
||||
//! scales with the number of workers: the columns around a point go through
|
||||
//! the whole pipeline (proto chunk, decoration, final column, light) as when a
|
||||
//! world loads, once per worker count, each on a freshly seeded generator.
|
||||
//!
|
||||
//! 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]
|
||||
//!
|
||||
//! 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
|
||||
//! column, the coordinator's (`Streaming::update`) share, and the workers' CPU
|
||||
//! time by job kind (`streaming::job_stats`). The shared worker pool is sized
|
||||
//! once per process (`HORIZON_JOB_THREADS`), so each worker count runs in a
|
||||
//! process of its own, started by this one. The checksum
|
||||
//! 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.
|
||||
//!
|
||||
//! `--simulate PROTO,DECORATE,FINISH` (milliseconds) swaps the generator for
|
||||
//! one whose jobs sleep that long and make empty columns: what is left is the
|
||||
//! pipeline itself (the coordinator, the in-flight limits, decoration order,
|
||||
//! neighbour waits). Sleeping workers don't compete for cores, so this shows
|
||||
//! how the pipeline scales to many workers even on a busy machine.
|
||||
extern crate horizon_client_lib as horizon_client;
|
||||
use horizon_client::{
|
||||
assets::Assets,
|
||||
biome::Biomes,
|
||||
blocks::BlockFacts,
|
||||
chunk::Chunk,
|
||||
light::LightTables,
|
||||
mesher::MeshTables,
|
||||
models,
|
||||
spans::thread_cpu,
|
||||
streaming::{ChunkGenerator, Proto, Streaming, WorkerContext, job_stats},
|
||||
vanillagen::VanillaTerrain,
|
||||
world::World,
|
||||
};
|
||||
use horizon_world::registry::BlockStateId;
|
||||
use std::{
|
||||
hash::{DefaultHasher, Hash, Hasher},
|
||||
path::PathBuf,
|
||||
process::Command,
|
||||
sync::Arc,
|
||||
thread,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
type Error = Box<dyn std::error::Error>;
|
||||
|
||||
fn flag<T: std::str::FromStr>(args: &[String], name: &str, default: T) -> T {
|
||||
args.iter()
|
||||
.position(|a| a == name)
|
||||
.and_then(|i| args.get(i + 1)?.parse().ok())
|
||||
.unwrap_or(default)
|
||||
}
|
||||
|
||||
fn process_cpu() -> Duration {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
let t = rustix::time::clock_gettime(rustix::time::ClockId::ProcessCPUTime);
|
||||
Duration::new(t.tv_sec as u64, t.tv_nsec as u32)
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
Duration::ZERO
|
||||
}
|
||||
|
||||
/// Jobs that take a fixed time and make empty columns (`--simulate`).
|
||||
struct Simulated {
|
||||
proto: Duration,
|
||||
decorate: Duration,
|
||||
finish: Duration,
|
||||
}
|
||||
impl ChunkGenerator for Simulated {
|
||||
type Worker = ();
|
||||
fn worker(&self) {}
|
||||
fn proto(&self, _worker: &mut (), x: i32, z: i32) -> Proto {
|
||||
thread::sleep(self.proto);
|
||||
Box::new((x, z))
|
||||
}
|
||||
fn decorates(&self) -> bool {
|
||||
true
|
||||
}
|
||||
fn decorate(&self, _worker: &mut (), _region: &mut [Proto; 9]) {
|
||||
thread::sleep(self.decorate);
|
||||
}
|
||||
fn finish(&self, proto: Proto) -> Chunk {
|
||||
thread::sleep(self.finish);
|
||||
let (x, z) = *proto.downcast::<(i32, i32)>().expect("simulated proto");
|
||||
Chunk::empty(x, z)
|
||||
}
|
||||
fn light_view(&self, proto: &Proto) -> Option<Chunk> {
|
||||
let &(x, z) = proto.downcast_ref::<(i32, i32)>()?;
|
||||
Some(Chunk::empty(x, z))
|
||||
}
|
||||
}
|
||||
|
||||
struct Run {
|
||||
wall: f64,
|
||||
columns: usize,
|
||||
lit: usize,
|
||||
cpu: f64,
|
||||
coordinator: f64,
|
||||
updates: usize,
|
||||
jobs: Vec<(&'static str, u64, u64)>,
|
||||
checksum: u64,
|
||||
half: f64,
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn run<G: ChunkGenerator>(
|
||||
generator: G,
|
||||
context: &Arc<WorkerContext>,
|
||||
center: (i32, i32),
|
||||
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)))
|
||||
// The columns Streaming finishes (`Ranges::in_main_range` with a
|
||||
// margin of 1). `in_view_distance` reaches a few columns past them.
|
||||
.filter(|&(x, z)| {
|
||||
let (dx, dz) = (i64::from(x - center.0), i64::from(z - center.1));
|
||||
dx * dx + dz * dz <= i64::from(radius + 1).pow(2)
|
||||
})
|
||||
.collect();
|
||||
job_stats::enable(true);
|
||||
job_stats::take();
|
||||
let (cpu0, started) = (process_cpu(), Instant::now());
|
||||
let mut coordinator = Duration::ZERO;
|
||||
let mut updates = 0;
|
||||
let mut half = None;
|
||||
let trace = std::env::var_os("GEN_LIGHT_TRACE").is_some();
|
||||
let mut next_trace = Duration::from_secs(2);
|
||||
loop {
|
||||
let t = thread_cpu().unwrap_or_default();
|
||||
let _ = streaming.update(&mut world);
|
||||
coordinator += thread_cpu().unwrap_or_default().saturating_sub(t);
|
||||
updates += 1;
|
||||
let lit = view
|
||||
.iter()
|
||||
.filter(|k| world.chunks.get(k).is_some_and(|c| c.lit))
|
||||
.count();
|
||||
if half.is_none() && lit * 2 >= view.len() {
|
||||
half = Some(started.elapsed().as_secs_f64());
|
||||
}
|
||||
if lit == view.len() {
|
||||
break;
|
||||
}
|
||||
if trace && started.elapsed() >= next_trace {
|
||||
next_trace += Duration::from_secs(2);
|
||||
let missing: Vec<_> = view
|
||||
.iter()
|
||||
.filter(|k| !world.chunks.get(k).is_some_and(|c| c.lit))
|
||||
.take(4)
|
||||
.collect();
|
||||
eprintln!(
|
||||
" {:.0} s: {lit}/{} lit, {} columns, {:?}, waiting e.g. {missing:?}",
|
||||
started.elapsed().as_secs_f64(),
|
||||
view.len(),
|
||||
world.chunks.len(),
|
||||
streaming.stages()
|
||||
);
|
||||
}
|
||||
if started.elapsed() > Duration::from_secs(1800) {
|
||||
return Err("timed out".into());
|
||||
}
|
||||
streaming.wait(period);
|
||||
}
|
||||
let wall = started.elapsed().as_secs_f64();
|
||||
let cpu = (process_cpu() - cpu0).as_secs_f64();
|
||||
let jobs = job_stats::take();
|
||||
job_stats::enable(false);
|
||||
let mut hash = DefaultHasher::new();
|
||||
let mut ids = Box::new([0u32; 4096]);
|
||||
for key in &view {
|
||||
let chunk = &world.chunks[key];
|
||||
key.hash(&mut hash);
|
||||
for section in &chunk.sections {
|
||||
section.unpack(&mut ids);
|
||||
ids.hash(&mut hash);
|
||||
(0..4096)
|
||||
.map(|i| section.light.get(i))
|
||||
.for_each(|v| v.hash(&mut hash));
|
||||
}
|
||||
}
|
||||
let lit = world.chunks.values().filter(|c| c.lit).count();
|
||||
Ok(Run {
|
||||
wall,
|
||||
columns: world.chunks.len(),
|
||||
lit,
|
||||
cpu,
|
||||
coordinator: coordinator.as_secs_f64(),
|
||||
updates,
|
||||
jobs,
|
||||
checksum: hash.finish(),
|
||||
half: half.unwrap_or(wall),
|
||||
})
|
||||
}
|
||||
|
||||
fn main() -> Result<(), Error> {
|
||||
let args: Vec<String> = std::env::args().collect();
|
||||
let root = PathBuf::from(args.get(1).ok_or("usage: gen_light_bench <resources>")?);
|
||||
let seed: i64 = flag(&args, "--seed", 1234);
|
||||
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()
|
||||
.position(|a| a == "--simulate")
|
||||
.map(|i| -> Result<Vec<f64>, Error> {
|
||||
let ms: Vec<f64> = args
|
||||
.get(i + 1)
|
||||
.ok_or("--simulate PROTO,DECORATE,FINISH")?
|
||||
.split(',')
|
||||
.map(str::parse)
|
||||
.collect::<Result<_, _>>()?;
|
||||
(ms.len() == 3)
|
||||
.then_some(ms)
|
||||
.ok_or_else(|| "--simulate PROTO,DECORATE,FINISH".into())
|
||||
})
|
||||
.transpose()?;
|
||||
let threads: Vec<usize> = flag(&args, "--threads", String::from("1,2,4,8"))
|
||||
.split(',')
|
||||
.map(str::parse)
|
||||
.collect::<Result<_, _>>()?;
|
||||
let center = args
|
||||
.iter()
|
||||
.position(|a| a == "--center")
|
||||
.map(|i| -> Result<(i32, i32), Error> {
|
||||
Ok((
|
||||
args.get(i + 1).ok_or("--center CX CZ")?.parse()?,
|
||||
args.get(i + 2).ok_or("--center CX CZ")?.parse()?,
|
||||
))
|
||||
})
|
||||
.transpose()?
|
||||
.unwrap_or((-60, 10));
|
||||
// The pool is made once per process with its size: one process per
|
||||
// worker count and repeat, each given the same flags but one count.
|
||||
if threads.len() > 1 || repeat > 1 {
|
||||
let exe = std::env::current_exe()?;
|
||||
let mut rest = Vec::new();
|
||||
let mut i = 1;
|
||||
while i < args.len() {
|
||||
if args[i] == "--threads" || args[i] == "--repeat" {
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
rest.push(args[i].clone());
|
||||
i += 1;
|
||||
}
|
||||
for _ in 0..repeat.max(1) {
|
||||
for &n in &threads {
|
||||
let status = Command::new(&exe)
|
||||
.args(&rest)
|
||||
.args(["--threads", &n.to_string()])
|
||||
.env("HORIZON_JOB_THREADS", n.to_string())
|
||||
.status()?;
|
||||
if !status.success() {
|
||||
return Err(format!("the run with {n} workers failed: {status}").into());
|
||||
}
|
||||
}
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
let n = threads[0];
|
||||
horizon_threads::jobs::configure(n);
|
||||
|
||||
let assets = Arc::new(Assets::load(&root)?);
|
||||
let render = Arc::new(assets.reload(&root)?);
|
||||
let facts = BlockFacts::load(&root, assets.registry)?;
|
||||
let visibility = models::load_visibility(&root, assets.registry)?;
|
||||
let biomes = Arc::new(Biomes::load(&root)?);
|
||||
let context = Arc::new(WorkerContext {
|
||||
tables: Arc::new(MeshTables::new(&assets, &render, &facts, visibility)?),
|
||||
light: Arc::new(LightTables::load(&root, assets.registry, &facts)?),
|
||||
biomes: biomes.clone(),
|
||||
assets: assets.clone(),
|
||||
render,
|
||||
});
|
||||
let terrain = || VanillaTerrain::new(&root.join("worldgen"), assets.registry, &biomes, seed);
|
||||
let go = |center, radius, n, loading| -> Result<Run, Error> {
|
||||
match &simulate {
|
||||
Some(ms) => {
|
||||
let ms = |i: usize| Duration::from_secs_f64(ms[i] / 1e3);
|
||||
let generator = Simulated {
|
||||
proto: ms(0),
|
||||
decorate: ms(1),
|
||||
finish: ms(2),
|
||||
};
|
||||
run(generator, &context, center, radius, n, period, loading)
|
||||
}
|
||||
None => run(terrain()?, &context, center, radius, n, period, loading),
|
||||
}
|
||||
};
|
||||
// Warm the shared generator data and the code away from the measured area.
|
||||
go((center.0 + 4000, center.1), 2, n, false)?;
|
||||
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 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, \
|
||||
{:.1} ms CPU/column, coordinator {:.2} s CPU in {} updates, workers busy {:.0}%, checksum {:016x}",
|
||||
r.wall,
|
||||
r.half,
|
||||
r.columns,
|
||||
r.lit,
|
||||
r.columns as f64 / r.wall,
|
||||
r.lit as f64 / r.wall,
|
||||
r.cpu * 1e3 / r.columns.max(1) as f64,
|
||||
r.coordinator,
|
||||
r.updates,
|
||||
busy * 100.0 / (r.wall * n as f64),
|
||||
r.checksum,
|
||||
);
|
||||
let line: Vec<String> = r
|
||||
.jobs
|
||||
.iter()
|
||||
.filter(|j| j.2 > 0)
|
||||
.map(|(kind, ns, count)| {
|
||||
format!(
|
||||
"{kind} {count} x {:.2} ms = {:.2} s",
|
||||
*ns as f64 / 1e6 / *count as f64,
|
||||
*ns as f64 / 1e9
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
println!(" {}", line.join(", "));
|
||||
if json {
|
||||
println!(
|
||||
"BENCH {}",
|
||||
serde_json::json!({
|
||||
"workers": n, "wall_s": r.wall, "half_s": r.half, "columns": r.columns,
|
||||
"lit": r.lit, "cpu_s": r.cpu, "coordinator_s": r.coordinator,
|
||||
"updates": r.updates, "checksum": format!("{:016x}", r.checksum),
|
||||
"jobs": r.jobs.iter().map(|(k, ns, c)| serde_json::json!({"kind": k, "s": *ns as f64 / 1e9, "count": c})).collect::<Vec<_>>(),
|
||||
})
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -116,6 +116,39 @@ impl Control {
|
||||
}
|
||||
}
|
||||
|
||||
/// The wait before a scene's flight, spent timing Horizon's own render passes on the GPU:
|
||||
/// `gpu_pass.<scene>.<pass>_ms`, where a slow GPU spends its frame. The timing is off again for
|
||||
/// the flight, whose frames it would slow. False, having waited for nothing, on Metal: its passes
|
||||
/// overlap on the GPU, so their times add up to more than the frame.
|
||||
fn time_passes(control: &Control, scene: &str, report: &mut Report) -> Result<bool, String> {
|
||||
if report
|
||||
.hardware
|
||||
.gpu
|
||||
.as_ref()
|
||||
.is_some_and(|g| g.backend == "metal")
|
||||
{
|
||||
return Ok(false);
|
||||
}
|
||||
control.call("gpu-passes on")?;
|
||||
let held = control.call(&format!("bench {}", SETTLE.as_secs_f64()));
|
||||
control.call("gpu-passes off")?;
|
||||
for (pass, ms) in held?["gpu_passes"].as_object().into_iter().flatten() {
|
||||
let pass: String = pass
|
||||
.trim()
|
||||
.chars()
|
||||
.map(|c| match c {
|
||||
'a'..='z' | '0'..='9' => c,
|
||||
'A'..='Z' => c.to_ascii_lowercase(),
|
||||
_ => '_',
|
||||
})
|
||||
.collect();
|
||||
if let Some(ms) = ms.as_f64() {
|
||||
report.metric(&format!("gpu_pass.{scene}.{pass}_ms"), &[ms]);
|
||||
}
|
||||
}
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
fn run(control: &Control, setup: Setup) -> Result<PathBuf, String> {
|
||||
let scenes =
|
||||
scenes(&setup.scenario).ok_or_else(|| format!("unknown scenario {}", setup.scenario))?;
|
||||
@@ -204,7 +237,9 @@ fn run(control: &Control, setup: Setup) -> Result<PathBuf, String> {
|
||||
&[chunks / load.as_secs_f64()],
|
||||
);
|
||||
}
|
||||
thread::sleep(SETTLE);
|
||||
if !time_passes(control, name, &mut report)? {
|
||||
thread::sleep(SETTLE);
|
||||
}
|
||||
let [ex, ey, ez] = end;
|
||||
let begin = Instant::now();
|
||||
let flight = control.call(&format!(
|
||||
@@ -499,6 +534,60 @@ mod tests {
|
||||
let _ = server.join();
|
||||
}
|
||||
|
||||
/// The wait before a flight times the passes and turns the timing off again before the
|
||||
/// flight; the report gets one metric per pass. Not on Metal.
|
||||
#[test]
|
||||
fn the_wait_times_the_passes_but_not_the_flight() {
|
||||
let time_on = |backend: &str| {
|
||||
let (tx, rx) = mpsc::channel::<Request>();
|
||||
let server = thread::spawn(move || {
|
||||
let mut lines = Vec::new();
|
||||
while let Ok(request) = rx.recv() {
|
||||
let reply = if request.line.starts_with("bench ") {
|
||||
serde_json::json!({
|
||||
"frames": 120,
|
||||
"gpu_passes": {"World": 1.5, "World after culling": 0.25, "HUD": 0.01},
|
||||
})
|
||||
} else {
|
||||
serde_json::json!({ "ok": true })
|
||||
};
|
||||
let _ = request.reply.send(reply.to_string());
|
||||
lines.push(request.line);
|
||||
}
|
||||
lines
|
||||
});
|
||||
let hardware = horizon_bench::Hardware {
|
||||
gpu: Some(horizon_bench::Gpu {
|
||||
backend: backend.into(),
|
||||
..Default::default()
|
||||
}),
|
||||
..Default::default()
|
||||
};
|
||||
let mut report = Report::with_hardware(Kind::Game, App::default(), hardware);
|
||||
let control = Control(tx);
|
||||
let timed = time_passes(&control, "forest", &mut report).expect("the wait runs");
|
||||
drop(control);
|
||||
(timed, server.join().unwrap(), report.metrics)
|
||||
};
|
||||
|
||||
let (timed, lines, metrics) = time_on("vulkan");
|
||||
assert!(timed);
|
||||
assert_eq!(lines, ["gpu-passes on", "bench 2", "gpu-passes off"]);
|
||||
let mean = |name: &str| metrics.get(name).map(|s| s.mean);
|
||||
assert_eq!(mean("gpu_pass.forest.world_ms"), Some(1.5));
|
||||
assert_eq!(mean("gpu_pass.forest.world_after_culling_ms"), Some(0.25));
|
||||
assert_eq!(mean("gpu_pass.forest.hud_ms"), Some(0.01));
|
||||
assert_eq!(metrics.len(), 3);
|
||||
|
||||
let (timed, lines, metrics) = time_on("metal");
|
||||
assert!(!timed);
|
||||
assert!(
|
||||
lines.is_empty(),
|
||||
"Metal's overlapping passes are not timed: {lines:?}"
|
||||
);
|
||||
assert!(metrics.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn yaw_matches_vanilla() {
|
||||
// South (+Z) is 0, west (-X) 90, east (+X) -90.
|
||||
|
||||
@@ -217,6 +217,8 @@ pub const COMMANDS: &[Cmd] = &[
|
||||
cmd!("hello", Session, "hello", "the control protocol's version, the game's release and the command groups it serves",
|
||||
"Replies protocol (MAJOR.MINOR.PATCH of the control protocol, docs/api/stability.md), release (the game's version, the string MCP serverInfo carries too) and features (the command groups the game answers, so a script can test for one before calling it).",
|
||||
["horizon-ctl hello"], &[], mcp: true, local: false),
|
||||
cmd!("memory", Session, "memory", "what holds the game's memory: resident set, allocator, client and server columns by part, renderer, GPU memory by resource",
|
||||
"Columns count blocks, biomes, light, heightmaps and records, each shared section once; server_columns are the single-player server thread's own levels. GPU memory by label needs Vulkan or DirectX 12. What the census doesn't reach is the resident set less its parts.", ["horizon-ctl memory"], &[], mcp: true, local: false),
|
||||
cmd!("worlds", Session, "worlds", "the saved worlds, newest first: folder, name, mode, last played, version",
|
||||
"folder is what open, world-rename, world-backup and world-delete take.", ["horizon-ctl worlds"], &[], mcp: true, local: false),
|
||||
cmd!("world-rename", Session, "world-rename FOLDER NAME", "rename a world, as Edit World does (its folder stays)",
|
||||
|
||||
@@ -36,7 +36,7 @@ pub fn native_living_breathe(
|
||||
consume: &mut i32,
|
||||
refill: &mut i32,
|
||||
) {
|
||||
let _ = (entity, kind, can_breathe, consume, refill);
|
||||
crate::native_client_hooks::living_breathe(entity, kind, can_breathe, consume, refill);
|
||||
}
|
||||
|
||||
/// `vanilla`'s breathing after the mods have had their say.
|
||||
|
||||
@@ -18,9 +18,9 @@ pub use horizon_client_render::{
|
||||
native_emissive, options_file, options_fixes, options_ui, overlay, pack_screen, packs, par,
|
||||
particles, poi_sections, post_chain, potent_sulfur, preedit, providers, screen_draw,
|
||||
screen_effects, sculk, section_mask, settings, shader_options, shader_pack_list, signs, skins,
|
||||
sound_engine, sound_events, sound_preview, spans, split_layout, split_profiles, sprites,
|
||||
streaming, structure_block, tab_list, telemetry, test_block, test_instance_block, testgen,
|
||||
title_edition, trims, ui, vault, weather, widgets, world, worldborder, zip,
|
||||
sound_engine, sound_events, sound_preview, spans, split_layout, split_map, split_profiles,
|
||||
sprites, streaming, structure_block, tab_list, telemetry, test_block, test_instance_block,
|
||||
testgen, title_edition, trims, ui, vault, weather, widgets, world, worldborder, zip,
|
||||
};
|
||||
pub mod attack_strength;
|
||||
pub mod attribute_updates;
|
||||
@@ -89,6 +89,7 @@ pub mod mod_content;
|
||||
pub mod motion;
|
||||
pub mod multiplayer;
|
||||
pub mod music;
|
||||
pub mod native_client_hooks;
|
||||
pub mod native_data;
|
||||
pub mod net;
|
||||
pub mod nether_portal;
|
||||
|
||||
@@ -33,7 +33,7 @@ pub fn native_ingredient(
|
||||
data: &serde_json::Map<String, Value>,
|
||||
items: &mut Vec<String>,
|
||||
) {
|
||||
let _ = (kind, data, items);
|
||||
crate::native_client_hooks::ingredient(kind, data, items);
|
||||
}
|
||||
|
||||
/// Keep first-seen order, drop the rest.
|
||||
|
||||
@@ -3725,6 +3725,10 @@ pub fn apply_item_components(m: &mut Mob, stack: &crate::items::ItemStack) {
|
||||
),
|
||||
// ZombieNautilus.applyImplicitComponents: the variant only.
|
||||
crate::mobs::Species::ZombieNautilus => (Some("minecraft:zombie_nautilus/variant"), None),
|
||||
// Fox and Frog.applyImplicitComponent: `setVariant` from the component
|
||||
// alone, so a spawn egg carrying one hatches that variant.
|
||||
crate::mobs::Species::Fox => (Some("minecraft:fox/variant"), None),
|
||||
crate::mobs::Species::Frog => (Some("minecraft:frog/variant"), None),
|
||||
_ => (None, None),
|
||||
};
|
||||
if let Some(name) = variant.and_then(|id| text(components.other(id))) {
|
||||
@@ -4581,6 +4585,49 @@ mod item_component_tests {
|
||||
assert_eq!(nautilus.extra.variant, 1, "minecraft:warm, not temperate");
|
||||
}
|
||||
|
||||
/// `Fox.applyImplicitComponent` and `Frog.applyImplicitComponent`: the
|
||||
/// variant component alone decides the mob, `setVariant` from the egg.
|
||||
#[test]
|
||||
fn a_fox_or_frog_spawn_egg_picks_its_variant_component() {
|
||||
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../../.local/horizon-client")
|
||||
.join(horizon_core::VANILLA_VERSION);
|
||||
if !root.join("items.json").exists() {
|
||||
return;
|
||||
}
|
||||
let data = crate::mobs::MobTypes::load(&root).unwrap();
|
||||
let spawn = |kind: &str, id: u32| {
|
||||
Mob::new(
|
||||
id,
|
||||
Arc::new(data.types[kind].clone()),
|
||||
Vec3::new(0.0, 4.0, 0.0),
|
||||
0.0,
|
||||
)
|
||||
};
|
||||
// A red fox, as a spawn in a forest leaves it.
|
||||
let mut fox = spawn("minecraft:fox", 1);
|
||||
assert_eq!(fox.extra.variant, 0);
|
||||
let mut egg = crate::items::ItemStack::new("minecraft:fox_spawn_egg", 1);
|
||||
egg.data_mut()
|
||||
.set_other("minecraft:fox/variant", Some(string("minecraft:snow")));
|
||||
apply_item_components(&mut fox, &egg);
|
||||
assert_eq!(fox.extra.variant, 1, "Fox.Variant.SNOW, not red");
|
||||
// A temperate frog, as a swamp spawn leaves it.
|
||||
let mut frog = spawn("minecraft:frog", 2);
|
||||
assert_eq!(frog.extra.variant, 0);
|
||||
let mut egg = crate::items::ItemStack::new("minecraft:frog_spawn_egg", 1);
|
||||
egg.data_mut()
|
||||
.set_other("minecraft:frog/variant", Some(string("minecraft:warm")));
|
||||
apply_item_components(&mut frog, &egg);
|
||||
assert_eq!(frog.extra.variant, 1, "minecraft:warm, not temperate");
|
||||
// An unknown name falls back to the codec's default, red.
|
||||
let mut egg = crate::items::ItemStack::new("minecraft:fox_spawn_egg", 1);
|
||||
egg.data_mut()
|
||||
.set_other("minecraft:fox/variant", Some(string("minecraft:temperate")));
|
||||
apply_item_components(&mut fox, &egg);
|
||||
assert_eq!(fox.extra.variant, 0, "unknown names read as red");
|
||||
}
|
||||
|
||||
/// `EntityType.updateCustomEntityTag`: the egg's `entity_data` on the mob it
|
||||
/// spawned, when the type is its own — the mob's compound, the data merged
|
||||
/// over it and loaded back, as the spawn egg site does it.
|
||||
|
||||
@@ -74,12 +74,59 @@ pub fn main_with(mods: crate::mod_api::Mods) {
|
||||
components,
|
||||
);
|
||||
},
|
||||
loot_function: |kind, data, ctx, stack| {
|
||||
crate::mod_api::dispatch_loot_function(
|
||||
crate::mod_api::mods(),
|
||||
kind,
|
||||
data,
|
||||
ctx,
|
||||
stack,
|
||||
);
|
||||
},
|
||||
loot_condition: |kind, data, ctx, holds| {
|
||||
crate::mod_api::dispatch_loot_condition(
|
||||
crate::mod_api::mods(),
|
||||
kind,
|
||||
data,
|
||||
ctx,
|
||||
holds,
|
||||
);
|
||||
},
|
||||
},
|
||||
)
|
||||
.is_err()
|
||||
{
|
||||
log::warn!("Native data-load hooks already installed; keeping the first callbacks");
|
||||
}
|
||||
if crate::native_client_hooks::install(crate::native_client_hooks::NativeClientHooks {
|
||||
menu_slot_rules: |menu, slot, stack, may_place, max_stack| {
|
||||
crate::mod_api::dispatch_menu_slot_rules(
|
||||
crate::mod_api::mods(),
|
||||
menu,
|
||||
slot,
|
||||
stack,
|
||||
may_place,
|
||||
max_stack,
|
||||
);
|
||||
},
|
||||
living_breathe: |entity, kind, can_breathe, consume, refill| {
|
||||
crate::mod_api::dispatch_living_breathe(
|
||||
crate::mod_api::mods(),
|
||||
entity,
|
||||
kind,
|
||||
can_breathe,
|
||||
consume,
|
||||
refill,
|
||||
);
|
||||
},
|
||||
ingredient: |kind, data, items| {
|
||||
crate::mod_api::dispatch_ingredient(crate::mod_api::mods(), kind, data, items);
|
||||
},
|
||||
})
|
||||
.is_err()
|
||||
{
|
||||
log::warn!("Native client hooks already installed; keeping the first callbacks");
|
||||
}
|
||||
raise_open_file_limit();
|
||||
if let Err(error) = game::run_with() {
|
||||
// A bad command line is the caller's mistake, not a crash: no report.
|
||||
|
||||
@@ -180,6 +180,7 @@ mod loom;
|
||||
mod mace;
|
||||
mod machines;
|
||||
mod maps;
|
||||
mod memory;
|
||||
mod minecarts;
|
||||
mod mob_blocks;
|
||||
mod mob_gear;
|
||||
@@ -386,6 +387,16 @@ use horizon_world::{
|
||||
},
|
||||
registry::BlockStateId,
|
||||
};
|
||||
pub use maps::{NativeMapDecoration, NativeMapInfo};
|
||||
pub use native_block_entity_draw::BlockEntityDraw;
|
||||
pub use native_claimed_items::{NativeHeldItem, NativeHit, NativeModEvent};
|
||||
pub use native_data_maps::NativeDataMapType;
|
||||
pub use native_falling::NativeFalling;
|
||||
pub use native_hooks::{NativeClock, NativeShake};
|
||||
pub use native_mob_draw::{NativeMobRender, NativePlayerRender};
|
||||
pub use native_server::NativeExperienceOrb;
|
||||
pub use native_world_columns::NativeColumnBlocks;
|
||||
pub use native_world_edit::{NativeBlockEdit, NativeBlockRun, NativeHeightmap};
|
||||
use std::{
|
||||
cell::RefCell,
|
||||
collections::{HashMap, HashSet},
|
||||
@@ -410,6 +421,10 @@ use winit::{
|
||||
/// The time a frame spends uploading chunk meshes when the player set no
|
||||
/// Chunk Updates or Upload Budget: a burst past it waits for the next frames.
|
||||
const AUTO_UPLOAD_TIME: Duration = Duration::from_millis(2);
|
||||
/// `AUTO_UPLOAD_TIME` while the view's terrain is still arriving (a world's
|
||||
/// open, a teleport): the meshes waiting then are the view itself, not edits
|
||||
/// to it, so a frame gives them more of its time to fill the view sooner.
|
||||
const AUTO_UPLOAD_TIME_ARRIVING: Duration = Duration::from_millis(6);
|
||||
/// A frame's finished meshes up to this many upload at once, unqueued.
|
||||
const AUTO_UPLOADS_AT_ONCE: usize = 8;
|
||||
|
||||
@@ -840,6 +855,16 @@ struct Hit {
|
||||
face: [i32; 3],
|
||||
point: [f64; 3],
|
||||
}
|
||||
impl Hit {
|
||||
/// The block the clicked face leads to (`pos.relative(direction)`).
|
||||
fn beside(&self) -> [i32; 3] {
|
||||
[
|
||||
self.pos[0] + self.face[0],
|
||||
self.pos[1] + self.face[1],
|
||||
self.pos[2] + self.face[2],
|
||||
]
|
||||
}
|
||||
}
|
||||
/// What a falling block's tick did (`FallingBlockEntity.tick`).
|
||||
enum FallStep {
|
||||
Falling,
|
||||
@@ -946,6 +971,7 @@ struct GameLevel<'w> {
|
||||
client_side: bool,
|
||||
/// Only the integrated or dedicated server dispatches native observations.
|
||||
native_authoritative: bool,
|
||||
native_player: Option<u128>,
|
||||
/// `Level.isDebug`: `setBlock` changes nothing.
|
||||
debug: bool,
|
||||
native_sounds: &'w mut Vec<creatures::NativeSound>,
|
||||
@@ -1153,6 +1179,13 @@ impl GameLevel<'_> {
|
||||
}
|
||||
}
|
||||
impl LevelAccess for GameLevel<'_> {
|
||||
fn allow_hopper_pickup_item(&self, pos: BlockPos, item: &str, count: u32) -> bool {
|
||||
let mut cancel = false;
|
||||
if self.native_authoritative {
|
||||
self.native_hopper_pickup_item(self.dimension, pos, item, count, &mut cancel);
|
||||
}
|
||||
!cancel
|
||||
}
|
||||
fn observe_block_changed(&self, pos: BlockPos, old: BlockStateId, new: BlockStateId) {
|
||||
if self.native_authoritative {
|
||||
self.native_block_changed(self.dimension, pos, old, new);
|
||||
@@ -1185,6 +1218,108 @@ impl LevelAccess for GameLevel<'_> {
|
||||
}
|
||||
!canceled
|
||||
}
|
||||
fn allow_fluid_level_change(
|
||||
&self,
|
||||
pos: BlockPos,
|
||||
fluid: &'static str,
|
||||
from: i32,
|
||||
to: i32,
|
||||
) -> bool {
|
||||
let mut cancel = false;
|
||||
if self.native_authoritative {
|
||||
self.native_fluid_level_change(self.dimension, pos, fluid, from, to, &mut cancel);
|
||||
}
|
||||
!cancel
|
||||
}
|
||||
fn allow_block_ignite(&self, pos: BlockPos, cause: &'static str) -> bool {
|
||||
let mut cancel = false;
|
||||
if self.native_authoritative {
|
||||
self.native_block_ignite(self.dimension, self.native_player, pos, cause, &mut cancel);
|
||||
}
|
||||
!cancel
|
||||
}
|
||||
fn allow_block_burn(&self, pos: BlockPos) -> bool {
|
||||
let mut cancel = false;
|
||||
if self.native_authoritative {
|
||||
self.native_block_burn(self.dimension, pos, &mut cancel);
|
||||
}
|
||||
!cancel
|
||||
}
|
||||
fn allow_leaves_decay(&self, pos: BlockPos) -> bool {
|
||||
let mut cancel = false;
|
||||
if self.native_authoritative {
|
||||
self.native_leaves_decay(self.dimension, pos, &mut cancel);
|
||||
}
|
||||
!cancel
|
||||
}
|
||||
fn allow_moisture_change(&self, pos: BlockPos, from: i32, to: i32) -> bool {
|
||||
let mut cancel = false;
|
||||
if self.native_authoritative {
|
||||
self.native_moisture_change(self.dimension, pos, from, to, &mut cancel);
|
||||
}
|
||||
!cancel
|
||||
}
|
||||
fn observe_sponge_absorb(&self, pos: BlockPos) {
|
||||
if self.native_authoritative {
|
||||
self.native_sponge_absorb(self.dimension, pos);
|
||||
}
|
||||
}
|
||||
fn allow_block_fade(&self, pos: BlockPos, from: BlockStateId, to: BlockStateId) -> bool {
|
||||
let mut cancel = false;
|
||||
if self.native_authoritative {
|
||||
self.native_block_fade(self.dimension, pos, from, to, &mut cancel);
|
||||
}
|
||||
!cancel
|
||||
}
|
||||
fn allow_block_form(&self, pos: BlockPos, from: BlockStateId, to: BlockStateId) -> bool {
|
||||
let mut cancel = false;
|
||||
if self.native_authoritative {
|
||||
self.native_block_form(self.dimension, pos, from, to, &mut cancel);
|
||||
}
|
||||
!cancel
|
||||
}
|
||||
fn allow_note_play(&self, pos: BlockPos, instrument: &str, note: i32) -> bool {
|
||||
let mut cancel = false;
|
||||
if self.native_authoritative {
|
||||
self.native_note_play(
|
||||
self.dimension,
|
||||
self.native_player,
|
||||
pos,
|
||||
instrument,
|
||||
note,
|
||||
&mut cancel,
|
||||
);
|
||||
}
|
||||
!cancel
|
||||
}
|
||||
fn allow_bell_ring(&self, pos: BlockPos) -> bool {
|
||||
let mut cancel = false;
|
||||
if self.native_authoritative {
|
||||
self.native_bell_ring(self.dimension, self.native_player, pos, &mut cancel);
|
||||
}
|
||||
!cancel
|
||||
}
|
||||
fn allow_cauldron_level_change(
|
||||
&self,
|
||||
pos: BlockPos,
|
||||
from: i32,
|
||||
to: i32,
|
||||
reason: &'static str,
|
||||
) -> bool {
|
||||
let mut cancel = false;
|
||||
if self.native_authoritative {
|
||||
self.native_cauldron_level_changed(
|
||||
self.dimension,
|
||||
self.native_player,
|
||||
pos,
|
||||
from,
|
||||
to,
|
||||
reason,
|
||||
&mut cancel,
|
||||
);
|
||||
}
|
||||
!cancel
|
||||
}
|
||||
fn allow_piston_move(
|
||||
&self,
|
||||
piston: BlockPos,
|
||||
@@ -2290,6 +2425,8 @@ struct Game<'a> {
|
||||
regrab_on_refocus: bool,
|
||||
/// Only the local player owning keyboard and mouse changes the native pointer.
|
||||
keyboard_mouse: bool,
|
||||
/// Offscreen rendering into a visible splitscreen window still accepts native input.
|
||||
split_window: bool,
|
||||
/// This view's corner in its shared window; cursor warps include the offset.
|
||||
view_at: [u32; 2],
|
||||
/// Splitscreen: what it draws over this player's view (its badge, questions, notices).
|
||||
@@ -2584,6 +2721,9 @@ struct Game<'a> {
|
||||
server_trips: Vec<server_travel::Trip>,
|
||||
/// Players a command sends to another level, once it has run.
|
||||
level_teleports: Vec<server_travel::LevelTeleport>,
|
||||
/// Teleports a command made inside a level, for `native_player_teleported`
|
||||
/// once the player's move has reached their client (`leave_guest`).
|
||||
teleport_notes: Vec<server_travel::TeleportNote>,
|
||||
/// A server's levels other than the one in place (game/levels.rs).
|
||||
levels: levels::Levels,
|
||||
/// Which pass of a server tick runs (`tick_server`): the first one reads
|
||||
@@ -2649,6 +2789,8 @@ struct Game<'a> {
|
||||
/// Models drawn only once such an entity shows up (lead knots, wind charges...).
|
||||
entity_models: EntityModels,
|
||||
carts: Vec<minecarts::Minecart>,
|
||||
/// The boats and minecarts `native_vehicle_moved` follows (lan_vehicles.rs).
|
||||
watched_vehicles: Vec<lan_vehicles::WatchedVehicle>,
|
||||
/// Songs playing from jukeboxes (tracked sound handles).
|
||||
jukebox_sounds: HashMap<(i32, i32, i32), u64>,
|
||||
bolts: Vec<lightning::Bolt>,
|
||||
@@ -2850,6 +2992,9 @@ struct Game<'a> {
|
||||
/// The world was saved for the application ending (`save_for_exit`).
|
||||
saved_for_exit: bool,
|
||||
pending_save: Option<saving::ClosingSave>,
|
||||
/// The last world frame reached the swap chain. A window the compositor
|
||||
/// stops showing gets no frames, and the event loop must not spin on it.
|
||||
frame_drawn: bool,
|
||||
/// The world list's icon in progress (`GameRenderer.tryTakeScreenshotIfNeeded`):
|
||||
/// `None` without a local world, or once the save has its icon.png.
|
||||
world_icon: Option<WorldIcon>,
|
||||
@@ -2920,6 +3065,9 @@ struct Game<'a> {
|
||||
lan_acting_name: Option<String>,
|
||||
/// Their UUID (`Player.getUUID`).
|
||||
lan_acting_uuid: Option<u128>,
|
||||
/// Where the mods refused to prime TNT: the sound and game event the level
|
||||
/// queued right after the prime are dropped (`prime_tnt_event`).
|
||||
tnt_refused: Option<BlockPos>,
|
||||
/// Their permission level: an op's above 0 (`PlayerList.isOp`).
|
||||
lan_acting_permission: Option<u8>,
|
||||
/// Their entity id, which the hub (often taken out of `lan` while they
|
||||
@@ -3717,6 +3865,8 @@ impl Game<'_> {
|
||||
velocity: [f64; 3],
|
||||
pickup_delay: u32,
|
||||
) {
|
||||
let item = stack.item.clone();
|
||||
let count = stack.count as i32;
|
||||
self.next_entity += 1;
|
||||
let p = Vec3::new(position[0], position[1], position[2]);
|
||||
// ItemEntity.bobOffs comes from the entity's own random, not the level's.
|
||||
@@ -3743,6 +3893,8 @@ impl Game<'_> {
|
||||
// ServerLevel.addFreshEntity.
|
||||
if !self.native_entity_joins(self.next_entity, "minecraft:item", position, false) {
|
||||
self.dropped.pop();
|
||||
} else {
|
||||
self.native_item_spawn(self.next_entity, &item, count, position);
|
||||
}
|
||||
}
|
||||
/// The block entity at `pos` saved with its id and position
|
||||
@@ -3801,8 +3953,7 @@ impl Game<'_> {
|
||||
match world_items!(self).block_loot_sequence(registry, state) {
|
||||
Some(sequence) => {
|
||||
let random = self.rand_sequences.get(&sequence, self.level.seed);
|
||||
self.res
|
||||
.items()
|
||||
world_items!(self)
|
||||
.block_drops_by_player(registry, state, held, &dynamic, random)
|
||||
}
|
||||
None => world_items!(self).block_drops_by_player(
|
||||
@@ -4288,6 +4439,7 @@ impl Game<'_> {
|
||||
lid_openers: &self.lid_openers,
|
||||
client_side: self.remote.is_some(),
|
||||
native_authoritative,
|
||||
native_player: self.lan_acting_uuid,
|
||||
debug: self.world_debug,
|
||||
native_sounds: &mut self.creatures.lan_sounds,
|
||||
native_particles: &mut self.creatures.lan_particles,
|
||||
@@ -4300,6 +4452,22 @@ impl Game<'_> {
|
||||
}
|
||||
result
|
||||
}
|
||||
pub(super) fn native_block_ignite_allowed(
|
||||
&mut self,
|
||||
pos: BlockPos,
|
||||
cause: &'static str,
|
||||
) -> bool {
|
||||
self.with_level(|_, level, _| level.allow_block_ignite(pos, cause))
|
||||
}
|
||||
pub(super) fn native_cauldron_level_change_allowed(
|
||||
&mut self,
|
||||
pos: BlockPos,
|
||||
from: i32,
|
||||
to: i32,
|
||||
reason: &'static str,
|
||||
) -> bool {
|
||||
self.with_level(|_, level, _| level.allow_cauldron_level_change(pos, from, to, reason))
|
||||
}
|
||||
/// Level.setBlock(pos, state, UPDATE_ALL) with neighbour/shape updates.
|
||||
fn level_set(&mut self, pos: [i32; 3], state: BlockStateId) -> Result<()> {
|
||||
self.level_set_with_flags(pos, state, level_flags::UPDATE_ALL)
|
||||
@@ -5019,6 +5187,15 @@ impl Game<'_> {
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
// A prime the mods refused is silent: no fuse sound, no game event.
|
||||
LevelEvent::Sound { pos, ref sound, .. }
|
||||
if self.tnt_refused == Some(pos)
|
||||
&& &**sound == "minecraft:entity.tnt.primed" => {}
|
||||
LevelEvent::GameEvent {
|
||||
pos,
|
||||
event: "minecraft:prime_fuse",
|
||||
..
|
||||
} if self.tnt_refused == Some(pos) => self.tnt_refused = None,
|
||||
LevelEvent::Sound {
|
||||
pos,
|
||||
sound,
|
||||
@@ -5108,7 +5285,7 @@ impl Game<'_> {
|
||||
}
|
||||
PlayerEffect::InteractLoot(_) => {}
|
||||
},
|
||||
LevelEvent::PrimeTnt { pos, rot } => self.add_primed_tnt(pos, rot, 80, None, None),
|
||||
LevelEvent::PrimeTnt { pos, rot } => self.prime_tnt_event(pos, rot),
|
||||
LevelEvent::BlockEvent {
|
||||
pos,
|
||||
state,
|
||||
@@ -5944,7 +6121,22 @@ impl Game<'_> {
|
||||
return;
|
||||
};
|
||||
let group = horizon_client::cooldowns::group(stack, default.as_ref());
|
||||
self.add_cooldown(group, component.ticks());
|
||||
let mut cancel = false;
|
||||
if let (Some(player), Some(name)) = (self.lan_acting_uuid, self.lan_acting_name.clone()) {
|
||||
self.native_player_item_cooldown(
|
||||
player,
|
||||
&name,
|
||||
&stack.item,
|
||||
component.ticks(),
|
||||
&mut cancel,
|
||||
);
|
||||
}
|
||||
if !cancel {
|
||||
self.add_cooldown(group, component.ticks());
|
||||
} else if self.lan_acting.is_some() {
|
||||
// The guest's client started the cooldown itself; 0 ends it there.
|
||||
self.guest_cooldowns.push((group, 0));
|
||||
}
|
||||
}
|
||||
/// `ItemCooldowns.addCooldown` (a guest also gets the packet).
|
||||
pub(super) fn add_cooldown(&mut self, group: Arc<str>, ticks: i32) {
|
||||
@@ -6115,6 +6307,13 @@ impl Game<'_> {
|
||||
self.stats.add("used", &used.item, 1);
|
||||
self.stats.custom("pot_flower", 1);
|
||||
self.consume_held();
|
||||
if let (Some(player), Some(name)) =
|
||||
(self.lan_acting_uuid, self.lan_acting_name.clone())
|
||||
{
|
||||
self.native_player_flower_pot_manipulate(
|
||||
player, &name, hit.pos, &used.item,
|
||||
);
|
||||
}
|
||||
}
|
||||
// NoteBlock.useWithoutItem (tuned) and BellBlock.onHit (rung).
|
||||
if result == horizon_world::level::InteractionResult::Success {
|
||||
@@ -6424,6 +6623,22 @@ impl Game<'_> {
|
||||
}
|
||||
Ok(true)
|
||||
}
|
||||
/// Asks the mods whether the acting guest may empty `stack` at `pos`
|
||||
/// (`native_bucket_empty`): true when one cancelled it. A game's own
|
||||
/// player is not asked.
|
||||
fn bucket_empty_cancelled(&mut self, stack: &ItemStack, pos: [i32; 3]) -> bool {
|
||||
let (Some(uuid), Some(name)) = (self.lan_acting_uuid, self.lan_acting_name.clone()) else {
|
||||
return false;
|
||||
};
|
||||
let fluid = match &*stack.item {
|
||||
"minecraft:lava_bucket" => "minecraft:lava",
|
||||
"minecraft:powder_snow_bucket" => "minecraft:powder_snow",
|
||||
_ => "minecraft:water",
|
||||
};
|
||||
let mut cancel = false;
|
||||
self.native_bucket_empty(uuid, &name, pos, fluid, &mut cancel);
|
||||
cancel
|
||||
}
|
||||
/// The player as a block's `useItemOn` / `useWithoutItem` sees them.
|
||||
fn use_context(&self) -> PlayerContext {
|
||||
PlayerContext {
|
||||
@@ -7549,6 +7764,7 @@ impl Game<'_> {
|
||||
horizon_client::spans::time("tick_cushions", || self.tick_cushions());
|
||||
horizon_client::spans::time("tick_boats", || self.tick_boats());
|
||||
horizon_client::spans::time("tick_carts", || self.tick_carts());
|
||||
self.tick_vehicle_watch();
|
||||
horizon_client::spans::time("tick_thunder", || self.tick_thunder());
|
||||
horizon_client::spans::time("tick_lecterns", || self.tick_lecterns());
|
||||
horizon_client::spans::time("tick_maps", || self.tick_maps());
|
||||
@@ -7932,7 +8148,12 @@ impl Game<'_> {
|
||||
// MouseHandler.grabMouse never captures an inactive window, and vanilla
|
||||
// grabs only on `setScreen(null)`: the pack prompt and server dialogs
|
||||
// are screens over the world that keep the pointer.
|
||||
if capture && (!self.focused || self.server_packs.prompt.is_some() || self.dialog_open()) {
|
||||
if capture
|
||||
&& (!self.focused
|
||||
|| self.server_packs.prompt.is_some()
|
||||
|| self.dialog_open()
|
||||
|| self.command_prompt.is_some())
|
||||
{
|
||||
return;
|
||||
}
|
||||
// The container screen's press state belongs to the screen: a screen
|
||||
@@ -7971,9 +8192,9 @@ impl Game<'_> {
|
||||
};
|
||||
let scale = HudBatch::new(size).scale;
|
||||
let was_grabbed = self.captured;
|
||||
if self.options.offscreen {
|
||||
// `--offscreen` shows no window: the system pointer stays where the
|
||||
// person at the computer has it, and the grab is the game's own (an
|
||||
if self.options.offscreen && !self.split_window {
|
||||
// A genuinely hidden `--offscreen` run leaves the system pointer where
|
||||
// the person at the computer has it. Its grab is internal (an
|
||||
// agent turns the view with `mouse-move`).
|
||||
if capture {
|
||||
self.mouse.set_ignore_first_move();
|
||||
@@ -8034,7 +8255,7 @@ impl Game<'_> {
|
||||
) {
|
||||
self.cursor = pos;
|
||||
}
|
||||
if !self.options.offscreen {
|
||||
if !self.options.offscreen || self.split_window {
|
||||
window.set_cursor_visible(!self.captured);
|
||||
}
|
||||
}
|
||||
@@ -8228,7 +8449,10 @@ impl Game<'_> {
|
||||
} else {
|
||||
self.hud_layers(size)
|
||||
};
|
||||
self.draw_dialog(&mut batch);
|
||||
// Vanilla's confirmation replaces the dialog as the screen.
|
||||
if self.command_prompt.is_none() {
|
||||
self.draw_dialog(&mut batch);
|
||||
}
|
||||
self.draw_pack_prompt(&mut batch);
|
||||
self.draw_code_of_conduct(&mut batch);
|
||||
self.draw_link_prompt(&mut batch);
|
||||
@@ -12630,24 +12854,29 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
/// `BucketItem.use`'s place position: a water bucket fills the clicked
|
||||
/// block itself when that block is a liquid container (a waterloggable
|
||||
/// stair, slab, fence, ...); every other bucket empties into the block the
|
||||
/// clicked face leads to. `SolidBucketItem` places like a block item, into
|
||||
/// a replaceable clicked block.
|
||||
/// block itself when that block can hold it (`canBlockContainFluid`: a
|
||||
/// waterloggable stair, slab, fence, ..., but not a double slab, kelp or
|
||||
/// seagrass); every other bucket empties into the block the clicked face
|
||||
/// leads to. `SolidBucketItem` places like a block item, into a
|
||||
/// replaceable clicked block.
|
||||
pub(super) fn bucket_target(&self, content: BucketContent, hit: &Hit) -> [i32; 3] {
|
||||
let clicked = self.world.block(hit.pos[0], hit.pos[1], hit.pos[2]);
|
||||
let behaviours = self.res.behaviours();
|
||||
if content == BucketContent::Powder {
|
||||
if self.res.facts().get(clicked).replaceable {
|
||||
return hit.pos;
|
||||
}
|
||||
} else if content.is_water() && self.res.behaviours().is_liquid_container(clicked) {
|
||||
} else if content.is_water()
|
||||
&& behaviours.is_liquid_container(clicked)
|
||||
&& behaviours.can_place_liquid(
|
||||
clicked,
|
||||
horizon_world::fluid::FluidKind::Water,
|
||||
self.creative,
|
||||
)
|
||||
{
|
||||
return hit.pos;
|
||||
}
|
||||
[
|
||||
hit.pos[0] + hit.face[0],
|
||||
hit.pos[1] + hit.face[1],
|
||||
hit.pos[2] + hit.face[2],
|
||||
]
|
||||
hit.beside()
|
||||
}
|
||||
/// The cauldron at `pos`, and whether water is above it
|
||||
/// (`CauldronInteractions.isUnderWater`).
|
||||
@@ -12922,6 +13151,30 @@ impl Game<'_> {
|
||||
let Ok(new) = registry.resolve(&horizon_client::cauldron::extinguished(&old)) else {
|
||||
return;
|
||||
};
|
||||
let level = |state: &horizon_world::chunk::BlockState| {
|
||||
if matches!(state.name.as_str(), "minecraft:cauldron") {
|
||||
0
|
||||
} else if state.name == "minecraft:lava_cauldron" {
|
||||
3
|
||||
} else {
|
||||
state
|
||||
.properties
|
||||
.get("level")
|
||||
.and_then(|level| level.parse().ok())
|
||||
.unwrap_or(1)
|
||||
}
|
||||
};
|
||||
let Some(new_state) = registry.state(new).cloned() else {
|
||||
return;
|
||||
};
|
||||
if !self.native_cauldron_level_change_allowed(
|
||||
pos,
|
||||
level(&old),
|
||||
level(&new_state),
|
||||
"extinguishing",
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if self.level_set(at, new).is_ok() {
|
||||
self.post_block_game_event("minecraft:block_change", at, None, Some(new));
|
||||
}
|
||||
@@ -13071,22 +13324,28 @@ impl Game<'_> {
|
||||
let target = self.bucket_target(content, &hit);
|
||||
let mut canceled = false;
|
||||
self.native_fill_bucket(stack, target, &mut canceled);
|
||||
if canceled {
|
||||
if canceled || self.bucket_empty_cancelled(stack, target) {
|
||||
return Ok(UseResult::Fail);
|
||||
}
|
||||
let pos = BlockPos::new(target[0], target[1], target[2]);
|
||||
// `Player.isShiftKeyDown`: only air takes a fluid under Shift.
|
||||
let [x, y, z] = hit.beside();
|
||||
let beside = Some(BlockPos::new(x, y, z));
|
||||
// `Player.isShiftKeyDown`: a block that refuses the fluid (any
|
||||
// non-air block under Shift) passes it to the block beside, where
|
||||
// Shift no longer counts.
|
||||
let sneaking = self.keys.contains(&KeyCode::ShiftLeft);
|
||||
let creative = self.creative;
|
||||
let emptied = self.with_level(|b, level, ticks| {
|
||||
b.bucket_empty(level, ticks, pos, content, sound, sneaking, creative)
|
||||
b.bucket_empty(
|
||||
level, ticks, pos, beside, content, sound, sneaking, creative,
|
||||
)
|
||||
});
|
||||
self.process_level_events()?;
|
||||
if !emptied {
|
||||
let Some(emptied) = emptied else {
|
||||
return Ok(UseResult::Fail);
|
||||
}
|
||||
};
|
||||
if self.dim == Dim::Nether && content.is_water() {
|
||||
self.evaporation_smoke(target);
|
||||
self.evaporation_smoke([emptied.x, emptied.y, emptied.z]);
|
||||
}
|
||||
// `BucketItem.emptyContents` uses `playSound(player, …)`: the
|
||||
// actor's client already played the empty / evaporate sound from
|
||||
@@ -13627,6 +13886,12 @@ impl Game<'_> {
|
||||
on_ground,
|
||||
);
|
||||
self.add_projectile(arrow);
|
||||
let (player, name, projectile) = (
|
||||
horizon_client::lan::playerdata::ints_uuid(self.acting_player_uuid()),
|
||||
self.acting_player_name().to_owned(),
|
||||
self.next_entity,
|
||||
);
|
||||
self.native_entity_shoot_bow(player, &name, projectile, power * 3.0);
|
||||
self.player_projectile_shot();
|
||||
let pitch = 1.0 / (self.world.random.next_float() * 0.4 + 1.2) + power * 0.5;
|
||||
self.audio
|
||||
@@ -14248,22 +14513,38 @@ impl Game<'_> {
|
||||
let moved = self
|
||||
.respawn_config
|
||||
.is_none_or(|c| (c.dim, c.pos) != (config.dim, config.pos));
|
||||
self.respawn_config = Some(config);
|
||||
if moved {
|
||||
self.tell_sleeper("block.minecraft.set_spawn", "Respawn point set", false);
|
||||
let mut cancel = false;
|
||||
if moved
|
||||
&& let (Some(player), Some(name)) =
|
||||
(self.lan_acting_uuid, self.lan_acting_name.clone())
|
||||
{
|
||||
self.native_player_spawn_point_set(
|
||||
player,
|
||||
&name,
|
||||
self.dim.id(),
|
||||
head,
|
||||
"bed",
|
||||
&mut cancel,
|
||||
);
|
||||
}
|
||||
if !cancel {
|
||||
self.respawn_config = Some(config);
|
||||
if moved {
|
||||
self.tell_sleeper("block.minecraft.set_spawn", "Respawn point set", false);
|
||||
}
|
||||
// The fallback becomes the world spawn: a guest's is none of its
|
||||
// own, so it follows `setworldspawn`.
|
||||
self.spawn = if self.lan_acting.is_some() {
|
||||
None
|
||||
} else {
|
||||
self.level
|
||||
.world_spawn
|
||||
.map(|[x, y, z]| {
|
||||
Vec3::new(f64::from(x) + 0.5, f64::from(y), f64::from(z) + 0.5)
|
||||
})
|
||||
.or(self.spawn)
|
||||
};
|
||||
}
|
||||
// The fallback becomes the world spawn: a guest's is none of its
|
||||
// own, so it follows `setworldspawn`.
|
||||
self.spawn = if self.lan_acting.is_some() {
|
||||
None
|
||||
} else {
|
||||
self.level
|
||||
.world_spawn
|
||||
.map(|[x, y, z]| {
|
||||
Vec3::new(f64::from(x) + 0.5, f64::from(y), f64::from(z) + 0.5)
|
||||
})
|
||||
.or(self.spawn)
|
||||
};
|
||||
}
|
||||
if !(self.dim == Dim::Overworld && self.sky_darken() >= 4) {
|
||||
self.tell_sleeper(
|
||||
@@ -14302,6 +14583,13 @@ impl Game<'_> {
|
||||
true,
|
||||
);
|
||||
}
|
||||
if let (Some(player), Some(name)) = (self.lan_acting_uuid, self.lan_acting_name.clone()) {
|
||||
let mut cancel = false;
|
||||
self.native_player_bed_enter(player, &name, head, &mut cancel);
|
||||
if cancel {
|
||||
return;
|
||||
}
|
||||
}
|
||||
self.stop_mining();
|
||||
self.left = false;
|
||||
self.right = false;
|
||||
@@ -14357,6 +14645,9 @@ impl Game<'_> {
|
||||
self.pitch = 0.0;
|
||||
self.snap_player_model_pitch();
|
||||
}
|
||||
if let (Some(player), Some(name)) = (self.lan_acting_uuid, self.lan_acting_name.clone()) {
|
||||
self.native_player_bed_leave(player, &name, head);
|
||||
}
|
||||
}
|
||||
fn tick_sleep(&mut self) {
|
||||
self.sleep_timer = tick_sleep_timer(self.sleep_timer, self.sleeping.is_some());
|
||||
@@ -14397,7 +14688,12 @@ impl Game<'_> {
|
||||
if self.enough_slept() {
|
||||
if self.ui.rules.bool("advance_time") {
|
||||
let t = self.world.day_time;
|
||||
self.world.day_time = t + 24000 - t.rem_euclid(24000);
|
||||
let target = t + 24000 - t.rem_euclid(24000);
|
||||
let mut cancel = false;
|
||||
self.native_time_skip(self.dim.id(), t, target, "sleep", &mut cancel);
|
||||
if !cancel {
|
||||
self.world.day_time = target;
|
||||
}
|
||||
}
|
||||
// Level.isRaining: the rain level is above 0.2.
|
||||
if self.ui.rules.bool("advance_weather") && self.world.weather.levels(1.0).0 > 0.2 {
|
||||
@@ -14977,7 +15273,9 @@ impl Game<'_> {
|
||||
}
|
||||
self.save_level(wait)?;
|
||||
self.save_parked_levels(wait)?;
|
||||
self.save_game_metadata()
|
||||
self.save_game_metadata()?;
|
||||
self.native_world_saved(wait);
|
||||
Ok(())
|
||||
}
|
||||
fn save_game_metadata(&mut self) -> Result<()> {
|
||||
self.save_maps();
|
||||
@@ -15087,6 +15385,11 @@ impl Game<'_> {
|
||||
});
|
||||
}
|
||||
fn render(&mut self) -> Result<bool> {
|
||||
// The window's GPU holds the atlas: its copy in memory is kept
|
||||
// compressed (a no-op once it is).
|
||||
if self.renderer.is_some() {
|
||||
self.gui.atlas.pack_pixels();
|
||||
}
|
||||
// Before any text of this frame: a full glyph region starts over.
|
||||
self.gui.atlas.font.begin_frame();
|
||||
self.glint_frame = self.capture_glint_frame();
|
||||
@@ -15714,6 +16017,8 @@ impl Game<'_> {
|
||||
}
|
||||
None => Err("renderer".into()),
|
||||
};
|
||||
// A frame the surface refused (occluded, lost, timed out) is `Ok(false)`.
|
||||
self.frame_drawn = matches!(result, Ok(true));
|
||||
// Restore storage even when rendering fails (for example, a lost surface).
|
||||
self.entity_vertices = entities;
|
||||
*self.entity_spans.get_mut() = entity_spans;
|
||||
@@ -16433,9 +16738,14 @@ impl Game<'_> {
|
||||
// AUTO_UPLOAD_TIME of this frame (at least one goes): a burst of
|
||||
// finished meshes spreads over frames, nearest first.
|
||||
let auto = !budgeted;
|
||||
let auto_time = if arriving {
|
||||
AUTO_UPLOAD_TIME_ARRIVING
|
||||
} else {
|
||||
AUTO_UPLOAD_TIME
|
||||
};
|
||||
let started = Instant::now();
|
||||
while let Some((_, mesh)) = pending.last() {
|
||||
if auto && count > 0 && started.elapsed() >= AUTO_UPLOAD_TIME {
|
||||
if auto && count > 0 && started.elapsed() >= auto_time {
|
||||
break;
|
||||
}
|
||||
let size = ((mesh.solid.len() + mesh.translucent.len())
|
||||
@@ -16571,11 +16881,13 @@ impl ApplicationHandler for Game<'_> {
|
||||
}
|
||||
}
|
||||
fn device_event(&mut self, _: &ActiveEventLoop, _: DeviceId, event: DeviceEvent) {
|
||||
// The real mouse never steers an offscreen game: its pointer is not grabbed.
|
||||
// A composited view renders offscreen but its keyboard/mouse owner still
|
||||
// controls a visible native window. A headless offscreen run never does.
|
||||
if self.pending_save.is_none()
|
||||
&& self.captured
|
||||
&& self.focused
|
||||
&& !self.options.offscreen
|
||||
&& self.keyboard_mouse
|
||||
&& (!self.options.offscreen || self.split_window)
|
||||
&& self.screen == Screen::Playing
|
||||
&& let DeviceEvent::MouseMotion { delta: (x, y) } = event
|
||||
{
|
||||
@@ -17525,9 +17837,9 @@ impl Game<'_> {
|
||||
// ConfirmScreen.keyPressed: Escape answers no.
|
||||
WindowEvent::KeyboardInput { event, .. } if self.command_prompt.is_some() => {
|
||||
if event.state == ElementState::Pressed
|
||||
&& event.physical_key == PhysicalKey::Code(KeyCode::Escape)
|
||||
&& let PhysicalKey::Code(key) = event.physical_key
|
||||
{
|
||||
self.answer_command_prompt(false);
|
||||
self.command_prompt_key(key);
|
||||
}
|
||||
}
|
||||
WindowEvent::KeyboardInput { event, .. } if self.link_prompt.is_some() => {
|
||||
@@ -18106,7 +18418,18 @@ impl Game<'_> {
|
||||
self.handle_window_event(event_loop, WindowId::dummy(), WindowEvent::RedrawRequested);
|
||||
return;
|
||||
}
|
||||
event_loop.set_control_flow(ControlFlow::Poll);
|
||||
// A window the compositor hides gets no `RedrawRequested` at all, so
|
||||
// polling would spin the loop with nothing to draw and pin a core.
|
||||
// Once a requested frame fails to reach the swap chain, wait for the
|
||||
// next event instead; a resize or occlusion change wakes the loop and
|
||||
// the frame that follows sets the pace back to polling.
|
||||
if self.frame_drawn {
|
||||
event_loop.set_control_flow(ControlFlow::Poll);
|
||||
} else {
|
||||
event_loop.set_control_flow(ControlFlow::WaitUntil(
|
||||
Instant::now() + Duration::from_millis(16),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
/// clouds.png as filled cells (`CloudRenderer.prepare`: alpha 10 or more).
|
||||
@@ -18254,6 +18577,7 @@ impl<'a> Game<'a> {
|
||||
captured: false,
|
||||
regrab_on_refocus: false,
|
||||
keyboard_mouse: true,
|
||||
split_window: false,
|
||||
view_at: [0, 0],
|
||||
split_hud: Default::default(),
|
||||
focused: true,
|
||||
@@ -18418,6 +18742,7 @@ impl<'a> Game<'a> {
|
||||
travelling: None,
|
||||
server_trips: Vec::new(),
|
||||
level_teleports: Vec::new(),
|
||||
teleport_notes: Vec::new(),
|
||||
levels: Default::default(),
|
||||
level_pass: Default::default(),
|
||||
portals: Default::default(),
|
||||
@@ -18458,6 +18783,7 @@ impl<'a> Game<'a> {
|
||||
cushion_model: prepared.cushion_model,
|
||||
entity_models: prepared.entity_models,
|
||||
carts: Vec::new(),
|
||||
watched_vehicles: Vec::new(),
|
||||
jukebox_sounds: HashMap::new(),
|
||||
bolts: Vec::new(),
|
||||
maps: maps::Maps::default(),
|
||||
@@ -18580,6 +18906,7 @@ impl<'a> Game<'a> {
|
||||
text_selection: Default::default(),
|
||||
saved_for_exit: false,
|
||||
pending_save: None,
|
||||
frame_drawn: true,
|
||||
world_icon: None,
|
||||
forced_hit: None,
|
||||
tick_hit: None,
|
||||
@@ -18612,6 +18939,7 @@ impl<'a> Game<'a> {
|
||||
guest_player_sounds: Vec::new(),
|
||||
lan_acting_name: None,
|
||||
lan_acting_uuid: None,
|
||||
tnt_refused: None,
|
||||
lan_acting_permission: None,
|
||||
lan_acting_entity: None,
|
||||
chat_capture: None,
|
||||
@@ -19227,7 +19555,7 @@ struct CoreTables {
|
||||
item_renderer: horizon_client::item_render::ItemRenderer,
|
||||
visibility: horizon_world::block_render::FaceVisibility,
|
||||
gameplay: GameplayTables,
|
||||
biomes: Biomes,
|
||||
biomes: Arc<Biomes>,
|
||||
environment: Environment,
|
||||
collision: BlockCollisionRegistry<'static>,
|
||||
}
|
||||
@@ -19251,6 +19579,7 @@ fn load_core(
|
||||
headless: bool,
|
||||
parallel: bool,
|
||||
progress: &Progress,
|
||||
launch_world: Option<&str>,
|
||||
) -> std::result::Result<std::result::Result<CoreTables, String>, String> {
|
||||
let assets = match startup::time("core.assets", || Assets::load(root)) {
|
||||
Ok(assets) => assets,
|
||||
@@ -19295,7 +19624,17 @@ fn load_core(
|
||||
.map_err(|e| e.to_string())
|
||||
});
|
||||
let biomes = Step::spawn(scope, parallel, || {
|
||||
startup::time("core.biomes", || Biomes::load(root)).map_err(|e| e.to_string())
|
||||
let biomes = Arc::new(
|
||||
startup::time("core.biomes", || Biomes::load(root))
|
||||
.map_err(|e| e.to_string())?,
|
||||
);
|
||||
// A launch that names a world: its data preload needs only the
|
||||
// block registry and the biomes, so it starts here rather than
|
||||
// after the block models and facts (~250 ms later).
|
||||
if let Some(folder) = launch_world {
|
||||
world_loading::preload_launch_world(folder, root, registry, biomes.clone());
|
||||
}
|
||||
Ok::<_, String>(biomes)
|
||||
});
|
||||
let environment = Step::spawn(scope, parallel, || {
|
||||
startup::time("core.environment", || Environment::load(root))
|
||||
@@ -19434,7 +19773,13 @@ fn load_resources(
|
||||
let (core, loaded_gui) = loop {
|
||||
let (ui, core) = std::thread::scope(|scope| {
|
||||
let core = Step::spawn(scope, parallel, || {
|
||||
startup::time("core", || load_core(&root, headless, parallel, progress))
|
||||
// A failed pack starts core again from the game's own
|
||||
// resources, which the world's preload must read: with packs
|
||||
// it waits for the core that loaded.
|
||||
let early = launch_world.filter(|_| root == vanilla_root);
|
||||
startup::time("core", || {
|
||||
load_core(&root, headless, parallel, progress, early)
|
||||
})
|
||||
});
|
||||
let ui = load_ui(&root).map(|ui| offer(ui, &root));
|
||||
(ui, core.join())
|
||||
@@ -19479,7 +19824,6 @@ fn load_resources(
|
||||
} = core;
|
||||
let assets = Arc::new(assets);
|
||||
let render = Arc::new(render);
|
||||
let biomes = Arc::new(biomes);
|
||||
if let Some(folder) = launch_world {
|
||||
world_loading::preload_launch_world(folder, &root, assets.registry, biomes.clone());
|
||||
}
|
||||
@@ -19821,6 +20165,7 @@ fn build_renderer(
|
||||
atlas.font.reset_cache();
|
||||
// Made here when the worker did not: the renderer's own fallback has no LabPBR maps.
|
||||
let prepared = Some(prepared.unwrap_or_else(|| prepare_renderer_textures(pack)));
|
||||
let atlas_pixels = atlas.pixels();
|
||||
let mut renderer = pollster::block_on(Renderer::new(
|
||||
window,
|
||||
offscreen,
|
||||
@@ -19830,7 +20175,7 @@ fn build_renderer(
|
||||
assets.texture_layers,
|
||||
&assets.sprite_mips,
|
||||
&assets.animations,
|
||||
(&atlas.pixels, atlas.width, atlas.height),
|
||||
(&atlas_pixels, atlas.width, atlas.height),
|
||||
(&atlas.glint.0, atlas.glint.1, atlas.glint.2),
|
||||
(
|
||||
&atlas.glint_armor.0,
|
||||
|
||||
@@ -10,6 +10,7 @@ use horizon_client::{
|
||||
toasts::Slide,
|
||||
};
|
||||
use horizon_core::geometry::Vec3;
|
||||
use horizon_protocol::packets::play::Clientbound as C;
|
||||
use serde_json::Value;
|
||||
use std::{
|
||||
collections::{BTreeMap, BTreeSet, VecDeque},
|
||||
@@ -626,18 +627,44 @@ impl Game<'_> {
|
||||
/// After `ServerRecipeBook.addRecipes`: the toast and the
|
||||
/// `recipe_unlocked` trigger of each recipe now known.
|
||||
pub(super) fn recipe_unlocked(&mut self, unlocked: &[String]) {
|
||||
let mut allowed = Vec::with_capacity(unlocked.len());
|
||||
let mut refused = Vec::new();
|
||||
if let (Some(player), Some(name)) = (self.lan_acting_uuid, self.lan_acting_name.clone()) {
|
||||
for recipe in unlocked {
|
||||
let mut cancel = false;
|
||||
self.native_player_recipe_discovered(player, &name, recipe, &mut cancel);
|
||||
if cancel {
|
||||
refused.push(recipe.clone());
|
||||
} else {
|
||||
allowed.push(recipe.clone());
|
||||
}
|
||||
}
|
||||
if !refused.is_empty() {
|
||||
let removed = self
|
||||
.level
|
||||
.recipe_book
|
||||
.remove_recipes(&world_stations!(self).recipe_index, &refused);
|
||||
if self.lan_acting.is_some() && !removed.is_empty() {
|
||||
self.guest_packets.push(C::RecipeBookRemove(
|
||||
horizon_protocol::packets::recipes::RecipeBookRemove { ids: removed },
|
||||
));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
allowed.extend_from_slice(unlocked);
|
||||
}
|
||||
if self.lan_acting.is_some() {
|
||||
// A network player's client gets them in ClientboundRecipeBookAddPacket.
|
||||
self.adv.unlocked.extend_from_slice(unlocked);
|
||||
self.adv.unlocked.extend_from_slice(&allowed);
|
||||
} else {
|
||||
self.recipe_toast(unlocked);
|
||||
self.recipe_toast(&allowed);
|
||||
}
|
||||
if unlocked.is_empty() || self.remote.is_some() || self.no_player_here() {
|
||||
if allowed.is_empty() || self.remote.is_some() || self.no_player_here() {
|
||||
return;
|
||||
}
|
||||
self.load_advancements();
|
||||
let tree = self.adv.tree.clone();
|
||||
for r in unlocked {
|
||||
for r in &allowed {
|
||||
let list = tree.recipe_criteria(r);
|
||||
self.trigger_criteria(&tree, &list, |c, _| {
|
||||
c["recipes"].as_str() == Some(r.as_str())
|
||||
@@ -681,6 +708,9 @@ impl Game<'_> {
|
||||
fn award(&mut self, index: usize) {
|
||||
let tree = self.adv.tree.clone();
|
||||
let a = &tree.list[index];
|
||||
if let (Some(player), Some(name)) = (self.lan_acting_uuid, self.lan_acting_name.clone()) {
|
||||
self.native_player_advancement_done(player, &name, &a.id);
|
||||
}
|
||||
// AdvancementRewards.grant
|
||||
if a.rewards.experience > 0 {
|
||||
self.experience.give(a.rewards.experience);
|
||||
|
||||
@@ -500,7 +500,7 @@ impl Game<'_> {
|
||||
json!({
|
||||
"dialog": self.dialog_status(),
|
||||
"screen": self.screen_label(),
|
||||
"menu": (self.title.is_some() || self.split_profiles_open()).then(|| self.title_value()),
|
||||
"menu": (self.title.is_some() || self.split_profiles_open() || self.split_setup_open()).then(|| self.title_value()),
|
||||
"world": (self.title.is_none()).then(|| match &self.integrated {
|
||||
Some(server) => server.world_name.clone(),
|
||||
None => self.level.name.clone(),
|
||||
@@ -535,11 +535,18 @@ impl Game<'_> {
|
||||
self.screen == Screen::Options
|
||||
&& self.ui.options.page() == horizon_client::options_ui::Page::SplitProfiles
|
||||
}
|
||||
fn split_setup_open(&self) -> bool {
|
||||
self.screen == Screen::Options
|
||||
&& self.ui.options.page() == horizon_client::options_ui::Page::SplitSetup
|
||||
}
|
||||
fn title_value(&self) -> Value {
|
||||
use horizon_client::title::Mode;
|
||||
if self.split_profiles_open() {
|
||||
return self.ui.options.split_profiles.state();
|
||||
}
|
||||
if self.split_setup_open() {
|
||||
return json!({ "mode": "splitscreen-setup", "status": self.ui.options.split_setup.status() });
|
||||
}
|
||||
let Some(title) = &self.title else {
|
||||
return Value::Null;
|
||||
};
|
||||
@@ -742,7 +749,7 @@ impl Game<'_> {
|
||||
v["chat_input"] = json!(chat.input.value);
|
||||
v["chat_suggestions"] = json!(chat.suggestions_visible());
|
||||
}
|
||||
if self.title.is_some() || self.split_profiles_open() {
|
||||
if self.title.is_some() || self.split_profiles_open() || self.split_setup_open() {
|
||||
v["menu"] = self.title_value();
|
||||
}
|
||||
if self.screen == Screen::Inventory && self.title.is_none() {
|
||||
@@ -1705,7 +1712,7 @@ impl Game<'_> {
|
||||
if let Some(server) = &self.integrated {
|
||||
let chat = server
|
||||
.call(move |g| g.owner_console_command(&command))
|
||||
.ok_or("the integrated server stopped")?;
|
||||
.ok_or(super::integrated::NO_ANSWER)?;
|
||||
return ok(json!({ "ok": true, "chat": chat }));
|
||||
}
|
||||
if self.remote.is_some() {
|
||||
|
||||
@@ -600,6 +600,23 @@ impl Game<'_> {
|
||||
if held.is_none() && current.is_none() {
|
||||
return false;
|
||||
}
|
||||
// A server's mods hear the swap before it happens, and may stop it.
|
||||
// A stack of several on an occupied slot goes nowhere (below).
|
||||
let moves = held
|
||||
.as_ref()
|
||||
.is_none_or(|h| h.count <= 1 || current.is_none());
|
||||
if moves
|
||||
&& target.is_some()
|
||||
&& let (Some(player), Some(name)) = (self.lan_acting_uuid, self.lan_acting_name.clone())
|
||||
{
|
||||
let stand = self.armor_stands[i].id as i32;
|
||||
let slot_name = ["mainhand", "offhand", "feet", "legs", "chest", "head"][slot];
|
||||
let mut cancel = false;
|
||||
self.native_armor_stand_manipulate(player, &name, stand, slot_name, &mut cancel);
|
||||
if cancel {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
// ArmorStand.swapItem.
|
||||
match held {
|
||||
// A player with infinite materials puts a copy of one on an empty
|
||||
|
||||
@@ -130,8 +130,13 @@ impl Game<'_> {
|
||||
let Some(BlockEntity::Beacon(mut b)) = self.world.block_entities.take(&key) else {
|
||||
return;
|
||||
};
|
||||
let previous = b.levels;
|
||||
self.tick_beacon(key, &mut b, false);
|
||||
let current = b.levels;
|
||||
self.world.block_entities.put(key, BlockEntity::Beacon(b));
|
||||
if (previous == 0) != (current == 0) {
|
||||
self.native_beacon_state_change(self.dim.id(), [key.0, key.1, key.2], current > 0);
|
||||
}
|
||||
}
|
||||
/// `ClientLevel.tickBlockEntities` for beacons: a server's chunk data
|
||||
/// carries no beam sections, so the client builds them too; effects,
|
||||
|
||||
@@ -150,8 +150,18 @@ impl Game<'_> {
|
||||
self.world
|
||||
.block_entities
|
||||
.put(key, BlockEntity::Beehive(hive));
|
||||
if stored && let Some(m) = self.mobs.iter_mut().find(|m| m.id == mob) {
|
||||
m.removed = true;
|
||||
if stored {
|
||||
let kind = self.mobs[i].kind.id.clone();
|
||||
let block = self
|
||||
.res
|
||||
.facts()
|
||||
.get(self.world.block(at[0], at[1], at[2]))
|
||||
.name
|
||||
.clone();
|
||||
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == mob) {
|
||||
m.removed = true;
|
||||
}
|
||||
self.native_entity_enter_block(mob, &kind, at, &block);
|
||||
}
|
||||
}
|
||||
/// `BeehiveBlockEntity.emptyAllLivingFromHive` with no player about.
|
||||
|
||||
@@ -714,6 +714,16 @@ impl Game<'_> {
|
||||
}
|
||||
/// `EndGatewayBlock.entityInside` on the server: whether the gateway at
|
||||
/// `pos` let the entity in (`triggerCooldown`, with its block event).
|
||||
pub(super) fn gateway_available(&self, pos: [i32; 3]) -> bool {
|
||||
self.be_anims
|
||||
.gateway_servers
|
||||
.get(&pos)
|
||||
.copied()
|
||||
.unwrap_or_else(|| self.gateway_loaded(pos))
|
||||
.teleport_cooldown
|
||||
<= 0
|
||||
}
|
||||
|
||||
pub(super) fn gateway_enter(&mut self, pos: [i32; 3]) -> bool {
|
||||
let initial = self.gateway_loaded(pos);
|
||||
let entered = self
|
||||
|
||||
@@ -215,16 +215,51 @@ impl Game<'_> {
|
||||
/// `AbstractFurnaceBlockEntity.serverTick` of the furnace at `pos`, and
|
||||
/// its block's `lit` following it.
|
||||
fn tick_furnace(&mut self, pos: Pos) -> Result<()> {
|
||||
let Some(BlockEntity::Furnace(f)) = self.world.block_entities.get_mut(&pos) else {
|
||||
return Ok(());
|
||||
// What the hooks need from before the tick, without cloning stacks: the
|
||||
// fuel's id only while the furnace is out (it can light this tick), the
|
||||
// input's id (a last item is gone after it smelts) and the result count.
|
||||
let before = match self.world.block_entities.get(&pos) {
|
||||
Some(BlockEntity::Furnace(f)) => (
|
||||
f.cooking_timer,
|
||||
(f.lit_remaining == 0)
|
||||
.then(|| f.items[1].as_ref().map(|s| s.item.clone()))
|
||||
.flatten(),
|
||||
f.items[0].as_ref().map(|s| s.item.clone()),
|
||||
f.items[2].as_ref().map_or(0, |s| s.count),
|
||||
),
|
||||
_ => return Ok(()),
|
||||
};
|
||||
let mut drops = Vec::new();
|
||||
let lit = f.tick(
|
||||
world_recipes!(self),
|
||||
world_items!(self),
|
||||
self.res.providers(),
|
||||
&mut drops,
|
||||
);
|
||||
let (lit, burning, smelted, smelting) = {
|
||||
let Some(BlockEntity::Furnace(f)) = self.world.block_entities.get_mut(&pos) else {
|
||||
return Ok(());
|
||||
};
|
||||
let lit = f.tick(
|
||||
world_recipes!(self),
|
||||
world_items!(self),
|
||||
self.res.providers(),
|
||||
&mut drops,
|
||||
);
|
||||
let burning = before.1.filter(|_| f.lit_remaining > 0);
|
||||
let smelted = f.items[2]
|
||||
.as_ref()
|
||||
.filter(|after| after.count > before.3)
|
||||
.map(|after| (before.2.clone(), after.item.clone()));
|
||||
let started = before.0 == 0 && f.cooking_timer > 0;
|
||||
let smelting = started
|
||||
.then(|| f.items[0].as_ref().map(|item| item.item.clone()))
|
||||
.flatten();
|
||||
(lit, burning, smelted, smelting)
|
||||
};
|
||||
if let Some(item) = smelting {
|
||||
self.native_furnace_start_smelt([pos.0, pos.1, pos.2], &item);
|
||||
}
|
||||
if let Some(fuel) = burning {
|
||||
self.native_furnace_burn([pos.0, pos.1, pos.2], &fuel);
|
||||
}
|
||||
if let Some((Some(item), result)) = smelted {
|
||||
self.native_furnace_smelt([pos.0, pos.1, pos.2], &item, &result);
|
||||
}
|
||||
for stack in drops {
|
||||
self.campfire_drop(pos, stack);
|
||||
}
|
||||
|
||||
@@ -76,7 +76,7 @@ impl Game<'_> {
|
||||
let words: Vec<&str> = owned.iter().map(String::as_str).collect();
|
||||
g.in_owner_level(|g| g.block_command(&words).map_err(|e| e.to_string()))
|
||||
})
|
||||
.ok_or("the integrated server stopped")?
|
||||
.ok_or(super::integrated::NO_ANSWER)?
|
||||
.map_err(Into::into);
|
||||
}
|
||||
// Another server's world changes only through its own commands, which
|
||||
|
||||
@@ -146,10 +146,22 @@ impl Game<'_> {
|
||||
}
|
||||
ClickEvent::OpenUrl(url) => self.click_url(url),
|
||||
// closeContainerOnServer, then clickCommandAction with no screen
|
||||
// after the command: the book closes and the command runs.
|
||||
// after the command: the book closes and the command runs. A
|
||||
// lectern's screen goes with its menu; a book is still the screen
|
||||
// a confirmation returns to.
|
||||
ClickEvent::RunCommand(command) => {
|
||||
let book = if lectern.is_none() {
|
||||
self.book.take()
|
||||
} else {
|
||||
None
|
||||
};
|
||||
self.close_book();
|
||||
self.click_command(&command);
|
||||
if let Some(book) = book {
|
||||
self.command_prompt_returns_to(super::remote_chat::Behind::Book(Box::new(
|
||||
book,
|
||||
)));
|
||||
}
|
||||
}
|
||||
// Screen.defaultHandleClickEvent: copied, the book stays open.
|
||||
ClickEvent::CopyToClipboard(value) => {
|
||||
@@ -334,9 +346,40 @@ impl Game<'_> {
|
||||
{
|
||||
return;
|
||||
}
|
||||
self.as_guest(host, conn, |g| {
|
||||
g.write_book(p.slot as usize, p.pages, p.title)
|
||||
let menu_slot = if p.slot == super::shield::OFFHAND_SLOT as i32 {
|
||||
45
|
||||
} else {
|
||||
36 + p.slot as usize
|
||||
};
|
||||
let cancelled = self.as_guest(host, conn, |g| {
|
||||
let mut cancel = false;
|
||||
let writable = g
|
||||
.stack_in(p.slot as usize)
|
||||
.is_some_and(|stack| &*stack.item == "minecraft:writable_book");
|
||||
if writable
|
||||
&& let (Some(player), Some(name)) = (g.lan_acting_uuid, g.lan_acting_name.clone())
|
||||
{
|
||||
g.native_player_edit_book(
|
||||
player,
|
||||
&name,
|
||||
p.pages.len() as i32,
|
||||
p.title.is_some(),
|
||||
&mut cancel,
|
||||
);
|
||||
}
|
||||
if cancel {
|
||||
return true;
|
||||
}
|
||||
g.write_book(p.slot as usize, p.pages, p.title);
|
||||
false
|
||||
});
|
||||
// A cancelled edit leaves the client's own copy of the book: forget
|
||||
// what was sent so the next broadcast resends the server's stack.
|
||||
if cancelled == Some(true)
|
||||
&& let Some(g) = host.hub.guests.iter_mut().find(|g| g.conn == conn)
|
||||
{
|
||||
g.menu_sent[menu_slot] = None;
|
||||
}
|
||||
}
|
||||
pub(super) fn book_key(&mut self, event: &KeyEvent) {
|
||||
if event.state != ElementState::Pressed {
|
||||
@@ -811,6 +854,9 @@ impl Game<'_> {
|
||||
self.audio
|
||||
.play("minecraft:item.book.put", Some(at), 1.0, 1.0);
|
||||
self.container_changed(key);
|
||||
if let (Some(player), Some(name)) = (self.lan_acting_uuid, self.lan_acting_name.clone()) {
|
||||
self.native_player_insert_lectern_book(player, &name, pos, &book.item);
|
||||
}
|
||||
true
|
||||
}
|
||||
/// `LecternBlock.openScreen`: reading the lectern's book.
|
||||
@@ -874,7 +920,21 @@ impl Game<'_> {
|
||||
/// lectern, so the screen closes (the server closes the menu once the
|
||||
/// book is gone; a server's client closes at once rather than wait).
|
||||
fn take_lectern_book(&mut self, key: (i32, i32, i32)) {
|
||||
let before = match self.world.block_entities.get(&key) {
|
||||
Some(BlockEntity::Lectern(l)) => l.book.clone(),
|
||||
_ => None,
|
||||
};
|
||||
self.lectern_button(key, 3);
|
||||
if let (Some(book), Some(player), Some(name)) = (
|
||||
before.filter(|_| {
|
||||
matches!(self.world.block_entities.get(&key),
|
||||
Some(BlockEntity::Lectern(l)) if l.book.is_none())
|
||||
}),
|
||||
self.lan_acting_uuid,
|
||||
self.lan_acting_name.clone(),
|
||||
) {
|
||||
self.native_player_take_lectern_book(player, &name, [key.0, key.1, key.2], &book.item);
|
||||
}
|
||||
if self.may_build()
|
||||
&& matches!(
|
||||
self.book,
|
||||
@@ -1023,6 +1083,15 @@ impl Game<'_> {
|
||||
self.lectern_pulses.push((key, 2));
|
||||
self.level_event_at(1043, [key.0, key.1, key.2], 0);
|
||||
self.container_changed(key);
|
||||
if let (Some(player), Some(name)) = (self.lan_acting_uuid, self.lan_acting_name.clone())
|
||||
{
|
||||
self.native_player_lectern_page_change(
|
||||
player,
|
||||
&name,
|
||||
[key.0, key.1, key.2],
|
||||
menu.data[0],
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
/// The open lectern screen follows its block entity (`LecternScreen`'s
|
||||
|
||||
@@ -48,6 +48,7 @@ impl Game<'_> {
|
||||
_ => false,
|
||||
};
|
||||
if placed {
|
||||
self.native_campfire_start(pos, &held.item);
|
||||
// ItemStack.consumeAndReturn: creative keeps the item.
|
||||
self.consume_held();
|
||||
self.stats.custom("interact_with_campfire", 1);
|
||||
@@ -102,6 +103,7 @@ impl Game<'_> {
|
||||
.recipes()
|
||||
.cook(KIND, &item)
|
||||
.map_or_else(|| item.clone(), |r| r.result.clone());
|
||||
self.native_block_cook([pos.0, pos.1, pos.2], &item.item, &result.item);
|
||||
self.campfire_drop(pos, result);
|
||||
self.mark_block_entity_updated(pos);
|
||||
self.post_block_game_event(
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
//! The dedicated server's chat filter, game side (docs/chat-filter.md): what the hub refused is
|
||||
//! written to `logs/chat-filter.log` and told to horizon-server's event stream as `chat_filtered`
|
||||
//! (never as `chat`), a temporary ban goes into banned-players.json, and operators lift mutes with
|
||||
//! `/unmute <player>` (or `unmute` on the console).
|
||||
//! (never as `chat`), a temporary ban goes into banned-players.json, and operators mute and unmute
|
||||
//! players by hand with `/mute`, `/tempmute`, `/mutelist` and `/unmute` (the same words on the console).
|
||||
use super::{Game, dedicated, lan::LanHost};
|
||||
use horizon_chat_filter::Action;
|
||||
use horizon_client::lan::hub::{HostEvent, chat_filter::Place};
|
||||
@@ -9,8 +9,11 @@ use horizon_protocol::packets::play::{self, Clientbound as C};
|
||||
use horizon_protocol::text::{literal, translatable};
|
||||
use horizon_server_files::user_lists::{BanEntry, NameAndId};
|
||||
|
||||
/// The operator level `/unmute` needs, as `/ban`'s.
|
||||
const UNMUTE_LEVEL: u8 = 3;
|
||||
/// The operator level the mute commands need, as `/ban`'s.
|
||||
const MUTE_LEVEL: u8 = 3;
|
||||
|
||||
/// The commands of `mute_command`.
|
||||
pub(super) const MUTE_COMMANDS: [&str; 4] = ["mute", "tempmute", "mutelist", "unmute"];
|
||||
|
||||
/// The reason a temporary ban gives at login.
|
||||
const BAN_REASON: &str = "Hate speech in chat (Horizon chat filter)";
|
||||
@@ -155,27 +158,114 @@ impl Game<'_> {
|
||||
allowed
|
||||
}
|
||||
|
||||
/// `/unmute <player>` from a guest: operators of level 3 (`/ban`'s); vanilla's unknown command
|
||||
/// for the others.
|
||||
pub(super) fn guest_unmute(&mut self, host: &mut LanHost, conn: u32, words: &[&str]) {
|
||||
/// A mute command from a guest: operators of level 3 (`/ban`'s); vanilla's unknown command for
|
||||
/// the others.
|
||||
pub(super) fn guest_mute_command(&mut self, host: &mut LanHost, conn: u32, words: &[&str]) {
|
||||
let Some(g) = host.hub.guest(conn) else {
|
||||
return;
|
||||
};
|
||||
let (permission, by) = (g.permission, g.name.clone());
|
||||
let reply = if permission < UNMUTE_LEVEL {
|
||||
translatable("command.unknown.command", &[])
|
||||
} else if words.len() != 2 {
|
||||
literal("/unmute <player>")
|
||||
let replies = if permission < MUTE_LEVEL {
|
||||
vec![translatable("command.unknown.command", &[])]
|
||||
} else {
|
||||
literal(&self.unmute(host, words[1], &by))
|
||||
let words: Vec<&str> = words.iter().copied().filter(|w| !w.is_empty()).collect();
|
||||
self.mute_command(host, &words, &by)
|
||||
.iter()
|
||||
.map(|line| literal(line))
|
||||
.collect()
|
||||
};
|
||||
host.hub.send_to(
|
||||
conn,
|
||||
&C::SystemChat(play::SystemChat {
|
||||
content: reply,
|
||||
overlay: false,
|
||||
}),
|
||||
for content in replies {
|
||||
host.hub.send_to(
|
||||
conn,
|
||||
&C::SystemChat(play::SystemChat {
|
||||
content,
|
||||
overlay: false,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// `/mute <player> [reason]`, `/tempmute <player> <duration> [reason]`, `/mutelist` and
|
||||
/// `/unmute <player>` (`words`, the command first) by `by`: the lines to answer.
|
||||
pub(super) fn mute_command(
|
||||
&mut self,
|
||||
host: &mut LanHost,
|
||||
words: &[&str],
|
||||
by: &str,
|
||||
) -> Vec<String> {
|
||||
let reason = |from: usize| (words.len() > from).then(|| words[from..].join(" "));
|
||||
match words {
|
||||
["mutelist"] => {
|
||||
let now = horizon_chat_filter::now_millis();
|
||||
let mutes = host.hub.mutes();
|
||||
if mutes.is_empty() {
|
||||
return vec!["There are no mutes".into()];
|
||||
}
|
||||
let mut lines = vec![format!("There are {} mute(s):", mutes.len())];
|
||||
for m in mutes {
|
||||
let by = m.by.as_deref().unwrap_or("the chat filter");
|
||||
let until = m.until.map_or_else(
|
||||
|| "until unmuted".into(),
|
||||
|u| {
|
||||
let left = horizon_chat_filter::duration_text(u - now);
|
||||
format!("for {}", left.replacen(' ', " more ", 1))
|
||||
},
|
||||
);
|
||||
let why = m.reason.map(|r| format!(": {r}")).unwrap_or_default();
|
||||
lines.push(format!("{} was muted by {by} {until}{why}", m.name));
|
||||
}
|
||||
lines
|
||||
}
|
||||
["unmute", name] => vec![self.unmute(host, name, by)],
|
||||
["mute", name, ..] => vec![self.mute(host, name, None, reason(2), by)],
|
||||
["tempmute", name, time, ..] => match horizon_chat_filter::parse_duration(time) {
|
||||
Some(ms) => vec![self.mute(host, name, Some(ms), reason(3), by)],
|
||||
None => vec![format!("{time} isn't a duration: 10m, 2h, 1d")],
|
||||
},
|
||||
["mute", ..] => vec!["/mute <player> [reason]".into()],
|
||||
["tempmute", ..] => vec!["/tempmute <player> <duration> [reason]".into()],
|
||||
["unmute", ..] => vec!["/unmute <player>".into()],
|
||||
_ => vec!["/mutelist".into()],
|
||||
}
|
||||
}
|
||||
|
||||
/// Mutes `name` for `time` milliseconds (`None`: until `/unmute`), logged; what to answer.
|
||||
fn mute(
|
||||
&mut self,
|
||||
host: &mut LanHost,
|
||||
name: &str,
|
||||
time: Option<i64>,
|
||||
reason: Option<String>,
|
||||
by: &str,
|
||||
) -> String {
|
||||
// A player here, else one the server has seen (the user cache).
|
||||
let user = host
|
||||
.hub
|
||||
.guests
|
||||
.iter()
|
||||
.find(|g| g.name.eq_ignore_ascii_case(name))
|
||||
.map(|g| (g.uuid, g.name.clone()))
|
||||
.or_else(|| self.resolve_name(name).map(|u| (u.id, u.name)));
|
||||
let Some((uuid, name)) = user else {
|
||||
return format!("No player was found named {name}");
|
||||
};
|
||||
let until = time.map(|ms| horizon_chat_filter::now_millis() + ms);
|
||||
let what = time.map_or_else(
|
||||
|| "until unmuted".into(),
|
||||
|ms| format!("for {}", horizon_chat_filter::duration_text(ms)),
|
||||
);
|
||||
let why = reason
|
||||
.as_deref()
|
||||
.map(|r| format!(": {r}"))
|
||||
.unwrap_or_default();
|
||||
host.hub.mute(uuid, &name, until, by, reason);
|
||||
if self.options.dedicated.is_some() {
|
||||
dedicated::log(
|
||||
"INFO",
|
||||
&format!("[chat-filter] {by} muted {name} {what}{why}"),
|
||||
);
|
||||
}
|
||||
format!("Muted {name} {what}{why}")
|
||||
}
|
||||
|
||||
/// Lifts `name`'s mute, logged; what to answer.
|
||||
|
||||
@@ -1469,6 +1469,7 @@ impl Game<'_> {
|
||||
let _ = self.in_target_level(t, |g| g.teleport_to_in_level(t, pos, rotation));
|
||||
}
|
||||
fn teleport_to_in_level(&mut self, t: Target, pos: [f64; 3], rotation: Option<[f32; 2]>) {
|
||||
let from = self.target_position(t);
|
||||
let v = Vec3::new(pos[0], pos[1], pos[2]);
|
||||
match t {
|
||||
Target::Guest(_) => {
|
||||
@@ -1483,6 +1484,10 @@ impl Game<'_> {
|
||||
if let Some(conn) = self.lan_acting {
|
||||
self.server_trips.retain(|trip| !trip.is(conn));
|
||||
}
|
||||
let from = self
|
||||
.player
|
||||
.as_ref()
|
||||
.map(|p| [p.position.x, p.position.y, p.position.z]);
|
||||
if let Some(p) = self.player.as_mut() {
|
||||
p.position = v;
|
||||
p.velocity = Vec3::new(0.0, 0.0, 0.0);
|
||||
@@ -1494,6 +1499,24 @@ impl Game<'_> {
|
||||
self.yaw = yaw;
|
||||
self.pitch = pitch.clamp(-90.0, 90.0);
|
||||
}
|
||||
// A server player's teleport is told to the mods once their
|
||||
// move is in the hub (`leave_guest`).
|
||||
if let (Some(conn), Some(uuid), Some(name), Some(from)) = (
|
||||
self.lan_acting,
|
||||
self.lan_acting_uuid,
|
||||
self.lan_acting_name.clone(),
|
||||
from,
|
||||
) {
|
||||
self.teleport_notes
|
||||
.push(super::server_travel::TeleportNote {
|
||||
conn,
|
||||
uuid,
|
||||
name,
|
||||
from,
|
||||
to: pos,
|
||||
dimension: self.dim.id().to_owned(),
|
||||
});
|
||||
}
|
||||
}
|
||||
Target::Mob(id) => {
|
||||
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == id) {
|
||||
@@ -1602,6 +1625,12 @@ impl Game<'_> {
|
||||
// Wall-mounted entities do not move.
|
||||
Target::Frame(_) | Target::Painting(_) => {}
|
||||
}
|
||||
if self.options.dedicated.is_some()
|
||||
&& let (Some(entity), Some(from), Some(to)) =
|
||||
(self.network_entity_id(t), from, self.target_position(t))
|
||||
{
|
||||
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.
|
||||
@@ -1875,8 +1904,12 @@ impl Game<'_> {
|
||||
if total == t {
|
||||
return Err(err("commands.time.set.already_at_time", &[&clock, &t]));
|
||||
}
|
||||
self.world.day_time = t;
|
||||
self.ui.clock.partial = 0.0;
|
||||
let mut cancel = false;
|
||||
self.native_time_skip(self.dim.id(), total, t, "command", &mut cancel);
|
||||
if !cancel {
|
||||
self.world.day_time = t;
|
||||
self.ui.clock.partial = 0.0;
|
||||
}
|
||||
self.feedback(Text::tr("commands.time.set.absolute", &[&clock, &t]));
|
||||
Ok(t as i32)
|
||||
}
|
||||
@@ -1896,8 +1929,13 @@ impl Game<'_> {
|
||||
&[&clock, &marker],
|
||||
));
|
||||
}
|
||||
self.world.day_time = m.resolve(total);
|
||||
self.ui.clock.partial = 0.0;
|
||||
let target = m.resolve(total);
|
||||
let mut cancel = false;
|
||||
self.native_time_skip(self.dim.id(), total, target, "command", &mut cancel);
|
||||
if !cancel {
|
||||
self.world.day_time = target;
|
||||
self.ui.clock.partial = 0.0;
|
||||
}
|
||||
self.feedback(Text::tr(
|
||||
"commands.time.set.time_marker",
|
||||
&[&clock, &marker],
|
||||
@@ -1906,7 +1944,12 @@ impl Game<'_> {
|
||||
}
|
||||
"time.add" => {
|
||||
let t = i64::from(Self::int(ctx, "time").unwrap_or(0));
|
||||
self.world.day_time = (total + t).max(0);
|
||||
let target = (total + t).max(0);
|
||||
let mut cancel = false;
|
||||
self.native_time_skip(self.dim.id(), total, target, "command", &mut cancel);
|
||||
if !cancel {
|
||||
self.world.day_time = target;
|
||||
}
|
||||
let now = self.world.day_time;
|
||||
self.feedback(Text::tr("commands.time.set.absolute", &[&clock, &now]));
|
||||
Ok((now % i64::from(i32::MAX)) as i32)
|
||||
@@ -1990,12 +2033,10 @@ impl Game<'_> {
|
||||
let sample = |min: i32, max: i32, r: &mut horizon_core::random::LegacyRandom| {
|
||||
min + r.next_int_bound(max - min + 1).unwrap_or(0)
|
||||
};
|
||||
let w = &mut self.world.weather;
|
||||
let r = &mut self.world.random;
|
||||
let (key, clear, time, raining, thundering) = if ctx.has("clear") {
|
||||
(
|
||||
"commands.weather.set.clear",
|
||||
duration.unwrap_or_else(|| sample(12_000, 180_000, r)),
|
||||
duration.unwrap_or_else(|| sample(12_000, 180_000, &mut self.world.random)),
|
||||
0,
|
||||
false,
|
||||
false,
|
||||
@@ -2004,7 +2045,7 @@ impl Game<'_> {
|
||||
(
|
||||
"commands.weather.set.rain",
|
||||
0,
|
||||
duration.unwrap_or_else(|| sample(12_000, 24_000, r)),
|
||||
duration.unwrap_or_else(|| sample(12_000, 24_000, &mut self.world.random)),
|
||||
true,
|
||||
false,
|
||||
)
|
||||
@@ -2012,17 +2053,46 @@ impl Game<'_> {
|
||||
(
|
||||
"commands.weather.set.thunder",
|
||||
0,
|
||||
duration.unwrap_or_else(|| sample(3_600, 15_600, r)),
|
||||
duration.unwrap_or_else(|| sample(3_600, 15_600, &mut self.world.random)),
|
||||
true,
|
||||
true,
|
||||
)
|
||||
};
|
||||
// ServerLevel.setWeatherParameters.
|
||||
w.clear_time = clear;
|
||||
w.rain_time = time;
|
||||
w.thunder_time = time;
|
||||
w.raining = raining;
|
||||
w.thundering = thundering;
|
||||
let previous = (
|
||||
self.world.weather.clear_time,
|
||||
self.world.weather.rain_time,
|
||||
self.world.weather.thunder_time,
|
||||
self.world.weather.raining,
|
||||
self.world.weather.thundering,
|
||||
);
|
||||
let mut cancel = false;
|
||||
self.native_weather_change(self.dim.id(), raining, thundering, &mut cancel);
|
||||
let old_thundering = self.world.weather.thundering;
|
||||
let old_thunder_time = self.world.weather.thunder_time;
|
||||
let mut cancel_thunder = false;
|
||||
self.native_thunder_change(self.dim.id(), thundering, &mut cancel_thunder);
|
||||
if cancel {
|
||||
let w = &mut self.world.weather;
|
||||
(
|
||||
w.clear_time,
|
||||
w.rain_time,
|
||||
w.thunder_time,
|
||||
w.raining,
|
||||
w.thundering,
|
||||
) = previous;
|
||||
} else {
|
||||
// ServerLevel.setWeatherParameters.
|
||||
let w = &mut self.world.weather;
|
||||
w.clear_time = clear;
|
||||
w.rain_time = time;
|
||||
w.thunder_time = time;
|
||||
w.raining = raining;
|
||||
w.thundering = thundering;
|
||||
if cancel_thunder {
|
||||
w.thunder_time = old_thunder_time;
|
||||
w.thundering = old_thundering;
|
||||
}
|
||||
}
|
||||
self.feedback(Text::translate(key, &[]));
|
||||
Ok(duration.unwrap_or(-1))
|
||||
}
|
||||
@@ -2222,6 +2292,14 @@ impl Game<'_> {
|
||||
name = Text::translate("entity.minecraft.experience_orb", &[]);
|
||||
}
|
||||
"lightning_bolt" => {
|
||||
let block = [
|
||||
pos[0].floor() as i32,
|
||||
pos[1].floor() as i32,
|
||||
pos[2].floor() as i32,
|
||||
];
|
||||
if !self.lightning_strike_allowed(self.lan_acting_uuid, block, "command") {
|
||||
return Err(err("commands.summon.failed", &[]));
|
||||
}
|
||||
self.spawn_bolt(pos, false);
|
||||
name = Text::translate("entity.minecraft.lightning_bolt", &[]);
|
||||
}
|
||||
@@ -3050,6 +3128,20 @@ impl Game<'_> {
|
||||
let level = self.dim;
|
||||
for &t in &targets {
|
||||
self.for_player(t, |g| {
|
||||
let mut cancel = false;
|
||||
if let (Some(player), Some(name)) = (g.lan_acting_uuid, g.lan_acting_name.clone()) {
|
||||
g.native_player_spawn_point_set(
|
||||
player,
|
||||
&name,
|
||||
level.id(),
|
||||
pos,
|
||||
"command",
|
||||
&mut cancel,
|
||||
);
|
||||
}
|
||||
if cancel {
|
||||
return;
|
||||
}
|
||||
if level == Dim::Overworld {
|
||||
g.spawn = Some(Vec3::new(
|
||||
f64::from(pos[0]) + 0.5,
|
||||
@@ -3106,6 +3198,7 @@ impl Game<'_> {
|
||||
// LevelData.setRespawnData: the world's spawn (level.dat `spawn`).
|
||||
let old = self.level.world_spawn.replace(pos);
|
||||
self.level.world_spawn_angle = rot;
|
||||
self.native_spawn_change(pos, rot);
|
||||
// A player whose fallback is the world spawn (no spawnpoint of its
|
||||
// own; a bed or an anchor that is gone falls back to it) respawns at
|
||||
// the new one.
|
||||
|
||||
@@ -365,6 +365,7 @@ impl Game<'_> {
|
||||
&[shown],
|
||||
));
|
||||
}
|
||||
self.native_world_border_bounds_changed();
|
||||
Ok((distance - current) as i32)
|
||||
}
|
||||
"worldborder.center" => {
|
||||
@@ -382,6 +383,7 @@ impl Game<'_> {
|
||||
));
|
||||
}
|
||||
self.border.set_center([x, z]);
|
||||
self.native_world_border_center_changed();
|
||||
self.feedback(Text::translate(
|
||||
"commands.worldborder.center.success",
|
||||
&[Text::literal(float2(x)), Text::literal(float2(z))],
|
||||
|
||||
@@ -59,6 +59,7 @@ impl Game<'_> {
|
||||
}
|
||||
if level < 7 {
|
||||
self.composter_add(pos, &layers);
|
||||
self.native_compost_item(pos, &stack.item);
|
||||
self.stats.add("used", &stack.item, 1);
|
||||
self.consume_held();
|
||||
}
|
||||
|
||||
@@ -377,6 +377,7 @@ impl Game<'_> {
|
||||
"sculk" => Ok(Some(self.sculk_command(&words)?)),
|
||||
"hello" => Ok(Some(hello())),
|
||||
"status" => Ok(Some(self.control_status())),
|
||||
"memory" => Ok(Some(self.memory_report())),
|
||||
"stall" => {
|
||||
let millis: u64 = arg(1)?.parse()?;
|
||||
if millis > 5000 {
|
||||
@@ -394,7 +395,7 @@ impl Game<'_> {
|
||||
json!({ "game_time": g.world.game_time, "projectiles": g.projectiles_status() })
|
||||
})
|
||||
})
|
||||
.ok_or("the integrated server stopped")?
|
||||
.ok_or(super::integrated::NO_ANSWER)?
|
||||
} else {
|
||||
json!({ "game_time": self.world.game_time, "projectiles": self.projectiles_status() })
|
||||
};
|
||||
@@ -410,7 +411,7 @@ impl Game<'_> {
|
||||
if let Some(server) = &self.integrated {
|
||||
server
|
||||
.call(move |g| g.agent.hold_server = held)
|
||||
.ok_or("the integrated server stopped")?;
|
||||
.ok_or(super::integrated::NO_ANSWER)?;
|
||||
} else if self.remote.is_some() {
|
||||
return Err("server-hold requires the world's host".into());
|
||||
} else {
|
||||
@@ -428,10 +429,10 @@ impl Game<'_> {
|
||||
)
|
||||
.into());
|
||||
}
|
||||
// ConfirmScreen.keyPressed: Escape answers no, nothing else goes past it.
|
||||
// The command confirmation takes the keys; none go past it.
|
||||
if self.command_prompt.is_some() {
|
||||
if key == KeyCode::Escape && arg(2)? != "up" {
|
||||
self.answer_command_prompt(false);
|
||||
if arg(2)? != "up" {
|
||||
self.command_prompt_key(key);
|
||||
}
|
||||
return ok();
|
||||
}
|
||||
@@ -674,7 +675,7 @@ impl Game<'_> {
|
||||
let command = format!("give @s {item} {count}");
|
||||
server
|
||||
.call(move |g| g.owner_console_command(&command))
|
||||
.ok_or("the integrated server stopped")?;
|
||||
.ok_or(super::integrated::NO_ANSWER)?;
|
||||
return Ok(Some(json!({ "ok": true, "given": count })));
|
||||
}
|
||||
let max = info.max_stack;
|
||||
@@ -727,7 +728,7 @@ impl Game<'_> {
|
||||
if let Some(server) = &self.integrated {
|
||||
server
|
||||
.call(move |g| g.world.day_time = time)
|
||||
.ok_or("the integrated server stopped")?;
|
||||
.ok_or(super::integrated::NO_ANSWER)?;
|
||||
}
|
||||
ok()
|
||||
}
|
||||
@@ -746,7 +747,7 @@ impl Game<'_> {
|
||||
let center = self.player.as_ref().map_or(Vec3::ZERO, |p| p.position);
|
||||
let value = server
|
||||
.call(move |g| g.in_owner_level(|g| g.control_mobs_at(radius, center)))
|
||||
.ok_or("the integrated server stopped")?;
|
||||
.ok_or(super::integrated::NO_ANSWER)?;
|
||||
return Ok(Some(value));
|
||||
}
|
||||
Ok(Some(self.control_mobs(radius)))
|
||||
@@ -756,7 +757,7 @@ impl Game<'_> {
|
||||
if let Some(server) = &self.integrated {
|
||||
let value = server
|
||||
.call(|g| g.control_hurt_log())
|
||||
.ok_or("the integrated server stopped")?;
|
||||
.ok_or(super::integrated::NO_ANSWER)?;
|
||||
return Ok(Some(value));
|
||||
}
|
||||
Ok(Some(self.control_hurt_log()))
|
||||
@@ -986,7 +987,7 @@ impl Game<'_> {
|
||||
if let Some(server) = &self.integrated {
|
||||
server
|
||||
.call(move |g| g.control_save(all).map_err(|e| e.to_string()))
|
||||
.ok_or("the integrated server stopped")??;
|
||||
.ok_or(super::integrated::NO_ANSWER)??;
|
||||
} else if self.remote.is_none() {
|
||||
self.begin_control_save(all, reply.clone())?;
|
||||
return Ok(None);
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
//! breeding, conversions, drops), projectiles, player interactions
|
||||
//! (feeding, shearing, milking, spawn eggs, throwing) and rendering.
|
||||
use super::{
|
||||
Dim, Game, InteractTarget, dispenser, native_mob_draw::NativePlayerRender,
|
||||
Dim, Game, Hand, InteractTarget, dispenser, native_mob_draw::NativePlayerRender,
|
||||
native_projectiles::Impact, view_vector,
|
||||
};
|
||||
use horizon_assets::item_model::DisplayContext;
|
||||
@@ -618,6 +618,14 @@ impl Game<'_> {
|
||||
}
|
||||
self.mobs.push(mob);
|
||||
self.native_mobs_joined(self.mobs.len() - 1, false);
|
||||
let transformed_kind = self
|
||||
.mobs
|
||||
.last()
|
||||
.expect("converted mob was just inserted")
|
||||
.kind
|
||||
.id
|
||||
.clone();
|
||||
self.native_entity_transform(from.id, &from.kind.id, id, &transformed_kind);
|
||||
Some(id)
|
||||
}
|
||||
/// The `summon` command: spawn a mob in front of the player or at a position.
|
||||
@@ -1691,7 +1699,16 @@ impl Game<'_> {
|
||||
None => None,
|
||||
};
|
||||
}
|
||||
let player = (p.owner == Some(Who::Player))
|
||||
.then(|| horizon_client::lan::playerdata::ints_uuid(self.acting_player_uuid()));
|
||||
let name = if player.is_some() {
|
||||
self.acting_player_name().to_owned()
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
let (id, kind) = (p.id, p.kind.id());
|
||||
self.creatures.projectiles.push(p);
|
||||
self.native_projectile_launch(player, &name, id, kind);
|
||||
}
|
||||
/// `Entity.sendBubbleColumnParticles` at the top of the column block `pos`: two
|
||||
/// splashes and two bubbles over it, which the level random places, shown here
|
||||
@@ -2309,9 +2326,15 @@ impl Game<'_> {
|
||||
self.with_level(|b, level, ticks| b.set_door_open(level, ticks, pos, open));
|
||||
self.process_level_events()?;
|
||||
}
|
||||
MobEvent::BreakDoor { pos } => {
|
||||
MobEvent::BreakDoor { mob, pos } => {
|
||||
// BreakDoorGoal.tick: removeBlock, then level events 1021
|
||||
// and 2001 with the state that is there now.
|
||||
let kind = self
|
||||
.mobs
|
||||
.iter()
|
||||
.find(|m| m.id == mob)
|
||||
.map(super::native_hooks::mob_kind)
|
||||
.unwrap_or_default();
|
||||
let air = self.world.air;
|
||||
let at = BlockPos::new(pos[0], pos[1], pos[2]);
|
||||
self.with_level(|b, level, ticks| b.set_block(level, ticks, at, air, 3));
|
||||
@@ -2319,6 +2342,7 @@ impl Game<'_> {
|
||||
self.level_event_at(1021, pos, 0);
|
||||
let left = self.world.block(pos[0], pos[1], pos[2]);
|
||||
self.level_event_at(2001, pos, left.0 as i32);
|
||||
self.native_entity_break_door(mob, &kind, pos);
|
||||
}
|
||||
MobEvent::DestroyProgress { mob, pos, progress } => {
|
||||
self.destroy_block_progress(mob as i32, pos, progress);
|
||||
@@ -2344,6 +2368,7 @@ impl Game<'_> {
|
||||
}
|
||||
MobEvent::LevelEvent { id, pos, data } => self.level_event_at(id, pos, data),
|
||||
MobEvent::SetBlockName {
|
||||
mob,
|
||||
pos,
|
||||
name,
|
||||
effect,
|
||||
@@ -2363,7 +2388,19 @@ impl Game<'_> {
|
||||
self.level_event_at(2001, pos, state.0 as i32);
|
||||
}
|
||||
if let Some(state) = self.pack.assets.registry.default_state(name) {
|
||||
let old_name = self
|
||||
.res
|
||||
.facts()
|
||||
.get(self.world.block(pos[0], pos[1], pos[2]))
|
||||
.name
|
||||
.clone();
|
||||
self.level_set_with_flags(pos, state, flags)?;
|
||||
if let Some(mob) = mob
|
||||
&& let Some(m) = self.mobs.iter().find(|m| m.id == mob)
|
||||
{
|
||||
let kind = m.kind.id.clone();
|
||||
self.native_entity_change_block(mob, &kind, pos, &old_name, name);
|
||||
}
|
||||
}
|
||||
}
|
||||
MobEvent::Infest {
|
||||
@@ -2566,7 +2603,12 @@ impl Game<'_> {
|
||||
parent,
|
||||
partner,
|
||||
llama,
|
||||
} => self.breed(parent, partner, llama),
|
||||
} => {
|
||||
let mob_count = self.mobs.len();
|
||||
self.breed(parent, partner, llama);
|
||||
let child = self.mobs.get(mob_count).map(|m| m.id);
|
||||
self.native_entity_breed(parent, partner, child);
|
||||
}
|
||||
MobEvent::LockGaze { mob, partner } => {
|
||||
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == mob) {
|
||||
horizon_client::mobs::animal_brain::lock_gaze(m, partner);
|
||||
@@ -3897,9 +3939,11 @@ impl Game<'_> {
|
||||
ArrowEvent::BubbleColumnParticles { pos } => self.bubble_column_particles(pos),
|
||||
ArrowEvent::Block { pos, reaction } => match reaction {
|
||||
BlockReaction::PrimeTnt => {
|
||||
self.spawn_primed_tnt(pos, false, None, None);
|
||||
self.with_level(|b, level, ticks| b.remove_block(level, ticks, pos, false));
|
||||
let _ = self.process_level_events();
|
||||
if self.tnt_prime_allowed(pos) {
|
||||
self.spawn_primed_tnt(pos, false, None, None);
|
||||
self.with_level(|b, level, ticks| b.remove_block(level, ticks, pos, false));
|
||||
let _ = self.process_level_events();
|
||||
}
|
||||
}
|
||||
BlockReaction::Light(lit) => {
|
||||
let _ = self.level_set([pos.x, pos.y, pos.z], lit);
|
||||
@@ -3988,6 +4032,7 @@ impl Game<'_> {
|
||||
});
|
||||
if let Some(strength) = hit {
|
||||
let _ = self.process_level_events();
|
||||
self.native_target_hit(self.dim.id(), pos, strength);
|
||||
if p.owner == Some(Who::Player) && (p.guest.is_none() || p.guest == self.lan_acting) {
|
||||
self.stats.custom("target_hit", 1);
|
||||
let kind = if p
|
||||
@@ -4862,6 +4907,34 @@ impl Game<'_> {
|
||||
if self.name_mob(index) {
|
||||
return Ok(true);
|
||||
}
|
||||
if self
|
||||
.held()
|
||||
.is_some_and(|stack| &*stack.item == "minecraft:shears")
|
||||
{
|
||||
let mob = &self.mobs[index];
|
||||
let shearable = match mob.species() {
|
||||
Species::Sheep => !mob.extra.sheared && !mob.is_baby(),
|
||||
Species::Mooshroom => !mob.is_baby(),
|
||||
Species::SnowGolem | Species::Bogged => mob.alive() && !mob.extra.sheared,
|
||||
_ => false,
|
||||
};
|
||||
if shearable
|
||||
&& let (Some(player), Some(name)) =
|
||||
(self.lan_acting_uuid, self.lan_acting_name.clone())
|
||||
{
|
||||
let mut cancel = false;
|
||||
self.native_player_shear_entity(
|
||||
player,
|
||||
&name,
|
||||
mob.id as i32,
|
||||
self.hand == Hand::Off,
|
||||
&mut cancel,
|
||||
);
|
||||
if cancel {
|
||||
return Ok(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
if self.spawn_egg_offspring(index) {
|
||||
self.swing_arm();
|
||||
return Ok(true);
|
||||
@@ -4900,9 +4973,10 @@ impl Game<'_> {
|
||||
self.audio
|
||||
.play(sound, Some(self.mobs[index].center()), 1.0, 1.0);
|
||||
self.trigger_item("minecraft:filled_bucket", &filled);
|
||||
let species = bucket.trim_end_matches("_bucket").to_owned();
|
||||
let id = self.mobs[index].id as i32;
|
||||
// Leashable.dropLeash, then discard.
|
||||
let id = self.mobs[index].id;
|
||||
self.break_leash(id, true, false);
|
||||
self.break_leash(id as u32, true, false);
|
||||
self.mobs[index].removed = true;
|
||||
if self.creative {
|
||||
if let Some(rest) = self.give(filled) {
|
||||
@@ -4911,13 +4985,27 @@ impl Game<'_> {
|
||||
} else {
|
||||
*self.held_mut() = Some(filled);
|
||||
}
|
||||
if let (Some(player), Some(name)) = (self.lan_acting_uuid, self.lan_acting_name.clone())
|
||||
{
|
||||
self.native_player_bucket_entity(player, &name, id, &species);
|
||||
}
|
||||
self.swing_arm();
|
||||
return Ok(true);
|
||||
}
|
||||
// AgeableMob.mobInteract: a golden dandelion on a baby freezes its
|
||||
// growth, or lets it grow again.
|
||||
if self.mobs[index].alive() && self.age_lock_takes(&self.mobs[index], &item) {
|
||||
let entity_id = self.mobs[index].id;
|
||||
self.set_age_locked(index);
|
||||
if let (Some(player), Some(name)) = (self.lan_acting_uuid, self.lan_acting_name.clone())
|
||||
{
|
||||
self.native_player_toggle_entity_age_lock(
|
||||
player,
|
||||
&name,
|
||||
entity_id,
|
||||
self.mobs[index].extra.age_locked,
|
||||
);
|
||||
}
|
||||
if let Some(stack) = &held {
|
||||
let target = self.mob_entity(&self.mobs[index]);
|
||||
self.trigger_item_entity("minecraft:player_interacted_with_entity", stack, &target);
|
||||
@@ -5037,6 +5125,7 @@ impl Game<'_> {
|
||||
let mut eat: Option<(&'static str, f32, f32)> = None;
|
||||
let feet = [m.position.x, m.position.y, m.position.z];
|
||||
let mut handled = true;
|
||||
let mut ignited_creeper = false;
|
||||
let mut drops: Vec<(String, u32)> = Vec::new();
|
||||
// The shearing table rolled, for the mods (none for a mooshroom).
|
||||
let mut drops_table = String::new();
|
||||
@@ -5387,6 +5476,7 @@ impl Game<'_> {
|
||||
}
|
||||
Species::Creeper if creeper_igniter => {
|
||||
m.extra.flag = true;
|
||||
ignited_creeper = true;
|
||||
// FIRECHARGE_USE for a fire charge, else FLINTANDSTEEL_USE,
|
||||
// at the creeper's feet with a pitch from its own random.
|
||||
let event = if item == "minecraft:fire_charge" {
|
||||
@@ -5530,8 +5620,11 @@ impl Game<'_> {
|
||||
// Animal.setInLove: entity event 18 (a baby that
|
||||
// only grew is not in love).
|
||||
if self.mobs[index].extra.in_love > 0 {
|
||||
self.love_cause.insert(self.mobs[index].id, self.lan_acting);
|
||||
self.mob_event_particles(self.mobs[index].id, 18);
|
||||
let id = self.mobs[index].id;
|
||||
self.love_cause.insert(id, self.lan_acting);
|
||||
let kind = self.mobs[index].kind.id.clone();
|
||||
self.native_entity_enter_love_mode(id, &kind, self.lan_acting_uuid);
|
||||
self.mob_event_particles(id, 18);
|
||||
}
|
||||
}
|
||||
// Animal.mobInteract still consumes the client click when
|
||||
@@ -5544,6 +5637,13 @@ impl Game<'_> {
|
||||
if !handled {
|
||||
return Ok(false);
|
||||
}
|
||||
if ignited_creeper {
|
||||
self.native_creeper_ignite(
|
||||
self.mobs[index].id,
|
||||
horizon_client::lan::playerdata::ints_uuid(acting),
|
||||
&item,
|
||||
);
|
||||
}
|
||||
if let Some(trusts) = ocelot_trust {
|
||||
// `spawnTrustingParticles` on the clients: entity event 41 (hearts) or 40 (smoke).
|
||||
self.mob_event_particles(self.mobs[index].id, if trusts { 41 } else { 40 });
|
||||
@@ -5837,6 +5937,11 @@ impl Game<'_> {
|
||||
*self.held_mut() = None;
|
||||
}
|
||||
}
|
||||
if kind == ProjectileKind::Egg
|
||||
&& let (Some(player), Some(name)) = (self.lan_acting_uuid, self.lan_acting_name.clone())
|
||||
{
|
||||
self.native_player_egg_throw(player, &name);
|
||||
}
|
||||
Ok(true)
|
||||
}
|
||||
/// `Mob.checkAndHandleImportantInteractions` with a spawn egg of mob
|
||||
|
||||
@@ -255,6 +255,12 @@ impl Game<'_> {
|
||||
}
|
||||
arrow.shoot(&mut self.world.random, dir.to_array(), power, 1.0);
|
||||
self.add_projectile(arrow);
|
||||
let (player, name, projectile) = (
|
||||
horizon_client::lan::playerdata::ints_uuid(self.acting_player_uuid()),
|
||||
self.acting_player_name().to_owned(),
|
||||
self.next_entity,
|
||||
);
|
||||
self.native_entity_shoot_bow(player, &name, projectile, power as f32);
|
||||
}
|
||||
self.player_projectile_shot();
|
||||
let pitch = if index == 0 {
|
||||
|
||||
@@ -1236,7 +1236,7 @@ impl Game<'_> {
|
||||
.iter()
|
||||
.map(|e| (e.user.id, e.level as u8))
|
||||
.collect();
|
||||
host.hub.resend_commands();
|
||||
self.resend_commands_filtered();
|
||||
}
|
||||
|
||||
/// A player on the server, by name.
|
||||
@@ -1457,13 +1457,17 @@ impl Game<'_> {
|
||||
}
|
||||
"whitelist" => self.console_whitelist(state, &rest),
|
||||
// The chat filter's (game/chat_filter.rs), on servers that filter.
|
||||
"unmute"
|
||||
if rest.len() == 1 && self.lan.as_ref().is_some_and(|h| h.hub.chat_filter_on()) =>
|
||||
command
|
||||
if super::chat_filter::MUTE_COMMANDS.contains(&command)
|
||||
&& self.lan.as_ref().is_some_and(|h| h.hub.chat_filter_on()) =>
|
||||
{
|
||||
if let Some(mut host) = self.lan.take() {
|
||||
let reply = self.unmute(&mut host, rest[0], "Server");
|
||||
let words: Vec<&str> = [command].into_iter().chain(rest).collect();
|
||||
let replies = self.mute_command(&mut host, &words, "Server");
|
||||
self.lan = Some(host);
|
||||
self.console_reply(lit(&reply));
|
||||
for reply in replies {
|
||||
self.console_reply(lit(&reply));
|
||||
}
|
||||
}
|
||||
}
|
||||
"ban" if !rest.is_empty() => {
|
||||
@@ -1685,7 +1689,7 @@ impl Game<'_> {
|
||||
match rest {
|
||||
["on"] | ["off"] => {
|
||||
let on = rest[0] == "on";
|
||||
let mut l = state.lists.lock().unwrap();
|
||||
let l = state.lists.lock().unwrap();
|
||||
let turned_on = on && !l.use_whitelist;
|
||||
let key = match (on, l.use_whitelist) {
|
||||
(true, true) => "commands.whitelist.alreadyOn",
|
||||
@@ -1693,9 +1697,8 @@ impl Game<'_> {
|
||||
(false, false) => "commands.whitelist.alreadyOff",
|
||||
(false, true) => "commands.whitelist.disabled",
|
||||
};
|
||||
l.use_whitelist = on;
|
||||
drop(l);
|
||||
self.store_property("white-list", on.to_string());
|
||||
self.set_using_whitelist(&state.lists, on);
|
||||
self.console_reply(Text::translate(key, &[]));
|
||||
if turned_on {
|
||||
let lists = state.lists.clone();
|
||||
@@ -1763,8 +1766,16 @@ impl Game<'_> {
|
||||
/// `DedicatedServer.setUsingWhitelist`: the flag, and `white-list` in
|
||||
/// server.properties.
|
||||
pub(super) fn set_using_whitelist(&mut self, lists: &Arc<Mutex<Lists>>, on: bool) {
|
||||
lists.lock().unwrap().use_whitelist = on;
|
||||
let changed = {
|
||||
let mut lists = lists.lock().unwrap();
|
||||
let changed = lists.use_whitelist != on;
|
||||
lists.use_whitelist = on;
|
||||
changed
|
||||
};
|
||||
self.store_property("white-list", on.to_string());
|
||||
if changed {
|
||||
self.native_whitelist_state_update(on);
|
||||
}
|
||||
}
|
||||
|
||||
/// DedicatedServerSettings.update: change one key and store the file.
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
//! connecting screen in configuration), its inputs, and the actions its
|
||||
//! buttons send back.
|
||||
use super::Game;
|
||||
use super::remote_chat::Behind;
|
||||
use horizon_client::{
|
||||
Result,
|
||||
clipboard::EditShortcut,
|
||||
@@ -35,6 +36,9 @@ struct Open {
|
||||
selecting: Option<usize>,
|
||||
}
|
||||
|
||||
/// A dialog a clicked command closed, kept for its confirmation's no.
|
||||
pub(super) struct ClosedDialog(Box<Open>);
|
||||
|
||||
/// A text input's control: `EditBox`, or `MultiLineEditBox` for a multiline one.
|
||||
enum Field {
|
||||
Line(ui::EditBox),
|
||||
@@ -252,6 +256,12 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// The confirmation's no: the dialog again, with what was typed in it.
|
||||
pub(super) fn reopen_dialog(&mut self, dialog: ClosedDialog) {
|
||||
self.dialogs.open = Some(*dialog.0);
|
||||
self.dialogs.waiting = None;
|
||||
}
|
||||
|
||||
/// `DialogScreen.runAction`: the click event, then the screen the dialog's
|
||||
/// `after_action` leaves.
|
||||
fn run_dialog_action(&mut self, click: Option<Click>, after: AfterAction) -> Result<()> {
|
||||
@@ -266,6 +276,14 @@ impl Game<'_> {
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
// openSendConfirmationWindow's `currentScreen`: a command that asks
|
||||
// first still has this dialog to go back to.
|
||||
let closing = match (&click, &after) {
|
||||
(Some(Click::RunCommand(_)), AfterAction::Close | AfterAction::WaitForResponse) => {
|
||||
self.dialogs.open.take()
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
match after {
|
||||
AfterAction::None => {}
|
||||
AfterAction::Close => self.dialogs.open = None,
|
||||
@@ -284,6 +302,11 @@ impl Game<'_> {
|
||||
// In configuration there is nothing to run it (vanilla warns).
|
||||
if self.player.is_some() {
|
||||
self.send_unattended_command(&command);
|
||||
if let Some(open) = closing {
|
||||
self.command_prompt_returns_to(Behind::Dialog(ClosedDialog(Box::new(
|
||||
open,
|
||||
))));
|
||||
}
|
||||
} else {
|
||||
log::warn!(
|
||||
"Horizon: commands are not supported in configuration, trying to run '{command}'"
|
||||
|
||||
@@ -634,6 +634,13 @@ impl Game<'_> {
|
||||
);
|
||||
}
|
||||
if let Some((left, event)) = mod_dispensed(answer) {
|
||||
if event == FAIL {
|
||||
self.native_block_failed_dispense(
|
||||
self.dim.id(),
|
||||
[pos.0, pos.1, pos.2],
|
||||
&stack.item,
|
||||
);
|
||||
}
|
||||
// DefaultDispenseItemBehavior.dispense: playSound, then playAnimation;
|
||||
// a NOOP behaviour (event 0) plays neither.
|
||||
if event != 0 {
|
||||
@@ -690,6 +697,9 @@ impl Game<'_> {
|
||||
}
|
||||
_ => self.dispense_default_method(pos, facing, stack)?,
|
||||
};
|
||||
if event == FAIL {
|
||||
self.native_block_failed_dispense(self.dim.id(), [pos.0, pos.1, pos.2], &item);
|
||||
}
|
||||
self.dispenser_event(event, pos, 0);
|
||||
self.dispenser_event(2000, pos, data_value(facing));
|
||||
Ok(left)
|
||||
@@ -899,6 +909,12 @@ impl Game<'_> {
|
||||
};
|
||||
self.player_equipped(name, Some(&one));
|
||||
self.apply_equipment();
|
||||
self.native_block_dispense_armor(
|
||||
self.dim.id(),
|
||||
[pos.0, pos.1, pos.2],
|
||||
&stack.item,
|
||||
"player",
|
||||
);
|
||||
return true;
|
||||
}
|
||||
Target::Stand(i) => {
|
||||
@@ -932,6 +948,16 @@ impl Game<'_> {
|
||||
let ctx = self.who_context(who, None);
|
||||
self.post_game_event("minecraft:equip", [at.x, at.y, at.z], ctx);
|
||||
}
|
||||
self.native_block_dispense_armor(
|
||||
self.dim.id(),
|
||||
[pos.0, pos.1, pos.2],
|
||||
&stack.item,
|
||||
match target {
|
||||
Target::Stand(_) => "armor_stand",
|
||||
Target::Mob(_) => "mob",
|
||||
Target::Player => "player",
|
||||
},
|
||||
);
|
||||
true
|
||||
}
|
||||
/// After `EntityType.spawn` / `MobBucketItem.spawn`: `mob.playAmbientSound()`.
|
||||
@@ -1247,10 +1273,10 @@ impl Game<'_> {
|
||||
horizon_client::items::bucket_contents(&item).expect("filled bucket dispatcher");
|
||||
let target_pos = Self::pos_of(target);
|
||||
let emptied = self.with_level(|b, level, ticks| {
|
||||
b.bucket_empty(level, ticks, target_pos, content, sound, false, false)
|
||||
b.bucket_empty(level, ticks, target_pos, None, content, sound, false, false)
|
||||
});
|
||||
self.process_level_events()?;
|
||||
if !emptied {
|
||||
if emptied.is_none() {
|
||||
return Ok((self.dispense_default(pos, facing, stack), CLICK));
|
||||
}
|
||||
if self.dim == super::Dim::Nether
|
||||
@@ -1354,6 +1380,9 @@ impl Game<'_> {
|
||||
if self
|
||||
.with_level(|b, level, ticks| b.fire_can_be_placed_at(level, ticks, target_pos, facing))
|
||||
{
|
||||
if !self.native_block_ignite_allowed(target_pos, "flint_and_steel") {
|
||||
return Ok((Some(stack), CLICK));
|
||||
}
|
||||
let fire =
|
||||
self.with_level(|b, level, ticks| b.fire_state_for(level, ticks, target_pos));
|
||||
self.level_set([target.0, target.1, target.2], fire)?;
|
||||
@@ -1374,7 +1403,7 @@ impl Game<'_> {
|
||||
None,
|
||||
);
|
||||
} else if &*name == "minecraft:tnt" {
|
||||
if self.ui.rules.bool("tnt_explodes") {
|
||||
if self.ui.rules.bool("tnt_explodes") && self.tnt_prime_allowed(target_pos) {
|
||||
self.spawn_primed_tnt(target_pos, false, None, None);
|
||||
self.with_level(|b, level, ticks| b.remove_block(level, ticks, target_pos, false));
|
||||
self.process_level_events()?;
|
||||
@@ -1435,9 +1464,13 @@ impl Game<'_> {
|
||||
if self.sulfur_cube_swallow(front, &stack) {
|
||||
return (shrink(stack), optional_event("tnt"));
|
||||
}
|
||||
let target = Self::pos_of(front);
|
||||
if !self.tnt_prime_allowed(target) {
|
||||
set_success("tnt", false);
|
||||
return (Some(stack), FAIL);
|
||||
}
|
||||
set_success("tnt", true);
|
||||
// new PrimedTnt(level, x + 0.5, y, z + 0.5, null), its sound and ENTITY_PLACE.
|
||||
let target = Self::pos_of(front);
|
||||
let rot = self.world.random.next_double() * f64::from(std::f32::consts::TAU);
|
||||
self.add_primed_tnt(target, rot, 80, None, None);
|
||||
self.audio.play(
|
||||
@@ -1746,10 +1779,7 @@ impl Game<'_> {
|
||||
let random = self.rand_sequences.get(&sequence, self.level.seed);
|
||||
world_items!(self).loot_table_from(table, false, held, random)
|
||||
}
|
||||
None => self
|
||||
.res
|
||||
.items()
|
||||
.loot_table(table, false, held, &mut self.world.random),
|
||||
None => world_items!(self).loot_table(table, false, held, &mut self.world.random),
|
||||
}
|
||||
}
|
||||
/// `Entity.spawnAtLocation(level, stack, offset)`: an item at the entity's
|
||||
@@ -1818,6 +1848,8 @@ impl Game<'_> {
|
||||
continue;
|
||||
}
|
||||
let at = m.position;
|
||||
let entity_id = m.id;
|
||||
let kind = m.kind.id.clone();
|
||||
let sound_at = Some([at.x, at.y, at.z]);
|
||||
match m.species() {
|
||||
Species::SulfurCube if sulfur_cube::has_body(m) => {
|
||||
@@ -1936,6 +1968,12 @@ impl Game<'_> {
|
||||
}
|
||||
_ => continue,
|
||||
}
|
||||
self.native_block_shear_entity(
|
||||
self.dim.id(),
|
||||
[target.0, target.1, target.2],
|
||||
entity_id,
|
||||
&kind,
|
||||
);
|
||||
self.post_block_game_event(
|
||||
"minecraft:shear",
|
||||
[target.0, target.1, target.2],
|
||||
|
||||
@@ -17,6 +17,7 @@ use horizon_client::{
|
||||
projectiles::ProjectileKind,
|
||||
text::Text,
|
||||
};
|
||||
use horizon_core::position::BlockPos;
|
||||
use horizon_core::{
|
||||
geometry::{Aabb, Vec3},
|
||||
random::{LegacyRandom, RandomSource},
|
||||
@@ -24,6 +25,42 @@ use horizon_core::{
|
||||
use horizon_protocol::entity_data::Value;
|
||||
use horizon_world::nbt::Tag;
|
||||
|
||||
/// `HealOrHarmMobEffect.applyInstantaneousEffect` at an `AreaEffectCloud`'s
|
||||
/// 0.5 scale: `(int) (scale * (6 << amplifier) + 0.5)`.
|
||||
fn harm_amount(amplifier: u32) -> f32 {
|
||||
(0.5 * f64::from(6u32 << amplifier) + 0.5) as i32 as f32
|
||||
}
|
||||
|
||||
/// Whether a cloud at `at` (its floor) reaches a living entity: its box is
|
||||
/// `radius * 2` wide and 0.5 tall, and the entity's position must lie within
|
||||
/// the radius horizontally.
|
||||
fn cloud_hits(at: [f64; 3], radius: f32, bounds: Aabb, pos: Vec3) -> bool {
|
||||
let r = f64::from(radius);
|
||||
let area = Aabb::new(
|
||||
Vec3::new(at[0] - r, at[1], at[2] - r),
|
||||
Vec3::new(at[0] + r, at[1] + 0.5, at[2] + r),
|
||||
);
|
||||
let (dx, dz) = (pos.x - at[0], pos.z - at[2]);
|
||||
bounds.intersects(area) && dx * dx + dz * dz <= f64::from(radius * radius)
|
||||
}
|
||||
|
||||
/// `DragonFireball.onHit`: the cloud moves to the first living entity in the
|
||||
/// fireball's box inflated by (4, 2, 4) that is within 4 blocks of it.
|
||||
/// `feet` is the fireball's position (the bottom of its 1x1x1 box).
|
||||
fn fireball_cloud_origin(feet: [f64; 3], targets: &[(Aabb, Vec3)]) -> [f64; 3] {
|
||||
let area = Aabb::new(
|
||||
Vec3::new(feet[0] - 4.5, feet[1] - 2.0, feet[2] - 4.5),
|
||||
Vec3::new(feet[0] + 4.5, feet[1] + 3.0, feet[2] + 4.5),
|
||||
);
|
||||
for (bounds, pos) in targets {
|
||||
let (dx, dy, dz) = (pos.x - feet[0], pos.y - feet[1], pos.z - feet[2]);
|
||||
if bounds.intersects(area) && dx * dx + dy * dy + dz * dz < 16.0 {
|
||||
return [pos.x, pos.y, pos.z];
|
||||
}
|
||||
}
|
||||
feet
|
||||
}
|
||||
|
||||
/// Mob ids from here on are dragon parts (`SPECIAL + part`) and crystals
|
||||
/// (`SPECIAL + 16 + crystal`), so projectiles can target them.
|
||||
pub(super) const SPECIAL: u32 = u32::MAX - 4096;
|
||||
@@ -1897,6 +1934,17 @@ impl Game<'_> {
|
||||
};
|
||||
super::view_vector(d.yaw, pitch).map(|v| v)
|
||||
}
|
||||
/// `getHeadLookVector(1).normalize()`, which the phases' `doClientTick`s
|
||||
/// take (a look the dragon has never turned from stays the zero vector).
|
||||
fn dragon_look_normalised(&self) -> [f64; 3] {
|
||||
let look = self.dragon_look();
|
||||
let norm = (look[0] * look[0] + look[1] * look[1] + look[2] * look[2]).sqrt();
|
||||
if norm < 1.0e-5 {
|
||||
[0.0; 3]
|
||||
} else {
|
||||
[look[0] / norm, look[1] / norm, look[2] / norm]
|
||||
}
|
||||
}
|
||||
/// `EnderDragon.aiStep` (and `tickDeath`) for the dragon.
|
||||
fn tick_dragon(&mut self) {
|
||||
let Some(d) = self.dragon.as_mut() else {
|
||||
@@ -2272,7 +2320,17 @@ impl Game<'_> {
|
||||
f.full_collision || f.legacy_solid
|
||||
})
|
||||
.map_or(64, |y| y + 1);
|
||||
let _ = self.level_set([0, y, 0], egg);
|
||||
let pos = BlockPos::new(0, y, 0);
|
||||
let allowed = self.with_level(|_, level, _| {
|
||||
let mut cancel = false;
|
||||
if level.native_authoritative {
|
||||
level.native_dragon_egg_form(level.dimension, pos, &mut cancel);
|
||||
}
|
||||
!cancel
|
||||
});
|
||||
if allowed {
|
||||
let _ = self.level_set([0, y, 0], egg);
|
||||
}
|
||||
}
|
||||
self.fight.previously_killed = true;
|
||||
self.fight.killed = true;
|
||||
@@ -2312,6 +2370,13 @@ impl Game<'_> {
|
||||
.filter(|p| p.alive && !p.spectator)
|
||||
.map(|p| p.bounds)
|
||||
.collect();
|
||||
// DragonFireball.onHit snaps to any living entity (mobs not ported here).
|
||||
let snap_targets: Vec<(Aabb, Vec3)> = self
|
||||
.fight_players()
|
||||
.iter()
|
||||
.filter(|p| p.alive)
|
||||
.map(|p| (p.bounds, p.position))
|
||||
.collect();
|
||||
balls.retain_mut(|b| {
|
||||
b.previous = b.position;
|
||||
b.age += 1;
|
||||
@@ -2341,9 +2406,13 @@ impl Game<'_> {
|
||||
});
|
||||
if hit_block || hit_player || b.age > 600 {
|
||||
let id = self.take_entity_id();
|
||||
// The cloud starts at the fireball's feet; DragonFireball.onHit
|
||||
// then moves it to the first living entity within 4 blocks.
|
||||
let feet = [next.x, next.y - 0.5, next.z];
|
||||
let at = fireball_cloud_origin(feet, &snap_targets);
|
||||
self.breath_clouds.push(Breath {
|
||||
id,
|
||||
at: [next.x, next.y, next.z],
|
||||
at,
|
||||
radius: 3.0,
|
||||
age: 0,
|
||||
duration: 600,
|
||||
@@ -2425,13 +2494,11 @@ impl Game<'_> {
|
||||
}
|
||||
// AreaEffectCloud: after its 20-tick wait, every 20 ticks per entity.
|
||||
if c.age >= 20 && c.age % 20 == 0 {
|
||||
// Instant damage: I (6) from the breath, II (12) from a fireball.
|
||||
let amount = if c.from_dragon { 6.0 } else { 12.0 };
|
||||
// Instant damage I from the flame cloud, II from a fireball's,
|
||||
// both at the cloud's 0.5 scale.
|
||||
let amount = harm_amount(u32::from(!c.from_dragon));
|
||||
for p in &players {
|
||||
let pp = p.position;
|
||||
if (pp.x - c.at[0]).hypot(pp.z - c.at[2]) <= f64::from(r)
|
||||
&& (pp.y - c.at[1]).abs() < 1.0
|
||||
{
|
||||
if cloud_hits(c.at, r, p.bounds, p.position) {
|
||||
harmed.push((p.who, amount));
|
||||
}
|
||||
}
|
||||
@@ -2571,6 +2638,67 @@ impl DragonFireball {
|
||||
);
|
||||
}
|
||||
}
|
||||
/// `DragonLandingPhase.doClientTick` and `DragonSittingFlamingPhase.doClientTick`:
|
||||
/// the dragon-breath particles one tick of `doClientTick` spawns at the head, as
|
||||
/// (position, velocity) pairs. `look` is `EnderDragon.getHeadLookVector(1)`
|
||||
/// normalised, `head` the head part's centre (`getY(0.5)`), `motion` the
|
||||
/// dragon's `getDeltaMovement`, and `random` the stream the jitter comes from.
|
||||
/// Both phases rotate `look` by a quarter turn and throw the result away, so
|
||||
/// every puff of a fan leaves along the same line; a flaming dragon walks down
|
||||
/// that line in six steps.
|
||||
fn breath_puffs(
|
||||
phase: &Phase,
|
||||
look: [f64; 3],
|
||||
head: [f64; 3],
|
||||
motion: [f64; 3],
|
||||
random: &mut LegacyRandom,
|
||||
) -> Vec<([f64; 3], [f64; 3])> {
|
||||
// A puff's start: the head's centre jittered by `nextGaussian() / 2`.
|
||||
let jitter = |random: &mut LegacyRandom| {
|
||||
[
|
||||
head[0] + random.next_gaussian() / 2.0,
|
||||
head[1] + random.next_gaussian() / 2.0,
|
||||
head[2] + random.next_gaussian() / 2.0,
|
||||
]
|
||||
};
|
||||
match phase {
|
||||
// Eight a tick, along `-look * 0.08F` (0.3 for the vertical part) plus
|
||||
// the dragon's own motion.
|
||||
Phase::Landing { .. } => (0..8)
|
||||
.map(|_| {
|
||||
(
|
||||
jitter(random),
|
||||
[
|
||||
-look[0] * f64::from(0.08_f32) + motion[0],
|
||||
-look[1] * f64::from(0.3_f32) + motion[1],
|
||||
-look[2] * f64::from(0.08_f32) + motion[2],
|
||||
],
|
||||
)
|
||||
})
|
||||
.collect(),
|
||||
// Every second tick of the first ten: eight starts, each a fan of six
|
||||
// puffs down the look line, the vertical part of the speed once.
|
||||
Phase::Flaming { ticks } if ticks % 2 == 0 && *ticks < 10 => {
|
||||
let mut puffs = Vec::with_capacity(48);
|
||||
for _ in 0..8 {
|
||||
let at = jitter(random);
|
||||
for j in 0..6 {
|
||||
let j = f64::from(j);
|
||||
puffs.push((
|
||||
at,
|
||||
[
|
||||
-look[0] * f64::from(0.08_f32) * j,
|
||||
-look[1] * f64::from(0.6_f32),
|
||||
-look[2] * f64::from(0.08_f32) * j,
|
||||
],
|
||||
));
|
||||
}
|
||||
}
|
||||
puffs
|
||||
}
|
||||
_ => Vec::new(),
|
||||
}
|
||||
}
|
||||
pub(super) struct Breath {
|
||||
/// Its entity id once a server's players track it (0 until then).
|
||||
pub(super) id: u32,
|
||||
@@ -3768,13 +3896,7 @@ impl Game<'_> {
|
||||
/// away in vanilla, so every puff leaves along the same line), the third
|
||||
/// puffs an explosion every 10 ticks and the last growls on every tick.
|
||||
fn remote_dragon_phase_tick(&mut self) {
|
||||
let look = self.dragon_look();
|
||||
let norm = (look[0] * look[0] + look[1] * look[1] + look[2] * look[2]).sqrt();
|
||||
let look = if norm < 1.0e-5 {
|
||||
[0.0; 3]
|
||||
} else {
|
||||
[look[0] / norm, look[1] / norm, look[2] / norm]
|
||||
};
|
||||
let look = self.dragon_look_normalised();
|
||||
let Some(d) = self.dragon.as_mut() else {
|
||||
return;
|
||||
};
|
||||
@@ -3782,51 +3904,20 @@ impl Game<'_> {
|
||||
let head_y = head.y + PART_SIZES[0].1 * 0.5;
|
||||
let motion = d.velocity;
|
||||
let at = [d.position.x, d.position.y, d.position.z];
|
||||
let mut puffs: Vec<([f64; 3], [f64; 3])> = Vec::new();
|
||||
// `flameTicks`: the client copy's own counter, advanced before it is read.
|
||||
if let Phase::Flaming { ticks } = &mut d.phase {
|
||||
*ticks += 1;
|
||||
}
|
||||
let puffs = breath_puffs(
|
||||
&d.phase,
|
||||
look,
|
||||
[head.x, head_y, head.z],
|
||||
[motion.x, motion.y, motion.z],
|
||||
&mut d.random,
|
||||
);
|
||||
let mut emitter = None;
|
||||
let mut growl = None;
|
||||
match &mut d.phase {
|
||||
Phase::Landing { .. } => {
|
||||
for _ in 0..8 {
|
||||
let r = &mut d.random;
|
||||
let p = [
|
||||
head.x + r.next_gaussian() / 2.0,
|
||||
head_y + r.next_gaussian() / 2.0,
|
||||
head.z + r.next_gaussian() / 2.0,
|
||||
];
|
||||
puffs.push((
|
||||
p,
|
||||
[
|
||||
-look[0] * f64::from(0.08_f32) + motion.x,
|
||||
-look[1] * f64::from(0.3_f32) + motion.y,
|
||||
-look[2] * f64::from(0.08_f32) + motion.z,
|
||||
],
|
||||
));
|
||||
}
|
||||
}
|
||||
Phase::Flaming { ticks } => {
|
||||
*ticks += 1;
|
||||
if *ticks % 2 == 0 && *ticks < 10 {
|
||||
for _ in 0..8 {
|
||||
let r = &mut d.random;
|
||||
let p = [
|
||||
head.x + r.next_gaussian() / 2.0,
|
||||
head_y + r.next_gaussian() / 2.0,
|
||||
head.z + r.next_gaussian() / 2.0,
|
||||
];
|
||||
for j in 0..6 {
|
||||
puffs.push((
|
||||
p,
|
||||
[
|
||||
-look[0] * f64::from(0.08_f32) * f64::from(j),
|
||||
-look[1] * f64::from(0.6_f32),
|
||||
-look[2] * f64::from(0.08_f32) * f64::from(j),
|
||||
],
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Phase::Dying { time, .. } => {
|
||||
if *time % 10 == 0 {
|
||||
let r = &mut d.random;
|
||||
@@ -3975,6 +4066,53 @@ fn push_seen_with_hurt(
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn player_box(feet: [f64; 3]) -> (Aabb, Vec3) {
|
||||
(
|
||||
Aabb::new(
|
||||
Vec3::new(feet[0] - 0.3, feet[1], feet[2] - 0.3),
|
||||
Vec3::new(feet[0] + 0.3, feet[1] + 1.8, feet[2] + 0.3),
|
||||
),
|
||||
Vec3::new(feet[0], feet[1], feet[2]),
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn instant_damage_at_cloud_scale() {
|
||||
assert_eq!(harm_amount(0), 3.0);
|
||||
assert_eq!(harm_amount(1), 6.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cloud_box_is_half_a_block_tall_from_its_floor() {
|
||||
let at = [0.0, 64.0, 0.0];
|
||||
let hit = |feet: [f64; 3], r: f32| {
|
||||
let (b, p) = player_box(feet);
|
||||
cloud_hits(at, r, b, p)
|
||||
};
|
||||
assert!(hit([0.0, 64.0, 0.0], 3.0));
|
||||
// Feet above the 0.5 box: the old |dy| < 1 test hit here.
|
||||
assert!(!hit([0.0, 64.6, 0.0], 3.0));
|
||||
// Feet below, head reaching into the box.
|
||||
assert!(hit([0.0, 63.0, 0.0], 3.0));
|
||||
// Box overlaps but the centre is outside the radius.
|
||||
assert!(!hit([3.2, 64.0, 0.0], 3.0));
|
||||
assert!(hit([3.0, 64.0, 0.0], 3.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fireball_cloud_snaps_to_a_player_within_four() {
|
||||
let (b, p) = player_box([2.0, 70.0, 1.0]);
|
||||
assert_eq!(
|
||||
fireball_cloud_origin([0.0, 70.0, 0.0], &[(b, p)]),
|
||||
[2.0, 70.0, 1.0]
|
||||
);
|
||||
let (b, p) = player_box([4.5, 70.0, 0.0]);
|
||||
assert_eq!(
|
||||
fireball_cloud_origin([0.0, 70.0, 0.0], &[(b, p)]),
|
||||
[0.0, 70.0, 0.0]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dying_dragons_keep_their_flight_pose_between_frames() {
|
||||
let mut dragon = Dragon::new(Vec3::new(0.0, 80.0, 0.0), 0.0, 432);
|
||||
@@ -4094,6 +4232,107 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// `DragonLandingPhase.doClientTick`: eight puffs a tick from the head,
|
||||
/// along `getHeadLookVector(1)` (a quarter turn of it is thrown away in
|
||||
/// vanilla) plus the dragon's own motion.
|
||||
#[test]
|
||||
fn a_landing_dragon_breathes_eight_puffs_from_its_head() {
|
||||
let look = [0.0, -0.6, 0.8];
|
||||
let head = [10.0, 70.5, -3.0];
|
||||
let motion = [0.25, -0.05, 0.5];
|
||||
let mut random = LegacyRandom::new(7);
|
||||
let puffs = breath_puffs(
|
||||
&Phase::Landing { target: None },
|
||||
look,
|
||||
head,
|
||||
motion,
|
||||
&mut random,
|
||||
);
|
||||
assert_eq!(puffs.len(), 8);
|
||||
let speed = [
|
||||
-look[0] * f64::from(0.08_f32) + motion[0],
|
||||
-look[1] * f64::from(0.3_f32) + motion[1],
|
||||
-look[2] * f64::from(0.08_f32) + motion[2],
|
||||
];
|
||||
for (at, v) in puffs {
|
||||
assert_eq!(v, speed);
|
||||
// The head's centre, jittered by `nextGaussian() / 2`.
|
||||
for a in 0..3 {
|
||||
assert!(
|
||||
(at[a] - head[a]).abs() < 2.0,
|
||||
"puff {} off the head: {at:?}",
|
||||
a
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `DragonSittingFlamingPhase.doClientTick`: on the even ticks of the first
|
||||
/// ten, eight starts each breathe a fan of six puffs down the look line.
|
||||
#[test]
|
||||
fn a_flaming_dragon_breathes_a_fan_on_every_second_tick() {
|
||||
let look = [0.6, -0.0, 0.8];
|
||||
let head = [0.0, 65.5, 4.0];
|
||||
for (tick, want) in [(1, 0), (2, 48), (3, 0), (4, 48), (8, 48), (9, 0), (10, 0)] {
|
||||
let mut random = LegacyRandom::new(11);
|
||||
let puffs = breath_puffs(
|
||||
&Phase::Flaming { ticks: tick },
|
||||
look,
|
||||
head,
|
||||
[0.0; 3],
|
||||
&mut random,
|
||||
);
|
||||
assert_eq!(puffs.len(), want, "flame tick {tick}");
|
||||
}
|
||||
let mut random = LegacyRandom::new(11);
|
||||
let puffs = breath_puffs(
|
||||
&Phase::Flaming { ticks: 2 },
|
||||
look,
|
||||
head,
|
||||
[0.0; 3],
|
||||
&mut random,
|
||||
);
|
||||
for (i, (at, v)) in puffs.iter().enumerate() {
|
||||
let j = (i % 6) as f64;
|
||||
// Six puffs leave from each of the eight starts.
|
||||
assert_eq!(at, &puffs[i - i % 6].0);
|
||||
assert_eq!(
|
||||
*v,
|
||||
[
|
||||
-look[0] * f64::from(0.08_f32) * j,
|
||||
-look[1] * f64::from(0.6_f32),
|
||||
-look[2] * f64::from(0.08_f32) * j,
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// No other phase has a `doClientTick` that breathes.
|
||||
#[test]
|
||||
fn only_the_landing_and_flaming_phases_breathe() {
|
||||
let mut random = LegacyRandom::new(3);
|
||||
for phase in [
|
||||
flying(),
|
||||
Phase::Scanning { time: 5 },
|
||||
Phase::Hover { target: None },
|
||||
Phase::Takeoff {
|
||||
first: false,
|
||||
path: None,
|
||||
target: None,
|
||||
},
|
||||
Phase::Attacking { ticks: 3 },
|
||||
Phase::Dying {
|
||||
target: None,
|
||||
time: 0,
|
||||
},
|
||||
] {
|
||||
assert!(
|
||||
breath_puffs(&phase, [0.0, -1.0, 0.0], [0.0; 3], [0.0; 3], &mut random).is_empty(),
|
||||
"{phase:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_perched_dragon_shrugs_arrows_off() {
|
||||
// AbstractDragonSittingPhase.onHurt: an arrow does nothing, melee does.
|
||||
|
||||
@@ -584,9 +584,21 @@ impl Game<'_> {
|
||||
return amount;
|
||||
}
|
||||
current.damage -= repaired;
|
||||
let item = current.item.to_string();
|
||||
let player = (self.serving.is_some() || self.options.dedicated.is_some())
|
||||
.then(|| self.native_acting_id());
|
||||
let remaining = amount.saturating_sub(
|
||||
(u64::from(repaired) * u64::from(amount) / u64::from(to_repair_xp)) as u32,
|
||||
);
|
||||
if let Some(player) = player
|
||||
&& let Some(name) = self
|
||||
.native_server_players()
|
||||
.into_iter()
|
||||
.find(|p| p.uuid == player)
|
||||
.map(|p| p.name)
|
||||
{
|
||||
self.native_player_item_mend(player, &name, &item, repaired, amount);
|
||||
}
|
||||
if remaining == 0 {
|
||||
return 0;
|
||||
}
|
||||
|
||||
@@ -706,6 +706,7 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
}
|
||||
self.native_portal_create("minecraft:the_end", origin);
|
||||
// Direction.WEST.toYRot().
|
||||
return Ok(Some((target, Some(90.0))));
|
||||
}
|
||||
@@ -1302,8 +1303,8 @@ impl Game<'_> {
|
||||
target
|
||||
};
|
||||
let survive = self.world.random.next_int_bound(5).unwrap_or(0) > 0;
|
||||
self.next_entity += 1;
|
||||
let id = self.next_entity;
|
||||
let at = [p.position.x, p.position.y, p.position.z];
|
||||
let id = self.take_entity_id();
|
||||
self.end.eyes.push(EyeOfEnder {
|
||||
id,
|
||||
position: start,
|
||||
@@ -1315,7 +1316,6 @@ impl Game<'_> {
|
||||
});
|
||||
let r = self.world.random.next_float();
|
||||
let pitch = 0.33 + r * (0.5 - 0.33);
|
||||
let at = [p.position.x, p.position.y, p.position.z];
|
||||
self.audio
|
||||
.play("minecraft:entity.ender_eye.launch", Some(at), 1.0, pitch);
|
||||
self.consume_held();
|
||||
|
||||
@@ -224,10 +224,10 @@ impl Game<'_> {
|
||||
let lifetime = 10 * (1 + i32::from(flight))
|
||||
+ r.next_int_bound(6).unwrap_or(0)
|
||||
+ r.next_int_bound(7).unwrap_or(0);
|
||||
// An id from the entity counter at once, so a command can name it.
|
||||
self.next_entity += 1;
|
||||
// Entity.<init>: the id comes from the one counter at construction.
|
||||
let id = self.take_entity_id();
|
||||
Rocket {
|
||||
id: self.next_entity,
|
||||
id,
|
||||
position: at,
|
||||
previous: at,
|
||||
velocity,
|
||||
@@ -345,6 +345,9 @@ impl Game<'_> {
|
||||
self.consume_held();
|
||||
}
|
||||
self.swing_arm();
|
||||
if let (Some(player), Some(name)) = (self.lan_acting_uuid, self.lan_acting_name.clone()) {
|
||||
self.native_player_elytra_boost(player, &name);
|
||||
}
|
||||
true
|
||||
}
|
||||
pub(super) fn tick_rockets(&mut self) {
|
||||
@@ -517,6 +520,7 @@ impl Game<'_> {
|
||||
let v = [r.velocity.x, r.velocity.y, r.velocity.z];
|
||||
self.particles
|
||||
.fireworks(at, v, r.explosions.clone(), camera);
|
||||
self.native_firework_explode(r.id, at, r.explosions.len());
|
||||
}
|
||||
fn rocket_hurt_mobs(&mut self, hits: &[(u32, f32)], attacker: Option<Who>, at: [f64; 3]) {
|
||||
for &(id, amount) in hits {
|
||||
|
||||
@@ -210,6 +210,8 @@ impl Game<'_> {
|
||||
} else if let Some(item) = self.dropped.iter_mut().find(|i| i.id == id) {
|
||||
item.velocity = pulled(item.velocity);
|
||||
hooked_item = true;
|
||||
} else if let Some(t) = self.tnts.iter_mut().find(|t| t.id != 0 && t.id == id) {
|
||||
t.velocity = pulled(t.velocity);
|
||||
} else if self.creatures.hook_targets.iter().any(|t| t.id == id) {
|
||||
// A player: the hub moves them when the tick ends.
|
||||
self.creatures
|
||||
@@ -607,6 +609,13 @@ impl Game<'_> {
|
||||
.iter()
|
||||
.find(|i| touches(i.bounds()))
|
||||
.map(|i| i.id)
|
||||
})
|
||||
// A lit TNT is pickable too (`PrimedTnt.isPickable`).
|
||||
.or_else(|| {
|
||||
self.tnts
|
||||
.iter()
|
||||
.find(|t| t.id != 0 && touches(t.bounds()))
|
||||
.map(|t| t.id)
|
||||
});
|
||||
if let Some(id) = hit {
|
||||
// Projectile.onHit on an entity: PROJECTILE_LAND.
|
||||
@@ -674,6 +683,12 @@ impl Game<'_> {
|
||||
.iter()
|
||||
.find(|i| i.id == id)
|
||||
.map(|i| (i.position, horizon_client::entities::ItemEntity::WIDTH))
|
||||
})
|
||||
.or_else(|| {
|
||||
self.tnts
|
||||
.iter()
|
||||
.find(|t| t.id != 0 && t.id == id)
|
||||
.map(|t| (t.position, super::tnt::PrimedTnt::SIZE))
|
||||
});
|
||||
match hooked {
|
||||
Some((at, height)) => h.position = Vec3::new(at.x, at.y + height * 0.8, at.z),
|
||||
|
||||
@@ -125,6 +125,17 @@ impl Game<'_> {
|
||||
.iter()
|
||||
.any(|o| o.id != f.id && o.facing == f.facing && overlaps(o.bounds()))
|
||||
}
|
||||
/// Asks the mods whether the acting guest may place a hanging entity
|
||||
/// of type `kind` at `pos` (`native_hanging_place`): true when one
|
||||
/// cancelled it. A game's own player is not asked.
|
||||
pub(super) fn hanging_place_cancelled(&mut self, kind: &str, pos: [i32; 3]) -> bool {
|
||||
let (Some(uuid), Some(name)) = (self.lan_acting_uuid, self.lan_acting_name.clone()) else {
|
||||
return false;
|
||||
};
|
||||
let mut cancel = false;
|
||||
self.native_hanging_place(uuid, &name, kind, pos, &mut cancel);
|
||||
cancel
|
||||
}
|
||||
/// `ItemFrameItem.useOn`.
|
||||
pub(super) fn place_item_frame(&mut self, hit: &Hit, stack: &ItemStack) -> bool {
|
||||
let glow = match &*stack.item {
|
||||
@@ -162,6 +173,14 @@ impl Game<'_> {
|
||||
if !self.frame_survives(&frame) {
|
||||
return false;
|
||||
}
|
||||
let kind = if glow {
|
||||
"minecraft:glow_item_frame"
|
||||
} else {
|
||||
"minecraft:item_frame"
|
||||
};
|
||||
if self.hanging_place_cancelled(kind, pos) {
|
||||
return true;
|
||||
}
|
||||
self.next_entity += 1;
|
||||
self.audio
|
||||
.play(&sound(&frame, "place"), Some(center(&frame)), 1.0, 1.0);
|
||||
@@ -224,11 +243,14 @@ impl Game<'_> {
|
||||
return false;
|
||||
}
|
||||
let at = center(&self.item_frames[i]);
|
||||
let entity_id = self.item_frames[i].id;
|
||||
let action;
|
||||
if self.item_frames[i].item.is_none() {
|
||||
let Some(held) = self.held().cloned() else {
|
||||
return false;
|
||||
};
|
||||
self.item_frames[i].item = Some(ItemStack { count: 1, ..held });
|
||||
action = "put";
|
||||
self.audio
|
||||
.play(&sound(&self.item_frames[i], "add_item"), Some(at), 1.0, 1.0);
|
||||
if !self.creative {
|
||||
@@ -237,12 +259,21 @@ impl Game<'_> {
|
||||
} else {
|
||||
let f = &mut self.item_frames[i];
|
||||
f.rotation = (f.rotation + 1) % 8;
|
||||
action = "rotate";
|
||||
let s = sound(f, "rotate_item");
|
||||
self.audio.play(&s, Some(at), 1.0, 1.0);
|
||||
}
|
||||
let p = self.item_frames[i].pos;
|
||||
self.world.modified.insert((p[0] >> 4, p[2] >> 4));
|
||||
self.frame_output_changed(p);
|
||||
if let (Some(player), Some(name)) = (self.lan_acting_uuid, self.lan_acting_name.clone()) {
|
||||
let frame = self.item_frames.iter().find(|f| f.id == entity_id);
|
||||
let item = frame
|
||||
.and_then(|f| f.item.as_ref())
|
||||
.map_or_else(String::new, |s| s.item.to_string());
|
||||
let rotation = frame.map_or(0, |f| f.rotation);
|
||||
self.native_player_item_frame_change(player, &name, entity_id, action, &item, rotation);
|
||||
}
|
||||
self.swing_arm();
|
||||
true
|
||||
}
|
||||
@@ -264,6 +295,13 @@ impl Game<'_> {
|
||||
return true;
|
||||
}
|
||||
let with_frame = self.item_frames[i].item.is_none();
|
||||
let entity_id = self.item_frames[i].id;
|
||||
let old_item = self.item_frames[i]
|
||||
.item
|
||||
.as_ref()
|
||||
.map_or("", |s| &*s.item)
|
||||
.to_owned();
|
||||
let rotation = self.item_frames[i].rotation;
|
||||
let at = center(&self.item_frames[i]);
|
||||
let f = if with_frame {
|
||||
self.item_frames.remove(i)
|
||||
@@ -284,6 +322,16 @@ impl Game<'_> {
|
||||
if f.item.is_some() {
|
||||
self.frame_output_changed(f.pos);
|
||||
}
|
||||
if let (Some(player), Some(name)) = (self.lan_acting_uuid, self.lan_acting_name.clone()) {
|
||||
self.native_player_item_frame_change(
|
||||
player,
|
||||
&name,
|
||||
entity_id,
|
||||
if with_frame { "break" } else { "remove_item" },
|
||||
if with_frame { "" } else { &old_item },
|
||||
rotation,
|
||||
);
|
||||
}
|
||||
true
|
||||
}
|
||||
/// `ItemFrame.setItem` / `setRotation`: `updateNeighbourForOutputSignal`, so
|
||||
|
||||
@@ -32,12 +32,19 @@ use std::{
|
||||
sync::{
|
||||
Arc, Mutex,
|
||||
atomic::{AtomicBool, AtomicI32, AtomicU16, AtomicU32, Ordering},
|
||||
mpsc::{Receiver, Sender, channel, sync_channel},
|
||||
mpsc::{Receiver, RecvTimeoutError, Sender, channel, sync_channel},
|
||||
},
|
||||
thread::JoinHandle,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
/// How long the window waits for the server thread to answer a `call`.
|
||||
const CALL_TIMEOUT: Duration = Duration::from_secs(10);
|
||||
/// The error of a `call` without an answer: the server stopped, or it was
|
||||
/// busy past `CALL_TIMEOUT` (the log says which).
|
||||
pub(super) const NO_ANSWER: &str =
|
||||
"the integrated server stopped or did not answer within 10 s (the game log says which)";
|
||||
|
||||
/// `LevelLoadProgressTracker.EXPECTED_PLAYER_CHUNKS`: the 7x7 chunks around the spawn.
|
||||
const PLAYER_CHUNKS: usize = 49;
|
||||
|
||||
@@ -177,7 +184,9 @@ impl Integrated {
|
||||
Some(self.flags.published.load(Ordering::Relaxed)).filter(|&p| p != 0)
|
||||
}
|
||||
/// `executeBlocking`: run `f` on the server thread between two ticks and
|
||||
/// wait for its answer; `None` once the server has stopped.
|
||||
/// wait for its answer; `None` once the server has stopped, or when it has
|
||||
/// not answered within `CALL_TIMEOUT` (`f` still runs there later). The log
|
||||
/// tells the two apart.
|
||||
pub fn call<T: Send + 'static>(
|
||||
&self,
|
||||
f: impl FnOnce(&mut Game<'_>) -> T + Send + 'static,
|
||||
@@ -188,7 +197,21 @@ impl Integrated {
|
||||
let _ = tx.send(f(g));
|
||||
}))
|
||||
.ok()?;
|
||||
rx.recv_timeout(Duration::from_secs(10)).ok()
|
||||
match rx.recv_timeout(CALL_TIMEOUT) {
|
||||
Ok(answer) => Some(answer),
|
||||
Err(RecvTimeoutError::Timeout) => {
|
||||
log::warn!(
|
||||
"Horizon: the server thread did not answer within {} s (running: {}, \
|
||||
autosaving: {}, smoothed tick {:.1} ms)",
|
||||
CALL_TIMEOUT.as_secs(),
|
||||
!self.stopped(),
|
||||
self.is_saving(),
|
||||
self.smoothed_tick_ms()
|
||||
);
|
||||
None
|
||||
}
|
||||
Err(RecvTimeoutError::Disconnected) => None,
|
||||
}
|
||||
}
|
||||
/// `IntegratedServer.halt(true)`: returns once the world is saved and let go.
|
||||
pub fn stop(mut self) -> Result<()> {
|
||||
|
||||
@@ -1133,7 +1133,7 @@ impl Game<'_> {
|
||||
// The hub is the server thread's.
|
||||
let status = server
|
||||
.call(|g| g.lan_status())
|
||||
.ok_or("the integrated server stopped")?;
|
||||
.ok_or(super::integrated::NO_ANSWER)?;
|
||||
return Ok(Some(status));
|
||||
}
|
||||
match words.get(1).copied() {
|
||||
@@ -1225,7 +1225,7 @@ impl Game<'_> {
|
||||
// ShareToLanScreen: IntegratedServer.publishServer.
|
||||
server
|
||||
.call(move |g| g.publish(settings))
|
||||
.ok_or("the integrated server stopped")??
|
||||
.ok_or(super::integrated::NO_ANSWER)??
|
||||
} else if self.serving() {
|
||||
return self.publish(settings).map(drop).map_err(Into::into);
|
||||
} else {
|
||||
@@ -1708,8 +1708,8 @@ impl Game<'_> {
|
||||
host.hub.config.guest_commands = effective;
|
||||
host.hub.config.force_game_mode = force;
|
||||
enforce_guest_game_mode(host, &self.world, self.collision);
|
||||
host.hub.resend_commands();
|
||||
}
|
||||
self.resend_commands_filtered();
|
||||
}
|
||||
|
||||
pub(super) fn lan_port(&self) -> Option<u16> {
|
||||
@@ -1884,6 +1884,27 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
HostEvent::Joined(conn) => {
|
||||
// The mods may refuse the login before the player is placed.
|
||||
if let Some((uuid, name)) =
|
||||
host.hub.guest(conn).map(|g| (g.uuid, g.name.clone()))
|
||||
{
|
||||
let mut cancel = false;
|
||||
let mut reason = None;
|
||||
self.native_player_login(uuid, &name, &mut cancel, &mut reason);
|
||||
if cancel {
|
||||
let reason = match reason {
|
||||
Some(text) => {
|
||||
horizon_client::lan::data::component_nbt(&text.component())
|
||||
}
|
||||
None => horizon_protocol::text::translatable(
|
||||
"multiplayer.disconnect.generic",
|
||||
&[],
|
||||
),
|
||||
};
|
||||
host.hub.kick(conn, reason);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
self.lan_guest_joined(&mut host, conn, state, entities, spawn);
|
||||
if self.options.dedicated.is_some()
|
||||
&& let Some(g) = host.hub.guest(conn)
|
||||
@@ -1996,6 +2017,18 @@ impl Game<'_> {
|
||||
HostEvent::Flight { conn, flying } => {
|
||||
self.lan_flight_toggled(&mut host, conn, flying);
|
||||
}
|
||||
HostEvent::MainHandChanged(conn, left) => {
|
||||
if let Some(g) = host.hub.guest(conn) {
|
||||
let (uuid, name) = (g.uuid, g.name.clone());
|
||||
self.native_player_change_main_hand(uuid, &name, left);
|
||||
}
|
||||
}
|
||||
HostEvent::LocaleChanged(conn, locale) => {
|
||||
if let Some(g) = host.hub.guest(conn) {
|
||||
let (uuid, name) = (g.uuid, g.name.clone());
|
||||
self.native_player_locale_change(uuid, &name, &locale);
|
||||
}
|
||||
}
|
||||
HostEvent::Packet(conn, packet) => {
|
||||
self.lan_guest_packet(&mut host, conn, *packet);
|
||||
if !host.commands.is_empty() {
|
||||
@@ -2381,6 +2414,8 @@ impl Game<'_> {
|
||||
g.dimension = in_place;
|
||||
}
|
||||
self.load_guest_records(host, conn);
|
||||
// The mods say which commands the newcomer's tree keeps.
|
||||
self.native_filter_commands(host, Some(conn));
|
||||
let mut theirs = States::default();
|
||||
let entities = if elsewhere {
|
||||
if let Err(e) = self.enter_level(Some(host), level) {
|
||||
@@ -2857,7 +2892,12 @@ impl Game<'_> {
|
||||
.map(|s| s.as_ref().map(|s| s.item.clone()));
|
||||
m.extra.armor = g.armor.clone();
|
||||
m.extra.crouching = g.sneaking;
|
||||
m.extra.avatar_pose = g.pose();
|
||||
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.
|
||||
|
||||
@@ -663,6 +663,28 @@ impl Game<'_> {
|
||||
}
|
||||
// A totem that saved the guest this turn (its hurt set it off).
|
||||
let totem = self.apply_totem();
|
||||
// The mods hear the food level the turn changed (eating, hunger,
|
||||
// starving...), once for the change it made as a whole, with the
|
||||
// guest's state still swapped in; they may change or undo it before it
|
||||
// reaches the guest.
|
||||
if let Some(now) = self.player.as_ref().map(|p| p.health.food.food)
|
||||
&& let Some(g) = host.hub.guests.get(swap.i)
|
||||
&& g.food != now
|
||||
{
|
||||
let (uuid, name, from, saturation) = (g.uuid, g.name.clone(), g.food, g.saturation);
|
||||
let (mut to, mut cancel) = (now, false);
|
||||
self.native_food_level_changed(uuid, &name, from, &mut to, &mut cancel);
|
||||
if let Some(p) = self.player.as_mut() {
|
||||
if cancel {
|
||||
p.health.food.food = from;
|
||||
p.health.food.saturation = saturation;
|
||||
} else if to != now {
|
||||
p.health.food.food = to.clamp(0, 20);
|
||||
p.health.food.saturation =
|
||||
p.health.food.saturation.min(p.health.food.food as f32);
|
||||
}
|
||||
}
|
||||
}
|
||||
let GuestSwap {
|
||||
conn,
|
||||
i,
|
||||
@@ -1025,6 +1047,8 @@ impl Game<'_> {
|
||||
if let Some(mob) = guest_mount {
|
||||
self.lan_open_mount(host, conn, mob);
|
||||
}
|
||||
// The turn's teleports are in the hub now: the mods hear them.
|
||||
self.flush_teleport_notes(host);
|
||||
}
|
||||
|
||||
/// `ServerLevel.broadcastDamageEvent` for a guest that took an unblocked hit.
|
||||
@@ -1098,6 +1122,31 @@ impl Game<'_> {
|
||||
self.creative = self.mode == GameMode::Creative;
|
||||
}
|
||||
|
||||
/// Whether the mods let the guest open the menu `kind` (`native_container_opened`),
|
||||
/// heard with the guest's state swapped in. Asked before the menu that
|
||||
/// was open closes.
|
||||
pub(super) fn guest_may_open(
|
||||
&mut self,
|
||||
host: &mut LanHost,
|
||||
conn: u32,
|
||||
kind: &str,
|
||||
pos: Option<[i32; 3]>,
|
||||
) -> bool {
|
||||
let Some((uuid, name)) = host.hub.guest(conn).map(|g| (g.uuid, g.name.clone())) else {
|
||||
return true;
|
||||
};
|
||||
let mut cancel = false;
|
||||
let mut hook =
|
||||
|game: &mut Self| game.native_container_opened(uuid, &name, kind, pos, &mut cancel);
|
||||
// The guest whose turn it is is in place already.
|
||||
if self.lan_acting == Some(conn) {
|
||||
hook(self);
|
||||
} else {
|
||||
self.as_guest(host, conn, hook);
|
||||
}
|
||||
!cancel
|
||||
}
|
||||
|
||||
/// Hand a container the guest opened over (ServerPlayer.openMenu);
|
||||
/// false when it is not one a guest can open.
|
||||
fn lan_open_container(&mut self, host: &mut LanHost, conn: u32, pos: (i32, i32, i32)) -> bool {
|
||||
@@ -1202,10 +1251,15 @@ impl Game<'_> {
|
||||
Slots::Block(k) => [Some(*k), None],
|
||||
_ => [None, None],
|
||||
};
|
||||
let locked = halves
|
||||
.iter()
|
||||
.flatten()
|
||||
.any(|&k| self.container_locked_for(k, spectator, hand.as_ref()));
|
||||
let Some((uuid, name)) = host.hub.guest(conn).map(|g| (g.uuid, g.name.clone())) else {
|
||||
return false;
|
||||
};
|
||||
let mut locked = false;
|
||||
for key in halves.iter().flatten().copied() {
|
||||
let mut check = self.container_locked_for(key, spectator, hand.as_ref());
|
||||
self.native_block_lock_check(uuid, &name, [key.0, key.1, key.2], &mut check);
|
||||
locked |= check;
|
||||
}
|
||||
if locked {
|
||||
host.hub.send_to(
|
||||
conn,
|
||||
@@ -1246,6 +1300,15 @@ impl Game<'_> {
|
||||
if !horizon_client::containers::menu_reaches(brand, menu) {
|
||||
return false;
|
||||
}
|
||||
// The mods may stop the menu; the click is spent.
|
||||
if !self.guest_may_open(host, conn, menu, Some([pos.0, pos.1, pos.2])) {
|
||||
return true;
|
||||
}
|
||||
let mut cancel = false;
|
||||
self.native_inventory_block_start(uuid, &name, [pos.0, pos.1, pos.2], &mut cancel);
|
||||
if cancel {
|
||||
return false;
|
||||
}
|
||||
// ServerPlayer.openMenu closes the menu that was open.
|
||||
self.guest_close_all(host, conn);
|
||||
let hidden = self.native_hidden_slots(pos, items.len());
|
||||
@@ -1294,6 +1357,10 @@ impl Game<'_> {
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
// The mods may stop the menu; the click is spent.
|
||||
if !self.guest_may_open(host, conn, menu, None) {
|
||||
return true;
|
||||
}
|
||||
// ServerPlayer.openMenu closes the menu that was open.
|
||||
self.guest_close_all(host, conn);
|
||||
host.hub
|
||||
@@ -1784,6 +1851,25 @@ impl Game<'_> {
|
||||
// SWAP_ITEM_WITH_OFFHAND: the hands swap and the item in use stops
|
||||
// (ServerGamePacketListenerImpl.handlePlayerAction), not for spectators.
|
||||
7 if !spectator => {
|
||||
// The mods hear the swap first, and may stop it: the
|
||||
// client, which swapped at once, gets its slots back.
|
||||
let (uuid, name) = {
|
||||
let g = &host.hub.guests[gi];
|
||||
(g.uuid, g.name.clone())
|
||||
};
|
||||
// With the player's state swapped in, so that the helpers
|
||||
// reach them.
|
||||
let cancel = self
|
||||
.as_guest(host, conn, |g| {
|
||||
let mut cancel = false;
|
||||
g.native_player_swap_hands(uuid, &name, &mut cancel);
|
||||
cancel
|
||||
})
|
||||
.unwrap_or(false);
|
||||
if cancel {
|
||||
host.hub.send_inventory(gi);
|
||||
return;
|
||||
}
|
||||
let g = &mut host.hub.guests[gi];
|
||||
let slot = g.selected;
|
||||
std::mem::swap(&mut g.inventory[slot], &mut g.offhand);
|
||||
@@ -1796,6 +1882,8 @@ impl Game<'_> {
|
||||
g.player_on_equip("offhand", main.as_ref(), offhand.as_ref());
|
||||
g.player_on_equip("mainhand", offhand.as_ref(), main.as_ref());
|
||||
});
|
||||
let guest = &host.hub.guests[gi];
|
||||
self.native_player_swap_with_equipment_slot(guest.uuid, &guest.name, "offhand");
|
||||
}
|
||||
// STAB: `PiercingWeapon.attack` of a left click with a spear.
|
||||
8 if !spectator => self.guest_jab(host, conn),
|
||||
@@ -1803,11 +1891,37 @@ impl Game<'_> {
|
||||
},
|
||||
// handleSetCarriedItem: a new slot stops a main-hand use.
|
||||
S::SetCarriedItem(p) => {
|
||||
let g = &mut host.hub.guests[gi];
|
||||
if !(0..9).contains(&p.slot) {
|
||||
eprintln!("{} tried to set an invalid carried item", g.name);
|
||||
eprintln!(
|
||||
"{} tried to set an invalid carried item",
|
||||
host.hub.guests[gi].name
|
||||
);
|
||||
return;
|
||||
}
|
||||
let (uuid, name, old_slot) = {
|
||||
let g = &host.hub.guests[gi];
|
||||
(g.uuid, g.name.clone(), g.selected)
|
||||
};
|
||||
if old_slot != p.slot as usize {
|
||||
let mut cancel = false;
|
||||
self.native_player_item_held(
|
||||
uuid,
|
||||
&name,
|
||||
old_slot as i32,
|
||||
i32::from(p.slot),
|
||||
&mut cancel,
|
||||
);
|
||||
if cancel {
|
||||
host.hub.send_to(
|
||||
conn,
|
||||
&C::SetHeldSlot(play::SetHeldSlot {
|
||||
slot: old_slot as i32,
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
let g = &mut host.hub.guests[gi];
|
||||
let changed = g.selected != p.slot as usize;
|
||||
g.selected = p.slot as usize;
|
||||
if let Some(play) = host.play.get_mut(&conn)
|
||||
@@ -1824,10 +1938,15 @@ impl Game<'_> {
|
||||
// click on nothing), seen by the players around, and the attack
|
||||
// strength starts again.
|
||||
S::Punch(_) => {
|
||||
let (player, name) = {
|
||||
let g = &host.hub.guests[gi];
|
||||
(g.uuid, g.name.clone())
|
||||
};
|
||||
self.as_guest(host, conn, |g| {
|
||||
g.swing_hand(Hand::Main, SwingAnimation::DEFAULT);
|
||||
g.reset_attack_strength();
|
||||
});
|
||||
self.native_player_attack_cooldown_reset(player, &name);
|
||||
}
|
||||
S::UseItemOn(p) => {
|
||||
host.hub.ack(conn, p.sequence);
|
||||
@@ -2034,6 +2153,9 @@ impl Game<'_> {
|
||||
if !self.creative_stack_valid(host, gi, &stack) {
|
||||
return;
|
||||
}
|
||||
if !self.guest_may_set_creative_slot(host, conn, i32::from(p.slot), Some(&stack)) {
|
||||
return;
|
||||
}
|
||||
let play = host.play.entry(conn).or_default();
|
||||
if play.creative_drops >= 1480 {
|
||||
eprintln!(
|
||||
@@ -2058,6 +2180,12 @@ impl Game<'_> {
|
||||
{
|
||||
return;
|
||||
}
|
||||
if !self.guest_may_set_creative_slot(host, conn, i32::from(p.slot), item.as_ref()) {
|
||||
// The creative client already holds the stack it sent: treat that as
|
||||
// what it was told, so the next broadcast sends the real slot back.
|
||||
host.hub.guests[gi].menu_sent[p.slot as usize] = item;
|
||||
return;
|
||||
}
|
||||
let slot = p.slot as usize;
|
||||
let g = &mut host.hub.guests[gi];
|
||||
let old = g.menu_slot(slot);
|
||||
@@ -2096,6 +2224,22 @@ impl Game<'_> {
|
||||
});
|
||||
}
|
||||
S::PlayerCommand(p) if p.action == 6 => self.lan_start_gliding(host, conn),
|
||||
// ServerboundResourcePackPacket in play: the server has nothing to
|
||||
// do with it (a declined required pack is handled on the connection).
|
||||
S::ResourcePack(r) => {
|
||||
let (uuid, name) = (host.hub.guests[gi].uuid, host.hub.guests[gi].name.clone());
|
||||
self.as_guest(host, conn, |g| {
|
||||
g.native_resource_pack_status(uuid, &name, r.action);
|
||||
});
|
||||
}
|
||||
// START_SPRINTING and STOP_SPRINTING: the hub has set the sprint
|
||||
// and sends the packet on when it changed it.
|
||||
S::PlayerCommand(p) if matches!(p.action, 1 | 2) => {
|
||||
let (uuid, name) = (host.hub.guests[gi].uuid, host.hub.guests[gi].name.clone());
|
||||
self.as_guest(host, conn, |g| {
|
||||
g.native_player_sprint(uuid, &name, p.action == 1);
|
||||
});
|
||||
}
|
||||
S::ContainerButtonClick(p) => {
|
||||
self.lan_station_button(host, conn, p.container, p.button);
|
||||
}
|
||||
@@ -2241,6 +2385,13 @@ impl Game<'_> {
|
||||
S::SetGameRule(p) if host.hub.guests[gi].permission >= 2 => {
|
||||
for entry in p.entries {
|
||||
if self.ui.rules.set_string(&entry.name, &entry.value) {
|
||||
let guest = &host.hub.guests[gi];
|
||||
self.native_world_game_rule_change(
|
||||
guest.uuid,
|
||||
&guest.name,
|
||||
&entry.name,
|
||||
&entry.value,
|
||||
);
|
||||
let message = horizon_client::text::Text::tr(
|
||||
"commands.gamerule.set",
|
||||
&[&entry.name, &entry.value],
|
||||
@@ -2364,7 +2515,7 @@ impl Game<'_> {
|
||||
// handlePickItemFromBlock: a block within the block reach plus 1, its
|
||||
// data only with infinite materials.
|
||||
S::PickItemFromBlock(p) => {
|
||||
self.guest_pick_item(host, conn, |g| {
|
||||
self.guest_pick_item(host, conn, false, p.pos, 0, |g| {
|
||||
// handlePickItemFromBlock: within reach and loaded.
|
||||
if !g.world.loaded(p.pos[0], p.pos[2]) {
|
||||
return None;
|
||||
@@ -2385,7 +2536,7 @@ impl Game<'_> {
|
||||
}
|
||||
// handlePickItemFromEntity.
|
||||
S::PickItemFromEntity(p) => {
|
||||
self.guest_pick_item(host, conn, |g| {
|
||||
self.guest_pick_item(host, conn, true, [0; 3], p.id, |g| {
|
||||
g.within_entity_range(p.id)
|
||||
.then(|| g.entity_pick_result(p.id))
|
||||
.flatten()
|
||||
@@ -2427,11 +2578,22 @@ impl Game<'_> {
|
||||
&mut self,
|
||||
host: &mut LanHost,
|
||||
conn: u32,
|
||||
from_entity: bool,
|
||||
pos: [i32; 3],
|
||||
entity_id: i32,
|
||||
stack: impl FnOnce(&mut Self) -> Option<ItemStack>,
|
||||
) {
|
||||
let Some((uuid, name)) = host.hub.guest(conn).map(|g| (g.uuid, g.name.clone())) else {
|
||||
return;
|
||||
};
|
||||
let picked = self.as_guest(host, conn, |g| {
|
||||
let stack = stack(g)?;
|
||||
let infinite = g.creative;
|
||||
let mut cancel = false;
|
||||
g.native_player_pick_item(uuid, &name, from_entity, pos, entity_id, &mut cancel);
|
||||
if cancel {
|
||||
return None;
|
||||
}
|
||||
horizon_client::pick_item::pick(
|
||||
&mut g.inventory[..36],
|
||||
&mut g.selected,
|
||||
@@ -2452,6 +2614,32 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the mods let a creative player set `slot` to `stack`
|
||||
/// (`native_creative_slot`), heard with the guest's state swapped in.
|
||||
fn guest_may_set_creative_slot(
|
||||
&mut self,
|
||||
host: &mut LanHost,
|
||||
conn: u32,
|
||||
slot: i32,
|
||||
stack: Option<&ItemStack>,
|
||||
) -> bool {
|
||||
let Some((uuid, name)) = host.hub.guest(conn).map(|g| (g.uuid, g.name.clone())) else {
|
||||
return true;
|
||||
};
|
||||
let (item, count) = stack.map_or(("minecraft:air", 0), |s| (&*s.item, s.count as i32));
|
||||
let mut cancel = false;
|
||||
let mut hook = |game: &mut Self| {
|
||||
game.native_creative_slot(uuid, &name, slot, item, count, &mut cancel)
|
||||
};
|
||||
// The guest whose turn it is is in place already.
|
||||
if self.lan_acting == Some(conn) {
|
||||
hook(self);
|
||||
} else {
|
||||
self.as_guest(host, conn, hook);
|
||||
}
|
||||
!cancel
|
||||
}
|
||||
|
||||
/// What ServerboundSetCreativeModeSlotPacket asks of a stack. Its codec
|
||||
/// (`ItemStack.validatedStreamCodec`) encodes the stack with `ItemStack.CODEC`, whose count is
|
||||
/// 1 to 99: more fails the packet and disconnects the player, as vanilla's decoder error does.
|
||||
@@ -2927,9 +3115,9 @@ impl Game<'_> {
|
||||
);
|
||||
return;
|
||||
}
|
||||
// The chat filter's `/unmute` (game/chat_filter.rs), on servers that filter.
|
||||
if words[0] == "unmute" && host.hub.chat_filter_on() {
|
||||
self.guest_unmute(host, conn, &words);
|
||||
// The chat filter's mute commands (game/chat_filter.rs), on servers that filter.
|
||||
if super::chat_filter::MUTE_COMMANDS.contains(&words[0]) && host.hub.chat_filter_on() {
|
||||
self.guest_mute_command(host, conn, &words);
|
||||
return;
|
||||
}
|
||||
// A native mod's command, for every player its level allows,
|
||||
@@ -3100,6 +3288,11 @@ impl Game<'_> {
|
||||
let Some(permission) = host.hub.guest(conn).map(|g| g.permission) else {
|
||||
return;
|
||||
};
|
||||
let Some((player_uuid, player_name)) =
|
||||
host.hub.guest(conn).map(|g| (g.uuid, g.name.clone()))
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let skip = usize::from(text.starts_with('/'));
|
||||
let command = &text[skip..];
|
||||
let d = vanilla::game_dispatcher();
|
||||
@@ -3146,6 +3339,13 @@ impl Game<'_> {
|
||||
let parse = d.parse(command, &self.ui.registries);
|
||||
let ((start, end), mut suggestions) =
|
||||
d.suggestions(&parse, command.len(), &self.ui.registries);
|
||||
self.native_tab_complete(
|
||||
player_uuid,
|
||||
&player_name,
|
||||
command,
|
||||
[start, end],
|
||||
&mut suggestions,
|
||||
);
|
||||
let reg = &mut self.ui.registries;
|
||||
reg.permission = level;
|
||||
reg.players = own_players;
|
||||
@@ -3619,6 +3819,11 @@ impl Game<'_> {
|
||||
/// teleport the client there.
|
||||
fn wake_guest_from(&mut self, host: &mut LanHost, gi: usize, head: [i32; 3]) {
|
||||
let yaw = host.hub.guests[gi].yaw;
|
||||
let (player, name) = {
|
||||
let g = &host.hub.guests[gi];
|
||||
(g.uuid, g.name.clone())
|
||||
};
|
||||
self.native_player_bed_leave(player, &name, head);
|
||||
let Some((stand, yaw)) = self.bed_wake_pose(head, yaw) else {
|
||||
return;
|
||||
};
|
||||
@@ -3664,7 +3869,10 @@ impl Game<'_> {
|
||||
/// sleeper who died or whose bed is gone.
|
||||
fn guest_sleep_tick(&mut self, host: &mut LanHost, gi: usize) {
|
||||
let g = &mut host.hub.guests[gi];
|
||||
let was_deep = g.sleep_timer >= 100;
|
||||
g.sleep_timer = super::tick_sleep_timer(g.sleep_timer, g.bed.is_some());
|
||||
let deep_sleep = !was_deep && g.sleep_timer >= 100 && g.bed.is_some();
|
||||
let identity = deep_sleep.then(|| (g.uuid, g.name.clone()));
|
||||
if let Some(head) = g.bed {
|
||||
let block = self.world.block(head[0], head[1], head[2]);
|
||||
let bed = self.res.facts().get(block).name.ends_with("_bed");
|
||||
@@ -3678,6 +3886,9 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some((player, name)) = identity {
|
||||
self.native_player_deep_sleep(player, &name);
|
||||
}
|
||||
}
|
||||
|
||||
/// `SleepStatus.update`'s counts over the host's player and the guests:
|
||||
@@ -4117,6 +4328,7 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
if let (Some(v), Some(g)) = (marked, host.hub.guest(conn)) {
|
||||
self.native_player_velocity(g.uuid, &g.name, [v.x, v.y, v.z]);
|
||||
g.send(&C::SetEntityMotion(play::SetEntityMotion {
|
||||
id: g.entity_id,
|
||||
velocity: [v.x, v.y, v.z],
|
||||
@@ -4139,8 +4351,16 @@ impl Game<'_> {
|
||||
.is_some_and(|p| p.fluids.was_touching_water || p.is_in_lava())
|
||||
})
|
||||
.unwrap_or(true);
|
||||
let mut changed = None;
|
||||
if let Some(g) = host.hub.guest_mut(conn) {
|
||||
g.fall_flying = start && !in_liquid;
|
||||
let gliding = start && !in_liquid;
|
||||
if g.fall_flying != gliding {
|
||||
g.fall_flying = gliding;
|
||||
changed = Some((g.uuid, g.name.clone(), gliding));
|
||||
}
|
||||
}
|
||||
if let Some((uuid, name, gliding)) = changed {
|
||||
self.native_entity_toggle_glide(uuid, &name, gliding);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4315,6 +4535,8 @@ impl Game<'_> {
|
||||
let swinging = play.swing.swinging();
|
||||
if progress != play.last_progress && progress >= 0 {
|
||||
play.last_progress = progress;
|
||||
let guest = &host.hub.guests[gi];
|
||||
self.native_block_break_progress_update(guest.uuid, &guest.name, pos, progress);
|
||||
// The host player sees it too (on the server thread its window is a guest).
|
||||
self.destroy_block_progress(host.hub.guests[gi].entity_id, pos, progress);
|
||||
host.hub.broadcast_near(
|
||||
@@ -4456,7 +4678,7 @@ impl Game<'_> {
|
||||
/// (`handleTakeItemEntity`).
|
||||
fn guest_arrows(&mut self, host: &mut LanHost, gi: usize) {
|
||||
use horizon_client::arrows::Touch;
|
||||
let (conn, id, at, creative, unable, dims) = {
|
||||
let (conn, id, at, creative, unable, dims, player, name) = {
|
||||
let g = &host.hub.guests[gi];
|
||||
(
|
||||
g.conn,
|
||||
@@ -4465,6 +4687,8 @@ impl Game<'_> {
|
||||
g.creative(),
|
||||
g.spectator() || g.dead || g.health <= 0.0,
|
||||
g.dimensions(),
|
||||
g.uuid,
|
||||
g.name.clone(),
|
||||
)
|
||||
};
|
||||
if unable || self.creatures.projectiles.is_empty() {
|
||||
@@ -4508,6 +4732,9 @@ impl Game<'_> {
|
||||
});
|
||||
self.creatures.projectiles = projectiles;
|
||||
for shot in picked {
|
||||
if let Some(arrow) = shot.arrow.as_ref() {
|
||||
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(
|
||||
@@ -4526,11 +4753,13 @@ impl Game<'_> {
|
||||
fn guest_orbs(&mut self, host: &mut LanHost, gi: usize) {
|
||||
let g = &host.hub.guests[gi];
|
||||
let height = f64::from(g.dimensions().height);
|
||||
let (conn, id, at) = (g.conn, g.entity_id, g.position);
|
||||
let (conn, id, at, player, name) =
|
||||
(g.conn, g.entity_id, g.position, g.uuid, g.name.clone());
|
||||
let unable = g.spectator() || g.dead || g.health <= 0.0;
|
||||
let play = host.play.entry(conn).or_default();
|
||||
if play.xp_delay > 0 {
|
||||
play.xp_delay -= 1;
|
||||
self.native_player_exp_cooldown_change(player, &name, play.xp_delay);
|
||||
}
|
||||
if unable || play.xp_delay > 0 {
|
||||
return;
|
||||
@@ -4598,6 +4827,7 @@ impl Game<'_> {
|
||||
self.orbs[index].count -= 1;
|
||||
}
|
||||
host.play.entry(conn).or_default().xp_delay = 2;
|
||||
self.native_player_exp_cooldown_change(player, &name, 2);
|
||||
host.hub.take_item(orb.id as i32, id, 1, at);
|
||||
// ClientPacketListener.handleTakeItemEntity's sound, at the orb.
|
||||
let pitch = (self.world.random.next_float() - self.world.random.next_float()) * 0.35 + 0.9;
|
||||
@@ -4620,7 +4850,14 @@ impl Game<'_> {
|
||||
self.as_guest(host, conn, |game| {
|
||||
let rest = game.mending(value);
|
||||
if rest > 0 {
|
||||
game.give_orb_points(rest);
|
||||
let (Some(player), Some(name)) =
|
||||
(game.lan_acting_uuid, game.lan_acting_name.clone())
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let mut amount = rest.min(i32::MAX as u32) as i32;
|
||||
game.native_player_exp_change(player, &name, &mut amount);
|
||||
game.give_orb_points(amount.max(0) as u32);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -175,7 +175,9 @@ impl Game<'_> {
|
||||
/// `ServerPlayer.setCamera`: the view moves to the new camera, which
|
||||
/// the client is told, or back to the player (`None`).
|
||||
pub(super) fn guest_set_camera(&mut self, host: &mut LanHost, gi: usize, camera: Option<i32>) {
|
||||
let (conn, own) = (host.hub.guests[gi].conn, host.hub.guests[gi].entity_id);
|
||||
let guest = &host.hub.guests[gi];
|
||||
let (conn, own, uuid, name) = (guest.conn, guest.entity_id, guest.uuid, guest.name.clone());
|
||||
let previous = self.cmds.guest_cameras.get(&conn).map(|c| c.id);
|
||||
let camera = camera.filter(|id| *id != own);
|
||||
if self.cmds.guest_cameras.get(&conn).map(|c| c.id) == camera {
|
||||
return;
|
||||
@@ -208,6 +210,11 @@ impl Game<'_> {
|
||||
if let Some(c) = self.cmds.guest_cameras.get_mut(&conn) {
|
||||
c.teleports = teleports;
|
||||
}
|
||||
if camera.is_none()
|
||||
&& let Some(entity_id) = previous
|
||||
{
|
||||
self.native_player_stop_spectating_entity(uuid, &name, entity_id);
|
||||
}
|
||||
}
|
||||
|
||||
/// The camera part of `ServerPlayer.tick` for every guest, after the
|
||||
|
||||
@@ -206,6 +206,12 @@ impl Game<'_> {
|
||||
.filter(|&s| menu.slot(s) != before[s].as_ref())
|
||||
.collect::<Vec<_>>()
|
||||
});
|
||||
let guest = &host.hub.guests[gi];
|
||||
let (uuid, name) = (guest.uuid, guest.name.clone());
|
||||
let mut result = menu.slot(0).cloned();
|
||||
self.native_prepare_item_craft(uuid, &name, None, &mut result);
|
||||
let env = self.menu_env();
|
||||
menu.set_slot(&env, 0, result);
|
||||
let g = &mut host.hub.guests[gi];
|
||||
for k in 0..4 {
|
||||
g.craft_grid[k] = menu.slot(1 + k).cloned();
|
||||
@@ -343,6 +349,17 @@ impl Game<'_> {
|
||||
let count = |s: Option<&ItemStack>| s.map_or(0, |s| s.count);
|
||||
if furnace && count(menu.slot(2)) < count(items[2].as_ref()) {
|
||||
let at = host.hub.guests[gi].position;
|
||||
let guest = &host.hub.guests[gi];
|
||||
let output = items[2].as_ref();
|
||||
if let Some(output) = output {
|
||||
self.native_furnace_extract(
|
||||
guest.uuid,
|
||||
&guest.name,
|
||||
[pos.0, pos.1, pos.2],
|
||||
&output.item,
|
||||
output.count - count(menu.slot(2)),
|
||||
);
|
||||
}
|
||||
self.award_furnace_recipes(pos, at);
|
||||
}
|
||||
// Slot.setChanged on a changed container slot: BlockEntity.setChanged,
|
||||
@@ -424,6 +441,8 @@ impl Game<'_> {
|
||||
{
|
||||
return;
|
||||
}
|
||||
let (player, name) = (g.uuid, g.name.clone());
|
||||
self.native_player_recipe_book_click(player, &name, &id, p.use_max);
|
||||
let creative = g.creative();
|
||||
let placed = if p.container == 0 {
|
||||
let crafting = host.play.entry(conn).or_default();
|
||||
@@ -469,6 +488,9 @@ impl Game<'_> {
|
||||
|
||||
/// The guest opened a crafting table (`CraftingTableBlock.getMenuProvider`).
|
||||
pub(super) fn lan_open_crafting(&mut self, host: &mut LanHost, conn: u32, pos: [i32; 3]) {
|
||||
if !self.guest_may_open(host, conn, "minecraft:crafting", Some(pos)) {
|
||||
return;
|
||||
}
|
||||
// ServerPlayer.openMenu closes the menu that was open.
|
||||
self.guest_close_all(host, conn);
|
||||
let title = horizon_protocol::text::translatable("container.crafting", &[]);
|
||||
@@ -506,7 +528,7 @@ impl Game<'_> {
|
||||
&& !host.hub.guests[gi].spectator()
|
||||
&& self.lan_table_valid(&host.hub.guests[gi], pos)
|
||||
{
|
||||
let changed = self.guest_menu_op(host, gi, &mut menu, |menu, env| {
|
||||
let mut changed = self.guest_menu_op(host, gi, &mut menu, |menu, env| {
|
||||
let before: Vec<_> = (0..menu.slot_count())
|
||||
.map(|s| menu.slot(s).cloned())
|
||||
.collect();
|
||||
@@ -515,6 +537,16 @@ impl Game<'_> {
|
||||
.filter(|&s| menu.slot(s) != before[s].as_ref())
|
||||
.collect::<Vec<_>>()
|
||||
});
|
||||
let guest = &host.hub.guests[gi];
|
||||
let (uuid, name) = (guest.uuid, guest.name.clone());
|
||||
let mut result = menu.slot(0).cloned();
|
||||
let vanilla = result.clone();
|
||||
self.native_prepare_item_craft(uuid, &name, Some(pos), &mut result);
|
||||
if result != vanilla && !changed.contains(&0) {
|
||||
changed.push(0);
|
||||
}
|
||||
let env = self.menu_env();
|
||||
menu.set_slot(&env, 0, result);
|
||||
agreed = p.state == state
|
||||
&& host.hub.click_agreed(
|
||||
gi,
|
||||
|
||||
@@ -40,7 +40,12 @@ pub(super) fn player_for(
|
||||
return Some(*conn);
|
||||
}
|
||||
}
|
||||
// An iron golem's anger is for the player who hit it (`NeutralMob` keeps
|
||||
// the angry-at UUID), and outlasts the 100 ticks of `getLastHurtByMob`:
|
||||
// while it lasts, the golem keeps that player, not whoever is nearest.
|
||||
let angry = m.species() == Species::IronGolem && m.extra.anger > 0;
|
||||
let targeting = m.ai.target == Some(Who::Player)
|
||||
|| angry
|
||||
|| m.ai.last_hurt_by == Some(Who::Player) && m.age as i32 - m.ai.last_hurt_by_time <= 100;
|
||||
// A tame wolf, cat or parrot sees its owner (`TamableAnimal.getOwner`), while they
|
||||
// are in play, whoever is nearer. Not while it targets or was just hurt by
|
||||
@@ -506,4 +511,35 @@ mod tests {
|
||||
let guests = vec![(7, view(12.0, false))];
|
||||
assert_eq!(choose(MOB, false, Some(&host), &guests, Some(7)), None);
|
||||
}
|
||||
|
||||
/// The mob of the prepared resources' type `kind` (None without them).
|
||||
fn mob_of(kind: &str) -> Option<Mob> {
|
||||
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../../.local/horizon-client")
|
||||
.join(horizon_core::VANILLA_VERSION);
|
||||
let types = horizon_client::mobs::MobTypes::load(&root).ok()?;
|
||||
let t = std::sync::Arc::new(types.types.get(kind)?.clone());
|
||||
Some(Mob::new(1, t, MOB, 0.0))
|
||||
}
|
||||
|
||||
/// #6781: a golem a guest hit stays angry for 400+ ticks but remembers
|
||||
/// its last attacker for 100. After that it must still see the guest it
|
||||
/// is angry at, not the bystander standing nearer.
|
||||
#[test]
|
||||
fn an_angry_golem_keeps_the_player_who_hit_it() {
|
||||
let Some(mut golem) = mob_of("minecraft:iron_golem") else {
|
||||
return;
|
||||
};
|
||||
golem.age = 500;
|
||||
golem.ai.last_hurt_by = Some(Who::Player);
|
||||
golem.ai.last_hurt_by_time = 100;
|
||||
golem.ai.target = None;
|
||||
golem.extra.anger = 300;
|
||||
// The attacker (conn 7) ran off; a bystander (conn 8) is nearer.
|
||||
let guests = vec![(7, view(30.0, false)), (8, view(3.0, false))];
|
||||
assert_eq!(player_for(&golem, None, &guests, Some(7)), Some(7));
|
||||
// Calm again: the nearest player is seen.
|
||||
golem.extra.anger = 0;
|
||||
assert_eq!(player_for(&golem, None, &guests, Some(7)), Some(8));
|
||||
}
|
||||
}
|
||||
@@ -31,6 +31,9 @@ impl Game<'_> {
|
||||
return;
|
||||
};
|
||||
let columns = super::mount_menu::mount_columns(&self.mobs[index]);
|
||||
if !self.guest_may_open(host, conn, "minecraft:horse_inventory", None) {
|
||||
return;
|
||||
}
|
||||
// ServerPlayer.openMenu closes the menu that was open.
|
||||
self.guest_close_all(host, conn);
|
||||
let Some(window) = host.hub.open_mount_window(conn, columns as i32, mob as i32) else {
|
||||
|
||||
@@ -69,6 +69,10 @@ impl Game<'_> {
|
||||
let Some((menu_type, kind, _)) = menu_of(station.kind) else {
|
||||
return false;
|
||||
};
|
||||
// The mods may stop the menu; the click is spent.
|
||||
if !self.guest_may_open(host, conn, menu_type, Some(station.pos)) {
|
||||
return true;
|
||||
}
|
||||
// ServerPlayer.openMenu closes the menu that was open.
|
||||
self.guest_close_all(host, conn);
|
||||
let title = horizon_protocol::text::translatable(station.kind.title_key(), &[]);
|
||||
@@ -121,7 +125,49 @@ impl Game<'_> {
|
||||
if let Some(gi) = host.hub.guests.iter().position(|g| g.conn == conn)
|
||||
&& !host.hub.guests[gi].spectator()
|
||||
{
|
||||
// createResult runs when the inputs change (or a rename): a click
|
||||
// elsewhere must not hand the hooks their own result again.
|
||||
let result_slot = match gs.kind {
|
||||
StationKind::Anvil | StationKind::Grindstone => Some(2),
|
||||
StationKind::Smithing => Some(3),
|
||||
_ => None,
|
||||
};
|
||||
let before = result_slot.map(|r| {
|
||||
let slots: Vec<_> = (0..=r).map(|s| gs.menu.slot(s).cloned()).collect();
|
||||
(slots, gs.menu.data[0])
|
||||
});
|
||||
self.guest_menu_op(host, gi, &mut gs.menu, op);
|
||||
let recomputed = before.is_some_and(|(slots, cost)| {
|
||||
cost != gs.menu.data[0]
|
||||
|| slots
|
||||
.iter()
|
||||
.enumerate()
|
||||
.any(|(s, b)| gs.menu.slot(s) != b.as_ref())
|
||||
});
|
||||
if recomputed && gs.kind == StationKind::Anvil {
|
||||
let guest = &host.hub.guests[gi];
|
||||
let (uuid, name) = (guest.uuid, guest.name.clone());
|
||||
let mut result = gs.menu.slot(2).cloned();
|
||||
self.native_prepare_anvil(uuid, &name, gs.pos, &mut result, &mut gs.menu.data[0]);
|
||||
let env = self.menu_env();
|
||||
gs.menu.set_slot(&env, 2, result);
|
||||
}
|
||||
if recomputed && gs.kind == StationKind::Grindstone {
|
||||
let guest = &host.hub.guests[gi];
|
||||
let (uuid, name) = (guest.uuid, guest.name.clone());
|
||||
let mut result = gs.menu.slot(2).cloned();
|
||||
self.native_prepare_grindstone(uuid, &name, gs.pos, &mut result);
|
||||
let env = self.menu_env();
|
||||
gs.menu.set_slot(&env, 2, result);
|
||||
}
|
||||
if recomputed && gs.kind == StationKind::Smithing {
|
||||
let guest = &host.hub.guests[gi];
|
||||
let (uuid, name) = (guest.uuid, guest.name.clone());
|
||||
let mut result = gs.menu.slot(3).cloned();
|
||||
self.native_prepare_smithing(uuid, &name, gs.pos, &mut result);
|
||||
let env = self.menu_env();
|
||||
gs.menu.set_slot(&env, 3, result);
|
||||
}
|
||||
self.lan_station_sounds(&mut gs);
|
||||
if let Some(amount) = gs.menu.ground.take() {
|
||||
self.grindstone_taken(gs.pos, amount);
|
||||
@@ -149,6 +195,9 @@ impl Game<'_> {
|
||||
/// `LecternBlock.openScreen` for a guest: `minecraft:lectern` over the
|
||||
/// block entity, with its book and page.
|
||||
pub(super) fn lan_open_lectern(&mut self, host: &mut LanHost, conn: u32, key: (i32, i32, i32)) {
|
||||
if !self.guest_may_open(host, conn, "minecraft:lectern", Some([key.0, key.1, key.2])) {
|
||||
return;
|
||||
}
|
||||
// ServerPlayer.openMenu closes the menu that was open.
|
||||
self.guest_close_all(host, conn);
|
||||
let title = horizon_protocol::text::translatable("container.lectern", &[]);
|
||||
@@ -286,9 +335,28 @@ impl Game<'_> {
|
||||
bookcases,
|
||||
gs.menu.slot(0),
|
||||
);
|
||||
let guest = &host.hub.guests[gi];
|
||||
let mut costs = offers.costs;
|
||||
self.native_prepare_item_enchant(guest.uuid, &guest.name, gs.pos, &mut costs);
|
||||
let mut clues = offers.clues;
|
||||
for i in 0..3 {
|
||||
gs.menu.data[i] = offers.costs[i];
|
||||
let clue = offers.clues[i].as_ref();
|
||||
// A cost a mod changed shows the clue of the list it will give.
|
||||
if costs[i] != offers.costs[i]
|
||||
&& let Some(stack) = gs.menu.slot(0)
|
||||
{
|
||||
clues[i] = horizon_client::stations::enchant_clue(
|
||||
&self.stations().enchantments,
|
||||
self.items(),
|
||||
seed,
|
||||
i,
|
||||
costs[i],
|
||||
stack,
|
||||
);
|
||||
}
|
||||
}
|
||||
for i in 0..3 {
|
||||
gs.menu.data[i] = costs[i];
|
||||
let clue = clues[i].as_ref();
|
||||
gs.menu.data[4 + i] = clue
|
||||
.and_then(|(e, _)| host.hub.registry_id("minecraft:enchantment", e))
|
||||
.unwrap_or(-1);
|
||||
@@ -305,7 +373,8 @@ impl Game<'_> {
|
||||
return;
|
||||
};
|
||||
let g = &host.hub.guests[gi];
|
||||
let (seed, creative, conn) = (g.xp_seed, g.creative(), g.conn);
|
||||
let (seed, creative, conn, uuid, name) =
|
||||
(g.xp_seed, g.creative(), g.conn, g.uuid, g.name.clone());
|
||||
let Some(enchanted) = horizon_client::stations::apply_enchantment(
|
||||
&self.stations().enchantments,
|
||||
self.items(),
|
||||
@@ -316,6 +385,7 @@ impl Game<'_> {
|
||||
) else {
|
||||
return;
|
||||
};
|
||||
let enchanted_for_hook = enchanted.clone();
|
||||
let need = button as i32 + 1;
|
||||
// Player.onEnchantmentPerformed, and EnchantmentMenu's awardStat(ENCHANT_ITEM).
|
||||
self.as_guest(host, conn, |game| {
|
||||
@@ -344,6 +414,7 @@ impl Game<'_> {
|
||||
1.0,
|
||||
pitch,
|
||||
);
|
||||
self.native_enchant_item(uuid, &name, gs.pos, &enchanted_for_hook, need);
|
||||
}
|
||||
|
||||
/// `ServerGamePacketListenerImpl.handleRenameItem`: the guest's anvil
|
||||
@@ -438,9 +509,28 @@ impl Game<'_> {
|
||||
}) {
|
||||
return;
|
||||
}
|
||||
let identity = host.hub.guest(conn).map(|g| (g.uuid, g.name.clone()));
|
||||
let mut selected = false;
|
||||
let kind = host
|
||||
.play
|
||||
.get(&conn)
|
||||
.and_then(|p| p.station.as_ref())
|
||||
.filter(|s| s.window == window)
|
||||
.map(|s| s.kind);
|
||||
self.lan_station_op(host, conn, Some(window), |menu, env| {
|
||||
menu.button(env, id);
|
||||
selected = menu.button(env, id);
|
||||
});
|
||||
if selected && let Some((player, name)) = identity {
|
||||
match kind {
|
||||
Some(StationKind::Loom) => {
|
||||
self.native_player_loom_pattern_select(player, &name, id);
|
||||
}
|
||||
Some(StationKind::Stonecutter) => {
|
||||
self.native_player_stonecutter_recipe_select(player, &name, id);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The whole station menu and its changed data slots for the client
|
||||
@@ -573,6 +663,15 @@ impl Game<'_> {
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let Some((player, name)) = host
|
||||
.hub
|
||||
.guests
|
||||
.iter()
|
||||
.find(|g| g.conn == conn)
|
||||
.map(|g| (g.uuid, g.name.clone()))
|
||||
else {
|
||||
return;
|
||||
};
|
||||
// An id the registry doesn't have fails the packet's codec.
|
||||
let mut powers: [Option<(i32, String)>; 2] = [None, None];
|
||||
for (k, id) in [primary, secondary].into_iter().enumerate() {
|
||||
@@ -615,10 +714,17 @@ impl Game<'_> {
|
||||
if let Some(BlockEntity::Beacon(b)) =
|
||||
self.world.block_entities.get_mut(&(pos[0], pos[1], pos[2]))
|
||||
{
|
||||
b.primary = primary;
|
||||
b.secondary = secondary;
|
||||
b.primary = primary.clone();
|
||||
b.secondary = secondary.clone();
|
||||
select_sound = !b.sections.is_empty();
|
||||
}
|
||||
self.native_player_change_beacon_effect(
|
||||
player,
|
||||
&name,
|
||||
pos,
|
||||
primary.as_deref().unwrap_or(""),
|
||||
secondary.as_deref().unwrap_or(""),
|
||||
);
|
||||
if select_sound {
|
||||
self.beacon_sound(pos, "minecraft:block.beacon.power_select");
|
||||
}
|
||||
|
||||
@@ -57,6 +57,10 @@ impl Game<'_> {
|
||||
state.session.update_special_prices();
|
||||
}
|
||||
let title = self.merchant_display_name(&state);
|
||||
if !self.guest_may_open(host, conn, "minecraft:merchant", None) {
|
||||
self.merchant_closed(&mut state);
|
||||
return;
|
||||
}
|
||||
// ServerPlayer.openMenu closes the menu that was open.
|
||||
self.guest_close_all(host, conn);
|
||||
let Some(window) = host.hub.open_merchant_window(conn, title) else {
|
||||
@@ -283,6 +287,12 @@ impl Game<'_> {
|
||||
gm.merchant.session = session;
|
||||
if !traded.is_empty() {
|
||||
// The guest's own statistic and advancements.
|
||||
if let Some(gi) = host.hub.guests.iter().position(|g| g.conn == conn) {
|
||||
let g = &host.hub.guests[gi];
|
||||
if let Some(entity_id) = gm.merchant.mob {
|
||||
self.native_player_trade(g.uuid, &g.name, entity_id);
|
||||
}
|
||||
}
|
||||
self.as_guest(host, conn, |g| g.merchant_traded(gm.merchant.mob, &traded));
|
||||
}
|
||||
self.drop_guest_items(host, gi, dropped);
|
||||
@@ -317,9 +327,20 @@ impl Game<'_> {
|
||||
let Ok(index) = usize::try_from(index) else {
|
||||
return;
|
||||
};
|
||||
self.lan_merchant_op(host, conn, None, |menu, env| {
|
||||
let Some(guest) = host.hub.guests.iter().find(|g| g.conn == conn) else {
|
||||
return;
|
||||
};
|
||||
let (uuid, name) = (guest.uuid, guest.name.clone());
|
||||
if self.lan_merchant_op(host, conn, None, |menu, env| {
|
||||
menu.merchant_select(env, index)
|
||||
});
|
||||
}) {
|
||||
let villager = host
|
||||
.play
|
||||
.get(&conn)
|
||||
.and_then(|p| p.merchant.as_ref())
|
||||
.and_then(|m| m.merchant.mob);
|
||||
self.native_trade_select(uuid, &name, villager, index);
|
||||
}
|
||||
}
|
||||
/// The guest closed the trading window (or it is closed for them):
|
||||
/// MerchantMenu.removed, then the villager gets its state back.
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
//! here with the rider's input (`ServerPlayer.getLastClientMoveIntent`).
|
||||
//! Sneaking gets out (`Player.rideTick`: `wantsToStopRiding`), and the client
|
||||
//! is teleported where the player lands (`ServerPlayer.dismountTo`).
|
||||
use super::{Game, Keys, lan::LanHost, minecarts::SEAT};
|
||||
use super::{Dim, Game, Keys, lan::LanHost, minecarts::SEAT};
|
||||
use horizon_client::collisions::SceneCollisions;
|
||||
use horizon_client::lan::hub::no_blocks_in;
|
||||
use horizon_client::mobs::equine;
|
||||
@@ -38,7 +38,116 @@ fn corners(b: Aabb) -> ([f64; 3], [f64; 3]) {
|
||||
([lo.x, lo.y, lo.z], [hi.x, hi.y, hi.z])
|
||||
}
|
||||
|
||||
/// A boat or minecart a server player rode, followed for
|
||||
/// `native_vehicle_moved`.
|
||||
pub(super) struct WatchedVehicle {
|
||||
id: u32,
|
||||
dim: Dim,
|
||||
/// The server player it carries now.
|
||||
rider: Option<u128>,
|
||||
/// Where the hook last heard of it.
|
||||
at: [f64; 3],
|
||||
/// Ticks it has stood still.
|
||||
still: u32,
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
/// Where the boat or minecart `id` of this level is.
|
||||
fn watched_vehicle_position(&self, id: u32) -> Option<[f64; 3]> {
|
||||
let at = |v: Vec3| [v.x, v.y, v.z];
|
||||
self.boats
|
||||
.iter()
|
||||
.find(|b| b.id == id)
|
||||
.map(|b| at(b.position))
|
||||
.or_else(|| {
|
||||
self.carts
|
||||
.iter()
|
||||
.find(|c| c.id == id)
|
||||
.map(|c| at(c.position))
|
||||
})
|
||||
}
|
||||
|
||||
/// `VehicleMoveEvent` for the boats and minecarts server players ride
|
||||
/// (and left less than ten seconds ago): once a tick, after they moved.
|
||||
pub(super) fn tick_vehicle_watch(&mut self) {
|
||||
let riders: Vec<(u32, u32, u128)> = self.lan.as_ref().map_or_else(Vec::new, |h| {
|
||||
h.hub
|
||||
.guests
|
||||
.iter()
|
||||
.filter_map(|g| Some((g.vehicle?, g.conn, g.uuid)))
|
||||
.collect()
|
||||
});
|
||||
if riders.is_empty() && self.watched_vehicles.is_empty() {
|
||||
return;
|
||||
}
|
||||
let dim = self.dim;
|
||||
for &(id, _, uuid) in &riders {
|
||||
let Some(at) = self.watched_vehicle_position(id) else {
|
||||
continue;
|
||||
};
|
||||
match self.watched_vehicles.iter_mut().find(|w| w.id == id) {
|
||||
Some(w) => (w.dim, w.rider) = (dim, Some(uuid)),
|
||||
None => self.watched_vehicles.push(WatchedVehicle {
|
||||
id,
|
||||
dim,
|
||||
rider: Some(uuid),
|
||||
at,
|
||||
still: 0,
|
||||
}),
|
||||
}
|
||||
}
|
||||
let mut kept = Vec::new();
|
||||
// (rider's connection and UUID, vehicle type, from, to)
|
||||
let mut moved = Vec::new();
|
||||
for mut w in std::mem::take(&mut self.watched_vehicles) {
|
||||
// Another level's vehicle is told of on that level's tick.
|
||||
if w.dim != dim {
|
||||
kept.push(w);
|
||||
continue;
|
||||
}
|
||||
// Broken, or gone from this level.
|
||||
let Some(now) = self.watched_vehicle_position(w.id) else {
|
||||
continue;
|
||||
};
|
||||
let rider = riders.iter().find(|r| r.0 == w.id);
|
||||
w.rider = rider.map(|r| r.2);
|
||||
if now != w.at {
|
||||
let from = std::mem::replace(&mut w.at, now);
|
||||
w.still = 0;
|
||||
let kind = self.target_type(
|
||||
self.level_entity(w.id as i32)
|
||||
.unwrap_or(super::commands::Target::Boat(w.id)),
|
||||
);
|
||||
moved.push((rider.map(|r| (r.1, r.2)), kind, from, now));
|
||||
} else {
|
||||
w.still += 1;
|
||||
if w.rider.is_none() && w.still > 200 {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
kept.push(w);
|
||||
}
|
||||
self.watched_vehicles = kept;
|
||||
// The rider's state is swapped in for the call, so the helpers reach
|
||||
// them; a vehicle nobody rides is told of with nobody swapped in.
|
||||
for (rider, kind, from, to) in moved {
|
||||
let Some((conn, uuid)) = rider else {
|
||||
self.native_vehicle_moved(None, &kind, from, to);
|
||||
continue;
|
||||
};
|
||||
let Some(mut host) = self.lan.take() else {
|
||||
continue;
|
||||
};
|
||||
let hook = |game: &mut Self| game.native_vehicle_moved(Some(uuid), &kind, from, to);
|
||||
if self.lan_acting == Some(conn) {
|
||||
hook(self);
|
||||
} else {
|
||||
self.as_guest(&mut host, conn, hook);
|
||||
}
|
||||
self.lan = Some(host);
|
||||
}
|
||||
}
|
||||
|
||||
/// The box of a boat, minecart, armor stand, item frame or painting, and
|
||||
/// the point an interact packet's location is measured from (the
|
||||
/// client's `remote_object_hit` uses the same points).
|
||||
|
||||
@@ -136,16 +136,23 @@ impl Game<'_> {
|
||||
}
|
||||
fn drop_leash(&mut self, id: u32, drop: bool, sound: bool, used: bool) {
|
||||
let killer = self.lead_killers.remove(&id).map(|(killer, _)| killer);
|
||||
let Some(m) = self.mobs.iter_mut().find(|m| m.id == id) else {
|
||||
let Some((alive, at, kind)) = self.mobs.iter_mut().find(|m| m.id == id).and_then(|m| {
|
||||
horizon_client::mobs::leash::remove(m)?;
|
||||
Some((
|
||||
m.alive(),
|
||||
[m.position.x, m.position.y, m.position.z],
|
||||
super::native_hooks::mob_kind(m),
|
||||
))
|
||||
}) else {
|
||||
return;
|
||||
};
|
||||
if horizon_client::mobs::leash::remove(m).is_none() {
|
||||
return;
|
||||
if used
|
||||
&& let (Some(player), Some(name)) = (self.lan_acting_uuid, self.lan_acting_name.clone())
|
||||
{
|
||||
self.native_player_unleash_entity(player, &name, id);
|
||||
}
|
||||
// Leashable.tickLeash gates a dying entity's lead drop on entity_drops.
|
||||
let drop = drop && (m.alive() || self.ui.rules.bool("entity_drops"));
|
||||
let at = [m.position.x, m.position.y, m.position.z];
|
||||
let kind = super::native_hooks::mob_kind(m);
|
||||
let drop = drop && (alive || self.ui.rules.bool("entity_drops"));
|
||||
if sound {
|
||||
self.lead_sound("break", at);
|
||||
}
|
||||
@@ -401,6 +408,14 @@ impl Game<'_> {
|
||||
{
|
||||
return false;
|
||||
}
|
||||
// A knot is made when a lead is tied to a fence that has none.
|
||||
if existing.is_empty()
|
||||
&& existing_boats.is_empty()
|
||||
&& (!in_hand.is_empty() || !boats_in_hand.is_empty())
|
||||
&& self.hanging_place_cancelled("minecraft:leash_knot", knot_pos)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
let mut attached = self.tie_player_boats(center, Holder::Knot(knot_pos));
|
||||
for j in in_hand {
|
||||
if self.can_attach_lead(j, center) {
|
||||
|
||||
@@ -132,6 +132,11 @@ impl Levels {
|
||||
self.parked.get(&dim).map(|level| &level.world)
|
||||
}
|
||||
|
||||
/// The worlds of the levels not in place.
|
||||
pub(super) fn parked_worlds(&self) -> impl Iterator<Item = &World> {
|
||||
self.parked.values().map(|level| &level.world)
|
||||
}
|
||||
|
||||
/// The world of the level not in place, in `dim`, to change.
|
||||
pub(super) fn parked_world_mut(&mut self, dim: Dim) -> Option<&mut World> {
|
||||
self.parked.get_mut(&dim).map(|level| &mut level.world)
|
||||
|
||||
@@ -88,6 +88,9 @@ impl Game<'_> {
|
||||
let trap = self.ui.rules.bool("spawn_mobs")
|
||||
&& self.world.random.next_double() < difficulty * 0.01
|
||||
&& !below.ends_with("lightning_rod");
|
||||
if !self.lightning_strike_allowed(None, pos, "weather") {
|
||||
continue;
|
||||
}
|
||||
if trap {
|
||||
let at = [f64::from(pos[0]), f64::from(pos[1]), f64::from(pos[2])];
|
||||
// ServerLevel.tickChunk: a trap horse waits for a player.
|
||||
@@ -243,6 +246,16 @@ impl Game<'_> {
|
||||
cause: None,
|
||||
});
|
||||
}
|
||||
pub(super) fn lightning_strike_allowed(
|
||||
&mut self,
|
||||
player: Option<u128>,
|
||||
pos: [i32; 3],
|
||||
cause: &str,
|
||||
) -> bool {
|
||||
let mut cancel = false;
|
||||
self.native_lightning_strike(player, pos, cause, &mut cancel);
|
||||
!cancel
|
||||
}
|
||||
/// `ClientLevel.addEntity` for a server's lightning bolt: on a client it
|
||||
/// only flashes, sounds and draws (`LightningBolt.tick` off the server).
|
||||
pub(super) fn spawn_remote_bolt(&mut self, id: u32, at: [f64; 3]) {
|
||||
@@ -294,6 +307,9 @@ impl Game<'_> {
|
||||
b.can_survive(level, ticks, at, fire).then_some(fire)
|
||||
});
|
||||
if let Some(fire) = fire {
|
||||
if !self.native_block_ignite_allowed(at, "lightning") {
|
||||
continue;
|
||||
}
|
||||
let _ = self.level_set(p, fire);
|
||||
set += 1;
|
||||
}
|
||||
@@ -497,6 +513,7 @@ impl Game<'_> {
|
||||
from: Some(b.at),
|
||||
};
|
||||
let mut events = Vec::new();
|
||||
let mut powered = Vec::new();
|
||||
let peaceful = self.ui.difficulty == 0;
|
||||
for m in self.mobs.iter_mut().filter(|m| m.alive()) {
|
||||
if !meets(
|
||||
@@ -544,7 +561,10 @@ impl Game<'_> {
|
||||
m.damage(f32::MAX, damage);
|
||||
continue;
|
||||
}
|
||||
Species::Creeper => m.extra.charged = true,
|
||||
Species::Creeper => {
|
||||
m.extra.charged = true;
|
||||
powered.push(m.id);
|
||||
}
|
||||
// CopperGolem.thunderHit (after Entity.thunderHit's fire and
|
||||
// damage): once per bolt, an oxidized golem goes back one step
|
||||
// and its weathering timer (or wax, -2) is reset to -1.
|
||||
@@ -569,6 +589,9 @@ impl Game<'_> {
|
||||
if !events.is_empty() {
|
||||
let _ = self.handle_mob_events(events);
|
||||
}
|
||||
for entity in powered {
|
||||
self.native_creeper_power(entity, [at[0], at[1], at[2]]);
|
||||
}
|
||||
// Cushion.thunderHit: a cushion whose box meets the strike box breaks and drops its item
|
||||
// (the bolt is no player, so no creative spare). Last first, since
|
||||
// break_cushion removes the entry.
|
||||
|
||||
@@ -61,8 +61,11 @@ impl Game<'_> {
|
||||
}
|
||||
// Animal.setInLove: entity event 18.
|
||||
if loved {
|
||||
self.love_cause.insert(self.mobs[index].id, self.lan_acting);
|
||||
self.mob_event_particles(self.mobs[index].id, 18);
|
||||
let id = self.mobs[index].id;
|
||||
self.love_cause.insert(id, self.lan_acting);
|
||||
let kind = self.mobs[index].kind.id.clone();
|
||||
self.native_entity_enter_love_mode(id, &kind, self.lan_acting_uuid);
|
||||
self.mob_event_particles(id, 18);
|
||||
}
|
||||
self.llama_sound(index, "entity.llama.eat", 1.0);
|
||||
self.consume_held();
|
||||
|
||||
@@ -15,7 +15,10 @@ use horizon_client::{
|
||||
world::World,
|
||||
};
|
||||
use horizon_core::{geometry::Direction, position::BlockPos, random::RandomSource};
|
||||
use horizon_world::{level::Behaviours, registry::BlockStateId};
|
||||
use horizon_world::{
|
||||
level::{Behaviours, LevelAccess},
|
||||
registry::BlockStateId,
|
||||
};
|
||||
use std::sync::Arc;
|
||||
|
||||
type Pos = (i32, i32, i32);
|
||||
@@ -826,7 +829,18 @@ impl Game<'_> {
|
||||
let mut taken = false;
|
||||
if !full {
|
||||
let stack = self.dropped[i].stack.clone();
|
||||
match self.add_item(None, &view, stack, None) {
|
||||
let allowed = self.with_level(|_, level, _| {
|
||||
level.allow_hopper_pickup_item(
|
||||
BlockPos::new(pos.0, pos.1, pos.2),
|
||||
&stack.item,
|
||||
stack.count,
|
||||
)
|
||||
});
|
||||
match allowed
|
||||
.then(|| self.add_item(None, &view, stack, None))
|
||||
.flatten()
|
||||
{
|
||||
None if !allowed => {}
|
||||
None => {
|
||||
self.remove_dropped(i);
|
||||
taken = true;
|
||||
@@ -922,7 +936,7 @@ impl Game<'_> {
|
||||
{
|
||||
return false;
|
||||
}
|
||||
self.suck_item_entities(hopper, min, max)
|
||||
self.suck_item_entities(Some(pos), hopper, min, max)
|
||||
}
|
||||
/// The container half of `HopperBlockEntity.suckInItems`: one item from the
|
||||
/// container at `above`, through its bottom face; with no block container
|
||||
@@ -1090,7 +1104,13 @@ impl Game<'_> {
|
||||
}
|
||||
/// The item half of `HopperBlockEntity.suckInItems` (`getItemsAtAndAbove`):
|
||||
/// adds the item entities that meet the box, until one is taken whole.
|
||||
fn suck_item_entities(&mut self, hopper: &View, min: [f64; 3], max: [f64; 3]) -> bool {
|
||||
fn suck_item_entities(
|
||||
&mut self,
|
||||
hopper_pos: Option<Pos>,
|
||||
hopper: &View,
|
||||
min: [f64; 3],
|
||||
max: [f64; 3],
|
||||
) -> bool {
|
||||
let mut i = 0;
|
||||
while i < self.dropped.len() {
|
||||
let b = self.dropped[i].bounds();
|
||||
@@ -1103,6 +1123,19 @@ impl Game<'_> {
|
||||
&& hi.z > min[2];
|
||||
if inside {
|
||||
let stack = self.dropped[i].stack.clone();
|
||||
let allowed = hopper_pos.is_none_or(|pos| {
|
||||
self.with_level(|_, level, _| {
|
||||
level.allow_hopper_pickup_item(
|
||||
BlockPos::new(pos.0, pos.1, pos.2),
|
||||
&stack.item,
|
||||
stack.count,
|
||||
)
|
||||
})
|
||||
});
|
||||
if !allowed {
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
match self.add_item(None, hopper, stack, None) {
|
||||
None => {
|
||||
self.remove_dropped(i);
|
||||
@@ -1136,6 +1169,7 @@ impl Game<'_> {
|
||||
let took = match self.suck_from_container(above, at, &view) {
|
||||
Some(took) => took,
|
||||
None => self.suck_item_entities(
|
||||
None,
|
||||
&view,
|
||||
[p.x - 0.5, p.y + 11.0 / 16.0, p.z - 0.5],
|
||||
[p.x + 0.5, p.y + 2.0, p.z + 0.5],
|
||||
@@ -1146,6 +1180,7 @@ impl Game<'_> {
|
||||
let b = cart.bounds();
|
||||
let (lo, hi) = (b.min_position(), b.max_position());
|
||||
self.suck_item_entities(
|
||||
None,
|
||||
&view,
|
||||
[lo.x - 0.25, lo.y, lo.z - 0.25],
|
||||
[hi.x + 0.25, hi.y, hi.z + 0.25],
|
||||
|
||||
@@ -137,6 +137,7 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
self.container_changed(key);
|
||||
self.native_crafter_craft([key.0, key.1, key.2], &recipe);
|
||||
Ok(())
|
||||
}
|
||||
/// `CrafterBlock.dispenseItem`: into the container in front, else out as an item.
|
||||
|
||||
@@ -40,7 +40,6 @@ pub(super) struct Decoration {
|
||||
}
|
||||
/// A map as a native mod sees it when `native_map_decorations` is asked.
|
||||
// Native mods read it; nothing in Horizon does.
|
||||
#[allow(dead_code)]
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct NativeMapInfo<'a> {
|
||||
/// The map's id (`map_<id>`).
|
||||
@@ -1131,6 +1130,9 @@ impl Game<'_> {
|
||||
self.consume_held();
|
||||
self.give_or_drop_stack(filled);
|
||||
}
|
||||
if let (Some(player), Some(name)) = (self.lan_acting_uuid, self.lan_acting_name.clone()) {
|
||||
self.native_player_map_filled(player, &name, id);
|
||||
}
|
||||
let at = [p.x, p.y, p.z];
|
||||
self.audio.play(
|
||||
"minecraft:ui.cartography_table.take_result",
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
//! `horizon-ctl memory`: what holds the game's memory, by category. The
|
||||
//! process's resident set and the allocator's committed bytes say how much;
|
||||
//! the census of the client's columns, the integrated server's levels, the
|
||||
//! renderer's bookkeeping and the GPU allocator's resources by label say
|
||||
//! where it goes. Bytes the census doesn't reach show as the difference.
|
||||
use super::Game;
|
||||
use horizon_client::chunk::ColumnCensus;
|
||||
use horizon_client::fast_hash::FxHashSet;
|
||||
use horizon_client::world::World;
|
||||
use serde_json::{Value, json};
|
||||
use std::sync::Arc;
|
||||
|
||||
const MB: f64 = 1048576.0;
|
||||
|
||||
fn mb(bytes: impl TryInto<u64>) -> f64 {
|
||||
let bytes: u64 = bytes.try_into().unwrap_or(0);
|
||||
(bytes as f64 / MB * 10.0).round() / 10.0
|
||||
}
|
||||
|
||||
/// The columns of `worlds`, each section counted once however many columns
|
||||
/// (or worlds) share it.
|
||||
fn census<'a>(worlds: impl IntoIterator<Item = &'a World>) -> ColumnCensus {
|
||||
let mut census = ColumnCensus::default();
|
||||
let mut seen = FxHashSet::default();
|
||||
for world in worlds {
|
||||
for chunk in world.chunks.values() {
|
||||
chunk.census(&mut census, &mut seen);
|
||||
}
|
||||
}
|
||||
census
|
||||
}
|
||||
|
||||
fn census_json(c: &ColumnCensus) -> Value {
|
||||
json!({
|
||||
"total_mb": mb(c.total_bytes()),
|
||||
"columns": c.columns,
|
||||
"sections": c.sections,
|
||||
"unique_sections": c.unique_sections,
|
||||
"uniform_block_sections": c.uniform_block_sections,
|
||||
"light_arrays": c.light_arrays,
|
||||
"blocks_mb": mb(c.block_bytes),
|
||||
"biomes_mb": mb(c.biome_bytes),
|
||||
"light_mb": mb(c.light_bytes),
|
||||
"heightmaps_mb": mb(c.heightmap_bytes),
|
||||
"section_records_mb": mb(c.section_record_bytes),
|
||||
"column_records_mb": mb(c.column_bytes),
|
||||
})
|
||||
}
|
||||
|
||||
/// The sections of `worlds` that are also in `others` (by address), and
|
||||
/// their heap bytes: what one copy of them costs.
|
||||
fn shared<'a>(
|
||||
worlds: impl IntoIterator<Item = &'a World>,
|
||||
others: &FxHashSet<usize>,
|
||||
) -> (usize, usize) {
|
||||
let mut seen = FxHashSet::default();
|
||||
let (mut count, mut bytes) = (0, 0);
|
||||
for world in worlds {
|
||||
for chunk in world.chunks.values() {
|
||||
for section in &chunk.sections {
|
||||
let at = Arc::as_ptr(section) as usize;
|
||||
if others.contains(&at) && seen.insert(at) {
|
||||
count += 1;
|
||||
bytes += section.heap_bytes();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
(count, bytes)
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
fn all_worlds(&self) -> impl Iterator<Item = &World> {
|
||||
std::iter::once(&self.world).chain(self.levels.parked_worlds())
|
||||
}
|
||||
/// The census of this game's levels: the one in place and the parked ones.
|
||||
fn level_census(&self) -> ColumnCensus {
|
||||
census(self.all_worlds())
|
||||
}
|
||||
/// The addresses of this game's sections.
|
||||
fn section_addresses(&self) -> FxHashSet<usize> {
|
||||
self.all_worlds()
|
||||
.flat_map(|w| w.chunks.values())
|
||||
.flat_map(|c| c.sections.iter().map(|s| Arc::as_ptr(s) as usize))
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub(super) fn memory_report(&self) -> Value {
|
||||
let (commit, peak_commit) = horizon_threads::allocator_commit();
|
||||
let client = self.level_census();
|
||||
// The single-player server thread's own levels: a second copy of the
|
||||
// columns both hold, unless they share sections.
|
||||
let server = self
|
||||
.integrated
|
||||
.as_ref()
|
||||
.and_then(|i| i.call(|g| (g.level_census(), g.section_addresses())));
|
||||
// The window's sections the server thread holds too (lan/handoff.rs).
|
||||
let shared = server.as_ref().map(|(_, at)| {
|
||||
let (sections, bytes) = shared(self.all_worlds(), at);
|
||||
json!({ "sections": sections, "mb": mb(bytes) })
|
||||
});
|
||||
let renderer = self.renderer.as_ref().map(|r| {
|
||||
let [arenas, vertices] = r.section_vertex_bytes();
|
||||
let cpu: serde_json::Map<String, Value> = r
|
||||
.cpu_census()
|
||||
.into_iter()
|
||||
.map(|(k, v)| (format!("{k}_mb"), mb(v).into()))
|
||||
.collect();
|
||||
let gpu = r
|
||||
.gpu_memory_by_label()
|
||||
.map(|(allocated, reserved, labels)| {
|
||||
json!({
|
||||
"allocated_mb": mb(allocated),
|
||||
"reserved_mb": mb(reserved),
|
||||
"by_label_mb": labels
|
||||
.iter()
|
||||
.take(40)
|
||||
.map(|(label, bytes)| json!([label, mb(*bytes)]))
|
||||
.collect::<Vec<_>>(),
|
||||
})
|
||||
});
|
||||
json!({
|
||||
"section_arenas_mb": mb(arenas),
|
||||
"section_vertices_mb": mb(vertices),
|
||||
"cpu": cpu,
|
||||
"gpu": gpu,
|
||||
})
|
||||
});
|
||||
json!({
|
||||
"process": {
|
||||
"resident_mb": horizon_threads::memory_limit::resident_bytes().map(mb),
|
||||
"allocator_commit_mb": mb(commit),
|
||||
"allocator_peak_commit_mb": mb(peak_commit),
|
||||
},
|
||||
"render_distance": self.streaming.radius,
|
||||
"client_columns": census_json(&client),
|
||||
"server_columns": server.as_ref().map(|(c, _)| census_json(c)),
|
||||
"shared_with_server": shared,
|
||||
"far_tiles": self.lod.as_ref().map(|lod| {
|
||||
let (active, cached) = lod.stats();
|
||||
json!({ "active": active, "cached": cached })
|
||||
}),
|
||||
"renderer": renderer,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -108,6 +108,10 @@ pub(super) struct Minecart {
|
||||
/// velocity), so a cart that ran into a block still counts as moving.
|
||||
collision_speed: f64,
|
||||
fall_distance: f64,
|
||||
/// The squared speed `MinecartTNT.causeFallDamage` blows up with, set by
|
||||
/// a landing of three blocks or more and taken by the cart's next tick
|
||||
/// step (vanilla explodes inside the move itself).
|
||||
fall_blast: Option<f64>,
|
||||
pub hurt_time: i32,
|
||||
pub hurt_dir: i32,
|
||||
pub damage: f32,
|
||||
@@ -156,6 +160,19 @@ fn display_offset(kind: CartKind) -> f64 {
|
||||
/// `MinecartFurnace.calculateNewPushAlong`: the push turned onto the
|
||||
/// movement's direction, at the push's own length (3D, so it holds from tick
|
||||
/// to tick while the movement has a vertical part).
|
||||
/// `AbstractMinecart.push(entity)` for a non-cart `entity` at `feet`: the
|
||||
/// push the cart at `cart` loses (the entity gets a quarter of it back).
|
||||
fn cart_push_from(feet: Vec3, cart: Vec3) -> Option<(f64, f64)> {
|
||||
let (xa, za) = (feet.x - cart.x, feet.z - cart.z);
|
||||
let dd = xa * xa + za * za;
|
||||
if dd < f64::from(1.0e-4f32) {
|
||||
return None;
|
||||
}
|
||||
let d = dd.sqrt();
|
||||
let pow = (1.0 / d).min(1.0);
|
||||
let k = f64::from(0.1f32) * 0.5;
|
||||
Some((xa / d * pow * k, za / d * pow * k))
|
||||
}
|
||||
fn push_along(push: Vec3, movement: Vec3) -> Vec3 {
|
||||
if push.horizontal_distance_squared() > 1.0e-4 && movement.horizontal_distance_squared() > 0.001
|
||||
{
|
||||
@@ -265,6 +282,7 @@ impl Minecart {
|
||||
horizontal_collision: false,
|
||||
collision_speed: 0.0,
|
||||
fall_distance: 0.0,
|
||||
fall_blast: None,
|
||||
hurt_time: 0,
|
||||
hurt_dir: 1,
|
||||
damage: 0.0,
|
||||
@@ -506,10 +524,20 @@ impl Game<'_> {
|
||||
if (moved.y - delta.y).abs() > 1.0e-7 {
|
||||
c.velocity.y = 0.0;
|
||||
}
|
||||
if c.on_ground || moved.y >= 0.0 {
|
||||
// Entity.checkFallDamage: the move's drop counts (not in water),
|
||||
// and a landing hands the whole fall to causeFallDamage, which
|
||||
// sets a TNT minecart off at (fall / 10) squared.
|
||||
if moved.y < 0.0 && !self.cart_in_water(c) {
|
||||
c.fall_distance -= f64::from(moved.y as f32);
|
||||
}
|
||||
if c.on_ground {
|
||||
if c.kind == CartKind::Tnt && c.fall_distance >= 3.0 {
|
||||
let power = c.fall_distance / 10.0;
|
||||
c.fall_blast = Some(power * power);
|
||||
}
|
||||
c.fall_distance = 0.0;
|
||||
} else if moved.y >= 0.0 {
|
||||
c.fall_distance = 0.0;
|
||||
} else {
|
||||
c.fall_distance -= moved.y;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -912,7 +940,6 @@ impl Game<'_> {
|
||||
c.velocity = Vec3::new(c.velocity.x * 0.5, c.velocity.y * 0.5, c.velocity.z * 0.5);
|
||||
}
|
||||
let v = c.velocity;
|
||||
let fall = c.fall_distance;
|
||||
self.move_cart(c, v);
|
||||
// `comeOffTrack` moves the cart and keeps the delta the move resolved
|
||||
// (then scales it by the air drag), so a cart an obstacle stopped here
|
||||
@@ -925,10 +952,6 @@ impl Game<'_> {
|
||||
c.velocity.y * 0.95,
|
||||
c.velocity.z * 0.95,
|
||||
);
|
||||
} else if c.kind == CartKind::Tnt && fall >= 3.0 {
|
||||
// MinecartTNT.causeFallDamage
|
||||
c.fuse = 0;
|
||||
c.velocity = Vec3::new(c.velocity.x, fall / 10.0, c.velocity.z);
|
||||
}
|
||||
}
|
||||
/// `AbstractMinecart.tick` for one cart; returns false once it is gone.
|
||||
@@ -989,6 +1012,11 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
CartKind::Tnt => {
|
||||
// MinecartTNT.causeFallDamage ran inside the move.
|
||||
if let Some(speed_sqr) = c.fall_blast.take() {
|
||||
self.explode_cart(c, speed_sqr);
|
||||
return false;
|
||||
}
|
||||
// MinecartTNT.tick: the fuse and the collision check are separate.
|
||||
if c.fuse > 0 {
|
||||
c.fuse -= 1;
|
||||
@@ -1132,6 +1160,47 @@ impl Game<'_> {
|
||||
c.velocity.z -= za;
|
||||
}
|
||||
}
|
||||
self.push_carts_from_mobs();
|
||||
}
|
||||
/// `AbstractMinecart.push(mob)` from each mob walking into a cart
|
||||
/// (`LivingEntity.pushEntities` -> `doPush`): the cart gets the push and
|
||||
/// the mob a quarter of it back (`Entity.push`, which needs the mob to be
|
||||
/// pushable and to carry no one). Bats push nothing (`Bat.pushEntities`).
|
||||
fn push_carts_from_mobs(&mut self) {
|
||||
let teams = self.mob_teams();
|
||||
// (mob index, feet, box)
|
||||
let pushers: Vec<(usize, Vec3, Aabb)> = self
|
||||
.mobs
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, m)| {
|
||||
!m.removed
|
||||
&& m.alive()
|
||||
&& m.species() != Species::Bat
|
||||
&& pushable_by(teams.of(Who::Mob(m.id)), None, true)
|
||||
})
|
||||
.map(|(j, m)| (j, m.position, m.bounds()))
|
||||
.collect();
|
||||
drop(teams);
|
||||
for (j, feet, bb) in pushers {
|
||||
for c in &mut self.carts {
|
||||
// hasPassenger(mob): a mob in this cart never pushes it.
|
||||
if self.mobs[j].extra.mount == Some(c.id) || !bb.intersects(c.bounds()) {
|
||||
continue;
|
||||
}
|
||||
let Some((xa, za)) = cart_push_from(feet, c.position) else {
|
||||
continue;
|
||||
};
|
||||
c.velocity.x -= xa;
|
||||
c.velocity.z -= za;
|
||||
let m = &mut self.mobs[j];
|
||||
if m.pushable() && m.rider.is_none() && m.extra.rider.is_none() {
|
||||
m.velocity.x += xa / 4.0;
|
||||
m.velocity.z += za / 4.0;
|
||||
m.needs_sync = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
/// `OldMinecartBehavior.pushAndPickupEntities` for carts, at the end of
|
||||
/// cart `i`'s own tick: each other cart in its box (0.2 wider on each
|
||||
@@ -2283,6 +2352,14 @@ fn tnt_ignites_by_collision(horizontal_collision: bool, speed_sqr: f64) -> bool
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn mob_walking_into_a_cart_pushes_it_by_a_twentieth() {
|
||||
// Issue #6776: a villager at x=0.13 next to a cart at x=0.5 east-west.
|
||||
let (xa, za) =
|
||||
super::cart_push_from(Vec3::new(0.13, 0.0, 0.0), Vec3::new(0.5, 0.0, 0.0)).unwrap();
|
||||
assert!((xa + 0.05).abs() < 1e-6 && za == 0.0, "{xa} {za}");
|
||||
assert!(super::cart_push_from(Vec3::ZERO, Vec3::ZERO).is_none());
|
||||
}
|
||||
/// The collision detonation of a TNT minecart needs both the collision and
|
||||
/// a cart that was still moving: a nudge below a tenth of a block a tick
|
||||
/// does not set it off, and a fast cart on an open track does not either.
|
||||
|
||||
@@ -159,6 +159,115 @@ impl Host for super::Game<'_> {
|
||||
) -> bool {
|
||||
self.native_set_block(dimension, pos, state, flags)
|
||||
}
|
||||
fn edit_blocks(&mut self, dimension: &str, edit: &NativeBlockEdit) -> Result<u64, String> {
|
||||
self.native_edit_blocks(dimension, edit)
|
||||
}
|
||||
fn surface_heights(
|
||||
&self,
|
||||
dimension: &str,
|
||||
columns: &[(i32, i32)],
|
||||
heightmap: NativeHeightmap,
|
||||
) -> Result<Vec<Option<i32>>, String> {
|
||||
self.native_surface_heights(dimension, columns, heightmap)
|
||||
}
|
||||
fn read_columns(
|
||||
&self,
|
||||
dimension: &str,
|
||||
columns: &[(i32, i32)],
|
||||
heightmap: NativeHeightmap,
|
||||
range: (u32, u32),
|
||||
) -> Result<NativeColumnBlocks, String> {
|
||||
self.native_read_columns(dimension, columns, heightmap, range)
|
||||
}
|
||||
fn refresh_biome_colors(&mut self) {
|
||||
self.native_refresh_biome_colors()
|
||||
}
|
||||
fn neighbor_signal(&mut self, dimension: &str, pos: [i32; 3]) -> i32 {
|
||||
self.native_neighbor_signal(dimension, pos)
|
||||
}
|
||||
fn light(&self, pos: [i32; 3]) -> (u8, u8) {
|
||||
self.native_light(pos)
|
||||
}
|
||||
fn weather(&self) -> (f32, f32) {
|
||||
self.native_weather()
|
||||
}
|
||||
fn day_time(&self) -> i64 {
|
||||
self.native_day_time()
|
||||
}
|
||||
fn world_height(&self) -> (i32, i32) {
|
||||
self.native_world_height()
|
||||
}
|
||||
fn max_stack_size(&self, item: &str) -> Option<u32> {
|
||||
self.native_max_stack_size(item)
|
||||
}
|
||||
fn item_model(
|
||||
&self,
|
||||
batch: &mut HudBatch,
|
||||
item: &str,
|
||||
model: &str,
|
||||
at: [f32; 2],
|
||||
size: f32,
|
||||
) -> bool {
|
||||
self.native_item_model(batch, item, model, at, size)
|
||||
}
|
||||
fn quad(&self, batch: &mut HudBatch, points: [[f32; 2]; 4], argb: u32) {
|
||||
self.native_quad(batch, points, argb)
|
||||
}
|
||||
fn mod_screen_open(&mut self, mod_id: &str) -> bool {
|
||||
self.native_mod_screen_open(mod_id)
|
||||
}
|
||||
fn mod_screen_close(&mut self, mod_id: &str) {
|
||||
self.native_mod_screen_close(mod_id)
|
||||
}
|
||||
fn mod_screen(&self) -> Option<&str> {
|
||||
self.native_mod_screen()
|
||||
}
|
||||
fn set_experience_orb(
|
||||
&mut self,
|
||||
dimension: &str,
|
||||
id: u32,
|
||||
value: Option<u32>,
|
||||
count: Option<u32>,
|
||||
age: Option<u32>,
|
||||
) -> bool {
|
||||
self.native_set_experience_orb(dimension, id, value, count, age)
|
||||
}
|
||||
fn experience_orbs(
|
||||
&self,
|
||||
dimension: &str,
|
||||
min: [f64; 3],
|
||||
max: [f64; 3],
|
||||
) -> Vec<NativeExperienceOrb> {
|
||||
self.native_experience_orbs(dimension, min, max)
|
||||
}
|
||||
fn clock(&self) -> Option<NativeClock> {
|
||||
self.native_clock()
|
||||
}
|
||||
fn spawn_falling_block(
|
||||
&mut self,
|
||||
dimension: &str,
|
||||
state: BlockStateId,
|
||||
pos: [f64; 3],
|
||||
velocity: [f64; 3],
|
||||
falling: NativeFalling,
|
||||
) -> bool {
|
||||
self.native_spawn_falling_block(dimension, state, pos, velocity, falling)
|
||||
}
|
||||
fn block_state_property(&self, state: BlockStateId, key: &str) -> Option<&str> {
|
||||
self.native_block_state_property(state, key)
|
||||
}
|
||||
fn raycast(&self, distance: f32) -> Option<NativeHit> {
|
||||
self.native_raycast(distance)
|
||||
}
|
||||
fn view_hit(&self) -> Option<(f32, NativeHit)> {
|
||||
self.native_view_hit()
|
||||
}
|
||||
fn claim_items(&mut self, mod_id: &str, ids: &[String]) {
|
||||
self.native_claim_items(mod_id, ids)
|
||||
}
|
||||
fn poll(&mut self, mod_id: &str) -> Vec<NativeModEvent> {
|
||||
self.native_poll(mod_id)
|
||||
}
|
||||
fn set_item_entity(&mut self, dimension: &str, id: u32, stack: ItemStack) -> bool {
|
||||
self.native_set_item_entity(dimension, id, stack)
|
||||
}
|
||||
|
||||
@@ -351,6 +351,8 @@ impl Game<'_> {
|
||||
// Animal.setInLove: entity event 18.
|
||||
if loved {
|
||||
self.love_cause.insert(id, self.lan_acting);
|
||||
let kind = self.mobs[index].kind.id.clone();
|
||||
self.native_entity_enter_love_mode(id, &kind, self.lan_acting_uuid);
|
||||
self.mob_event_particles(id, 18);
|
||||
}
|
||||
self.consume_held();
|
||||
@@ -613,6 +615,18 @@ impl Game<'_> {
|
||||
// AbstractHorse.tameWithName: entity event 7, spawnTamingParticles(true).
|
||||
self.mob_event_particles(mob, 7);
|
||||
}
|
||||
if let Some(kind) = self
|
||||
.mobs
|
||||
.iter()
|
||||
.find(|m| m.id == mob)
|
||||
.map(|m| m.kind.id.clone())
|
||||
{
|
||||
let owner = self
|
||||
.level
|
||||
.player_uuid
|
||||
.map(horizon_client::lan::playerdata::ints_uuid);
|
||||
self.native_entity_tame(mob, &kind, owner);
|
||||
}
|
||||
}
|
||||
MobEvent::Buck { mob } => {
|
||||
if self.riding == Some(mob) {
|
||||
|
||||
@@ -29,11 +29,18 @@ impl Game<'_> {
|
||||
if !m.alive() {
|
||||
return false;
|
||||
}
|
||||
let entity_id = m.id;
|
||||
let entity_name = name.clone();
|
||||
m.custom_name = Some(name);
|
||||
m.persistent = true;
|
||||
// itemStack.shrink(1), in creative too.
|
||||
self.consume_held();
|
||||
self.swing_arm();
|
||||
if let (Some(player), Some(player_name)) =
|
||||
(self.lan_acting_uuid, self.lan_acting_name.clone())
|
||||
{
|
||||
self.native_player_name_entity(player, &player_name, entity_id, &entity_name);
|
||||
}
|
||||
true
|
||||
}
|
||||
/// `Entity.belowNameDisplay`: the score holder's score in the objective
|
||||
|
||||
@@ -43,7 +43,6 @@ fn in_frustum(frustum: Option<&impl Cull>, origin: Vec3, bounds: [f32; 6]) -> bo
|
||||
/// partial tick, game time and light, and the pose stack the helpers draw
|
||||
/// under (`native_draw_quad`, `native_draw_cube`, `native_draw_block`).
|
||||
// Native mods read and drive it; nothing in Horizon does.
|
||||
#[allow(dead_code)]
|
||||
pub struct BlockEntityDraw {
|
||||
/// The frame's partial tick, 0 to 1 (`BlockEntityRenderState`).
|
||||
pub partial: f32,
|
||||
@@ -174,7 +173,6 @@ impl BlockEntityDraw {
|
||||
/// One face of a `native_draw_cube` box: its sprite (`mymod:entity/cog`,
|
||||
/// `mymod:block/casing`) and its UVs in texels of 16 per side, as a model
|
||||
/// face's `uv` (none takes the box's own, as a model's default).
|
||||
#[allow(dead_code)]
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct CubeFace<'a> {
|
||||
pub sprite: &'a str,
|
||||
|
||||
@@ -5,36 +5,33 @@ use super::Game;
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
/// A stack as a client mod's use events carry it.
|
||||
#[allow(dead_code)]
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub(crate) struct NativeHeldItem {
|
||||
pub(crate) id: String,
|
||||
pub(crate) count: u32,
|
||||
pub struct NativeHeldItem {
|
||||
pub id: String,
|
||||
pub count: u32,
|
||||
/// The inventory slot: 0 to 8, the hotbar (a mod use is the main hand's).
|
||||
pub(crate) slot: u32,
|
||||
pub slot: u32,
|
||||
/// `minecraft:custom_name` as SNBT, when the stack has one.
|
||||
pub(crate) custom_name: Option<String>,
|
||||
pub custom_name: Option<String>,
|
||||
/// `minecraft:custom_data` as SNBT, when the stack has some.
|
||||
pub(crate) custom_data: Option<String>,
|
||||
pub custom_data: Option<String>,
|
||||
}
|
||||
/// The block a ray along the view meets.
|
||||
#[allow(dead_code)]
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub(crate) struct NativeHit {
|
||||
pub(crate) block_x: i32,
|
||||
pub(crate) block_y: i32,
|
||||
pub(crate) block_z: i32,
|
||||
pub struct NativeHit {
|
||||
pub block_x: i32,
|
||||
pub block_y: i32,
|
||||
pub block_z: i32,
|
||||
/// Where the ray meets the block.
|
||||
pub(crate) x: f64,
|
||||
pub(crate) y: f64,
|
||||
pub(crate) z: f64,
|
||||
pub x: f64,
|
||||
pub y: f64,
|
||||
pub z: f64,
|
||||
/// The block's id (`minecraft:stone`).
|
||||
pub(crate) block: String,
|
||||
pub block: String,
|
||||
}
|
||||
/// What happened to a mod's claimed item, oldest first.
|
||||
#[allow(dead_code)]
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub(crate) enum NativeModEvent {
|
||||
pub enum NativeModEvent {
|
||||
/// A right-click with the item began its use: the stack, and 0 ticks.
|
||||
UseStart(NativeHeldItem, u32),
|
||||
/// The use ended: the stack it began with, and the ticks it was held. It
|
||||
|
||||
@@ -15,12 +15,12 @@ use std::collections::{BTreeMap, HashMap, HashSet};
|
||||
|
||||
/// A data map type a mod registers (`RegisterDataMapTypesEvent`).
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub(crate) struct NativeDataMapType {
|
||||
pub struct NativeDataMapType {
|
||||
/// `mod_id:name`: not in the `minecraft` namespace. Its files are
|
||||
/// `data/<mod_id>/data_maps/<registry path>/<name>.json`.
|
||||
pub(crate) id: String,
|
||||
pub id: String,
|
||||
/// The registry it is for: `minecraft:item` or `minecraft:block`.
|
||||
pub(crate) registry: String,
|
||||
pub registry: String,
|
||||
}
|
||||
|
||||
/// The registries data maps can be attached to so far, with their folder
|
||||
|
||||
@@ -159,8 +159,8 @@ impl Game<'_> {
|
||||
let first = self.next_entity + 1;
|
||||
let deferred = (self.tnts.len(), self.falling.len(), self.rockets.len());
|
||||
let target = self.spawn_entity_from_nbt(tag);
|
||||
// These roots load with id 0 and have no command Target variant.
|
||||
// Select the newly appended root, never an older waiting entity.
|
||||
// These roots take their id as they load, and a rocket has no command
|
||||
// Target variant. Select the newly appended root, never an older waiting entity.
|
||||
let root = match kind {
|
||||
"minecraft:tnt" => self.tnts.get_mut(deferred.0).map(|e| &mut e.id),
|
||||
"minecraft:falling_block" => self.falling.get_mut(deferred.1).map(|e| &mut e.id),
|
||||
|
||||
@@ -16,7 +16,7 @@ const NATIVE_FALLING_MAX_SPEED: f64 = 3.9;
|
||||
|
||||
/// What a mod's falling block does when it lands and when it hits.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(super) struct NativeFalling {
|
||||
pub struct NativeFalling {
|
||||
/// `FallingBlockEntity.dropItem`: whether it drops as an item where it
|
||||
/// can't be placed.
|
||||
pub drop_item: bool,
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -20,7 +20,6 @@ use horizon_client::{
|
||||
/// `LivingEntityRenderState`: what a model reads to pose and animate itself.
|
||||
/// Angles are in degrees, interpolated to the frame's partial tick.
|
||||
// Native mods read it; nothing in Horizon does.
|
||||
#[allow(dead_code)]
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct NativeMobRender<'a> {
|
||||
/// The mob's network id, as the mob helpers and mod messages name it.
|
||||
@@ -83,7 +82,6 @@ impl<'a> NativeMobRender<'a> {
|
||||
/// Which player a [`NativeMobRender`] is, for `native_player_submit` and
|
||||
/// `native_mob_layer`.
|
||||
// Native mods read it; nothing in Horizon does.
|
||||
#[allow(dead_code)]
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct NativePlayerRender<'a> {
|
||||
/// The player's profile name.
|
||||
|
||||
@@ -375,7 +375,12 @@ impl Game<'_> {
|
||||
)
|
||||
}
|
||||
/// A system line in every player's chat and the server's log.
|
||||
pub(super) fn native_broadcast(&self, line: Text) {
|
||||
pub(super) fn native_broadcast(&self, mut line: Text) {
|
||||
let mut cancel = false;
|
||||
self.native_broadcast_message(&mut line, &mut cancel);
|
||||
if cancel {
|
||||
return;
|
||||
}
|
||||
let plain = line.plain();
|
||||
if self.options.dedicated.is_some() {
|
||||
super::dedicated::log("INFO", &plain);
|
||||
@@ -722,9 +727,8 @@ impl Game<'_> {
|
||||
|
||||
/// An experience orb as a server mod reads it (`native_experience_orbs`).
|
||||
/// Read by mods only.
|
||||
#[allow(dead_code)]
|
||||
#[derive(Clone, Debug)]
|
||||
pub(super) struct NativeExperienceOrb {
|
||||
pub struct NativeExperienceOrb {
|
||||
/// The entity's ID, as `native_entity_join_level` names it.
|
||||
pub id: u32,
|
||||
pub position: [f64; 3],
|
||||
@@ -893,6 +897,49 @@ impl Game<'_> {
|
||||
host.native_commands = Some(levels);
|
||||
}
|
||||
|
||||
/// Asks the mods what root commands each player's tree keeps
|
||||
/// (`native_player_commands_sent`) and stores the answer in the hub,
|
||||
/// which sends the trees without what the mods took out: for `only`, or
|
||||
/// for every placed player.
|
||||
pub(super) fn native_filter_commands(&mut self, host: &mut LanHost, only: Option<u32>) {
|
||||
let players: Vec<(u32, u128, String)> = host
|
||||
.hub
|
||||
.guests
|
||||
.iter()
|
||||
.filter(|g| only.map_or(g.placed, |conn| g.conn == conn))
|
||||
.map(|g| (g.conn, g.uuid, g.name.clone()))
|
||||
.collect();
|
||||
for (conn, uuid, name) in players {
|
||||
let names = host.hub.command_names(conn);
|
||||
let mut kept = names.clone();
|
||||
let placed = host.hub.guest(conn).is_some_and(|g| g.placed);
|
||||
if !placed {
|
||||
// A joining player has no world yet: nothing can be told them.
|
||||
self.native_player_commands_sent(uuid, &name, &mut kept);
|
||||
} else if self.lan_acting == Some(conn) {
|
||||
self.native_player_commands_sent(uuid, &name, &mut kept);
|
||||
} else {
|
||||
self.as_guest(host, conn, |g| {
|
||||
g.native_player_commands_sent(uuid, &name, &mut kept);
|
||||
});
|
||||
}
|
||||
if let Some(g) = host.hub.guest_mut(conn) {
|
||||
g.hidden_commands = names.into_iter().filter(|n| !kept.contains(n)).collect();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `Hub::resend_commands` with the mods' say on each tree.
|
||||
pub(super) fn resend_commands_filtered(&mut self) {
|
||||
let Some(mut host) = self.lan.take() else {
|
||||
return;
|
||||
};
|
||||
host.hub.refresh_permissions();
|
||||
self.native_filter_commands(&mut host, None);
|
||||
host.hub.resend_commands();
|
||||
self.lan = Some(host);
|
||||
}
|
||||
|
||||
/// The level a guest needs for a mod's command (`line`, without its
|
||||
/// slash), when a mod registered it.
|
||||
pub(super) fn native_command_level(host: &LanHost, line: &str) -> Option<u8> {
|
||||
|
||||
@@ -7,11 +7,11 @@ use super::native_world_edit::NativeHeightmap;
|
||||
/// What `native_read_columns` read: the blocks round each column's height (`bases`, none where it isn't loaded),
|
||||
/// as indexes into `palette`, a column after another, and three biome tints a column (grass, foliage, water).
|
||||
#[derive(Clone, Debug, PartialEq, Default)]
|
||||
pub(crate) struct NativeColumnBlocks {
|
||||
pub(crate) palette: Vec<String>,
|
||||
pub(crate) bases: Vec<Option<i32>>,
|
||||
pub(crate) blocks: Vec<u16>,
|
||||
pub(crate) tints: Vec<u32>,
|
||||
pub struct NativeColumnBlocks {
|
||||
pub palette: Vec<String>,
|
||||
pub bases: Vec<Option<i32>>,
|
||||
pub blocks: Vec<u16>,
|
||||
pub tints: Vec<u32>,
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
@@ -51,8 +51,9 @@ impl Game<'_> {
|
||||
dimension: &str,
|
||||
columns: &[(i32, i32)],
|
||||
heightmap: NativeHeightmap,
|
||||
(below, above): (u32, u32),
|
||||
range: (u32, u32),
|
||||
) -> Result<NativeColumnBlocks, String> {
|
||||
let (below, above) = range;
|
||||
if !self.native_level_open(dimension) {
|
||||
return Err(format!("{dimension} is not the level being ticked"));
|
||||
}
|
||||
|
||||
@@ -3,9 +3,8 @@
|
||||
use super::Game;
|
||||
|
||||
/// Which of a column's heights (`Heightmap.Types`) a run's `y` counts from.
|
||||
#[allow(dead_code)]
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(crate) enum NativeHeightmap {
|
||||
pub enum NativeHeightmap {
|
||||
WorldSurface,
|
||||
MotionBlocking,
|
||||
MotionBlockingNoLeaves,
|
||||
@@ -14,22 +13,22 @@ pub(crate) enum NativeHeightmap {
|
||||
/// A run: `height` blocks up a column from `y` (from the column's `surface` height when one is
|
||||
/// given), set to palette entry `block`, only where filter `only` matches when one is given.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub(crate) struct NativeBlockRun {
|
||||
pub(crate) x: i32,
|
||||
pub(crate) z: i32,
|
||||
pub(crate) y: i32,
|
||||
pub(crate) height: u32,
|
||||
pub(crate) surface: Option<NativeHeightmap>,
|
||||
pub(crate) block: u32,
|
||||
pub(crate) only: Option<u32>,
|
||||
pub struct NativeBlockRun {
|
||||
pub x: i32,
|
||||
pub z: i32,
|
||||
pub y: i32,
|
||||
pub height: u32,
|
||||
pub surface: Option<NativeHeightmap>,
|
||||
pub block: u32,
|
||||
pub only: Option<u32>,
|
||||
}
|
||||
/// A bulk edit: block states and block predicates as commands write them (without NBT), and the runs naming them
|
||||
/// by index.
|
||||
#[derive(Clone, Debug, PartialEq, Default)]
|
||||
pub(crate) struct NativeBlockEdit {
|
||||
pub(crate) palette: Vec<String>,
|
||||
pub(crate) filters: Vec<String>,
|
||||
pub(crate) runs: Vec<NativeBlockRun>,
|
||||
pub struct NativeBlockEdit {
|
||||
pub palette: Vec<String>,
|
||||
pub filters: Vec<String>,
|
||||
pub runs: Vec<NativeBlockRun>,
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
|
||||
Loaded 100 of 395 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user