mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-09 10:39:46 -10:00
Compare commits
131
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
110a92a1aa | ||
|
|
3a8d647c8c | ||
|
|
3c346ba1a4 | ||
|
|
b41db51bf9 | ||
|
|
1ecc5a9bdc | ||
|
|
d05675d755 | ||
|
|
2568835cc6 | ||
|
|
f0cb1e42c0 | ||
|
|
38174db59b | ||
|
|
093f5efda8 | ||
|
|
b444a6b64f | ||
|
|
e8d192f2ac | ||
|
|
6ad39aa412 | ||
|
|
6342ce83b2 | ||
|
|
595421d8ee | ||
|
|
7169886142 | ||
|
|
cdcac3463e | ||
|
|
e71c626484 | ||
|
|
194ede9376 | ||
|
|
12578352dc | ||
|
|
11ebc3f199 | ||
|
|
29ac1e17b9 | ||
|
|
bc08885276 | ||
|
|
bc7b8889de | ||
|
|
7180a52bf2 | ||
|
|
8e37c4a4b7 | ||
|
|
94ba80f90d | ||
|
|
238fc99add | ||
|
|
ac96d3fa35 | ||
|
|
ceff25fca1 | ||
|
|
36d0695742 | ||
|
|
94484675a8 | ||
|
|
9097d2973d | ||
|
|
00e573cf87 | ||
|
|
4881a0862f | ||
|
|
1498fdecd3 | ||
|
|
8cf1e6b4d6 | ||
|
|
e859476293 | ||
|
|
89235891ce | ||
|
|
2081a0dd95 | ||
|
|
3f1a50ccb2 | ||
|
|
8534f12db0 | ||
|
|
7e95a1f57f | ||
|
|
e0e4c5e9db | ||
|
|
3dcfd7276b | ||
|
|
bfbf06a439 | ||
|
|
723c4e7c2b | ||
|
|
c32f43b4b0 | ||
|
|
75071c8b9b | ||
|
|
b26a050b2b | ||
|
|
971f341977 | ||
|
|
63a6a92bef | ||
|
|
cf93d0dd3a | ||
|
|
2e9f807458 | ||
|
|
21aabee4c0 | ||
|
|
7be13f1e9a | ||
|
|
ff83eccf6a | ||
|
|
8d7ca30ea4 | ||
|
|
e39ec11e59 | ||
|
|
b5f396461e | ||
|
|
e963d24058 | ||
|
|
3eec96a8b1 | ||
|
|
249e8db13c | ||
|
|
6bb4c0eda9 | ||
|
|
2f06f532e5 | ||
|
|
092dee299b | ||
|
|
c992f24496 | ||
|
|
005249b984 | ||
|
|
23ca4a855a | ||
|
|
16172da6b7 | ||
|
|
5720b15eff | ||
|
|
2e754b1297 | ||
|
|
70c8d7089d | ||
|
|
eb3157d251 | ||
|
|
d577d49a78 | ||
|
|
4643ea9357 | ||
|
|
8a9bd15a3d | ||
|
|
a40f3d82c1 | ||
|
|
af3c22b9f1 | ||
|
|
182eec2cb7 | ||
|
|
b7615761ee | ||
|
|
93e0536aca | ||
|
|
396353c6b1 | ||
|
|
4c28a9b5b9 | ||
|
|
59d958c09e | ||
|
|
1a2015e305 | ||
|
|
197172ef3c | ||
|
|
9d6d292348 | ||
|
|
1e789800af | ||
|
|
74e0245c47 | ||
|
|
e5eacec45c | ||
|
|
cf23e5001a | ||
|
|
0e9233b230 | ||
|
|
358456bb02 | ||
|
|
aa741b3570 | ||
|
|
1b4f056692 | ||
|
|
3b03b98836 | ||
|
|
4e4767bbfe | ||
|
|
a2d34c7dbd | ||
|
|
e42acfc7ea | ||
|
|
9fd16efb45 | ||
|
|
34b5df1397 | ||
|
|
72c6784172 | ||
|
|
d82a6941d1 | ||
|
|
f7772b587f | ||
|
|
d1098c9a91 | ||
|
|
d50d56d837 | ||
|
|
1feb0c7b0b | ||
|
|
8a5af429d7 | ||
|
|
f0c1ec9d84 | ||
|
|
443a42f97a | ||
|
|
755b7800c1 | ||
|
|
30149bfb41 | ||
|
|
93ecc8d0c1 | ||
|
|
230b167744 | ||
|
|
0031c67d04 | ||
|
|
2726614191 | ||
|
|
2e45a0750d | ||
|
|
8ad2c758ad | ||
|
|
c71978372d | ||
|
|
5152376c4b | ||
|
|
8f331a10f5 | ||
|
|
cbba6ead3a | ||
|
|
2a44a86383 | ||
|
|
de9ab4646d | ||
|
|
2d2a2621f2 | ||
|
|
e0b91d71bf | ||
|
|
0f8225e608 | ||
|
|
b2ba0cb395 | ||
|
|
72f748a785 | ||
|
|
9ad798b74b |
No files matched your search
@@ -1441,6 +1441,16 @@ pub enum Rumble {
|
||||
},
|
||||
Totem,
|
||||
}
|
||||
impl From<crate::input_source::Feedback> for Rumble {
|
||||
fn from(event: crate::input_source::Feedback) -> Self {
|
||||
use crate::input_source::Feedback;
|
||||
match event {
|
||||
Feedback::BlockBroken => Rumble::BlockBroken,
|
||||
Feedback::Explosion { distance, radius } => Rumble::Explosion { distance, radius },
|
||||
Feedback::Totem => Rumble::Totem,
|
||||
}
|
||||
}
|
||||
}
|
||||
/// A vibration: the heavy (low-frequency) and light motors, 0..1, and how
|
||||
/// long it lasts.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
//! The engine's side of the input source (docs/layers.md, "Controllers, splitscreen, replays and
|
||||
//! UI pages"): what gameplay tells a device beyond the keyboard and mouse. Gameplay names these
|
||||
//! types, never a controller's; the controller code answers.
|
||||
|
||||
/// Something that happened to the player that a device may let them feel (a controller's
|
||||
/// vibration). The device decides how, or whether.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub enum Feedback {
|
||||
/// The player broke a block.
|
||||
BlockBroken,
|
||||
/// An explosion this far away (blocks) with this power.
|
||||
Explosion { distance: f64, radius: f32 },
|
||||
/// A totem of undying saved the player.
|
||||
Totem,
|
||||
}
|
||||
@@ -9,6 +9,7 @@ pub mod gamepad;
|
||||
pub mod gameplay_guide;
|
||||
pub mod held;
|
||||
pub mod input;
|
||||
pub mod input_source;
|
||||
pub mod interaction_input;
|
||||
pub mod key_names;
|
||||
pub mod keybinds;
|
||||
|
||||
@@ -76,6 +76,10 @@ wgpu = { version = "30.0.1", default-features = false, features = ["gles"] }
|
||||
audio = ["dep:cpal"]
|
||||
# Helpers horizon-client's tests build their fixtures with.
|
||||
test-support = []
|
||||
# horizon-client's `horizon-extras`: asking the device for hardware ray tracing on request
|
||||
# (`gfx/ray_query.rs`, `HORIZON_RAY_TRACING=1`). Without it the device is requested as without
|
||||
# the variable.
|
||||
horizon-extras = []
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
//! Audio occlusion, attenuation and mixing benchmark.
|
||||
//! Run with: `cargo run --profile fast -p horizon-client-render --example audio_bench`
|
||||
use horizon_client_render::{
|
||||
audio::Audio,
|
||||
sound_engine::{MobSound, SoundInstance, Sounds, TickContext},
|
||||
};
|
||||
use std::{path::Path, time::Instant};
|
||||
|
||||
fn main() {
|
||||
println!("=== Horizon Audio Attenuation & Mixing Benchmark ===");
|
||||
|
||||
let mut audio = Audio::silent(Path::new("/nonexistent"));
|
||||
audio.engine.loaded = true;
|
||||
audio.backend_live_for_bench();
|
||||
audio.record_heard = false;
|
||||
|
||||
// Register a sound event with attenuation distance 16
|
||||
audio.engine.registry.add_file(
|
||||
"minecraft",
|
||||
br#"{"bench.loop":{"attenuation_distance":16,"sounds":[{"name":"bench/loop","stream":false}]}}"#,
|
||||
&|_, _| true,
|
||||
);
|
||||
// Insert 1 second of audio into cache (48000 mono samples)
|
||||
let samples: Vec<f32> = (0..48_000)
|
||||
.map(|i| ((i as f32) * 440.0 * 2.0 * std::f32::consts::PI / 48000.0).sin() * 0.5)
|
||||
.collect();
|
||||
audio.insert_cached_sound("minecraft:bench/loop", samples, 48_000, 1);
|
||||
|
||||
// 1. Benchmark set_listener (called every client render frame)
|
||||
let listener_iterations = 200_000;
|
||||
let start = Instant::now();
|
||||
for i in 0..listener_iterations {
|
||||
let angle = (i as f32) * 0.01;
|
||||
audio.set_listener(
|
||||
[
|
||||
100.0 + (angle.cos() as f64) * 5.0,
|
||||
64.0,
|
||||
200.0 + (angle.sin() as f64) * 5.0,
|
||||
],
|
||||
angle,
|
||||
0.0,
|
||||
);
|
||||
}
|
||||
let listener_elapsed = start.elapsed();
|
||||
println!(
|
||||
"1. set_listener moving (render frame): {:?} for {} calls ({:.2} ns/call)",
|
||||
listener_elapsed,
|
||||
listener_iterations,
|
||||
(listener_elapsed.as_nanos() as f64) / (listener_iterations as f64)
|
||||
);
|
||||
|
||||
// 1b. Benchmark set_listener stationary (lock elimination early-out)
|
||||
let stationary_iterations = 200_000;
|
||||
let start = Instant::now();
|
||||
for _ in 0..stationary_iterations {
|
||||
audio.set_listener([100.0, 64.0, 200.0], 0.0, 0.0);
|
||||
}
|
||||
let stationary_elapsed = start.elapsed();
|
||||
println!(
|
||||
"1b. set_listener stationary (lock early-out): {:?} for {} calls ({:.2} ns/call)",
|
||||
stationary_elapsed,
|
||||
stationary_iterations,
|
||||
(stationary_elapsed.as_nanos() as f64) / (stationary_iterations as f64)
|
||||
);
|
||||
|
||||
// 2. Play 128 sounds in the world at various distances from listener at [100.0, 64.0, 200.0]
|
||||
// 64 sounds within attenuation distance (<16 blocks)
|
||||
// 64 sounds outside attenuation distance (>16 blocks)
|
||||
for i in 0..128 {
|
||||
let dist = if i < 64 {
|
||||
(i as f64) * 0.24 // 0 to 15.1 blocks (audible)
|
||||
} else {
|
||||
17.0 + ((i - 64) as f64) * 1.5 // 17 to 111.5 blocks (out of attenuation range)
|
||||
};
|
||||
let pos = [100.0 + dist, 64.0, 200.0];
|
||||
let instance = SoundInstance::new(
|
||||
"minecraft:bench.loop",
|
||||
crate_sound_source(i),
|
||||
1.0,
|
||||
1.0,
|
||||
horizon_core::random::LegacyRandom::new(i as i64),
|
||||
true, // looping
|
||||
0,
|
||||
horizon_client_render::sound_engine::Attenuation::Linear,
|
||||
pos,
|
||||
false,
|
||||
);
|
||||
audio.play_instance(instance, false);
|
||||
}
|
||||
|
||||
// 3. Benchmark mix_buffer (5000 buffers of 1024 stereo frames @ 48kHz = ~106 seconds of audio mixing)
|
||||
let mut buffer = vec![0.0f32; 1024 * 2];
|
||||
let mix_iterations = 5_000;
|
||||
let start = Instant::now();
|
||||
for _ in 0..mix_iterations {
|
||||
audio.mix_buffer(&mut buffer, 2, 48_000);
|
||||
}
|
||||
let mix_elapsed = start.elapsed();
|
||||
println!(
|
||||
"2. mix_buffer (128 active channels, 1024 stereo frames): {:?} for {} buffers ({:.2} µs/buffer)",
|
||||
mix_elapsed,
|
||||
mix_iterations,
|
||||
(mix_elapsed.as_micros() as f64) / (mix_iterations as f64)
|
||||
);
|
||||
|
||||
// 4. Benchmark SoundEngine::tick with ticking mob sounds
|
||||
let mut mobs = Vec::new();
|
||||
for i in 0..64 {
|
||||
mobs.push(MobSound {
|
||||
id: 2000 + i,
|
||||
position: [100.0 + (i as f64) * 0.5, 64.0, 200.0 + (i as f64) * 0.5],
|
||||
speed: 0.25,
|
||||
baby: false,
|
||||
angry: i % 2 == 0,
|
||||
silent: false,
|
||||
attack_scale: 0.5,
|
||||
sniffer_digging: false,
|
||||
});
|
||||
}
|
||||
|
||||
let tick_ctx = TickContext {
|
||||
player_underwater: false,
|
||||
player_removed: false,
|
||||
vehicle: None,
|
||||
carts: Vec::new(),
|
||||
mobs: mobs.clone(),
|
||||
frozen: false,
|
||||
player_position: [100.0, 64.0, 200.0],
|
||||
player_speed_sqr: 0.1,
|
||||
player_fall_flying: false,
|
||||
ridden: None,
|
||||
camera_position: [100.0, 64.0, 200.0],
|
||||
};
|
||||
|
||||
let tick_iterations = 10_000;
|
||||
let start = Instant::now();
|
||||
for _ in 0..tick_iterations {
|
||||
audio.tick(false, &tick_ctx);
|
||||
}
|
||||
let tick_elapsed = start.elapsed();
|
||||
println!(
|
||||
"3. SoundEngine::tick (64 channels + ticking): {:?} for {} ticks ({:.2} µs/tick)",
|
||||
tick_elapsed,
|
||||
tick_iterations,
|
||||
(tick_elapsed.as_micros() as f64) / (tick_iterations as f64)
|
||||
);
|
||||
}
|
||||
|
||||
fn crate_sound_source(i: u32) -> usize {
|
||||
match i % 6 {
|
||||
0 => 0, // master
|
||||
1 => 1, // music
|
||||
2 => 2, // records
|
||||
3 => 3, // weather
|
||||
4 => 4, // blocks
|
||||
_ => 5, // hostile
|
||||
}
|
||||
}
|
||||
@@ -382,8 +382,8 @@ impl RenderAssets {
|
||||
};
|
||||
// After the connected tiles: a tile identical to a block shares its layer, and the
|
||||
// overlays skip shared layers.
|
||||
if draws {
|
||||
emissive = crate::native_emissive::load_optifine(root, &mut sprites);
|
||||
if draws && let Some(suffix) = crate::pack_hooks::formats().emissive_suffix(root) {
|
||||
emissive = crate::native_emissive::load_overlays(root, &suffix, &mut sprites);
|
||||
}
|
||||
sprites.save_disk_cache();
|
||||
if draws {
|
||||
|
||||
@@ -233,11 +233,11 @@ pub fn texture_path(root: &Path, texture: &str, directory: &str) -> PathBuf {
|
||||
}
|
||||
let (namespace, path) = texture.split_once(':').unwrap_or(("minecraft", texture));
|
||||
let rel = format!("assets/{namespace}/{directory}/{path}.png");
|
||||
root.join(if horizon_optifine::paths::valid(&rel) {
|
||||
&rel
|
||||
} else {
|
||||
NOWHERE
|
||||
})
|
||||
// No `.`, `..` or empty segment, and no `\` (a separator on Windows, where `..\..` climbs).
|
||||
let stays = rel
|
||||
.split('/')
|
||||
.all(|segment| !matches!(segment, "" | "." | "..") && !segment.contains('\\'));
|
||||
root.join(if stays { &rel } else { NOWHERE })
|
||||
}
|
||||
|
||||
/// `SpriteSourceList.list`: every sprite id of the atlas and its origin.
|
||||
|
||||
@@ -478,6 +478,7 @@ pub struct Audio {
|
||||
pub record_heard: bool,
|
||||
pub listener: [f64; 3],
|
||||
pub listener_yaw: f32,
|
||||
pub listener_pitch: f32,
|
||||
pub master: f32,
|
||||
/// Seeds of positional plays (the client level random's `nextLong`).
|
||||
random: LegacyRandom,
|
||||
@@ -722,6 +723,29 @@ impl Backend for MixerBackend {
|
||||
fn set_unmuffled(&mut self, channel: ChannelId, unmuffled: bool) {
|
||||
self.with(channel, |c| c.unmuffled = unmuffled);
|
||||
}
|
||||
fn update_channel(&mut self, channel: ChannelId, volume: f32, pitch: f32, position: [f64; 3]) {
|
||||
if let Ok(mut m) = self.mixer.lock()
|
||||
&& let Some(c) = m.channels.get_mut(&channel)
|
||||
{
|
||||
c.gain = volume;
|
||||
c.pitch = pitch;
|
||||
c.at = [position[0] as f32, position[1] as f32, position[2] as f32];
|
||||
}
|
||||
}
|
||||
fn filter_stopped(&mut self, live: &[(u64, ChannelId)], stopped: &mut Vec<u64>) {
|
||||
if let Ok(m) = self.mixer.lock() {
|
||||
for &(h, c) in live {
|
||||
if m.channels
|
||||
.get(&c)
|
||||
.is_none_or(|ch| ch.state == State::Stopped)
|
||||
{
|
||||
stopped.push(h);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
stopped.extend(live.iter().map(|&(h, _)| h));
|
||||
}
|
||||
}
|
||||
/// `getCompleteBuffer` (cached, else decoded on the decoder thread) or
|
||||
/// `getStream` (decoded on a thread of its own), then attach and
|
||||
/// `alSourcePlay`. A sound that cannot load leaves its source initial,
|
||||
@@ -822,18 +846,24 @@ impl Backend for MixerBackend {
|
||||
/// pinned client's) rendered through `ALC_SOFT_loopback` within 1e-5.
|
||||
fn stereo_gains(right: f32, up: f32, forward: f32) -> [f32; 2] {
|
||||
let length = (right * right + up * up + forward * forward).sqrt();
|
||||
stereo_gains_with_length(right, up, forward, length)
|
||||
}
|
||||
#[inline]
|
||||
fn stereo_gains_with_length(right: f32, up: f32, forward: f32, length: f32) -> [f32; 2] {
|
||||
if length <= f32::EPSILON {
|
||||
return stereo_gains(0.0, 0.0, 1.0);
|
||||
return stereo_gains_with_length(0.0, 0.0, 1.0, 1.0);
|
||||
}
|
||||
let elevation = (up / length).clamp(-1.0, 1.0).asin();
|
||||
let u = (up / length).clamp(-1.0, 1.0);
|
||||
let cos_elev = (1.0 - u * u).max(0.0).sqrt();
|
||||
let mut azimuth = right.atan2(forward);
|
||||
if azimuth.abs() < std::f32::consts::FRAC_PI_2 {
|
||||
azimuth = (azimuth.abs() * 1.5)
|
||||
.min(std::f32::consts::FRAC_PI_2)
|
||||
.copysign(azimuth);
|
||||
}
|
||||
let side = azimuth.sin() * elevation.cos();
|
||||
let front = azimuth.cos() * elevation.cos() * 0.095_660_8;
|
||||
let (sin_az, cos_az) = azimuth.sin_cos();
|
||||
let side = sin_az * cos_elev;
|
||||
let front = cos_az * cos_elev * 0.095_660_8;
|
||||
[0.5 - 0.5 * side + front, 0.5 + 0.5 * side + front]
|
||||
}
|
||||
/// The gain OpenAL applies for distance (`AL_LINEAR_DISTANCE` with reference
|
||||
@@ -853,16 +883,10 @@ fn mix(mixer: &mut Mixer, data: &mut [f32], channels: usize, rate: u32) {
|
||||
// and OpenAL's right = forward x up.
|
||||
let basis = |yaw: f32, pitch: f32| {
|
||||
let (yaw, pitch) = (f64::from(yaw.to_radians()), f64::from(pitch.to_radians()));
|
||||
let forward = [
|
||||
-yaw.sin() * pitch.cos(),
|
||||
-pitch.sin(),
|
||||
yaw.cos() * pitch.cos(),
|
||||
];
|
||||
let up = [
|
||||
-yaw.sin() * pitch.sin(),
|
||||
pitch.cos(),
|
||||
yaw.cos() * pitch.sin(),
|
||||
];
|
||||
let (yaw_sin, yaw_cos) = yaw.sin_cos();
|
||||
let (pitch_sin, pitch_cos) = pitch.sin_cos();
|
||||
let forward = [-yaw_sin * pitch_cos, -pitch_sin, yaw_cos * pitch_cos];
|
||||
let up = [-yaw_sin * pitch_sin, pitch_cos, yaw_cos * pitch_sin];
|
||||
let right = [
|
||||
forward[1] * up[2] - forward[2] * up[1],
|
||||
forward[2] * up[0] - forward[0] * up[2],
|
||||
@@ -896,7 +920,7 @@ fn mix(mixer: &mut Mixer, data: &mut [f32], channels: usize, rate: u32) {
|
||||
// Gains and pans per source for this buffer.
|
||||
let mut voices = std::mem::take(&mut mixer.voices);
|
||||
voices.clear();
|
||||
for (id, c) in &mixer.channels {
|
||||
for (id, c) in &mut mixer.channels {
|
||||
let Some((_, source_rate, source_channels)) = &c.samples else {
|
||||
continue;
|
||||
};
|
||||
@@ -907,6 +931,7 @@ fn mix(mixer: &mut Mixer, data: &mut [f32], channels: usize, rate: u32) {
|
||||
if c.state != State::Playing {
|
||||
continue;
|
||||
}
|
||||
|
||||
let at = [f64::from(c.at[0]), f64::from(c.at[1]), f64::from(c.at[2])];
|
||||
let away = |listener: [f64; 3]| {
|
||||
[
|
||||
@@ -915,45 +940,56 @@ fn mix(mixer: &mut Mixer, data: &mut [f32], channels: usize, rate: u32) {
|
||||
at[2] - listener[2],
|
||||
]
|
||||
};
|
||||
let length = |d: [f64; 3]| d[0] * d[0] + d[1] * d[1] + d[2] * d[2];
|
||||
// The nearest camera hears it (one camera but in splitscreen).
|
||||
let camera = mixer
|
||||
.cameras
|
||||
.iter()
|
||||
.filter(|camera| c.relative || camera.2 == c.world)
|
||||
.min_by(|a, b| length(away(a.0)).total_cmp(&length(away(b.0))))
|
||||
.map(|camera| (camera.0, camera.1));
|
||||
// Unheard sources still advance: returning to a world must not resume stale sounds.
|
||||
let length_sq = |d: [f64; 3]| d[0] * d[0] + d[1] * d[1] + d[2] * d[2];
|
||||
let camera = if !shared {
|
||||
let cam = &mixer.cameras[0];
|
||||
if c.relative || cam.2 == c.world {
|
||||
Some((cam.0, cam.1))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
} else {
|
||||
mixer
|
||||
.cameras
|
||||
.iter()
|
||||
.filter(|camera| c.relative || camera.2 == c.world)
|
||||
.min_by(|a, b| length_sq(away(a.0)).total_cmp(&length_sq(away(b.0))))
|
||||
.map(|camera| (camera.0, camera.1))
|
||||
};
|
||||
let (listener, (forward, up, right)) = camera.unwrap_or(([0.0; 3], basis(0.0, 0.0)));
|
||||
let d = if c.relative { at } else { away(listener) };
|
||||
let distance = (d[0] * d[0] + d[1] * d[1] + d[2] * d[2]).sqrt() as f32;
|
||||
let gain = if camera.is_some() {
|
||||
c.gain.clamp(0.0, 1.0)
|
||||
* if direct {
|
||||
1.0
|
||||
} else {
|
||||
distance_gain(c.linear, distance)
|
||||
let d_sqr = length_sq(d);
|
||||
|
||||
let (gain, [left, right]) = if camera.is_none() {
|
||||
(0.0, [0.0, 0.0])
|
||||
} else if direct {
|
||||
let gain = c.gain.clamp(0.0, 1.0);
|
||||
(gain, [gain, gain])
|
||||
} else {
|
||||
let mut zero = false;
|
||||
if let Some(max) = c.linear {
|
||||
let max_d = max as f64;
|
||||
if max <= 0.0 || (!c.relative && d_sqr >= max_d * max_d) {
|
||||
zero = true;
|
||||
}
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
let [left, right] = if direct {
|
||||
[gain, gain]
|
||||
} else {
|
||||
// Relative sources are already in OpenAL's listener space (-Z ahead).
|
||||
let [mut left, mut right] = if c.relative {
|
||||
stereo_gains(d[0] as f32, d[1] as f32, -d[2] as f32)
|
||||
} else {
|
||||
stereo_gains(dot(d, right), dot(d, up), dot(d, forward))
|
||||
};
|
||||
if shared {
|
||||
// Several players on the speakers: half the stereo width, as left and right are
|
||||
// nobody's in particular.
|
||||
let middle = (left + right) / 2.0;
|
||||
left = middle + (left - middle) / 2.0;
|
||||
right = middle + (right - middle) / 2.0;
|
||||
}
|
||||
[left * gain, right * gain]
|
||||
if zero {
|
||||
(0.0, [0.0, 0.0])
|
||||
} else {
|
||||
let distance = d_sqr.sqrt() as f32;
|
||||
let gain = c.gain.clamp(0.0, 1.0) * distance_gain(c.linear, distance);
|
||||
let [mut left, mut right] = if c.relative {
|
||||
stereo_gains_with_length(d[0] as f32, d[1] as f32, -d[2] as f32, distance)
|
||||
} else {
|
||||
stereo_gains_with_length(dot(d, right), dot(d, up), dot(d, forward), distance)
|
||||
};
|
||||
if shared {
|
||||
let middle = (left + right) * 0.5;
|
||||
left = middle + (left - middle) * 0.5;
|
||||
right = middle + (right - middle) * 0.5;
|
||||
}
|
||||
(gain, [left * gain, right * gain])
|
||||
}
|
||||
};
|
||||
let step = f64::from(*source_rate) / f64::from(rate) * f64::from(c.pitch.max(0.0));
|
||||
voices.push((*id, [left, right, gain], step));
|
||||
@@ -974,6 +1010,40 @@ fn mix(mixer: &mut Mixer, data: &mut [f32], channels: usize, rate: u32) {
|
||||
};
|
||||
let source_channels = source_channels as usize;
|
||||
let frames = samples.len() / source_channels;
|
||||
let inaudible = if channels >= 2 {
|
||||
left == 0.0 && right == 0.0
|
||||
} else {
|
||||
gain == 0.0
|
||||
};
|
||||
if inaudible {
|
||||
let buffer_frames = data.len() / channels;
|
||||
// As the loop below: a sound of one frame or none stops, looping or not.
|
||||
if !c.looping || frames <= 1 {
|
||||
// `frames` may be 0: no `frames - 1` in usize. A step of 0 never
|
||||
// reaches the end, as in the loop below.
|
||||
let needed = frames as f64 - 1.0 - c.position;
|
||||
if needed <= 0.0 {
|
||||
c.state = State::Stopped;
|
||||
} else if step > 0.0 {
|
||||
let steps_to_stop = needed / step;
|
||||
if (buffer_frames as f64) >= steps_to_stop {
|
||||
c.state = State::Stopped;
|
||||
} else {
|
||||
c.position += step * (buffer_frames as f64);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for _ in 0..buffer_frames {
|
||||
let i = c.position as usize;
|
||||
if i + 1 >= frames {
|
||||
c.position = 0.0;
|
||||
}
|
||||
c.position += step;
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
let out: &mut [f32] = if muffling && !c.unmuffled {
|
||||
&mut bus
|
||||
} else {
|
||||
@@ -992,8 +1062,8 @@ fn mix(mixer: &mut Mixer, data: &mut [f32], channels: usize, rate: u32) {
|
||||
}
|
||||
let t = (c.position - i as f64) as f32;
|
||||
if source_channels > 1 {
|
||||
let l = samples[i * 2] * (1.0 - t) + samples[(i + 1) * 2] * t;
|
||||
let r = samples[i * 2 + 1] * (1.0 - t) + samples[(i + 1) * 2 + 1] * t;
|
||||
let l = samples[i * 2] + (samples[(i + 1) * 2] - samples[i * 2]) * t;
|
||||
let r = samples[i * 2 + 1] + (samples[(i + 1) * 2 + 1] - samples[i * 2 + 1]) * t;
|
||||
if channels >= 2 {
|
||||
frame[0] += l * left;
|
||||
frame[1] += r * right;
|
||||
@@ -1001,7 +1071,7 @@ fn mix(mixer: &mut Mixer, data: &mut [f32], channels: usize, rate: u32) {
|
||||
frame[0] += (l + r) * 0.5 * gain;
|
||||
}
|
||||
} else {
|
||||
let s = samples[i] * (1.0 - t) + samples[i + 1] * t;
|
||||
let s = samples[i] + (samples[i + 1] - samples[i]) * t;
|
||||
if channels >= 2 {
|
||||
frame[0] += s * left;
|
||||
frame[1] += s * right;
|
||||
@@ -1412,6 +1482,7 @@ impl Audio {
|
||||
record_heard: false,
|
||||
listener: [0.0; 3],
|
||||
listener_yaw: 0.0,
|
||||
listener_pitch: 0.0,
|
||||
master: 1.0,
|
||||
random: crate::random_source::create(),
|
||||
source_override: None,
|
||||
@@ -1535,8 +1606,12 @@ impl Audio {
|
||||
/// `SoundManager.updateSource`: the camera position and rotation
|
||||
/// (`Listener.setTransform` takes its forward and up vectors).
|
||||
pub fn set_listener(&mut self, position: [f64; 3], yaw: f32, pitch: f32) {
|
||||
if self.listener == position && self.listener_yaw == yaw && self.listener_pitch == pitch {
|
||||
return;
|
||||
}
|
||||
self.listener = position;
|
||||
self.listener_yaw = yaw;
|
||||
self.listener_pitch = pitch;
|
||||
if let Ok(mut m) = self.backend.mixer.lock() {
|
||||
if self.backend.owner == 0 {
|
||||
m.listener = position;
|
||||
@@ -1576,6 +1651,7 @@ impl Audio {
|
||||
record_heard: false,
|
||||
listener: [0.0; 3],
|
||||
listener_yaw: 0.0,
|
||||
listener_pitch: 0.0,
|
||||
master: self.master,
|
||||
random: crate::random_source::create(),
|
||||
source_override: None,
|
||||
@@ -2109,6 +2185,25 @@ impl Audio {
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
/// Mixes one buffer of audio into `data` (channels: 1 or 2, rate in Hz).
|
||||
pub fn mix_buffer(&mut self, data: &mut [f32], channels: usize, rate: u32) {
|
||||
if let Ok(mut m) = self.backend.mixer.lock() {
|
||||
mix(&mut m, data, channels, rate);
|
||||
}
|
||||
}
|
||||
#[doc(hidden)]
|
||||
pub fn insert_cached_sound(&mut self, key: &str, samples: Vec<f32>, rate: u32, channels: u16) {
|
||||
if let Ok(mut c) = self.backend.cache.lock() {
|
||||
c.insert(
|
||||
key.to_owned(),
|
||||
Some((std::sync::Arc::new(samples), rate, channels)),
|
||||
);
|
||||
}
|
||||
}
|
||||
#[doc(hidden)]
|
||||
pub fn backend_live_for_bench(&mut self) {
|
||||
self.backend.live = true;
|
||||
}
|
||||
}
|
||||
impl Sounds for Audio {
|
||||
fn engine(&mut self) -> &mut SoundEngine {
|
||||
@@ -2613,6 +2708,25 @@ mod tests {
|
||||
assert_eq!(data, [0.0; 4], "returning must not resume the old one-shot");
|
||||
}
|
||||
|
||||
/// A sound of one frame or none, heard or not, looping or not, stops at its first
|
||||
/// buffer as the mixing loop stops it: none stays playing (and holds its channel).
|
||||
#[test]
|
||||
fn sounds_too_short_to_mix_stop_even_unheard() {
|
||||
for (samples, looping) in [(vec![], false), (vec![1.0], false), (vec![1.0], true)] {
|
||||
let mut mixer = Mixer::default();
|
||||
let mut source = channel(samples.clone(), None, [0.0; 3], looping);
|
||||
source.gain = 0.0;
|
||||
mixer.channels.insert(1, source);
|
||||
let mut data = [0.0; 4];
|
||||
mix(&mut mixer, &mut data, 2, 48_000);
|
||||
assert_eq!(
|
||||
mixer.channels[&1].state,
|
||||
State::Stopped,
|
||||
"{samples:?} {looping}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn variants_of_one_sound_are_the_same_sound() {
|
||||
assert_eq!(
|
||||
|
||||
@@ -569,6 +569,25 @@ pub(crate) fn polytone_noise() -> &'static horizon_core::noise::SimplexNoise {
|
||||
}
|
||||
|
||||
impl horizon_polytone::expr::Host for BlockHost<'_> {
|
||||
fn field(
|
||||
&mut self,
|
||||
root: horizon_polytone::expr::Root,
|
||||
field: horizon_polytone::expr::Field,
|
||||
) -> Option<horizon_polytone::expr::Value> {
|
||||
use horizon_polytone::expr::Field as F;
|
||||
if root != horizon_polytone::expr::Root::Object {
|
||||
return None;
|
||||
}
|
||||
Some(horizon_polytone::expr::Value::Num(match field {
|
||||
F::X => f64::from(self.at[0]),
|
||||
F::Y => f64::from(self.at[1]),
|
||||
F::Z => f64::from(self.at[2]),
|
||||
F::Temperature => f64::from(self.biome.temperature),
|
||||
F::Downfall => f64::from(self.biome.downfall),
|
||||
_ => return None,
|
||||
}))
|
||||
}
|
||||
|
||||
fn root(&mut self, root: horizon_polytone::expr::Root) -> horizon_polytone::expr::Value {
|
||||
use horizon_polytone::expr::Root;
|
||||
horizon_polytone::expr::Value::Object(match root {
|
||||
|
||||
@@ -231,8 +231,10 @@ pub struct FogInputs<'a> {
|
||||
/// `GameRenderer.nightVisionScale` while Night Vision is on without
|
||||
/// Darkness, else 0 (`FogRenderer.computeFogColor`'s brightenFactor).
|
||||
pub night_vision: f32,
|
||||
/// The packs' Polytone `environment` fog settings.
|
||||
pub environment: horizon_polytone::colors::Environment,
|
||||
/// The packs' `environment` fog settings (Polytone's).
|
||||
pub environment: crate::pack_palette::Environment,
|
||||
/// The packs' fluid modifiers' fog.
|
||||
pub fluid_fog: crate::pack_palette::FluidFogs,
|
||||
}
|
||||
|
||||
/// `FogRenderer.setupFog` + `computeFogColor`.
|
||||
@@ -397,6 +399,24 @@ pub fn setup_fog(tables: &FogTables, state: &FogState, input: &FogInputs) -> Fog
|
||||
}
|
||||
}
|
||||
}
|
||||
// A pack's fluid modifier over its fluid's fog, after everything else (wiki "Custom Colors").
|
||||
let fluid = match input.fog_type {
|
||||
FogType::Lava => input.fluid_fog.lava,
|
||||
FogType::Water if input.blindness.is_none() && input.darkness.is_none() => {
|
||||
input.fluid_fog.water
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
if let Some(f) = fluid {
|
||||
if let Some(rgb) = f.color {
|
||||
let [r, g, b] = self::rgb(rgb);
|
||||
fog.color = [r, g, b, 1.0];
|
||||
}
|
||||
let end = fog.environmental_end * f.radius;
|
||||
let start = end - (end - fog.environmental_start * f.radius) * f.fade;
|
||||
(fog.environmental_start, fog.environmental_end) = (start, end);
|
||||
(fog.sky_end, fog.cloud_end) = (end, end);
|
||||
}
|
||||
let span = (rd / 10.0).clamp(4.0, 64.0);
|
||||
fog.render_distance_start = rd - span;
|
||||
fog.render_distance_end = rd;
|
||||
@@ -495,6 +515,7 @@ mod tests {
|
||||
full_water_vision: false,
|
||||
night_vision: 0.0,
|
||||
environment: Default::default(),
|
||||
fluid_fog: Default::default(),
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
@@ -581,7 +602,7 @@ mod tests {
|
||||
let tables = FogTables {
|
||||
water_fog_end: vec![1.0],
|
||||
};
|
||||
let environment = horizon_polytone::colors::Environment {
|
||||
let environment = crate::pack_palette::Environment {
|
||||
powder_snow_fog: Some(0xFF0000),
|
||||
blindness_fog_distance: 40.0,
|
||||
water_fog_brightening: 0.0,
|
||||
@@ -611,9 +632,41 @@ mod tests {
|
||||
let kept = setup_fog(&tables, &state, &input).color;
|
||||
assert_eq!(&kept[..3], &[0.1, 0.2, 0.4]);
|
||||
}
|
||||
#[test]
|
||||
fn a_fluid_modifier_scales_and_colours_its_fog_last() {
|
||||
let sky = Sky {
|
||||
fog_start: 10.0,
|
||||
fog_end: 96.0,
|
||||
..Sky::default()
|
||||
};
|
||||
let tables = FogTables {
|
||||
water_fog_end: vec![1.0],
|
||||
};
|
||||
let mut input = inputs(&sky);
|
||||
input.fog_type = FogType::Lava;
|
||||
input.fire_resistance = true;
|
||||
let vanilla = setup_fog(&tables, &FogState::default(), &input);
|
||||
assert_eq!(
|
||||
(vanilla.environmental_start, vanilla.environmental_end),
|
||||
(0.0, 5.0)
|
||||
);
|
||||
input.fluid_fog.lava = Some(crate::pack_palette::FluidFog {
|
||||
color: Some(0x00FF00),
|
||||
radius: 2.0,
|
||||
fade: 0.5,
|
||||
});
|
||||
let fog = setup_fog(&tables, &FogState::default(), &input);
|
||||
assert_eq!(fog.color, [0.0, 1.0, 0.0, 1.0]);
|
||||
// The end doubles; half the fade from 0 to 10 starts it at 5.
|
||||
assert_eq!(
|
||||
(fog.environmental_start, fog.environmental_end),
|
||||
(5.0, 10.0)
|
||||
);
|
||||
assert_eq!((fog.sky_end, fog.cloud_end), (10.0, 10.0));
|
||||
}
|
||||
fn inputs_with<'a>(
|
||||
input: &FogInputs<'a>,
|
||||
environment: horizon_polytone::colors::Environment,
|
||||
environment: crate::pack_palette::Environment,
|
||||
) -> FogInputs<'a> {
|
||||
FogInputs {
|
||||
environment,
|
||||
|
||||
@@ -239,6 +239,7 @@ mod reuse;
|
||||
mod stars;
|
||||
mod surface_setup;
|
||||
use surface_setup::{AdapterChoice, AdapterFailure, open_adapter, pick_adapter, set_up_surface};
|
||||
mod off_page;
|
||||
pub mod submit;
|
||||
mod sunrise_fan;
|
||||
mod timing;
|
||||
@@ -250,6 +251,7 @@ mod wgsl_prefix;
|
||||
mod world_post;
|
||||
pub mod world_primitives;
|
||||
mod world_warp;
|
||||
use off_page::move_off_page;
|
||||
|
||||
/// A section's instance: its camera-relative origin and, in `w`, the step of
|
||||
/// its fixed-point UVs (`terrain.wgsl`'s `terrain_uv`).
|
||||
@@ -726,62 +728,6 @@ impl Part {
|
||||
}
|
||||
}
|
||||
}
|
||||
/// `Renderer::compact_arena`'s and `shrink_translucent`'s moves: what the meshes
|
||||
/// hold on `page` of `arena` (`part`) goes to its other pages in video memory,
|
||||
/// until the next range would pass `budget` bytes (it keeps what is left; the
|
||||
/// first range moves whatever its size, so none keeps the page closed), the
|
||||
/// copies into `encoder`, the ranges' flat draw ids (`flat`) and, in the
|
||||
/// section arena, GPU culling's records (`records`) along. False when the other
|
||||
/// pages have no room left.
|
||||
fn move_off_page<'a>(
|
||||
arena: &mut arena::Arena,
|
||||
encoder: &mut wgpu::CommandEncoder,
|
||||
mut flat: Option<(&mut flat::Flat, &wgpu::Device, &mut upload::Uploads)>,
|
||||
mut records: Option<&mut gpu_culling::Records>,
|
||||
meshes: impl Iterator<Item = &'a mut SectionGpu>,
|
||||
page: u32,
|
||||
part: Part,
|
||||
budget: &mut u64,
|
||||
) -> bool {
|
||||
debug_assert!(
|
||||
records.is_none() || matches!(part, Part::Section { .. }),
|
||||
"records follow the section arena's ranges"
|
||||
);
|
||||
let mut moved = false;
|
||||
for s in meshes {
|
||||
// Nothing of a mesh here: split, one without solid quads (its (0, 0) is no
|
||||
// range of page 0); in the other arenas, one without translucent quads.
|
||||
let Some((_, start, vertices)) = part.of(s).filter(|r| r.0 == page) else {
|
||||
continue;
|
||||
};
|
||||
let bytes = vertices * arena.stride();
|
||||
if moved && bytes > *budget {
|
||||
break;
|
||||
}
|
||||
let Some(to) = arena.alloc_moved(vertices) else {
|
||||
return false;
|
||||
};
|
||||
arena.copy(encoder, (page, start), to, vertices);
|
||||
arena.release(page, start, vertices);
|
||||
if let (Some((flat, device, uploads)), Some(id)) = (&mut flat, s.flat_id) {
|
||||
let key = match part {
|
||||
Part::Translucent => to.0 + flat::TRANSLUCENT_KEYS,
|
||||
_ => to.0,
|
||||
};
|
||||
flat.place(device, encoder, uploads, id, key, to.1, vertices);
|
||||
}
|
||||
part.set(s, to);
|
||||
// Its solid quads start the range in either layout.
|
||||
if let (Part::Section { .. }, Some(records), Some(id)) =
|
||||
(part, records.as_deref_mut(), s.gpu_id)
|
||||
{
|
||||
records.relocate(id, to.0, u64::from(s.solid_base()));
|
||||
}
|
||||
*budget = budget.saturating_sub(bytes);
|
||||
moved = true;
|
||||
}
|
||||
true
|
||||
}
|
||||
/// Starts moving a page of a large arena far larger than its ranges in use
|
||||
/// (`Arena::oversized`) into a new page of the size they get (`Arena::open_page`),
|
||||
/// for `Renderer::shrink_translucent`: the page closes and `move_off_page` empties
|
||||
@@ -7095,8 +7041,10 @@ impl Renderer {
|
||||
}
|
||||
}
|
||||
/// This renderer's output as applied, with what its destination offers and reports.
|
||||
pub fn output(&self) -> &output::EffectiveOutput {
|
||||
&self.output
|
||||
pub fn output(&self) -> output::EffectiveOutput {
|
||||
let native = self.native.as_ref().map(|n| n.frame.api);
|
||||
self.output
|
||||
.with_pack(self.shader_pack_kind() == Some("iris"), native)
|
||||
}
|
||||
/// What this renderer's display last reported: a renderer made again for the window
|
||||
/// starts from it (`Renderer::new`'s `display`).
|
||||
@@ -8373,9 +8321,7 @@ impl Renderer {
|
||||
let taa = !frame.title
|
||||
&& self.pack_post.as_ref().is_none_or(|p| p.1.is_none())
|
||||
&& self.native.as_ref().is_none_or(|n| n.post.is_none());
|
||||
let hdr_lighting = self.native.is_none()
|
||||
&& self.pack_post.as_ref().is_none_or(|p| p.1.is_none())
|
||||
&& std::env::var_os("HORIZON_HDR_LIGHTING").is_none_or(|v| v != "0");
|
||||
let hdr_lighting = self.shader_pack_kind().is_none();
|
||||
if let Some(hdr) = &mut self.hdr {
|
||||
hdr.lighting = hdr_lighting && hdr.look;
|
||||
}
|
||||
@@ -10116,7 +10062,11 @@ impl Renderer {
|
||||
let pack_reads_world = pack_post || native_post;
|
||||
laps.lap("r_uploads");
|
||||
// Phase 2 of occlusion culling draws over the depth, and a shader pack or TAA reads it.
|
||||
// Post effects (a resource pack's chains, `use_depth_buffer`) read the world's depth
|
||||
// after the world pass, as `GameRenderer.render` hands them the main target's.
|
||||
let post_reads_depth = !frame.title && !frame.post_effects.effects.is_empty();
|
||||
let keep_depth = pack_reads_world
|
||||
|| post_reads_depth
|
||||
|| (taa && self.world_post.taa)
|
||||
|| oit_on
|
||||
|| native_render::render_needs().world_depth
|
||||
@@ -13714,6 +13664,7 @@ mod tests {
|
||||
Some(Reported {
|
||||
paper_white: 240,
|
||||
peak: 998,
|
||||
peak_guessed: true,
|
||||
})
|
||||
);
|
||||
assert_eq!(
|
||||
@@ -13721,6 +13672,7 @@ mod tests {
|
||||
Some(Reported {
|
||||
paper_white: 480,
|
||||
peak: 480,
|
||||
peak_guessed: true,
|
||||
})
|
||||
);
|
||||
// White above the calibrated peak (a Samsung QD-OLED's 380, the slider at 456).
|
||||
@@ -13729,6 +13681,7 @@ mod tests {
|
||||
Some(Reported {
|
||||
paper_white: 456,
|
||||
peak: 456,
|
||||
peak_guessed: true,
|
||||
})
|
||||
);
|
||||
assert_eq!(os_reported(&info(Some(1000.0), None)), None);
|
||||
@@ -15419,11 +15372,9 @@ fn log_os_reported(reported: Option<crate::graphics::Reported>) {
|
||||
None => "Horizon renderer: the system doesn't report the display's luminance".into(),
|
||||
});
|
||||
}
|
||||
/// What the OS reports in absolute nits (Windows: DXGI's `MaxLuminance` and the SDR
|
||||
/// white level the brightness slider sets), which is HDR output's scale there: SDR
|
||||
/// white is Paper White, the display's peak is Peak Brightness. Both are kept within
|
||||
/// the settings' ranges. The OS's figures often come from the display's EDID and may
|
||||
/// run optimistic; the player's own value always wins.
|
||||
/// What the OS reports in absolute nits, HDR output's scale (Windows: DXGI's SDR white level,
|
||||
/// the brightness slider's, as Paper White, and its `MaxLuminance`, mostly the display's EDID
|
||||
/// guess, as Peak Brightness), kept within the settings' ranges. The player's values win.
|
||||
fn os_reported(info: &wgpu::DisplayHdrInfo) -> Option<crate::graphics::Reported> {
|
||||
let luminance = info.luminance?;
|
||||
let usable = |n: f32| n.is_finite() && n > 0.0;
|
||||
@@ -15435,6 +15386,7 @@ fn os_reported(info: &wgpu::DisplayHdrInfo) -> Option<crate::graphics::Reported>
|
||||
// Windows' SDR brightness slider can set white above the reported peak (480 against
|
||||
// a calibrated 380): the highlights then stay at white instead of dropping below it.
|
||||
peak: (peak.round() as u32).clamp(200, 10_000).max(paper_white),
|
||||
peak_guessed: true,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -15470,6 +15422,7 @@ fn reported(l: horizon_hdr::wayland::Luminances) -> crate::graphics::Reported {
|
||||
crate::graphics::Reported {
|
||||
paper_white: 203,
|
||||
peak: peak.clamp(200, 10_000),
|
||||
peak_guessed: false,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -191,7 +191,7 @@ pub struct Hdr {
|
||||
pub masks: Masks,
|
||||
/// HDR lighting, whether Horizon lifts this frame's world: off under a Horizon shader pack
|
||||
/// that draws, while a `shaders/` pack's post or material stage runs (the pack's own),
|
||||
/// with HDR Lighting (`look`) off, and with `HORIZON_HDR_LIGHTING=0`, to compare.
|
||||
/// and with HDR Lighting (`look`) off.
|
||||
pub lighting: bool,
|
||||
/// The HDR Lighting setting: off, nothing in the world is lifted, light sources and the
|
||||
/// sun included, so the picture is SDR's on the HDR canvas.
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
//! `move_off_page`, moved out of `gfx.rs` unchanged.
|
||||
use super::*;
|
||||
|
||||
/// `Renderer::compact_arena`'s and `shrink_translucent`'s moves: what the meshes
|
||||
/// hold on `page` of `arena` (`part`) goes to its other pages in video memory,
|
||||
/// until the next range would pass `budget` bytes (it keeps what is left; the
|
||||
/// first range moves whatever its size, so none keeps the page closed), the
|
||||
/// copies into `encoder`, the ranges' flat draw ids (`flat`) and, in the
|
||||
/// section arena, GPU culling's records (`records`) along. False when the other
|
||||
/// pages have no room left.
|
||||
pub(super) fn move_off_page<'a>(
|
||||
arena: &mut arena::Arena,
|
||||
encoder: &mut wgpu::CommandEncoder,
|
||||
mut flat: Option<(&mut flat::Flat, &wgpu::Device, &mut upload::Uploads)>,
|
||||
mut records: Option<&mut gpu_culling::Records>,
|
||||
meshes: impl Iterator<Item = &'a mut SectionGpu>,
|
||||
page: u32,
|
||||
part: Part,
|
||||
budget: &mut u64,
|
||||
) -> bool {
|
||||
debug_assert!(
|
||||
records.is_none() || matches!(part, Part::Section { .. }),
|
||||
"records follow the section arena's ranges"
|
||||
);
|
||||
let mut moved = false;
|
||||
for s in meshes {
|
||||
// Nothing of a mesh here: split, one without solid quads (its (0, 0) is no
|
||||
// range of page 0); in the other arenas, one without translucent quads.
|
||||
let Some((_, start, vertices)) = part.of(s).filter(|r| r.0 == page) else {
|
||||
continue;
|
||||
};
|
||||
let bytes = vertices * arena.stride();
|
||||
if moved && bytes > *budget {
|
||||
break;
|
||||
}
|
||||
let Some(to) = arena.alloc_moved(vertices) else {
|
||||
return false;
|
||||
};
|
||||
arena.copy(encoder, (page, start), to, vertices);
|
||||
arena.release(page, start, vertices);
|
||||
if let (Some((flat, device, uploads)), Some(id)) = (&mut flat, s.flat_id) {
|
||||
let key = match part {
|
||||
Part::Translucent => to.0 + flat::TRANSLUCENT_KEYS,
|
||||
_ => to.0,
|
||||
};
|
||||
flat.place(device, encoder, uploads, id, key, to.1, vertices);
|
||||
}
|
||||
part.set(s, to);
|
||||
// Its solid quads start the range in either layout.
|
||||
if let (Part::Section { .. }, Some(records), Some(id)) =
|
||||
(part, records.as_deref_mut(), s.gpu_id)
|
||||
{
|
||||
records.relocate(id, to.0, u64::from(s.solid_base()));
|
||||
}
|
||||
*budget = budget.saturating_sub(bytes);
|
||||
moved = true;
|
||||
}
|
||||
true
|
||||
}
|
||||
@@ -144,6 +144,9 @@ pub struct EffectiveOutput {
|
||||
pub(crate) nits: [f32; 3],
|
||||
/// The HUD and menus' brightness, as a share of paper white: their nits over its.
|
||||
pub(crate) ui_brightness: f32,
|
||||
/// The running shader pack lights the world itself: Horizon lifts none of it, so HDR
|
||||
/// Lighting has nothing to switch (`with_pack`).
|
||||
pub(crate) pack_lights: bool,
|
||||
}
|
||||
impl Default for EffectiveOutput {
|
||||
fn default() -> Self {
|
||||
@@ -166,7 +169,22 @@ impl EffectiveOutput {
|
||||
},
|
||||
nits: [203.0, 1000.0, 0.0],
|
||||
ui_brightness: 1.0,
|
||||
pack_lights: false,
|
||||
};
|
||||
/// This output under the running shader pack: a `shaders/` one (`shaders`), or one of
|
||||
/// Horizon's own of API `native`. Under a 0.1 pack on the 8- or 16-bit frame the last pass
|
||||
/// still lifts the light sources and the sun its marks hold (`lifts_world` in
|
||||
/// `Renderer::render`); under the rest Horizon lifts nothing.
|
||||
pub(crate) fn with_pack(self, shaders: bool, native: Option<horizon_shader_api::Api>) -> Self {
|
||||
let pack_lights = shaders
|
||||
|| native.is_some_and(|api| {
|
||||
api != horizon_shader_api::Api::V0_1 || self.frame.format == super::LINEAR_FRAME
|
||||
});
|
||||
Self {
|
||||
pack_lights,
|
||||
..self
|
||||
}
|
||||
}
|
||||
/// Whether the destination offers HDR output.
|
||||
pub fn hdr_offered(&self) -> bool {
|
||||
self.offered.is_some()
|
||||
@@ -273,6 +291,7 @@ pub(crate) fn resolve_output(
|
||||
],
|
||||
ui_brightness: settings.ui_nits(reading.luminance) as f32
|
||||
/ settings.paper_white_nits(reading.luminance) as f32,
|
||||
pack_lights: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -300,6 +319,7 @@ pub(crate) mod tests {
|
||||
luminance: Some(Reported {
|
||||
paper_white: 400,
|
||||
peak: 1000,
|
||||
peak_guessed: false,
|
||||
}),
|
||||
hdr: system,
|
||||
},
|
||||
@@ -650,6 +670,7 @@ pub(crate) mod tests {
|
||||
window_reading.luminance = Some(Reported {
|
||||
paper_white: 300,
|
||||
peak: 800,
|
||||
peak_guessed: false,
|
||||
});
|
||||
assert_eq!(window_output(window_reading).nits[..2], [300.0, 800.0]);
|
||||
assert_eq!(
|
||||
|
||||
@@ -549,6 +549,7 @@ mod tests {
|
||||
reading.luminance = Some(Reported {
|
||||
paper_white: 250,
|
||||
peak: 600,
|
||||
peak_guessed: false,
|
||||
});
|
||||
// 150 nits under the reported paper white of 250: 0.6 of it.
|
||||
settings.hdr_ui_nits = 150;
|
||||
@@ -584,6 +585,7 @@ mod tests {
|
||||
reading.luminance = Some(Reported {
|
||||
paper_white,
|
||||
peak: 1000,
|
||||
peak_guessed: false,
|
||||
});
|
||||
let next = candidate(&settings, caps, reading, &published);
|
||||
let again = commit(&mut swapchain, &published, next, caps, &mut rejected);
|
||||
@@ -729,6 +731,7 @@ mod tests {
|
||||
luminance: (hdr == Some(true)).then_some(Reported {
|
||||
paper_white: 300,
|
||||
peak: 800,
|
||||
peak_guessed: false,
|
||||
}),
|
||||
hdr,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
//! Hardware ray queries, only on request (#13780, after the #1180 spike): `HORIZON_RAY_TRACING=1`
|
||||
//! at startup asks the device for wgpu's `EXPERIMENTAL_RAY_QUERY` when the GPU has it. Nothing
|
||||
//! draws with it yet. Without the variable, and in a build without `horizon-extras` (a Horizon
|
||||
//! extra, docs/layers.md), the device is opened exactly as before.
|
||||
|
||||
/// The ray query feature.
|
||||
const RAY_QUERY: wgpu::Features = wgpu::Features::EXPERIMENTAL_RAY_QUERY;
|
||||
|
||||
/// Whether the player asked for ray tracing (the device needs the feature from its creation).
|
||||
fn requested() -> bool {
|
||||
asked(std::env::var("HORIZON_RAY_TRACING").ok().as_deref())
|
||||
}
|
||||
|
||||
/// Whether `HORIZON_RAY_TRACING` set to `var` asks for ray tracing: only in a `horizon-extras`
|
||||
/// build.
|
||||
fn asked(var: Option<&str>) -> bool {
|
||||
cfg!(feature = "horizon-extras") && var == Some("1")
|
||||
}
|
||||
|
||||
/// The features to request on `adapter`: ray queries, when asked for and offered.
|
||||
pub(super) fn features(adapter: &wgpu::Adapter) -> wgpu::Features {
|
||||
let features = pick(requested(), adapter.features());
|
||||
if requested() {
|
||||
let info = adapter.get_info();
|
||||
if features.is_empty() {
|
||||
log::warn!(
|
||||
"Horizon renderer: ray tracing asked for, but {} on {:?} has no hardware ray \
|
||||
queries; it stays off",
|
||||
info.name,
|
||||
info.backend
|
||||
);
|
||||
} else {
|
||||
log::info!(
|
||||
"Horizon renderer: ray queries on ({} on {:?})",
|
||||
info.name,
|
||||
info.backend
|
||||
);
|
||||
}
|
||||
}
|
||||
features
|
||||
}
|
||||
|
||||
fn pick(requested: bool, offered: wgpu::Features) -> wgpu::Features {
|
||||
if requested {
|
||||
offered & RAY_QUERY
|
||||
} else {
|
||||
wgpu::Features::empty()
|
||||
}
|
||||
}
|
||||
|
||||
/// The device's opt-in to `features`' experimental ones.
|
||||
pub(super) fn experimental(features: wgpu::Features) -> wgpu::ExperimentalFeatures {
|
||||
if features.contains(RAY_QUERY) {
|
||||
horizon_hdr::experimental_features()
|
||||
} else {
|
||||
wgpu::ExperimentalFeatures::disabled()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn only_an_extras_build_asks_for_ray_tracing() {
|
||||
assert_eq!(asked(Some("1")), cfg!(feature = "horizon-extras"));
|
||||
assert!(!asked(None));
|
||||
assert!(!asked(Some("0")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ray_queries_only_when_asked_and_offered() {
|
||||
let all = wgpu::Features::all();
|
||||
assert_eq!(pick(true, all), RAY_QUERY);
|
||||
assert!(pick(false, all).is_empty());
|
||||
assert!(pick(true, all - RAY_QUERY).is_empty());
|
||||
assert!(!experimental(wgpu::Features::empty()).is_enabled());
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,9 @@ use super::{
|
||||
Renderer, check_layers, cull, describe_adapter, is_software, oit, startup_step, surface_format,
|
||||
timing,
|
||||
};
|
||||
// Ray tracing on request, which the device needs from its creation (gfx.rs may not grow).
|
||||
#[path = "ray_query.rs"]
|
||||
mod ray_query;
|
||||
use std::sync::Arc;
|
||||
use winit::window::Window;
|
||||
|
||||
@@ -134,6 +137,7 @@ pub(super) async fn open_adapter(
|
||||
let features = timing::GpuTimer::features(adapter)
|
||||
| cull::Cull::features(adapter)
|
||||
| oit::features(adapter)
|
||||
| ray_query::features(adapter)
|
||||
| (adapter.features()
|
||||
& (wgpu::Features::INDIRECT_FIRST_INSTANCE
|
||||
| wgpu::Features::MULTI_DRAW_INDIRECT_COUNT
|
||||
@@ -166,6 +170,7 @@ pub(super) async fn open_adapter(
|
||||
// with the larger blocks a few small buffers kept whole blocks the arena's pages had
|
||||
// left, and over half the video memory reserved was unused (1,344 MiB for 500).
|
||||
memory_hints: wgpu::MemoryHints::MemoryUsage,
|
||||
experimental_features: ray_query::experimental(features),
|
||||
..Default::default()
|
||||
};
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
|
||||
@@ -30,6 +30,22 @@ fn marker_driver(text: &str) -> &str {
|
||||
pub struct Reported {
|
||||
pub paper_white: u32,
|
||||
pub peak: u32,
|
||||
/// The peak is the display's own claim (DXGI's `MaxLuminance`, mostly its EDID figure),
|
||||
/// which often runs high: Auto still uses it, but the HDR page asks to calibrate.
|
||||
pub peak_guessed: bool,
|
||||
}
|
||||
|
||||
/// Whether HDR is set up for the display, and whether the player kept the system's setup.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum HdrSetup {
|
||||
/// The system says nothing: the player calibrates.
|
||||
Unknown,
|
||||
/// The system says, but only guesses the peak: the player calibrates.
|
||||
Guessed,
|
||||
/// Peak Brightness and Paper White follow the system.
|
||||
Automatic,
|
||||
/// The system said, and the player changed them anyway.
|
||||
Changed,
|
||||
}
|
||||
|
||||
/// The top of Peak Brightness's scale: `peak_nits_at(PEAK_STEPS)` is PQ's 10 000 nits.
|
||||
@@ -1207,6 +1223,15 @@ impl Graphics {
|
||||
n => n,
|
||||
}
|
||||
}
|
||||
/// How HDR is set up for the display the system `reported`.
|
||||
pub fn hdr_setup(&self, reported: Option<Reported>) -> HdrSetup {
|
||||
match reported {
|
||||
None => HdrSetup::Unknown,
|
||||
Some(_) if self.hdr_peak != 0 || self.hdr_paper_white != 0 => HdrSetup::Changed,
|
||||
Some(r) if r.peak_guessed => HdrSetup::Guessed,
|
||||
Some(_) => HdrSetup::Automatic,
|
||||
}
|
||||
}
|
||||
/// The black level, in nits. Not the system's: files from before saved 0 for that
|
||||
/// (Auto), and a reported black would lift every display's shadows by default.
|
||||
pub fn black_level_nits(&self) -> f32 {
|
||||
@@ -1320,6 +1345,7 @@ mod tests {
|
||||
let reported = Some(Reported {
|
||||
paper_white: 300,
|
||||
peak: 1000,
|
||||
peak_guessed: false,
|
||||
});
|
||||
assert_eq!(g.ui_nits(reported), 300);
|
||||
g.hdr_paper_white = 120;
|
||||
@@ -1328,6 +1354,33 @@ mod tests {
|
||||
assert_eq!((g.ui_nits(None), g.ui_nits(reported)), (400, 400));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hdr_setup_asks_to_calibrate_where_the_peak_is_guessed() {
|
||||
let wayland = Reported {
|
||||
paper_white: 203,
|
||||
peak: 1015,
|
||||
peak_guessed: false,
|
||||
};
|
||||
let windows = Reported {
|
||||
paper_white: 400,
|
||||
peak: 1000,
|
||||
peak_guessed: true,
|
||||
};
|
||||
let mut g = Graphics::default();
|
||||
assert_eq!(g.hdr_setup(None), HdrSetup::Unknown);
|
||||
assert_eq!(g.hdr_setup(Some(wayland)), HdrSetup::Automatic);
|
||||
assert_eq!(g.hdr_setup(Some(windows)), HdrSetup::Guessed);
|
||||
// Auto still takes the guess, over the 1000-nit default.
|
||||
assert_eq!(g.peak_nits(Some(windows)), 1000);
|
||||
assert_eq!(g.paper_white_nits(Some(windows)), 400);
|
||||
for (peak, paper_white) in [(800, 0), (0, 240)] {
|
||||
(g.hdr_peak, g.hdr_paper_white) = (peak, paper_white);
|
||||
assert_eq!(g.hdr_setup(Some(windows)), HdrSetup::Changed);
|
||||
assert_eq!(g.hdr_setup(Some(wayland)), HdrSetup::Changed);
|
||||
assert_eq!(g.hdr_setup(None), HdrSetup::Unknown);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hdr_modes_keep_boolean_files_and_round_trip_auto() {
|
||||
assert_eq!(Graphics::parse("").hdr, HdrMode::Off);
|
||||
|
||||
@@ -8,7 +8,8 @@ use crate::{
|
||||
gfx::EffectiveOutput,
|
||||
graphics::{
|
||||
EntityCullingMode, FarDetail, FrameGeneration, GpuCullingMode, GraphicsApi, HdrMode,
|
||||
LeafCullingMode, Overlay, Pacing, ParticleCullingMode, PresentMode, Preset, Upscaling,
|
||||
HdrSetup as Setup, LeafCullingMode, Overlay, Pacing, ParticleCullingMode, PresentMode,
|
||||
Preset, Upscaling,
|
||||
},
|
||||
graphics_style::Cost,
|
||||
lang,
|
||||
@@ -675,28 +676,9 @@ const LOD_STEPS: [u32; 24] = [
|
||||
crate::lod::MAX_CHUNKS,
|
||||
];
|
||||
|
||||
/// Whether the system set HDR up for the display, and whether the player kept it.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
enum Setup {
|
||||
/// The system says nothing: the player calibrates.
|
||||
Unknown,
|
||||
/// Peak Brightness and Paper White follow the system.
|
||||
Automatic,
|
||||
/// The system said, and the player changed them anyway.
|
||||
Changed,
|
||||
}
|
||||
/// `out`: the output of the renderer the settings are shown for, with what its display reports.
|
||||
fn setup(s: &Settings, out: &EffectiveOutput) -> Setup {
|
||||
setup_of(&s.horizon, out.reported.is_some())
|
||||
}
|
||||
fn setup_of(g: &crate::graphics::Graphics, reported: bool) -> Setup {
|
||||
if !reported {
|
||||
Setup::Unknown
|
||||
} else if g.hdr_peak == 0 && g.hdr_paper_white == 0 {
|
||||
Setup::Automatic
|
||||
} else {
|
||||
Setup::Changed
|
||||
}
|
||||
s.horizon.hdr_setup(out.reported)
|
||||
}
|
||||
/// What the HDR page says while HDR output is held back on this window's graphics driver
|
||||
/// (`EffectiveOutput::held`): picking a mode tries again (`H::cycle`).
|
||||
@@ -714,13 +696,19 @@ fn description(
|
||||
(Item::Horizon(H::HdrCalibrate), Setup::Unknown) => {
|
||||
"Your display didn't say how bright it gets: calibrate to set it up."
|
||||
}
|
||||
(Item::Horizon(H::HdrCalibrate), Setup::Guessed) => {
|
||||
"Your display's reported peak is often too high: calibrate to set it up."
|
||||
}
|
||||
(Item::Horizon(H::HdrCalibrate), Setup::Automatic) => {
|
||||
"Already set up for your display. Only calibrate if HDR looks wrong."
|
||||
}
|
||||
(Item::Horizon(H::HdrCalibrate), Setup::Changed) => {
|
||||
"Puts Peak Brightness and Paper White back to your display's setup."
|
||||
}
|
||||
(Item::Horizon(H::HdrPeak | H::HdrPaperWhite), Setup::Automatic | Setup::Changed) => {
|
||||
// A guessed peak's Auto is what calibration replaced; paper white is the player's own.
|
||||
(Item::Horizon(h @ (H::HdrPeak | H::HdrPaperWhite)), Setup::Automatic | Setup::Changed)
|
||||
if h == H::HdrPaperWhite || out.reported.is_some_and(|r| !r.peak_guessed) =>
|
||||
{
|
||||
"Set up for your display. Leave on Auto unless it looks wrong."
|
||||
}
|
||||
(Item::Horizon(H::GpuCulling), _) if s.horizon.gpu_culling == GpuCullingMode::Auto => {
|
||||
@@ -944,7 +932,7 @@ impl H {
|
||||
H::FrameGeneration => labelled("Frame Generation", g.frame_generation.label()),
|
||||
H::Hdr => labelled("HDR", g.hdr.label()),
|
||||
H::HdrCalibrate => Text::literal(lang::get(match setup(s, out) {
|
||||
Setup::Unknown => "Calibrate...",
|
||||
Setup::Unknown | Setup::Guessed => "Calibrate...",
|
||||
Setup::Automatic => "Calibrate Anyway...",
|
||||
Setup::Changed => "Use Automatic Setup",
|
||||
})),
|
||||
@@ -1122,7 +1110,8 @@ impl Item {
|
||||
/// active; saved values wait for it. Translucent Batching switches where the device can batch
|
||||
/// (`graphics::translucent_batching_offered`), its saved value waiting for one that can,
|
||||
/// Smooth Gradients only where it can draw the 16-bit frame, the same way, and GPU Culling
|
||||
/// only where it is offered (`graphics::gpu_culling_offer`), with the reason it isn't.
|
||||
/// only where it is offered (`graphics::gpu_culling_offer`), with the reason it isn't. HDR
|
||||
/// Lighting has nothing to switch where the running shader pack lights the world itself.
|
||||
fn blocked(self, s: &Settings, out: &EffectiveOutput) -> Option<&'static str> {
|
||||
if !self.available() {
|
||||
return Some("This feature is not yet available in Horizon.");
|
||||
@@ -1153,6 +1142,9 @@ impl Item {
|
||||
} else {
|
||||
"Turn HDR on to use it."
|
||||
}),
|
||||
Item::Horizon(H::HdrLighting) if out.pack_lights => {
|
||||
Some("The shader pack lights the world itself.")
|
||||
}
|
||||
Item::Horizon(H::TranslucentBatching)
|
||||
if !crate::graphics::translucent_batching_offered() =>
|
||||
{
|
||||
|
||||
@@ -31,6 +31,49 @@ fn smooth_gradients_wait_for_a_device_that_draws_the_16_bit_frame() {
|
||||
assert_eq!(item.blocked(&s, &out), Some("Turn HDR on to use it."));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hdr_lighting_is_greyed_only_where_the_running_pack_lights_the_world() {
|
||||
use horizon_shader_api::Api;
|
||||
let mut s = Settings::default();
|
||||
s.horizon.hdr = HdrMode::On;
|
||||
let item = Item::Horizon(H::HdrLighting);
|
||||
let lights = Some("The shader pack lights the world itself.");
|
||||
let out = offering(true, None);
|
||||
// No pack running, as with one picked but refused, missing or its mod off: live.
|
||||
assert_eq!(item.blocked(&s, &out.with_pack(false, None)), None);
|
||||
// A 0.1 pack on the 16-bit frame: the light sources and the sun still shine.
|
||||
assert_eq!(
|
||||
item.blocked(&s, &out.with_pack(false, Some(Api::V0_1))),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
item.blocked(&s, &out.with_pack(false, Some(Api::V0_2))),
|
||||
lights
|
||||
);
|
||||
assert_eq!(item.blocked(&s, &out.with_pack(true, None)), lights);
|
||||
let float = EffectiveOutput {
|
||||
frame: crate::gfx::FrameConfiguration {
|
||||
format: wgpu::TextureFormat::Rgba16Float,
|
||||
..out.frame
|
||||
},
|
||||
..out
|
||||
};
|
||||
assert_eq!(
|
||||
item.blocked(&s, &float.with_pack(false, Some(Api::V0_1))),
|
||||
lights
|
||||
);
|
||||
// HDR's own reasons come first.
|
||||
assert_eq!(
|
||||
item.blocked(&s, &NONE.with_pack(true, None)),
|
||||
Some("Your display or system doesn't offer HDR output.")
|
||||
);
|
||||
s.horizon.hdr = HdrMode::Off;
|
||||
assert_eq!(
|
||||
item.blocked(&s, &out.with_pack(true, None)),
|
||||
Some("Turn HDR on to use it.")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hdr_auto_can_be_selected_before_querying_and_brightness_waits_for_output() {
|
||||
let mut s = Settings::default();
|
||||
@@ -120,14 +163,86 @@ fn auto_levels_and_setup_read_their_own_renderers_report() {
|
||||
assert_eq!(label(H::HdrCalibrate, &silent), "Calibrate...");
|
||||
}
|
||||
|
||||
/// The setup rows' label and description for each kind of report: none, a compositor's
|
||||
/// (Wayland), and Windows', whose peak is the display's own guess.
|
||||
#[test]
|
||||
fn hdr_setup_follows_what_the_system_reported() {
|
||||
let mut g = crate::graphics::Graphics::default();
|
||||
assert_eq!(setup_of(&g, false), Setup::Unknown);
|
||||
assert_eq!(setup_of(&g, true), Setup::Automatic);
|
||||
g.hdr_paper_white = 80;
|
||||
assert_eq!(setup_of(&g, true), Setup::Changed);
|
||||
assert_eq!(setup_of(&g, false), Setup::Unknown);
|
||||
fn the_setup_rows_ask_to_calibrate_unless_the_system_set_the_display_up() {
|
||||
let trusted = offering(true, Some(true));
|
||||
let mut guessed = trusted;
|
||||
guessed.reported = guessed.reported.map(|r| crate::graphics::Reported {
|
||||
peak_guessed: true,
|
||||
..r
|
||||
});
|
||||
let rows = |s: &Settings, out: &EffectiveOutput| {
|
||||
[H::HdrCalibrate, H::HdrPeak, H::HdrPaperWhite].map(|h| {
|
||||
let item = Item::Horizon(h);
|
||||
(item.label(s, out).plain(), description(item, s, out, "-"))
|
||||
})
|
||||
};
|
||||
let calibrate = "Calibrate...";
|
||||
let set_up = "Set up for your display. Leave on Auto unless it looks wrong.";
|
||||
let mut s = Settings::default();
|
||||
s.horizon.hdr = HdrMode::On;
|
||||
let none = (
|
||||
calibrate.into(),
|
||||
"Your display didn't say how bright it gets: calibrate to set it up.",
|
||||
);
|
||||
assert_eq!(
|
||||
rows(&s, &NONE),
|
||||
[
|
||||
none.clone(),
|
||||
("Peak Brightness: Auto (1000)".into(), "-"),
|
||||
("Paper White: Auto (203)".into(), "-"),
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
rows(&s, &guessed),
|
||||
[
|
||||
(
|
||||
calibrate.into(),
|
||||
"Your display's reported peak is often too high: calibrate to set it up."
|
||||
),
|
||||
("Peak Brightness: Auto (1000)".into(), "-"),
|
||||
("Paper White: Auto (400)".into(), "-"),
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
rows(&s, &trusted),
|
||||
[
|
||||
(
|
||||
"Calibrate Anyway...".into(),
|
||||
"Already set up for your display. Only calibrate if HDR looks wrong."
|
||||
),
|
||||
("Peak Brightness: Auto (1000)".into(), set_up),
|
||||
("Paper White: Auto (400)".into(), set_up),
|
||||
]
|
||||
);
|
||||
// Calibrated or set by hand, either report offers its automatic setup back.
|
||||
s.horizon.hdr_peak = 800;
|
||||
// Peak's Auto stays a guess on Windows: only Paper White is said to be set up there.
|
||||
let changed = |peak| {
|
||||
[
|
||||
(
|
||||
"Use Automatic Setup".into(),
|
||||
"Puts Peak Brightness and Paper White back to your display's setup.",
|
||||
),
|
||||
("Peak Brightness: 800".into(), peak),
|
||||
("Paper White: Auto (400)".into(), set_up),
|
||||
]
|
||||
};
|
||||
assert_eq!(rows(&s, &guessed), changed("-"));
|
||||
assert_eq!(rows(&s, &trusted), changed(set_up));
|
||||
assert_eq!(rows(&s, &NONE)[0], none);
|
||||
let mut ui = ui();
|
||||
ui.screen.output = guessed;
|
||||
let at = |[x, y, ..]: [f32; 4]| (x + 8.0, y + 4.0);
|
||||
let calibrate = row(&mut ui, &s, Item::Horizon(H::HdrCalibrate));
|
||||
assert_eq!(press(&mut ui, &mut s, at(calibrate)), [CLICK, CHANGED]);
|
||||
assert_eq!((s.horizon.hdr_peak, s.horizon.hdr_paper_white), (0, 0));
|
||||
assert_eq!(
|
||||
press(&mut ui, &mut s, at(calibrate)),
|
||||
[CLICK, Output::Push(OptionsPage::HdrCalibration)]
|
||||
);
|
||||
}
|
||||
|
||||
/// UI Brightness reads as Paper White does, in nits apart from it: Auto shows the paper white
|
||||
|
||||
@@ -67,7 +67,6 @@ mod jei_sprites;
|
||||
pub mod jigsaw_block;
|
||||
pub mod keypad;
|
||||
pub mod terrain_model;
|
||||
pub use horizon_polytone::colors as polytone_colors;
|
||||
pub use horizon_polytone::creative_tabs as polytone_creative_tabs;
|
||||
pub use horizon_world::json_folder;
|
||||
pub mod level_loading;
|
||||
|
||||
@@ -2593,8 +2593,7 @@ impl Lod {
|
||||
self.returned_at = std::time::Instant::now();
|
||||
let nodes = &self.plan.nodes;
|
||||
self.mesh_samples.retain(|key| nodes.contains(key));
|
||||
// Queued tiles the view left are not sampled (a running one is
|
||||
// dropped when it finishes, `finish_sample`).
|
||||
// Queued tiles the view left are not sampled; a running one is dropped (`finish_sample`).
|
||||
drop_queued_samples(&self.shared, &mut self.pending, |key| {
|
||||
self.plan.nodes.contains(key)
|
||||
});
|
||||
@@ -2680,6 +2679,7 @@ impl Lod {
|
||||
}
|
||||
if let Some((columns, mask)) = self.kept.take(&key) {
|
||||
self.ready.insert(key, columns);
|
||||
self.sync_sample(key);
|
||||
if let Some(mask) = mask {
|
||||
self.detail_masks.insert(key, mask);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
//! Optional, texture-matched overlays drawn full bright over their base texture in the same
|
||||
//! terrain fragment: native mods' (`<ns>:emissive/...`) and OptiFine's emissive block textures
|
||||
//! (`block/stone_e.png` beside `block/stone.png`). No extra draw calls or world light changes.
|
||||
//! terrain fragment: native mods' (`<ns>:emissive/...`) and a pack's emissive block textures
|
||||
//! (OptiFine's `block/stone_e.png` beside `block/stone.png`, the suffix from the pack formats,
|
||||
//! [`crate::pack_hooks`]). No extra draw calls or world light changes.
|
||||
use crate::sprites::Sprites;
|
||||
use std::{collections::BTreeMap, path::Path};
|
||||
|
||||
@@ -35,14 +36,12 @@ fn tile_file(name: &str) -> Option<&str> {
|
||||
name.strip_suffix(".png")
|
||||
}
|
||||
|
||||
/// OptiFine's emissive overlays of the block sprites already loaded (what the terrain draws;
|
||||
/// items draw from the HUD atlas): for each
|
||||
/// `<ns>:block/<name>`, or connected-texture tile `assets/<ns>/.../<name>.png`, whose
|
||||
/// `<name><suffix>.png` sits beside it, the overlay is loaded too and paired with it, blocks
|
||||
/// first, then tiles, each by name, at most [`MAX`] - [`NATIVE`] (native mods keep their share of
|
||||
/// the table). A pack without such files adds nothing.
|
||||
pub fn load_optifine(root: &Path, sprites: &mut Sprites) -> Vec<(String, String)> {
|
||||
let suffix = horizon_optifine::emissive::suffix(root);
|
||||
/// A pack's emissive overlays of the block sprites already loaded (what the terrain draws;
|
||||
/// items draw from the HUD atlas): for each `<ns>:block/<name>`, or connected-texture tile
|
||||
/// `assets/<ns>/.../<name>.png`, whose `<name><suffix>.png` sits beside it, the overlay is loaded
|
||||
/// too and paired with it, blocks first, then tiles, each by name, at most [`MAX`] - [`NATIVE`]
|
||||
/// (native mods keep their share of the table). A pack without such files adds nothing.
|
||||
pub fn load_overlays(root: &Path, suffix: &str, sprites: &mut Sprites) -> Vec<(String, String)> {
|
||||
// Blocks with identical pixels share a layer (and the shader pairs by layer): a shared
|
||||
// base would glow on every block that shares it, so it gets no overlay.
|
||||
let mut sharing: BTreeMap<u32, usize> = BTreeMap::new();
|
||||
@@ -56,8 +55,8 @@ pub fn load_optifine(root: &Path, sprites: &mut Sprites) -> Vec<(String, String)
|
||||
sharing[layer] == 1
|
||||
&& (name
|
||||
.split_once(':')
|
||||
.is_some_and(|(_, p)| p.starts_with("block/") && !p.ends_with(&suffix))
|
||||
|| tile_file(name).is_some_and(|stem| !stem.ends_with(&suffix)))
|
||||
.is_some_and(|(_, p)| p.starts_with("block/") && !p.ends_with(suffix))
|
||||
|| tile_file(name).is_some_and(|stem| !stem.ends_with(suffix)))
|
||||
})
|
||||
.map(|(name, _)| name.clone())
|
||||
.collect();
|
||||
@@ -110,7 +109,7 @@ pub fn load_optifine(root: &Path, sprites: &mut Sprites) -> Vec<(String, String)
|
||||
pub const CUTOUT: u32 = 1 << 31;
|
||||
|
||||
/// The frame's overlay table: native mods' pairs (at most [`NATIVE`] requested), then the packs'
|
||||
/// OptiFine pairs flagged [`CUTOUT`] (at most [`MAX`] - [`NATIVE`], as [`load_optifine`] loads),
|
||||
/// OptiFine pairs flagged [`CUTOUT`] (at most [`MAX`] - [`NATIVE`], as [`load_overlays`] loads),
|
||||
/// one per base layer with native mods' first, sorted by base layer for the shader's early exit.
|
||||
pub fn table<'a>(
|
||||
layers: &BTreeMap<String, u32>,
|
||||
@@ -204,7 +203,7 @@ mod tests {
|
||||
// Items draw from the HUD atlas, not the terrain: no pair.
|
||||
sprites.insert(&root, "minecraft:item/stick").unwrap();
|
||||
// glowstone isn't loaded: its overlay pairs with nothing.
|
||||
let pairs = load_optifine(&root, &mut sprites);
|
||||
let pairs = load_overlays(&root, "_e", &mut sprites);
|
||||
assert_eq!(
|
||||
pairs,
|
||||
[(
|
||||
@@ -215,9 +214,6 @@ mod tests {
|
||||
assert!(sprites.layers.contains_key("minecraft:block/stone_e"));
|
||||
assert!(!sprites.layers.contains_key("minecraft:block/granite_e"));
|
||||
// The pack's own suffix.
|
||||
let props = root.join("assets/minecraft/optifine/emissive.properties");
|
||||
std::fs::create_dir_all(props.parent().unwrap()).unwrap();
|
||||
std::fs::write(&props, "suffix.emissive=_glow").unwrap();
|
||||
// Connected-texture tiles pair with the overlay beside them.
|
||||
let tiles = root.join("assets/minecraft/optifine/ctm/lamp");
|
||||
png(&tiles.join("0.png"), 230);
|
||||
@@ -234,7 +230,7 @@ mod tests {
|
||||
.insert_file(&root, &format!("assets/minecraft/optifine/{tile}.png"))
|
||||
.unwrap();
|
||||
}
|
||||
let pairs = load_optifine(&root, &mut sprites);
|
||||
let pairs = load_overlays(&root, "_glow", &mut sprites);
|
||||
assert_eq!(
|
||||
pairs,
|
||||
// Blocks first, then tiles.
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
//! Polytone's custom colours (`horizon_polytone::colors`, `colors.json` or `color.properties`)
|
||||
//! as the engine's pack colours ([`crate::pack_palette::PackColors`]).
|
||||
use std::path::Path;
|
||||
|
||||
use horizon_polytone::colors::PackColors;
|
||||
|
||||
use crate::block_entity_render::DYES;
|
||||
use crate::pack_palette::{self, Environment};
|
||||
|
||||
fn fluid(fog: horizon_polytone::colors::FluidFog) -> pack_palette::FluidFog {
|
||||
pack_palette::FluidFog {
|
||||
color: fog.color,
|
||||
radius: fog.radius,
|
||||
fade: fog.fade,
|
||||
}
|
||||
}
|
||||
|
||||
/// The colours of every namespace's file under the pack overlay at `root`.
|
||||
pub fn read(root: &Path) -> pack_palette::PackColors {
|
||||
let colors = PackColors::read(root);
|
||||
let by_dye = |f: &dyn Fn(&str) -> Option<u32>| std::array::from_fn(|i| f(DYES[i].0));
|
||||
let env = colors.env;
|
||||
pack_palette::PackColors {
|
||||
dye_diffuse: by_dye(&|name| colors.dye(name, "diffuse")),
|
||||
dye_text: by_dye(&|name| colors.dye(name, "text")),
|
||||
sheep: by_dye(&|name| colors.sheep(name)),
|
||||
redstone: std::array::from_fn(|i| colors.redstone(i as u8)),
|
||||
effects: colors.effects(),
|
||||
entity_shadows: colors.entity_shadows(),
|
||||
xp_bar: colors.xp_bar(),
|
||||
env: Environment {
|
||||
powder_snow_fog: env.powder_snow_fog,
|
||||
blindness_fog_distance: env.blindness_fog_distance,
|
||||
darkness_fog_distance: env.darkness_fog_distance,
|
||||
boss_fog_distance: env.boss_fog_distance,
|
||||
water_fog_brightening: env.water_fog_brightening,
|
||||
water_fog_brightening_time: env.water_fog_brightening_time,
|
||||
horizon_height: env.horizon_height,
|
||||
},
|
||||
fluid_fog: pack_palette::FluidFogs {
|
||||
water: colors.fluid_fog.water.map(fluid),
|
||||
lava: colors.fluid_fog.lava.map(fluid),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::pack_palette::{diffuse, redstone, sheep_wool, text};
|
||||
|
||||
#[test]
|
||||
fn a_pack_recolours_only_what_it_names() {
|
||||
// Dyes no other test draws: the table is process-wide.
|
||||
let dir = std::env::temp_dir().join(format!("pack-dyes-{}", std::process::id()));
|
||||
let polytone = dir.join("assets/obs/polytone");
|
||||
std::fs::create_dir_all(&polytone).unwrap();
|
||||
std::fs::write(
|
||||
polytone.join("colors.json"),
|
||||
r#"{"dye": {"cyan": {"diffuse": "00ff00", "text": "0000ff"}}, "sheep": {"pink": "2040ff"}, "redstone": {"7": "0x123456"}}"#,
|
||||
)
|
||||
.unwrap();
|
||||
crate::pack_palette::set(&read(&dir));
|
||||
let (cyan, pink, purple) = (9, 6, 10);
|
||||
assert_eq!(diffuse(cyan), 0x00ff00);
|
||||
assert_eq!(text(cyan), 0x0000ff);
|
||||
assert_eq!(sheep_wool(pink as u8), 0x2040ff);
|
||||
// Wool reads vanilla's dye, not the pack's diffuse: 75% of 0x169C9C.
|
||||
assert_eq!(sheep_wool(cyan as u8), 0x107575);
|
||||
assert_eq!(diffuse(purple), 0x8932B8);
|
||||
assert_eq!(text(purple), 0xA020F0);
|
||||
assert_eq!((redstone(7), redstone(15)), (Some(0x123456), None));
|
||||
crate::pack_palette::set(&Default::default());
|
||||
assert_eq!(redstone(7), None);
|
||||
assert_eq!(diffuse(cyan), 0x169C9C);
|
||||
assert_eq!(sheep_wool(pink as u8), 0xB6687F);
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
//! Polytone's item modifiers' durability bar colours, on the engine's
|
||||
//! [`crate::pack_palette::ItemBars`].
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
|
||||
/// An item modifier's `bar_color` of the `damage` type: a row of colours picked by the stack's
|
||||
/// damage, the first pixel undamaged, the last at full damage (measured on the real mod).
|
||||
struct DamageBar {
|
||||
targets: horizon_polytone::Targets,
|
||||
row: Vec<u32>,
|
||||
}
|
||||
|
||||
/// The packs' damage bars, the last read first.
|
||||
struct Bars(Vec<DamageBar>);
|
||||
|
||||
impl crate::pack_palette::ItemBars for Bars {
|
||||
fn color(&self, item: &str, damage: f32) -> Option<u32> {
|
||||
let bar = self
|
||||
.0
|
||||
.iter()
|
||||
.rev()
|
||||
.find(|b| b.targets.matches(item, |_| false))?;
|
||||
let last = (bar.row.len() - 1) as f32;
|
||||
Some(bar.row[(damage.clamp(0.0, 1.0) * last).round() as usize])
|
||||
}
|
||||
}
|
||||
|
||||
/// Reads the packs' item modifiers' durability bar colours under a pack overlay root: an
|
||||
/// `<item>_bar.png` alone, or a json whose `bar_color` is `damage` with its `<name>_bar.png` (or
|
||||
/// `<name>.png`) beside it. Colormap ids and inline colormaps aren't read. `None` without any.
|
||||
pub fn read(root: &Path) -> Option<Arc<dyn crate::pack_palette::ItemBars>> {
|
||||
let mut bars = Vec::new();
|
||||
for namespace in std::fs::read_dir(root.join("assets"))
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.flatten()
|
||||
{
|
||||
let base = namespace.path().join("polytone/item_modifiers");
|
||||
let mut stack = vec![base.clone()];
|
||||
while let Some(dir) = stack.pop() {
|
||||
for entry in std::fs::read_dir(&dir).into_iter().flatten().flatten() {
|
||||
let path = entry.path();
|
||||
if path.is_dir() {
|
||||
stack.push(path);
|
||||
} else if let Some(bar) = item_bar(root, &base, &path) {
|
||||
bars.push(bar);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
(!bars.is_empty()).then(|| Arc::new(Bars(bars)) as _)
|
||||
}
|
||||
|
||||
fn item_bar(root: &Path, base: &Path, path: &Path) -> Option<DamageBar> {
|
||||
let name = path.file_name()?.to_str()?;
|
||||
let row = |png: &Path| {
|
||||
let (width, _, rgba) = crate::sprites::read_rgba(png).ok()?;
|
||||
let row: Vec<u32> = rgba[..width as usize * 4]
|
||||
.as_chunks::<4>()
|
||||
.0
|
||||
.iter()
|
||||
.map(|p| u32::from(p[0]) << 16 | u32::from(p[1]) << 8 | u32::from(p[2]))
|
||||
.collect();
|
||||
(!row.is_empty()).then_some(row)
|
||||
};
|
||||
if let Some(stem) = name.strip_suffix("_bar.png") {
|
||||
// A json of the same name owns its png.
|
||||
if path.with_file_name(format!("{stem}.json")).exists() {
|
||||
return None;
|
||||
}
|
||||
let relative = path.parent()?.strip_prefix(base).ok()?.join(stem);
|
||||
let namespace = base.parent()?.parent()?.file_name()?.to_str()?;
|
||||
let id = format!("{namespace}:{}", relative.to_str()?.replace('\\', "/"));
|
||||
let targets = horizon_polytone::Targets::parse(None, Some(&id)).ok()?;
|
||||
return Some(DamageBar {
|
||||
targets,
|
||||
row: row(path)?,
|
||||
});
|
||||
}
|
||||
let stem = name.strip_suffix(".json")?;
|
||||
let json: serde_json::Value = horizon_world::json_file::read(path).ok()?;
|
||||
if json.get("bar_color")?.as_str()? != "damage" {
|
||||
return None;
|
||||
}
|
||||
let png = [format!("{stem}_bar.png"), format!("{stem}.png")]
|
||||
.into_iter()
|
||||
.map(|png| path.with_file_name(png))
|
||||
.find(|png| png.exists())?;
|
||||
let relative = path.strip_prefix(root).unwrap_or(path).to_string_lossy();
|
||||
let implicit = horizon_polytone::implicit_id(&relative, "item_modifiers");
|
||||
let targets =
|
||||
horizon_polytone::Targets::parse(json.get("targets"), implicit.as_deref()).ok()?;
|
||||
Some(DamageBar {
|
||||
targets,
|
||||
row: row(&png)?,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn png_row(path: &Path, pixels: &[[u8; 3]]) {
|
||||
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||
let file = std::fs::File::create(path).unwrap();
|
||||
let mut encoder = png::Encoder::new(file, pixels.len() as u32, 1);
|
||||
encoder.set_color(png::ColorType::Rgba);
|
||||
let data: Vec<u8> = pixels
|
||||
.iter()
|
||||
.flat_map(|p| [p[0], p[1], p[2], 255])
|
||||
.collect();
|
||||
encoder
|
||||
.write_header()
|
||||
.unwrap()
|
||||
.write_image_data(&data)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_damage_bar_picks_its_pixel_by_the_stacks_damage() {
|
||||
let dir = std::env::temp_dir().join(format!("pack-bars-{}", std::process::id()));
|
||||
let modifiers = dir.join("assets/minecraft/polytone/item_modifiers");
|
||||
let rgby = [[255, 0, 0], [0, 255, 0], [0, 0, 255], [255, 255, 0]];
|
||||
png_row(&modifiers.join("diamond_pickaxe_bar.png"), &rgby);
|
||||
std::fs::write(
|
||||
modifiers.join("tools.json"),
|
||||
r#"{"targets": ["minecraft:iron_axe"], "bar_color": "damage"}"#,
|
||||
)
|
||||
.unwrap();
|
||||
png_row(&modifiers.join("tools_bar.png"), &[[1, 2, 3], [4, 5, 6]]);
|
||||
let bars = read(&dir).unwrap();
|
||||
// The real mod: damage 200 of 1561 shows the first pixel, 1400 the last.
|
||||
assert_eq!(
|
||||
bars.color("minecraft:diamond_pickaxe", 200.0 / 1561.0),
|
||||
Some(0xFF0000)
|
||||
);
|
||||
assert_eq!(
|
||||
bars.color("minecraft:diamond_pickaxe", 1400.0 / 1561.0),
|
||||
Some(0xFFFF00)
|
||||
);
|
||||
assert_eq!(bars.color("minecraft:iron_axe", 0.9), Some(0x040506));
|
||||
assert_eq!(bars.color("minecraft:diamond_shovel", 0.5), None);
|
||||
assert!(read(&dir.join("none")).is_none());
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,9 @@
|
||||
//! crate until the platform's executable installs it: it uses only the crate's public API, so it
|
||||
//! moves to a platform crate unchanged, and nothing else in the crate names it.
|
||||
|
||||
mod colors;
|
||||
mod custom_sky;
|
||||
mod item_bars;
|
||||
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
@@ -14,10 +16,22 @@ use crate::pack_hooks::PackFormats;
|
||||
pub struct Formats;
|
||||
|
||||
impl PackFormats for Formats {
|
||||
fn colors(&self, root: &Path) -> Option<crate::pack_palette::PackColors> {
|
||||
Some(colors::read(root))
|
||||
}
|
||||
|
||||
fn emissive_suffix(&self, root: &Path) -> Option<String> {
|
||||
Some(horizon_optifine::emissive::suffix(root))
|
||||
}
|
||||
|
||||
fn custom_sky(&self, root: &Path) -> Option<Arc<dyn crate::custom_sky::SkyLayers>> {
|
||||
let sky = custom_sky::CustomSky::load(root);
|
||||
(!sky.is_empty()).then(|| Arc::new(sky) as _)
|
||||
}
|
||||
|
||||
fn item_bars(&self, root: &Path) -> Option<Arc<dyn crate::pack_palette::ItemBars>> {
|
||||
item_bars::read(root)
|
||||
}
|
||||
}
|
||||
|
||||
/// Installs these formats ([`crate::pack_hooks::install`]); the game does at startup.
|
||||
|
||||
@@ -10,10 +10,26 @@ use std::sync::{Arc, OnceLock};
|
||||
/// What the pack formats beyond vanilla's add to a pack. Each method reads one overlay of a pack
|
||||
/// (its folder at `root`); the default is "nothing in this format".
|
||||
pub trait PackFormats: Send + Sync {
|
||||
/// The colours the overlay replaces where vanilla hardcodes them.
|
||||
fn colors(&self, _root: &Path) -> Option<crate::pack_palette::PackColors> {
|
||||
None
|
||||
}
|
||||
|
||||
/// The name ending of the overlay's emissive textures (`stone_e.png` beside `stone.png`),
|
||||
/// if its format has them.
|
||||
fn emissive_suffix(&self, _root: &Path) -> Option<String> {
|
||||
None
|
||||
}
|
||||
|
||||
/// The sky layers the overlay draws over the vanilla sky, if it has any.
|
||||
fn custom_sky(&self, _root: &Path) -> Option<Arc<dyn crate::custom_sky::SkyLayers>> {
|
||||
None
|
||||
}
|
||||
|
||||
/// The durability bar colours the overlay sets by item, if any.
|
||||
fn item_bars(&self, _root: &Path) -> Option<Arc<dyn crate::pack_palette::ItemBars>> {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// No format beyond vanilla's.
|
||||
|
||||
@@ -1,14 +1,106 @@
|
||||
//! Polytone's dye, sheep and redstone colours (`dye.<name>.diffuse|text`, `sheep.<name>`,
|
||||
//! `redstone.<power>`), for drawing only: the pack recolours collars, tropical fish, banners, wool,
|
||||
//! sign text and redstone wire and dust, while gameplay (crafting, item data, the beacon) keeps
|
||||
//! vanilla's `DyeColor`. Lock-free: the draw paths and the mesher's workers read it per face.
|
||||
//! The colours a pack replaces where vanilla hardcodes them (Polytone's `colors.json`, read by the
|
||||
//! platform's pack formats, [`crate::pack_hooks`]): dye, sheep and redstone colours, for drawing
|
||||
//! only (the pack recolours collars, tropical fish, banners, wool, sign text and redstone wire and
|
||||
//! dust, while gameplay keeps vanilla's `DyeColor`), mob effect colours, the experience number's,
|
||||
//! entity shadows, the effect fogs' settings and durability bar colours. Lock-free: the draw paths
|
||||
//! and the mesher's workers read the dye, sheep and redstone tables per face.
|
||||
use std::path::Path;
|
||||
use std::sync::RwLock;
|
||||
use std::sync::atomic::{AtomicU64, Ordering::Relaxed};
|
||||
use std::sync::{Arc, RwLock};
|
||||
|
||||
use crate::block_entity_render::DYES;
|
||||
use crate::signs::DYE_TEXT;
|
||||
use horizon_polytone::colors::PackColors;
|
||||
|
||||
/// The fog settings vanilla hardcodes, as the packs set them (vanilla's by default).
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct Environment {
|
||||
/// The fog colour inside powder snow (RGB).
|
||||
pub powder_snow_fog: Option<u32>,
|
||||
/// The fog ends of Blindness (5), Darkness (15) and bosses that add world fog (96).
|
||||
pub blindness_fog_distance: f32,
|
||||
pub darkness_fog_distance: f32,
|
||||
pub boss_fog_distance: f32,
|
||||
/// How much the underwater fog colour washes out (1 vanilla, 0 none).
|
||||
pub water_fog_brightening: f32,
|
||||
/// The ticks under water it takes (vanilla's 600).
|
||||
pub water_fog_brightening_time: f32,
|
||||
/// Below it the sky draws its dark disc (vanilla's 63; flat worlds keep their floor).
|
||||
pub horizon_height: f32,
|
||||
}
|
||||
|
||||
impl Default for Environment {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
powder_snow_fog: None,
|
||||
blindness_fog_distance: 5.0,
|
||||
darkness_fog_distance: 15.0,
|
||||
boss_fog_distance: 96.0,
|
||||
water_fog_brightening: 1.0,
|
||||
water_fog_brightening_time: 600.0,
|
||||
horizon_height: 63.0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The packs' colours; `None` (and the defaults) where they keep vanilla's.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct PackColors {
|
||||
/// By dye id (`DyeColor` order): `getTextureDiffuseColor`, `getTextColor`, the sheep's wool.
|
||||
pub dye_diffuse: [Option<u32>; 16],
|
||||
pub dye_text: [Option<u32>; 16],
|
||||
pub sheep: [Option<u32>; 16],
|
||||
/// `RedStoneWireBlock.COLORS` by power.
|
||||
pub redstone: [Option<u32>; 16],
|
||||
/// Mob effects' colours, by effect id without vanilla's namespace.
|
||||
pub effects: Vec<(String, u32)>,
|
||||
/// False hides every entity's round shadow, whatever the video option says.
|
||||
pub entity_shadows: bool,
|
||||
/// The experience level number's colour and outline.
|
||||
pub xp_bar: [Option<u32>; 2],
|
||||
/// The effect fogs' settings: the fog reads them every frame.
|
||||
pub env: Environment,
|
||||
/// The fog the packs' fluid modifiers give water and lava.
|
||||
pub fluid_fog: FluidFogs,
|
||||
}
|
||||
|
||||
/// A fluid modifier's fog: a flat colour (RGB), and the fog's radius and fade multipliers (1 is
|
||||
/// vanilla's).
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct FluidFog {
|
||||
pub color: Option<u32>,
|
||||
pub radius: f32,
|
||||
pub fade: f32,
|
||||
}
|
||||
|
||||
/// The fog the packs' fluid modifiers give water and lava.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq)]
|
||||
pub struct FluidFogs {
|
||||
pub water: Option<FluidFog>,
|
||||
pub lava: Option<FluidFog>,
|
||||
}
|
||||
|
||||
impl Default for PackColors {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
dye_diffuse: [None; 16],
|
||||
dye_text: [None; 16],
|
||||
sheep: [None; 16],
|
||||
redstone: [None; 16],
|
||||
effects: Vec::new(),
|
||||
entity_shadows: true,
|
||||
xp_bar: [None; 2],
|
||||
env: Environment::default(),
|
||||
fluid_fog: FluidFogs::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl PackColors {
|
||||
/// Whether entities draw their round shadow, as far as the packs say.
|
||||
pub fn entity_shadows(&self) -> bool {
|
||||
self.entity_shadows
|
||||
}
|
||||
}
|
||||
|
||||
/// 0 is unset, else `1 << 32 | rgb`.
|
||||
static DIFFUSE: [AtomicU64; 16] = [const { AtomicU64::new(0) }; 16];
|
||||
@@ -16,6 +108,30 @@ static TEXT: [AtomicU64; 16] = [const { AtomicU64::new(0) }; 16];
|
||||
static SHEEP: [AtomicU64; 16] = [const { AtomicU64::new(0) }; 16];
|
||||
static REDSTONE: [AtomicU64; 16] = [const { AtomicU64::new(0) }; 16];
|
||||
|
||||
/// The colours of the pack overlay at `root`, from the installed pack formats; their dye, sheep
|
||||
/// and redstone colours go to the draw paths' tables ([`set`]).
|
||||
pub fn read(root: &Path) -> PackColors {
|
||||
let formats = crate::pack_hooks::formats();
|
||||
let colors = formats.colors(root).unwrap_or_default();
|
||||
set(&colors);
|
||||
*ITEM_BARS.write().unwrap_or_else(|e| e.into_inner()) = formats.item_bars(root);
|
||||
colors
|
||||
}
|
||||
|
||||
/// The packs' durability bar colours by item (Polytone's item modifiers' `bar_color`).
|
||||
pub trait ItemBars: Send + Sync {
|
||||
/// The bar colour (RGB) of `item` at this damage (0 to 1), when a pack sets one.
|
||||
fn color(&self, item: &str, damage: f32) -> Option<u32>;
|
||||
}
|
||||
|
||||
static ITEM_BARS: RwLock<Option<Arc<dyn ItemBars>>> = RwLock::new(None);
|
||||
|
||||
/// A pack's durability bar colour (RGB) for an item at this damage (0 to 1).
|
||||
pub fn item_bar_color(item: &str, damage: f32) -> Option<u32> {
|
||||
let bars = ITEM_BARS.read().unwrap_or_else(|e| e.into_inner());
|
||||
bars.as_ref()?.color(item, damage)
|
||||
}
|
||||
|
||||
/// Takes the colours of the packs just loaded (vanilla's where they set none).
|
||||
pub fn set(colors: &PackColors) {
|
||||
let store = |table: &[AtomicU64; 16], i: usize, color: Option<u32>| {
|
||||
@@ -24,129 +140,39 @@ pub fn set(colors: &PackColors) {
|
||||
Relaxed,
|
||||
);
|
||||
};
|
||||
for (i, (name, _)) in DYES.iter().enumerate() {
|
||||
store(&DIFFUSE, i, colors.dye(name, "diffuse"));
|
||||
store(&TEXT, i, colors.dye(name, "text"));
|
||||
store(&SHEEP, i, colors.sheep(name));
|
||||
store(&REDSTONE, i, colors.redstone(i as u8));
|
||||
for i in 0..16 {
|
||||
store(&DIFFUSE, i, colors.dye_diffuse[i]);
|
||||
store(&TEXT, i, colors.dye_text[i]);
|
||||
store(&SHEEP, i, colors.sheep[i]);
|
||||
store(&REDSTONE, i, colors.redstone[i]);
|
||||
}
|
||||
}
|
||||
|
||||
/// An item modifier's `bar_color` of the `damage` type: a row of colours picked by the stack's
|
||||
/// damage, the first pixel undamaged, the last at full damage (measured on the real mod).
|
||||
struct DamageBar {
|
||||
targets: horizon_polytone::Targets,
|
||||
row: Vec<u32>,
|
||||
}
|
||||
static ITEM_BARS: RwLock<Vec<DamageBar>> = RwLock::new(Vec::new());
|
||||
|
||||
/// Reads the packs' item modifiers' durability bar colours under a pack overlay root: an
|
||||
/// `<item>_bar.png` alone, or a json whose `bar_color` is `damage` with its `<name>_bar.png` (or
|
||||
/// `<name>.png`) beside it. Colormap ids and inline colormaps aren't read.
|
||||
pub fn set_item_bars(root: &Path) {
|
||||
let mut bars = Vec::new();
|
||||
for namespace in std::fs::read_dir(root.join("assets"))
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.flatten()
|
||||
{
|
||||
let base = namespace.path().join("polytone/item_modifiers");
|
||||
let mut stack = vec![base.clone()];
|
||||
while let Some(dir) = stack.pop() {
|
||||
for entry in std::fs::read_dir(&dir).into_iter().flatten().flatten() {
|
||||
let path = entry.path();
|
||||
if path.is_dir() {
|
||||
stack.push(path);
|
||||
} else if let Some(bar) = item_bar(root, &base, &path) {
|
||||
bars.push(bar);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
*ITEM_BARS.write().unwrap_or_else(|e| e.into_inner()) = bars;
|
||||
}
|
||||
|
||||
fn item_bar(root: &Path, base: &Path, path: &Path) -> Option<DamageBar> {
|
||||
let name = path.file_name()?.to_str()?;
|
||||
let row = |png: &Path| {
|
||||
let (width, _, rgba) = crate::sprites::read_rgba(png).ok()?;
|
||||
let row: Vec<u32> = rgba[..width as usize * 4]
|
||||
.as_chunks::<4>()
|
||||
.0
|
||||
.iter()
|
||||
.map(|p| u32::from(p[0]) << 16 | u32::from(p[1]) << 8 | u32::from(p[2]))
|
||||
.collect();
|
||||
(!row.is_empty()).then_some(row)
|
||||
};
|
||||
if let Some(stem) = name.strip_suffix("_bar.png") {
|
||||
// A json of the same name owns its png.
|
||||
if path.with_file_name(format!("{stem}.json")).exists() {
|
||||
return None;
|
||||
}
|
||||
let relative = path.parent()?.strip_prefix(base).ok()?.join(stem);
|
||||
let namespace = base.parent()?.parent()?.file_name()?.to_str()?;
|
||||
let id = format!("{namespace}:{}", relative.to_str()?.replace('\\', "/"));
|
||||
let targets = horizon_polytone::Targets::parse(None, Some(&id)).ok()?;
|
||||
return Some(DamageBar {
|
||||
targets,
|
||||
row: row(path)?,
|
||||
});
|
||||
}
|
||||
let stem = name.strip_suffix(".json")?;
|
||||
let json: serde_json::Value = horizon_world::json_file::read(path).ok()?;
|
||||
if json.get("bar_color")?.as_str()? != "damage" {
|
||||
return None;
|
||||
}
|
||||
let png = [format!("{stem}_bar.png"), format!("{stem}.png")]
|
||||
.into_iter()
|
||||
.map(|png| path.with_file_name(png))
|
||||
.find(|png| png.exists())?;
|
||||
let relative = path.strip_prefix(root).unwrap_or(path).to_string_lossy();
|
||||
let implicit = horizon_polytone::implicit_id(&relative, "item_modifiers");
|
||||
let targets =
|
||||
horizon_polytone::Targets::parse(json.get("targets"), implicit.as_deref()).ok()?;
|
||||
Some(DamageBar {
|
||||
targets,
|
||||
row: row(&png)?,
|
||||
})
|
||||
}
|
||||
|
||||
/// A pack's durability bar colour (RGB) for an item at this damage (0 to 1).
|
||||
pub fn item_bar_color(item: &str, damage: f32) -> Option<u32> {
|
||||
let bars = ITEM_BARS.read().unwrap_or_else(|e| e.into_inner());
|
||||
let bar = bars
|
||||
.iter()
|
||||
.rev()
|
||||
.find(|b| b.targets.matches(item, |_| false))?;
|
||||
let last = (bar.row.len() - 1) as f32;
|
||||
Some(bar.row[(damage.clamp(0.0, 1.0) * last).round() as usize])
|
||||
}
|
||||
|
||||
fn read(table: &[AtomicU64; 16], i: usize) -> Option<u32> {
|
||||
fn get(table: &[AtomicU64; 16], i: usize) -> Option<u32> {
|
||||
let v = table[i & 15].load(Relaxed);
|
||||
(v != 0).then_some(v as u32)
|
||||
}
|
||||
|
||||
/// `DyeColor.getTextureDiffuseColor` (RGB) as the packs draw it.
|
||||
pub fn diffuse(dye: usize) -> u32 {
|
||||
read(&DIFFUSE, dye).unwrap_or(DYES[dye & 15].1)
|
||||
get(&DIFFUSE, dye).unwrap_or(DYES[dye & 15].1)
|
||||
}
|
||||
|
||||
/// `DyeColor.getTextColor` (RGB) as the packs draw it.
|
||||
pub fn text(dye: usize) -> u32 {
|
||||
read(&TEXT, dye).unwrap_or(DYE_TEXT[dye & 15].1)
|
||||
get(&TEXT, dye).unwrap_or(DYE_TEXT[dye & 15].1)
|
||||
}
|
||||
|
||||
/// A pack's `RedStoneWireBlock.COLORS` entry for this power (RGB), when it sets one.
|
||||
pub fn redstone(power: u8) -> Option<u32> {
|
||||
read(&REDSTONE, usize::from(power))
|
||||
get(&REDSTONE, usize::from(power))
|
||||
}
|
||||
|
||||
/// `ColorLerper.Type.SHEEP` wool colour: a pack's `sheep.<name>`, else white 0xE6E6E6 and the
|
||||
/// others 75% of vanilla's dye (a pack's `dye.*.diffuse` doesn't reach wool).
|
||||
pub fn sheep_wool(dye: u8) -> u32 {
|
||||
let i = usize::from(dye & 15);
|
||||
if let Some(color) = read(&SHEEP, i) {
|
||||
if let Some(color) = get(&SHEEP, i) {
|
||||
return color;
|
||||
}
|
||||
if i == 0 {
|
||||
@@ -156,81 +182,3 @@ pub fn sheep_wool(dye: u8) -> u32 {
|
||||
let f = |c: u32| ((c as f32 * 0.75).floor() as u32).min(255);
|
||||
f(src >> 16 & 255) << 16 | f(src >> 8 & 255) << 8 | f(src & 255)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_pack_recolours_only_what_it_names() {
|
||||
// Dyes no other test draws: the table is process-wide.
|
||||
let dir = std::env::temp_dir().join(format!("pack-dyes-{}", std::process::id()));
|
||||
let polytone = dir.join("assets/obs/polytone");
|
||||
std::fs::create_dir_all(&polytone).unwrap();
|
||||
std::fs::write(
|
||||
polytone.join("colors.json"),
|
||||
r#"{"dye": {"cyan": {"diffuse": "00ff00", "text": "0000ff"}}, "sheep": {"pink": "2040ff"}, "redstone": {"7": "0x123456"}}"#,
|
||||
)
|
||||
.unwrap();
|
||||
set(&PackColors::read(&dir));
|
||||
let (cyan, pink, purple) = (9, 6, 10);
|
||||
assert_eq!(diffuse(cyan), 0x00ff00);
|
||||
assert_eq!(text(cyan), 0x0000ff);
|
||||
assert_eq!(sheep_wool(pink as u8), 0x2040ff);
|
||||
// Wool reads vanilla's dye, not the pack's diffuse: 75% of 0x169C9C.
|
||||
assert_eq!(sheep_wool(cyan as u8), 0x107575);
|
||||
assert_eq!(diffuse(purple), 0x8932B8);
|
||||
assert_eq!(text(purple), 0xA020F0);
|
||||
assert_eq!((redstone(7), redstone(15)), (Some(0x123456), None));
|
||||
set(&PackColors::default());
|
||||
assert_eq!(redstone(7), None);
|
||||
assert_eq!(diffuse(cyan), 0x169C9C);
|
||||
assert_eq!(sheep_wool(pink as u8), 0xB6687F);
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
fn png_row(path: &Path, pixels: &[[u8; 3]]) {
|
||||
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||
let file = std::fs::File::create(path).unwrap();
|
||||
let mut encoder = png::Encoder::new(file, pixels.len() as u32, 1);
|
||||
encoder.set_color(png::ColorType::Rgba);
|
||||
let data: Vec<u8> = pixels
|
||||
.iter()
|
||||
.flat_map(|p| [p[0], p[1], p[2], 255])
|
||||
.collect();
|
||||
encoder
|
||||
.write_header()
|
||||
.unwrap()
|
||||
.write_image_data(&data)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_damage_bar_picks_its_pixel_by_the_stacks_damage() {
|
||||
let dir = std::env::temp_dir().join(format!("pack-bars-{}", std::process::id()));
|
||||
let modifiers = dir.join("assets/minecraft/polytone/item_modifiers");
|
||||
let rgby = [[255, 0, 0], [0, 255, 0], [0, 0, 255], [255, 255, 0]];
|
||||
png_row(&modifiers.join("diamond_pickaxe_bar.png"), &rgby);
|
||||
std::fs::write(
|
||||
modifiers.join("tools.json"),
|
||||
r#"{"targets": ["minecraft:iron_axe"], "bar_color": "damage"}"#,
|
||||
)
|
||||
.unwrap();
|
||||
png_row(&modifiers.join("tools_bar.png"), &[[1, 2, 3], [4, 5, 6]]);
|
||||
set_item_bars(&dir);
|
||||
// The real mod: damage 200 of 1561 shows the first pixel, 1400 the last.
|
||||
assert_eq!(
|
||||
item_bar_color("minecraft:diamond_pickaxe", 200.0 / 1561.0),
|
||||
Some(0xFF0000)
|
||||
);
|
||||
assert_eq!(
|
||||
item_bar_color("minecraft:diamond_pickaxe", 1400.0 / 1561.0),
|
||||
Some(0xFFFF00)
|
||||
);
|
||||
assert_eq!(item_bar_color("minecraft:iron_axe", 0.9), Some(0x040506));
|
||||
assert_eq!(item_bar_color("minecraft:diamond_shovel", 0.5), None);
|
||||
set_item_bars(&dir.join("none"));
|
||||
assert_eq!(item_bar_color("minecraft:diamond_pickaxe", 0.5), None);
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
}
|
||||
@@ -801,6 +801,26 @@ impl Host for Ctx<'_, '_> {
|
||||
_ => Err(format!("{name} is not available here")),
|
||||
}
|
||||
}
|
||||
|
||||
/// A particle's own fields, as `particle_method` answers them.
|
||||
fn field(&mut self, root: Root, field: horizon_polytone::expr::Field) -> Option<V> {
|
||||
use horizon_polytone::expr::Field as F;
|
||||
let Subject::Particle(p) = &self.subject else {
|
||||
return None;
|
||||
};
|
||||
if root != Root::Object {
|
||||
return None;
|
||||
}
|
||||
Some(V::Num(match field {
|
||||
F::Age => f64::from(p.age),
|
||||
F::Life => f64::from(p.lifetime),
|
||||
F::Custom => *self.custom,
|
||||
F::X => p.position[0],
|
||||
F::Y => p.position[1],
|
||||
F::Z => p.position[2],
|
||||
F::Temperature | F::Downfall => return None,
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
fn eval(program: &Program, ctx: &mut Ctx) -> Option<f64> {
|
||||
|
||||
@@ -913,6 +913,20 @@ pub trait Backend {
|
||||
fn unpause(&mut self, channel: ChannelId);
|
||||
/// `Channel.stopped`.
|
||||
fn stopped(&mut self, channel: ChannelId) -> bool;
|
||||
/// Update volume, pitch, and position in a single backend call.
|
||||
fn update_channel(&mut self, channel: ChannelId, volume: f32, pitch: f32, position: [f64; 3]) {
|
||||
self.set_volume(channel, volume);
|
||||
self.set_pitch(channel, pitch);
|
||||
self.set_position(channel, position);
|
||||
}
|
||||
/// Filter which handles have stopped playing in a single pass.
|
||||
fn filter_stopped(&mut self, live: &[(u64, ChannelId)], stopped: &mut Vec<u64>) {
|
||||
for &(h, c) in live {
|
||||
if self.stopped(c) {
|
||||
stopped.push(h);
|
||||
}
|
||||
}
|
||||
}
|
||||
/// Whether the channel plays clearly through a mod's muffle (`Audio::muffle`): its sound
|
||||
/// event is one the mod exempted. A backend without a muffle ignores it.
|
||||
fn set_unmuffled(&mut self, _channel: ChannelId, _unmuffled: bool) {}
|
||||
@@ -1494,13 +1508,14 @@ impl SoundEngine {
|
||||
.iter()
|
||||
.filter_map(|(h, handle)| handle.channel.map(|c| (*h, c))),
|
||||
);
|
||||
for (h, c) in live.drain(..) {
|
||||
if backend.stopped(c) {
|
||||
self.release_handle(h, backend);
|
||||
}
|
||||
let mut stopped = std::mem::take(&mut self.mapped_scratch);
|
||||
stopped.clear();
|
||||
backend.filter_stopped(&live, &mut stopped);
|
||||
for h in stopped.drain(..) {
|
||||
self.release_handle(h, backend);
|
||||
}
|
||||
// Released handles nobody maps any more.
|
||||
let mut mapped = std::mem::take(&mut self.mapped_scratch);
|
||||
let mut mapped = stopped;
|
||||
mapped.clear();
|
||||
mapped.extend(self.instance_to_channel.iter().map(|(_, h)| *h));
|
||||
self.handles
|
||||
@@ -1550,9 +1565,7 @@ impl SoundEngine {
|
||||
h.pitch_edited = pitch_edited;
|
||||
}
|
||||
if let Some(c) = self.handles.get(&handle).and_then(handle_channel) {
|
||||
backend.set_volume(c, volume);
|
||||
backend.set_pitch(c, pitch);
|
||||
backend.set_position(c, position);
|
||||
backend.update_channel(c, volume, pitch, position);
|
||||
}
|
||||
let instance = self.instances.get_mut(&id).expect("present");
|
||||
if instance.looping
|
||||
|
||||
@@ -1251,6 +1251,9 @@ pub struct EditBox {
|
||||
/// `getMessage`: what the narrator calls the box ("<message> edit box:
|
||||
/// <value>").
|
||||
pub message: String,
|
||||
/// `EditBox.setInvertHighlightedTextColor`: a selection draws its text
|
||||
/// inverted (the default) or in the box's own colour (`AnvilScreen`).
|
||||
pub invert_highlighted_text_color: bool,
|
||||
/// Where a borderless box drawn in a frame of its own focuses: a controller's outline goes
|
||||
/// round the frame, not over the text.
|
||||
pub frame: Option<[f32; 4]>,
|
||||
@@ -1543,6 +1546,7 @@ impl EditBox {
|
||||
text_color_uneditable: rgba(112, 112, 112, 255),
|
||||
editable: true,
|
||||
message: String::new(),
|
||||
invert_highlighted_text_color: true,
|
||||
frame: None,
|
||||
}
|
||||
}
|
||||
@@ -1918,7 +1922,11 @@ impl EditBox {
|
||||
let right = self.x + self.width;
|
||||
let (x0, x1) = (cursor_x.min(right), (hx - 1.0).min(right));
|
||||
let (a, b) = (x0.min(x1), x0.max(x1));
|
||||
batch.text_highlight(atlas, (a, ty - 1.0, b - a, 11.0), true);
|
||||
batch.text_highlight(
|
||||
atlas,
|
||||
(a, ty - 1.0, b - a, 11.0),
|
||||
self.invert_highlighted_text_color,
|
||||
);
|
||||
}
|
||||
if show_cursor {
|
||||
if insert {
|
||||
@@ -2628,6 +2636,20 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// `EditBox.setInvertHighlightedTextColor`: a selection inverts its text
|
||||
/// unless the box asks it not to (the anvil's rename box).
|
||||
#[test]
|
||||
fn a_selection_inverts_its_text_unless_the_box_says_not_to() {
|
||||
let mut edit = EditBox::new(0.0, 0.0, 100.0, 20.0);
|
||||
assert!(edit.invert_highlighted_text_color);
|
||||
edit.invert_highlighted_text_color = false;
|
||||
edit.set_value("name");
|
||||
edit.set_cursor(0, false);
|
||||
edit.set_cursor(4, true);
|
||||
assert_eq!(edit.selected_text(), "name");
|
||||
assert!(!edit.invert_highlighted_text_color);
|
||||
}
|
||||
|
||||
/// `EditBox.setEditable(false)`: the box keeps the focus, the caret keys
|
||||
/// and copy, but `charTyped`/paste/Backspace/Delete do nothing, and Cut
|
||||
/// copies without taking the text out. A value and the caret still show,
|
||||
|
||||
@@ -119,10 +119,11 @@ audio = ["horizon-client-render/audio", "horizon-client-lib/audio"]
|
||||
controllers = ["horizon-client-lib/controllers", "dep:gilrs", "dep:horizon-dualsense", "horizon-client-input/controllers"]
|
||||
# Horizon's own extras over the vanilla game, for a fork that wants them out (docs/forking.md):
|
||||
# cosmetics and emotes, the "Horizon player" presence, the official server added to
|
||||
# the server list, the Horizon Settings and Mods pages, Discord and Play with Friends. Players always get them; there is no switch. `grep -rn horizon-extras crates/` lists
|
||||
# the server list, the Horizon Settings and Mods pages, Discord and Play with Friends, and ray
|
||||
# tracing on request (`HORIZON_RAY_TRACING=1`). Players always get them; there is no switch. `grep -rn horizon-extras crates/` lists
|
||||
# every place it gates. Without it (`--no-default-features --features audio,controllers`) the
|
||||
# client is best effort: CI builds only the default.
|
||||
horizon-extras = ["horizon-client-lib/horizon-extras", "discord", "friends"]
|
||||
horizon-extras = ["horizon-client-lib/horizon-extras", "horizon-client-render/horizon-extras", "discord", "friends"]
|
||||
# Online extras a server or a port can leave out; the server build has none of them. "Playing Horizon"
|
||||
# on Discord (game/discord.rs).
|
||||
discord = ["horizon-client-lib/discord"]
|
||||
|
||||
@@ -287,6 +287,7 @@
|
||||
"This feature is not yet available in Horizon.": "This feature is not yet available in Horizon.",
|
||||
"Your display or system doesn't offer HDR output.": "Your display or system doesn't offer HDR output.",
|
||||
"Turn HDR on to use it.": "Turn HDR on to use it.",
|
||||
"The shader pack lights the world itself.": "The shader pack lights the world itself.",
|
||||
"Your GPU or graphics API doesn't support it.": "Your GPU or graphics API doesn't support it.",
|
||||
"Not with software rendering, which runs it on the CPU.": "Not with software rendering, which runs it on the CPU.",
|
||||
"Out of video memory: off until the next start.": "Out of video memory: off until the next start.",
|
||||
@@ -441,6 +442,7 @@
|
||||
"HDR Calibration %s/%s: %s": "HDR Calibration %s/%s: %s",
|
||||
"Sets the three below full screen, one test picture at a time.": "Sets the three below full screen, one test picture at a time.",
|
||||
"Your display didn't say how bright it gets: calibrate to set it up.": "Your display didn't say how bright it gets: calibrate to set it up.",
|
||||
"Your display's reported peak is often too high: calibrate to set it up.": "Your display's reported peak is often too high: calibrate to set it up.",
|
||||
"Already set up for your display. Only calibrate if HDR looks wrong.": "Already set up for your display. Only calibrate if HDR looks wrong.",
|
||||
"Puts Peak Brightness and Paper White back to your display's setup.": "Puts Peak Brightness and Paper White back to your display's setup.",
|
||||
"Set up for your display. Leave on Auto unless it looks wrong.": "Set up for your display. Leave on Auto unless it looks wrong.",
|
||||
|
||||
@@ -21,11 +21,11 @@ pub use horizon_client_render::{
|
||||
mipmap, mod_browser, mod_list, model_library, models, multiline_edit, native_emissive,
|
||||
options_file, options_fixes, options_ui, overlay, pack_browser, pack_formats, pack_hooks,
|
||||
pack_palette, pack_screen, packs, pad_cursor, pad_glyph, par, particles, poi_sections,
|
||||
polytone_colors, polytone_creative_tabs, polytone_post, post_chain, potent_sulfur, preedit,
|
||||
providers, pvp, pvp_hud, random_source, screen_draw, screen_effects, sculk, section_mask,
|
||||
server_terrain, settings, shader_options, shader_pack_list, signs, skins, social, sound_engine,
|
||||
sound_events, sound_preview, spans, split_layout, split_map, split_profiles, sprites,
|
||||
streaming, structure_block, tab_list, terrain_model, test_block, test_instance_block, testgen,
|
||||
polytone_creative_tabs, polytone_post, post_chain, potent_sulfur, preedit, providers, pvp,
|
||||
pvp_hud, random_source, screen_draw, screen_effects, sculk, section_mask, server_terrain,
|
||||
settings, shader_options, shader_pack_list, signs, skins, social, sound_engine, sound_events,
|
||||
sound_preview, spans, split_layout, split_map, split_profiles, sprites, streaming,
|
||||
structure_block, tab_list, terrain_model, test_block, test_instance_block, testgen,
|
||||
title_edition, trims, ui, vault, weather, widgets, world, worldborder, zip,
|
||||
};
|
||||
pub mod attack_strength;
|
||||
@@ -171,6 +171,7 @@ pub use horizon_client_input::gamepad;
|
||||
pub use horizon_client_input::gameplay_guide;
|
||||
pub use horizon_client_input::held;
|
||||
pub use horizon_client_input::input;
|
||||
pub use horizon_client_input::input_source;
|
||||
pub use horizon_client_input::interaction_input;
|
||||
pub use horizon_client_input::key_names;
|
||||
pub use horizon_client_input::keybinds;
|
||||
|
||||
@@ -2666,9 +2666,9 @@ pub fn mob_from_nbt_with_random(
|
||||
m.extra.counter = number(tag.get("still_timeout")).unwrap_or(0) as i32;
|
||||
}
|
||||
if m.species() == crate::mobs::Species::Shulker {
|
||||
m.extra.variant3 = number(tag.get("AttachFace")).unwrap_or(0).clamp(0, 5) as u8;
|
||||
m.extra.counter = number(tag.get("Peek")).unwrap_or(0).clamp(0, 100) as i32;
|
||||
m.extra.color = number(tag.get("Color")).map_or(16, |c| c.clamp(0, 16) as u8);
|
||||
m.extra.variant3 = (number(tag.get("AttachFace")).unwrap_or(0) % 6).unsigned_abs() as u8;
|
||||
m.extra.counter = number(tag.get("Peek")).map_or(0, |v| v as i8 as i32);
|
||||
m.extra.color = number(tag.get("Color")).map_or(16, |v| v as i8 as u8);
|
||||
m.anim.v[0] = m.extra.counter as f32 * 0.01;
|
||||
m.anim.vo[0] = m.anim.v[0];
|
||||
// Shulker.setPos: square on its block.
|
||||
|
||||
@@ -2028,7 +2028,7 @@ pub struct PackResources {
|
||||
/// The packs' Polytone post chains (#11486), when a pack has some.
|
||||
polytone_post: Option<Arc<horizon_client::polytone_post::PolytonePost>>,
|
||||
/// The packs' Polytone custom colours (`polytone/colors.json`).
|
||||
polytone_colors: Arc<horizon_client::polytone_colors::PackColors>,
|
||||
polytone_colors: Arc<horizon_client::pack_palette::PackColors>,
|
||||
}
|
||||
/// A quick craft drag as the screen draws it: each dragged slot's count and
|
||||
/// whether the slot caps it (None: hidden), and what stays in hand.
|
||||
@@ -2882,19 +2882,8 @@ struct Game<'a> {
|
||||
/// The open connection joined from Realms: `ServerData.isRealm` for the friends
|
||||
/// service's presence status.
|
||||
realm_session: bool,
|
||||
/// Active packet replay session (game/studio.rs).
|
||||
pub(super) studio_replay: Option<horizon_client::studio::ReplayDriver>,
|
||||
pub(super) studio_freecam: horizon_client::studio::StudioFreecam,
|
||||
pub(super) studio_timeline: horizon_client::studio::TimelineUi,
|
||||
pub(super) studio_topbar: horizon_client::studio::TopBarUi,
|
||||
pub(super) studio_rmb_down: bool,
|
||||
studio_camera_track_dirty: bool,
|
||||
/// Camera perspective to restore after leaving replay playback.
|
||||
studio_replay_previous_camera_type: Option<perspective::CameraType>,
|
||||
/// Replay opened from the title screen owns the synthetic remote session.
|
||||
studio_replay_standalone: bool,
|
||||
last_replay_tick: Option<Instant>,
|
||||
pub(super) studio_virtual_tick_ms: f32,
|
||||
/// Horizon Studio: replay playback, its free camera, timeline and top bar (game/studio.rs).
|
||||
pub(super) studio: studio::Studio,
|
||||
/// This world is open to the network (game/lan.rs).
|
||||
lan: Option<lan::LanHost>,
|
||||
/// Single player's server thread, which `remote` is connected to.
|
||||
@@ -5026,7 +5015,7 @@ impl Game<'_> {
|
||||
if canceled {
|
||||
return Ok(false);
|
||||
}
|
||||
self.pad_rumble(horizon_client::gamepad::Rumble::BlockBroken);
|
||||
self.input_feedback(horizon_client::input_source::Feedback::BlockBroken);
|
||||
let broken = state;
|
||||
let facts = self.res.facts().get(state).clone();
|
||||
let state = self.crack_mined_pot(pos, state);
|
||||
@@ -5845,7 +5834,7 @@ impl Game<'_> {
|
||||
batch
|
||||
}
|
||||
fn hud_layers(&mut self, size: [u32; 2]) -> HudBatch {
|
||||
self.pad.guide_shown.clear();
|
||||
self.input_hud_begin();
|
||||
let mut batch = HudBatch::new(size);
|
||||
batch.glint_colour = self.settings.extras.pvp.tweaks.glint_texel();
|
||||
// GlobalSettingsUniform's GameTime, for a resource pack's text shader.
|
||||
@@ -5942,7 +5931,7 @@ impl Game<'_> {
|
||||
// the sleep overlay, debug screen and open screens still draw.
|
||||
let spectator = self.spectator();
|
||||
let (wi, hi) = (w as i32, h as i32);
|
||||
let in_studio = self.studio_replay.is_some();
|
||||
let in_studio = self.studio.replay.is_some();
|
||||
let show_hud = !self.ui.hide_gui && !in_studio;
|
||||
// Hud.extractRenderState asks `Screen.isInGameUi` twice: such a screen
|
||||
// defers the subtitles over itself, and keeps them while F1 hides the
|
||||
@@ -6142,7 +6131,7 @@ impl Game<'_> {
|
||||
let atlas = &g.gui.atlas;
|
||||
let width = |s: &str| HudBatch::text_width(atlas, s) as i32;
|
||||
let draws = hud_layout::experience_colored(
|
||||
g.pack.polytone_colors.xp_bar(),
|
||||
g.pack.polytone_colors.xp_bar,
|
||||
wi,
|
||||
hi,
|
||||
g.experience.progress,
|
||||
@@ -6284,19 +6273,19 @@ impl Game<'_> {
|
||||
}
|
||||
if !self.ui.hide_gui
|
||||
&& self.screen == Screen::Playing
|
||||
&& (self.studio_replay.is_some() || self.studio_freecam.active)
|
||||
&& (self.studio.replay.is_some() || self.studio.freecam.active)
|
||||
{
|
||||
self.studio_topbar.render(
|
||||
self.studio.topbar.render(
|
||||
&mut batch,
|
||||
&self.gui.atlas,
|
||||
self.cursor,
|
||||
self.studio_freecam.locked_to_path,
|
||||
self.studio.freecam.locked_to_path,
|
||||
);
|
||||
self.studio_timeline.render(
|
||||
self.studio.timeline.render(
|
||||
&mut batch,
|
||||
&self.gui.atlas,
|
||||
self.cursor,
|
||||
&self.studio_freecam,
|
||||
&self.studio.freecam,
|
||||
);
|
||||
}
|
||||
if show_hud {
|
||||
@@ -6475,18 +6464,6 @@ impl Game<'_> {
|
||||
self.draw_tooltip_lines(batch, &lines, self.cursor.0, self.cursor.1);
|
||||
}
|
||||
}
|
||||
/// `AbstractContainerScreen` keeps `isQuickCrafting` and the drag it
|
||||
/// belongs to on the screen. Horizon keeps both on `Game` (`inv_clicks`,
|
||||
/// `inv_drag`, `last_inv_click`), so they are dropped when the pointer
|
||||
/// changes hands: a screen that opens or closes starts with its own
|
||||
/// state, and a press whose release never reached the screen -- the
|
||||
/// pointer left the window, or the screen closed first -- would
|
||||
/// otherwise leave every later press on every later screen swallowed.
|
||||
pub(super) fn reset_inventory_clicks(&mut self) {
|
||||
self.inv_clicks.cancel_drag();
|
||||
self.inv_drag = None;
|
||||
self.last_inv_click = None;
|
||||
}
|
||||
pub(super) fn close_container(&mut self) {
|
||||
// `Gui.setScreen` clears the old screen's focus before it goes: the
|
||||
// key that closed the container (Tab bound to the inventory) has
|
||||
@@ -6530,29 +6507,15 @@ impl Game<'_> {
|
||||
}
|
||||
self.close_ender_chest();
|
||||
}
|
||||
/// A sound a menu click made the player play (a bundle's insert or
|
||||
/// remove): `Entity.playSound` at the player.
|
||||
pub(super) fn player_menu_sound(&mut self, sound: &str) {
|
||||
let Some(p) = self.player.as_ref() else {
|
||||
return;
|
||||
};
|
||||
let at = [p.position.x, p.position.y, p.position.z];
|
||||
let (volume, pitch) = if sound == horizon_client::bundle::INSERT_FAIL {
|
||||
(1.0, 1.0)
|
||||
} else {
|
||||
(0.8, 0.8 + self.world.random.next_float() * 0.4)
|
||||
};
|
||||
self.audio.play(sound, Some(at), volume, pitch);
|
||||
}
|
||||
/// `Camera.setRotation`: the camera's orientation, turned round in
|
||||
/// third-person front view.
|
||||
pub(super) fn camera_orientation(&self) -> glam::Quat {
|
||||
if self.studio_freecam.active {
|
||||
if self.studio.freecam.active {
|
||||
return camera_rotation(
|
||||
perspective::CameraType::FirstPerson,
|
||||
self.studio_freecam.yaw,
|
||||
self.studio_freecam.pitch,
|
||||
) * glam::Quat::from_rotation_z(self.studio_freecam.roll.to_radians());
|
||||
self.studio.freecam.yaw,
|
||||
self.studio.freecam.pitch,
|
||||
) * glam::Quat::from_rotation_z(self.studio.freecam.roll.to_radians());
|
||||
}
|
||||
match self.posed_rotation {
|
||||
// The frame's camera, already turned round in front view.
|
||||
@@ -6759,8 +6722,8 @@ impl Game<'_> {
|
||||
self.mark_player(out.len(), !self.own_body_in_view());
|
||||
let player_start = out.len();
|
||||
self.mark_motion(player_start, Self::MOTION_OWN_PLAYER);
|
||||
let player_alpha = if self.studio_replay.is_some() {
|
||||
(self.studio_virtual_tick_ms as f64 / 50.0).clamp(0.0, 1.0)
|
||||
let player_alpha = if self.studio.replay.is_some() {
|
||||
(self.studio.virtual_tick_ms as f64 / 50.0).clamp(0.0, 1.0)
|
||||
} else {
|
||||
self.clock.camera_fraction()
|
||||
};
|
||||
@@ -9262,7 +9225,7 @@ impl Game<'_> {
|
||||
let camera_alpha = self.clock.camera_fraction();
|
||||
let vanilla = self.camera_view(camera_alpha as f32);
|
||||
let view = vanilla.map(|view| {
|
||||
if self.shows_panorama() || self.studio_freecam.active {
|
||||
if self.shows_panorama() || self.studio.freecam.active {
|
||||
view
|
||||
} else {
|
||||
self.native_camera_pose_now(camera_alpha as f32, view)
|
||||
@@ -9440,9 +9403,9 @@ impl Game<'_> {
|
||||
// hitboxes (F3+G, F3+B).
|
||||
let mut lines = self.fishing_line(alpha, size);
|
||||
let mut g = self.debug_gizmos(alpha as f32, camera.to_array());
|
||||
if self.studio_freecam.active || self.studio_replay.is_some() {
|
||||
if self.studio.freecam.active || self.studio.replay.is_some() {
|
||||
horizon_client::studio::render_camera_path(
|
||||
&self.studio_freecam.track,
|
||||
&self.studio.freecam.track,
|
||||
&mut g,
|
||||
None,
|
||||
100,
|
||||
@@ -9469,7 +9432,7 @@ impl Game<'_> {
|
||||
Vec::new()
|
||||
};
|
||||
pr.lap("pr_native_visuals");
|
||||
let view = if self.studio_freecam.active {
|
||||
let view = if self.studio.freecam.active {
|
||||
glam::Mat4::from_quat(self.camera_orientation().conjugate())
|
||||
} else {
|
||||
horizon_client::camera::view_matrix(view_yaw, view_pitch)
|
||||
@@ -9595,7 +9558,7 @@ impl Game<'_> {
|
||||
} else {
|
||||
self.fov_degrees(camera_alpha as f32, camera.to_array())
|
||||
};
|
||||
let camera_effects = fov::camera_effects_enabled(title, self.studio_freecam.active);
|
||||
let camera_effects = fov::camera_effects_enabled(title, self.studio.freecam.active);
|
||||
let bob = if !camera_effects {
|
||||
glam::Mat4::IDENTITY
|
||||
} else {
|
||||
@@ -10100,7 +10063,7 @@ impl Game<'_> {
|
||||
)
|
||||
)
|
||||
{
|
||||
self.pad.keyboard_took_over = true;
|
||||
self.input_keyboard_used();
|
||||
}
|
||||
if let WindowEvent::KeyboardInput { event, .. } = &event
|
||||
&& event.state == ElementState::Pressed
|
||||
@@ -10489,19 +10452,19 @@ impl Game<'_> {
|
||||
let previous = self.cursor;
|
||||
self.cursor = (position.x as f32 / b.scale, position.y as f32 / b.scale);
|
||||
if self.screen == Screen::Playing
|
||||
&& (self.studio_timeline.dragging_playhead
|
||||
|| self.studio_timeline.dragging_keyframe.is_some())
|
||||
&& (self.studio.timeline.dragging_playhead
|
||||
|| self.studio.timeline.dragging_keyframe.is_some())
|
||||
{
|
||||
let action = self.studio_timeline.handle_mouse_drag(
|
||||
let action = self.studio.timeline.handle_mouse_drag(
|
||||
self.cursor,
|
||||
b.width,
|
||||
&mut self.studio_freecam,
|
||||
&mut self.studio.freecam,
|
||||
);
|
||||
if matches!(
|
||||
action,
|
||||
horizon_client::studio::TimelineAction::KeyframeMoved
|
||||
) {
|
||||
self.studio_camera_track_dirty = true;
|
||||
self.studio.camera_track_dirty = true;
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
@@ -10553,7 +10516,7 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
WindowEvent::MouseWheel { .. }
|
||||
if self.screen == Screen::Playing && self.studio_replay.is_some() => {}
|
||||
if self.screen == Screen::Playing && self.studio.replay.is_some() => {}
|
||||
WindowEvent::MouseWheel { delta, .. } => {
|
||||
let (raw_x, raw_y) = match delta {
|
||||
MouseScrollDelta::LineDelta(x, y) => (f64::from(x), f64::from(y)),
|
||||
@@ -10831,44 +10794,44 @@ impl Game<'_> {
|
||||
WindowEvent::MouseInput { state, button, .. } => {
|
||||
let pressed = state == ElementState::Pressed;
|
||||
if !pressed && button == MouseButton::Left {
|
||||
let action = self.studio_timeline.handle_mouse_up();
|
||||
let action = self.studio.timeline.handle_mouse_up();
|
||||
if !matches!(action, horizon_client::studio::TimelineAction::None) {
|
||||
self.handle_studio_timeline_action(action);
|
||||
}
|
||||
}
|
||||
if (self.studio_freecam.active || self.studio_replay.is_some())
|
||||
if (self.studio.freecam.active || self.studio.replay.is_some())
|
||||
&& button == MouseButton::Right
|
||||
{
|
||||
if let Some(renderer) = &self.renderer {
|
||||
let batch =
|
||||
HudBatch::new([renderer.config.width, renderer.config.height]);
|
||||
if self.studio_timeline.visible
|
||||
&& self.cursor.1 >= batch.height - self.studio_timeline.height()
|
||||
if self.studio.timeline.visible
|
||||
&& self.cursor.1 >= batch.height - self.studio.timeline.height()
|
||||
{
|
||||
if pressed
|
||||
&& let Some(index) = self.studio_timeline.keyframe_at_cursor(
|
||||
&& let Some(index) = self.studio.timeline.keyframe_at_cursor(
|
||||
self.cursor,
|
||||
batch.width,
|
||||
batch.height,
|
||||
&self.studio_freecam,
|
||||
&self.studio.freecam,
|
||||
)
|
||||
{
|
||||
if index < self.studio_freecam.track.keyframes.len() {
|
||||
self.studio_freecam.track.keyframes.remove(index);
|
||||
self.studio_camera_track_dirty = true;
|
||||
if index < self.studio.freecam.track.keyframes.len() {
|
||||
self.studio.freecam.track.keyframes.remove(index);
|
||||
self.studio.camera_track_dirty = true;
|
||||
}
|
||||
self.studio_timeline.selected_keyframe = None;
|
||||
self.studio.timeline.selected_keyframe = None;
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
if pressed {
|
||||
self.studio_topbar.close_dropdown();
|
||||
self.studio_rmb_down = true;
|
||||
self.studio.topbar.close_dropdown();
|
||||
self.studio.rmb_down = true;
|
||||
self.capture(true);
|
||||
return Ok(());
|
||||
} else if self.studio_rmb_down {
|
||||
self.studio_rmb_down = false;
|
||||
} else if self.studio.rmb_down {
|
||||
self.studio.rmb_down = false;
|
||||
self.capture(false);
|
||||
return Ok(());
|
||||
}
|
||||
@@ -10876,7 +10839,7 @@ impl Game<'_> {
|
||||
if pressed
|
||||
&& button == MouseButton::Left
|
||||
&& self.screen == Screen::Playing
|
||||
&& (self.studio_replay.is_some() || self.studio_freecam.active)
|
||||
&& (self.studio.replay.is_some() || self.studio.freecam.active)
|
||||
{
|
||||
let size = self
|
||||
.renderer
|
||||
@@ -10884,8 +10847,8 @@ impl Game<'_> {
|
||||
.map(|renderer| [renderer.config.width, renderer.config.height])
|
||||
.unwrap_or([1, 1]);
|
||||
let batch = HudBatch::new(size);
|
||||
let had_open_menu = self.studio_topbar.open_menu.is_some();
|
||||
let topbar_action = self.studio_topbar.handle_mouse_click(
|
||||
let had_open_menu = self.studio.topbar.open_menu.is_some();
|
||||
let topbar_action = self.studio.topbar.handle_mouse_click(
|
||||
self.cursor,
|
||||
batch.width,
|
||||
batch.height,
|
||||
@@ -10894,26 +10857,26 @@ impl Game<'_> {
|
||||
self.handle_studio_topbar_action(topbar_action);
|
||||
return Ok(());
|
||||
}
|
||||
if had_open_menu || self.cursor.1 <= self.studio_topbar.height {
|
||||
if had_open_menu || self.cursor.1 <= self.studio.topbar.height {
|
||||
return Ok(());
|
||||
}
|
||||
if self.studio_timeline.visible {
|
||||
let action = self.studio_timeline.handle_mouse_down(
|
||||
if self.studio.timeline.visible {
|
||||
let action = self.studio.timeline.handle_mouse_down(
|
||||
self.cursor,
|
||||
batch.width,
|
||||
batch.height,
|
||||
&self.studio_freecam,
|
||||
&self.studio.freecam,
|
||||
);
|
||||
if !matches!(action, horizon_client::studio::TimelineAction::None) {
|
||||
self.handle_studio_timeline_action(action);
|
||||
return Ok(());
|
||||
}
|
||||
if self.cursor.1 >= batch.height - self.studio_timeline.height() {
|
||||
if self.cursor.1 >= batch.height - self.studio.timeline.height() {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
}
|
||||
if self.screen == Screen::Playing && self.studio_replay.is_some() {
|
||||
if self.screen == Screen::Playing && self.studio.replay.is_some() {
|
||||
return Ok(());
|
||||
}
|
||||
if matches!(self.screen, Screen::Inventory | Screen::Creative)
|
||||
@@ -11383,7 +11346,7 @@ impl Game<'_> {
|
||||
let between = now.saturating_duration_since(self.frame_end);
|
||||
self.frame_ticks = elapsed.as_secs_f32() * 20.0;
|
||||
self.previous = now;
|
||||
if self.studio_freecam.active
|
||||
if self.studio.freecam.active
|
||||
&& self.screen == Screen::Playing
|
||||
&& !self.dialog_open()
|
||||
{
|
||||
@@ -11393,7 +11356,7 @@ impl Game<'_> {
|
||||
let vertical =
|
||||
held(KeyCode::KeyE) - held(KeyCode::KeyQ) + held(KeyCode::Space);
|
||||
let fast = self.keys.raw(&KeyCode::ControlRight);
|
||||
self.studio_freecam.update_flight_motion(
|
||||
self.studio.freecam.update_flight_motion(
|
||||
forward,
|
||||
strafe,
|
||||
vertical,
|
||||
@@ -11473,7 +11436,7 @@ impl Game<'_> {
|
||||
self.extras_frame(elapsed);
|
||||
self.extras_retry()?;
|
||||
self.apply_mouse_look();
|
||||
self.pad_look(elapsed.as_secs_f32());
|
||||
self.input_look(elapsed.as_secs_f32());
|
||||
self.advance_flight();
|
||||
self.tick_world_icon();
|
||||
let ticked = Instant::now();
|
||||
@@ -12183,16 +12146,7 @@ impl<'a> Game<'a> {
|
||||
entity_attachments: Default::default(),
|
||||
remote: None,
|
||||
realm_session: false,
|
||||
studio_replay: None,
|
||||
studio_freecam: Default::default(),
|
||||
studio_timeline: Default::default(),
|
||||
studio_topbar: Default::default(),
|
||||
studio_rmb_down: false,
|
||||
studio_camera_track_dirty: false,
|
||||
studio_replay_previous_camera_type: None,
|
||||
studio_replay_standalone: false,
|
||||
last_replay_tick: None,
|
||||
studio_virtual_tick_ms: 0.0,
|
||||
studio: Default::default(),
|
||||
lan: None,
|
||||
integrated: None,
|
||||
level_shapes: Default::default(),
|
||||
|
||||
@@ -42,12 +42,25 @@ impl Game<'_> {
|
||||
self.trial_spawner_tick_positions.clear();
|
||||
self.item_columns = Some(Default::default());
|
||||
self.pass_mobs = Some(Default::default());
|
||||
for slot in self.world.block_entity_tickers().iter().copied() {
|
||||
let tickers = self.world.block_entity_tickers();
|
||||
let mut last_chunk = (i32::MIN, i32::MIN);
|
||||
let mut last_chunk_ticking = false;
|
||||
for slot in tickers.iter().copied() {
|
||||
let pos = slot.0;
|
||||
let chunk = (pos.0 >> 4, pos.2 >> 4);
|
||||
let chunk_ticking = if chunk == last_chunk {
|
||||
last_chunk_ticking
|
||||
} else {
|
||||
last_chunk = chunk;
|
||||
last_chunk_ticking = ticking(pos.0, pos.2);
|
||||
last_chunk_ticking
|
||||
};
|
||||
if !chunk_ticking {
|
||||
continue;
|
||||
}
|
||||
// Ticks before this one can remove it (`TickingBlockEntity.isRemoved`).
|
||||
if self.world.block_entities.is_asleep(&pos)
|
||||
|| !self.world.block_entities.is_live(slot)
|
||||
|| !ticking(pos.0, pos.2)
|
||||
|| !self.border.contains(f64::from(pos.0), f64::from(pos.2))
|
||||
{
|
||||
continue;
|
||||
@@ -210,8 +223,14 @@ impl Game<'_> {
|
||||
/// (`ServerLevel.areEntitiesLoaded`).
|
||||
pub(super) fn block_ticking_area(&self) -> impl Fn(i32, i32) -> bool + use<> {
|
||||
let levels = self.tickets.levels();
|
||||
let pending = self.world.entities_pending.clone();
|
||||
move |x, z| levels.block_ticking_at(x, z) && !pending.contains(&(x >> 4, z >> 4))
|
||||
let has_pending = !self.world.entities_pending.is_empty();
|
||||
let pending = has_pending.then(|| self.world.entities_pending.clone());
|
||||
move |x, z| {
|
||||
levels.block_ticking_at(x, z)
|
||||
&& pending
|
||||
.as_ref()
|
||||
.is_none_or(|p| !p.contains(&(x >> 4, z >> 4)))
|
||||
}
|
||||
}
|
||||
/// `DaylightDetectorBlock.tickEntity` of the detector at `pos`.
|
||||
fn tick_daylight_detector(&mut self, pos: Pos) {
|
||||
|
||||
@@ -3831,12 +3831,9 @@ impl Game<'_> {
|
||||
return;
|
||||
}
|
||||
g.spawn = None;
|
||||
g.respawn_config = Some(super::respawn_anchor::RespawnConfig {
|
||||
dim: level,
|
||||
pos,
|
||||
yaw,
|
||||
forced: true,
|
||||
});
|
||||
g.respawn_config = Some(super::respawn_anchor::RespawnConfig::forced(
|
||||
level, pos, yaw, pitch,
|
||||
));
|
||||
});
|
||||
}
|
||||
let dim = Text::literal(self.dim.id());
|
||||
|
||||
@@ -644,7 +644,7 @@ impl Game<'_> {
|
||||
.into());
|
||||
}
|
||||
if arg(2)? != "up" {
|
||||
self.pad.keyboard_took_over = true;
|
||||
self.input_keyboard_used();
|
||||
}
|
||||
// The command confirmation takes the keys; none go past it.
|
||||
if self.command_prompt.is_some() {
|
||||
@@ -800,7 +800,7 @@ impl Game<'_> {
|
||||
// controller still holding one keeps it.
|
||||
let options = [self.settings.toggle_crouch, self.settings.toggle_sprint];
|
||||
for (slot, name) in [(0, "key.sneak"), (1, "key.sprint")] {
|
||||
if !self.pad.holds_action(&self.settings.controller, name) {
|
||||
if !self.input_holds(name) {
|
||||
self.toggles.key(
|
||||
slot,
|
||||
horizon_client::keymapping::KeyAction::Release,
|
||||
|
||||
@@ -9,7 +9,7 @@ impl Game<'_> {
|
||||
pub(super) fn mapped_key_down(&self, mapping: &str) -> bool {
|
||||
keybinds::key_code(self.settings.keys.get(mapping))
|
||||
.is_some_and(|k| self.physical_keys.contains(&k))
|
||||
|| self.pad.holds_action(&self.settings.controller, mapping)
|
||||
|| self.input_holds(mapping)
|
||||
}
|
||||
|
||||
/// `CreativeModeInventoryScreen.selectTab` on the HOTBAR tab: each saved
|
||||
|
||||
@@ -2317,7 +2317,7 @@ impl Game<'_> {
|
||||
a.spawn_velocity = Some(p.velocity);
|
||||
a.spawn_rot = Some((p.pitch, p.yaw));
|
||||
}
|
||||
self.creatures.projectiles.push(p);
|
||||
self.creatures.projectiles.put_back(p);
|
||||
self.native_projectile_launch(player, &name, id, kind);
|
||||
}
|
||||
/// `MushroomCow.shear`'s `sendParticles(EXPLOSION, x, getY(0.5), z, 1, 0, 0, 0, 0)`:
|
||||
@@ -6949,7 +6949,7 @@ impl Game<'_> {
|
||||
{
|
||||
m.heal(25.0);
|
||||
consume = true;
|
||||
sound = Some("entity.iron_golem.repair");
|
||||
eat = Some(("entity.iron_golem.repair", 1.0, m.golem_repair_pitch()));
|
||||
}
|
||||
Species::Creeper if creeper_igniter => {
|
||||
m.extra.flag = true;
|
||||
|
||||
@@ -353,7 +353,7 @@ impl Game<'_> {
|
||||
let found =
|
||||
self.find_respawn_and_use(config, op["consume"].as_bool().unwrap_or(true))?;
|
||||
used.push(match found {
|
||||
Some((p, yaw)) => format!(
|
||||
Some((p, yaw, _pitch)) => format!(
|
||||
"respawn {} {} {} {}",
|
||||
num(p.x),
|
||||
num(p.y),
|
||||
@@ -579,6 +579,7 @@ impl Game<'_> {
|
||||
dim: self.dim,
|
||||
pos: [p(0), p(1), p(2)],
|
||||
yaw: 0.0,
|
||||
pitch: 0.0,
|
||||
forced: false,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -739,7 +739,8 @@ impl Game<'_> {
|
||||
// anchor puts the player, facing it; else onto the spawn column.
|
||||
let mut target = target;
|
||||
if let Some(c) = self.respawn_config.filter(|c| c.dim == self.dim) {
|
||||
if let Some((at, yaw)) = self.find_respawn_and_use(c, false)? {
|
||||
// This existing owner transition adapter carries yaw only; its pitch path remains open.
|
||||
if let Some((at, yaw, _pitch)) = self.find_respawn_and_use(c, false)? {
|
||||
return Ok(Some((at, Some(yaw))));
|
||||
}
|
||||
target = fallback_spawn(self.spawn, self.level.world_spawn);
|
||||
|
||||
@@ -1008,7 +1008,10 @@ impl Game<'_> {
|
||||
p.position.z - center[2],
|
||||
];
|
||||
let distance = (d[0] * d[0] + d[1] * d[1] + d[2] * d[2]).sqrt();
|
||||
self.pad_rumble(horizon_client::gamepad::Rumble::Explosion { distance, radius });
|
||||
self.input_feedback(horizon_client::input_source::Feedback::Explosion {
|
||||
distance,
|
||||
radius,
|
||||
});
|
||||
}
|
||||
// Explosion.explode: the blast is heard.
|
||||
let ctx = horizon_client::sculk::Context::default();
|
||||
|
||||
@@ -70,9 +70,10 @@ fn overworld_spawnpoint_is_validated_at_both_player_blocks() {
|
||||
assert_eq!(actual.is_some(), allowed, "{block} at height {height}");
|
||||
assert_eq!(game.respawn_config.is_some(), allowed);
|
||||
assert_eq!(game.level.world_spawn, Some([0, 80, 0]));
|
||||
if let Some((at, yaw)) = actual {
|
||||
if let Some((at, yaw, pitch)) = actual {
|
||||
assert_eq!(at, Vec3::new(5.5, 64.1, 5.5));
|
||||
assert_eq!(yaw, 0.0);
|
||||
assert_eq!(pitch.to_bits(), 0.0f32.to_bits());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -135,8 +135,8 @@ impl Game<'_> {
|
||||
}
|
||||
/// `Camera.calculateFov` with `modifyFovBasedOnDeathOrFluid`.
|
||||
pub(super) fn fov_degrees(&self, partial: f32, camera: [f64; 3]) -> f32 {
|
||||
if self.studio_freecam.active {
|
||||
return self.studio_freecam.fov.clamp(1.0, 179.0);
|
||||
if self.studio.freecam.active {
|
||||
return self.studio.freecam.fov.clamp(1.0, 179.0);
|
||||
}
|
||||
let fov = self.view.calculate_fov(
|
||||
partial,
|
||||
|
||||
@@ -193,7 +193,7 @@ impl Game<'_> {
|
||||
// replay player's hands remain visible in first person; freecam has
|
||||
// no player-view hand.
|
||||
if !fov::studio_draws_hand(
|
||||
self.studio_freecam.active,
|
||||
self.studio.freecam.active,
|
||||
self.ui.hands_drawn(
|
||||
self.spectator(),
|
||||
self.sleeping.is_some(),
|
||||
|
||||
@@ -1820,7 +1820,7 @@ impl Game<'_> {
|
||||
// from carries those, as `save_guest` gives them on leaving. The map
|
||||
// stays their one owner.
|
||||
let live = self.guest_attachments.get(&uuid).cloned();
|
||||
let g = &mut host.hub.guests[i];
|
||||
let g = playerdata::with_warden(&mut host.hub.guests[i], self.warden_tracker_tag());
|
||||
let held = live.map(|a| std::mem::replace(&mut g.attachments, a));
|
||||
playerdata::store(g, &mut self.level, vehicle);
|
||||
if let Some(held) = held {
|
||||
|
||||
@@ -4190,8 +4190,8 @@ impl Game<'_> {
|
||||
.map_or([0.5, 80.0, 0.5], |[x, y, z]| {
|
||||
[f64::from(x) + 0.5, f64::from(y), f64::from(z) + 0.5]
|
||||
});
|
||||
let at = found.map_or(world, |(at, yaw)| {
|
||||
host.hub.guests[i].yaw = yaw;
|
||||
let at = found.map_or(world, |(at, yaw, pitch)| {
|
||||
(host.hub.guests[i].yaw, host.hub.guests[i].pitch) = (yaw, pitch);
|
||||
[at.x, at.y, at.z]
|
||||
});
|
||||
if found.is_none() {
|
||||
|
||||
@@ -118,35 +118,79 @@ impl View {
|
||||
}
|
||||
None
|
||||
}
|
||||
/// `WorldlyContainer.getSlotsForFace` or every slot.
|
||||
fn slots_for_face(&self, face: Option<Direction>) -> Vec<usize> {
|
||||
/// `WorldlyContainer.getSlotsForFace` or every slot, zero-allocation iterator.
|
||||
pub(super) fn slots_for_face_iter(&self, face: Option<Direction>) -> Slots<'_> {
|
||||
match (self.kind, face) {
|
||||
(Kind::Furnace, Some(Direction::Down)) => vec![2, 1],
|
||||
(Kind::Furnace, Some(Direction::Up)) => vec![0],
|
||||
(Kind::Furnace, Some(_)) => vec![1],
|
||||
(Kind::Brewing, Some(Direction::Up)) => vec![3],
|
||||
(Kind::Brewing, Some(Direction::Down)) => vec![0, 1, 2, 3],
|
||||
(Kind::Brewing, Some(_)) => vec![0, 1, 2, 4],
|
||||
(Kind::Composter(8), Some(Direction::Down)) => vec![0],
|
||||
(Kind::Composter(level), Some(Direction::Up)) if level < 7 => vec![0],
|
||||
(Kind::Composter(_), _) => vec![],
|
||||
(Kind::Furnace, Some(Direction::Down)) => Slots::Slice(&[2, 1]),
|
||||
(Kind::Furnace, Some(Direction::Up)) => Slots::Slice(&[0]),
|
||||
(Kind::Furnace, Some(_)) => Slots::Slice(&[1]),
|
||||
(Kind::Brewing, Some(Direction::Up)) => Slots::Slice(&[3]),
|
||||
(Kind::Brewing, Some(Direction::Down)) => Slots::Slice(&[0, 1, 2, 3]),
|
||||
(Kind::Brewing, Some(_)) => Slots::Slice(&[0, 1, 2, 4]),
|
||||
(Kind::Composter(8), Some(Direction::Down)) => Slots::Slice(&[0]),
|
||||
(Kind::Composter(level), Some(Direction::Up)) if level < 7 => Slots::Slice(&[0]),
|
||||
(Kind::Composter(_), _) => Slots::Slice(&[]),
|
||||
(Kind::Mod, Some(face)) => self
|
||||
.container
|
||||
.as_ref()
|
||||
.map_or_else(Vec::new, |c| c.faces[face as usize].clone()),
|
||||
_ => (0..self.len()).collect(),
|
||||
.map_or(Slots::Slice(&[]), |c| Slots::Mod(&c.faces[face as usize])),
|
||||
_ => Slots::Range(0..self.len()),
|
||||
}
|
||||
}
|
||||
/// The slots `HopperBlockEntity.addItem` tries through `face`, zero-allocation iterator.
|
||||
pub(super) fn slots_to_add_iter(&self, face: Option<Direction>) -> Slots<'_> {
|
||||
if matches!(self.kind, Kind::Furnace | Kind::Brewing | Kind::Mod) && face.is_some() {
|
||||
self.slots_for_face_iter(face)
|
||||
} else {
|
||||
Slots::Range(0..self.len())
|
||||
}
|
||||
}
|
||||
/// `WorldlyContainer.getSlotsForFace` or every slot.
|
||||
fn slots_for_face(&self, face: Option<Direction>) -> Vec<usize> {
|
||||
self.slots_for_face_iter(face).collect()
|
||||
}
|
||||
/// The slots `HopperBlockEntity.addItem` tries through `face`.
|
||||
fn slots_to_add(&self, face: Option<Direction>) -> Vec<usize> {
|
||||
if matches!(self.kind, Kind::Furnace | Kind::Brewing | Kind::Mod) && face.is_some() {
|
||||
self.slots_for_face(face)
|
||||
} else {
|
||||
(0..self.len()).collect()
|
||||
}
|
||||
self.slots_to_add_iter(face).collect()
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) enum Slots<'a> {
|
||||
Range(std::ops::Range<usize>),
|
||||
Slice(&'static [usize]),
|
||||
Mod(&'a [usize]),
|
||||
}
|
||||
|
||||
impl Iterator for Slots<'_> {
|
||||
type Item = usize;
|
||||
#[inline]
|
||||
fn next(&mut self) -> Option<usize> {
|
||||
match self {
|
||||
Slots::Range(r) => r.next(),
|
||||
Slots::Slice(s) => {
|
||||
let (&first, rest) = s.split_first()?;
|
||||
*s = rest;
|
||||
Some(first)
|
||||
}
|
||||
Slots::Mod(s) => {
|
||||
let (&first, rest) = s.split_first()?;
|
||||
*s = rest;
|
||||
Some(first)
|
||||
}
|
||||
}
|
||||
}
|
||||
#[inline]
|
||||
fn size_hint(&self) -> (usize, Option<usize>) {
|
||||
let len = match self {
|
||||
Slots::Range(r) => r.len(),
|
||||
Slots::Slice(s) => s.len(),
|
||||
Slots::Mod(s) => s.len(),
|
||||
};
|
||||
(len, Some(len))
|
||||
}
|
||||
}
|
||||
impl ExactSizeIterator for Slots<'_> {}
|
||||
|
||||
pub(super) fn dir_from_name(name: &str) -> Option<Direction> {
|
||||
Some(match name {
|
||||
"down" => Direction::Down,
|
||||
@@ -180,8 +224,6 @@ struct Containers<'a> {
|
||||
registry: &'static horizon_world::registry::BlockRegistry,
|
||||
/// Brewing recipes, for what a brewing stand accepts (not needed for signals).
|
||||
brewing: Option<&'a horizon_client::brewing::Brewing>,
|
||||
/// The stack of a full composter's `OutputContainer`.
|
||||
bone_meal: ItemStack,
|
||||
}
|
||||
impl Containers<'_> {
|
||||
fn prop(&self, state: BlockStateId, key: &str) -> Option<&str> {
|
||||
@@ -346,7 +388,7 @@ impl Containers<'_> {
|
||||
fn get(&self, view: &View, slot: usize) -> Option<&ItemStack> {
|
||||
if let Kind::Composter(level) = view.kind {
|
||||
// OutputContainer holds one bone meal; InputContainer composts at once.
|
||||
return (level == 8 && slot == 0).then_some(&self.bone_meal);
|
||||
return (level == 8 && slot == 0).then_some(bone_meal_stack());
|
||||
}
|
||||
let (pos, i) = view.locate(slot)?;
|
||||
self.world
|
||||
@@ -452,7 +494,7 @@ impl Containers<'_> {
|
||||
}
|
||||
/// `HopperBlockEntity.isFullContainer`.
|
||||
fn is_full(&self, view: &View, face: Direction) -> bool {
|
||||
view.slots_for_face(Some(face)).into_iter().all(|i| {
|
||||
view.slots_for_face_iter(Some(face)).all(|i| {
|
||||
self.insertion_item(view, i)
|
||||
.is_some_and(|s| s.count >= self.max_stack(&s))
|
||||
})
|
||||
@@ -498,6 +540,9 @@ impl ItemColumns {
|
||||
x: i32,
|
||||
z: i32,
|
||||
) -> bool {
|
||||
if items.is_empty() {
|
||||
return false;
|
||||
}
|
||||
let (len, last) = (items.len(), items.last().map(|i| i.id));
|
||||
let stale = self
|
||||
.seen
|
||||
@@ -556,13 +601,14 @@ impl StampedItemColumns {
|
||||
}
|
||||
}
|
||||
|
||||
fn bone_meal() -> ItemStack {
|
||||
ItemStack {
|
||||
fn bone_meal_stack() -> &'static ItemStack {
|
||||
static STACK: std::sync::LazyLock<ItemStack> = std::sync::LazyLock::new(|| ItemStack {
|
||||
item: "minecraft:bone_meal".into(),
|
||||
count: 1,
|
||||
damage: 0,
|
||||
extra: None,
|
||||
}
|
||||
});
|
||||
&STACK
|
||||
}
|
||||
|
||||
/// Comparator input from a container block entity (0 when there is none).
|
||||
@@ -579,7 +625,6 @@ pub(super) fn container_signal(
|
||||
items,
|
||||
registry,
|
||||
brewing: None,
|
||||
bone_meal: bone_meal(),
|
||||
};
|
||||
c.view((pos.x, pos.y, pos.z), true)
|
||||
.filter(|v| !matches!(v.kind, Kind::Composter(_)))
|
||||
@@ -635,7 +680,6 @@ impl Game<'_> {
|
||||
items: self.items(),
|
||||
registry: self.pack.assets.registry,
|
||||
brewing: Some(&self.stations().brewing),
|
||||
bone_meal: bone_meal(),
|
||||
}
|
||||
}
|
||||
pub(super) fn block_can_place(&self, pos: [i32; 3], slot: usize, stack: &ItemStack) -> bool {
|
||||
@@ -847,7 +891,7 @@ impl Game<'_> {
|
||||
return self.composter_container_insert([x, y, z], stack, face == Some(Direction::Up));
|
||||
}
|
||||
let mut rest = Some(stack);
|
||||
for slot in view.slots_to_add(face) {
|
||||
for slot in view.slots_to_add_iter(face) {
|
||||
let Some(stack) = rest.take() else { break };
|
||||
rest = self.try_move_in_item(from_hopper, view, stack, slot, face);
|
||||
}
|
||||
@@ -880,32 +924,31 @@ impl Game<'_> {
|
||||
/// is still a hopper (`BlockEntityType.isValid`).
|
||||
pub(super) fn tick_hopper(&mut self, pos: Pos) {
|
||||
let time = self.world.game_time;
|
||||
let waiting = {
|
||||
let state = self.hoppers.entry(pos).or_default();
|
||||
state.cooldown = state.cooldown.wrapping_sub(1);
|
||||
state.ticked = time;
|
||||
state.cooldown > 0
|
||||
};
|
||||
if waiting {
|
||||
// Every hopper keeps its cooldown and the tick it last ticked
|
||||
// (`HopperBlockEntity.tickedGameTime`), idle ones too: a hopper that
|
||||
// receives an item reads it for its cooldown (8, or 7 once it ticked).
|
||||
let state = self.hoppers.entry(pos).or_default();
|
||||
state.cooldown = state.cooldown.wrapping_sub(1);
|
||||
state.ticked = time;
|
||||
if state.cooldown > 0 {
|
||||
return;
|
||||
}
|
||||
state.cooldown = 0;
|
||||
// Most ticks only advance the cooldown: the block is checked when the
|
||||
// hopper is about to move, not on every tick it waits.
|
||||
let hopper = self
|
||||
.res
|
||||
.facts()
|
||||
.get(self.world.block(pos.0, pos.1, pos.2))
|
||||
.name
|
||||
.as_ref()
|
||||
== "minecraft:hopper";
|
||||
if !hopper {
|
||||
let state = self.world.block(pos.0, pos.1, pos.2);
|
||||
let Some(info) = self.pack.assets.registry.state(state) else {
|
||||
self.hoppers.remove(&pos);
|
||||
return;
|
||||
};
|
||||
if info.name != "minecraft:hopper" {
|
||||
self.hoppers.remove(&pos);
|
||||
return;
|
||||
}
|
||||
if let Some(state) = self.hoppers.get_mut(&pos) {
|
||||
state.cooldown = 0;
|
||||
if info.properties.get("enabled").is_some_and(|v| v == "false") {
|
||||
return;
|
||||
}
|
||||
self.hopper_try_move_items(pos);
|
||||
self.hopper_try_move_items_with_state(pos, state);
|
||||
}
|
||||
/// Drops the cooldowns of hoppers whose block entity is gone.
|
||||
pub(super) fn forget_gone_hoppers(&mut self) {
|
||||
@@ -917,23 +960,69 @@ impl Game<'_> {
|
||||
/// `HopperBlockEntity.tryMoveItems` with `suckInItems`.
|
||||
pub(super) fn hopper_try_move_items(&mut self, pos: Pos) {
|
||||
let state = self.world.block(pos.0, pos.1, pos.2);
|
||||
let c = self.containers();
|
||||
let enabled = c.prop(state, "enabled") != Some("false");
|
||||
let Some(view) = c.view(pos, false) else {
|
||||
self.hopper_try_move_items_with_state(pos, state);
|
||||
}
|
||||
fn hopper_try_move_items_with_state(&mut self, pos: Pos, state: BlockStateId) {
|
||||
let Some(info) = self.pack.assets.registry.state(state) else {
|
||||
return;
|
||||
};
|
||||
if self.hoppers.get(&pos).is_some_and(|h| h.cooldown > 0) || !enabled {
|
||||
if info.properties.get("enabled").is_some_and(|v| v == "false") {
|
||||
return;
|
||||
}
|
||||
self.native_prepare_hopper_transfer([pos.0, pos.1, pos.2]);
|
||||
let mut changed = false;
|
||||
if !c.is_empty(&view) {
|
||||
changed = self.hopper_eject(pos, &view);
|
||||
if self.hoppers.get(&pos).is_some_and(|h| h.cooldown > 0) {
|
||||
return;
|
||||
}
|
||||
let c = self.containers();
|
||||
let full =
|
||||
(0..view.len()).all(|i| c.get(&view, i).is_some_and(|s| s.count == c.max_stack(s)));
|
||||
if !full {
|
||||
let Some(BlockEntity::Chest(items)) = self.world.block_entities.get(&pos) else {
|
||||
return;
|
||||
};
|
||||
if items.len() != HOPPER_SLOTS {
|
||||
return;
|
||||
}
|
||||
let is_empty = items
|
||||
.iter()
|
||||
.all(|s| s.as_ref().is_none_or(|s| s.count == 0));
|
||||
let has_empty_slot = items
|
||||
.iter()
|
||||
.any(|s| s.as_ref().is_none_or(|s| s.count == 0));
|
||||
|
||||
let above = (pos.0, pos.1 + 1, pos.2);
|
||||
// Fast path for empty hopper with no source container and no item entities:
|
||||
// if empty, cannot eject; if no container above, cannot suck container;
|
||||
// if no dropped items nearby, cannot suck entities -> nothing can happen!
|
||||
if is_empty
|
||||
&& self.world.block_entities.get(&above).is_none()
|
||||
&& self.carts.is_empty()
|
||||
&& self.boats.is_empty()
|
||||
&& (self.dropped.is_empty()
|
||||
|| self
|
||||
.item_columns
|
||||
.as_mut()
|
||||
.is_some_and(|c| !c.may_reach(&self.dropped, pos.0, pos.2)))
|
||||
&& self
|
||||
.pack
|
||||
.assets
|
||||
.registry
|
||||
.state(self.world.block(above.0, above.1, above.2))
|
||||
.is_none_or(|s| s.name != "minecraft:composter")
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
self.native_prepare_hopper_transfer([pos.0, pos.1, pos.2]);
|
||||
let view = View {
|
||||
parts: vec![(pos, HOPPER_SLOTS)],
|
||||
kind: Kind::Hopper,
|
||||
container: None,
|
||||
};
|
||||
let mut changed = false;
|
||||
if !is_empty {
|
||||
changed = self.hopper_eject_with_state(pos, state, &view);
|
||||
}
|
||||
let is_full = !changed && !has_empty_slot && {
|
||||
let c = self.containers();
|
||||
(0..HOPPER_SLOTS).all(|i| c.get(&view, i).is_some_and(|s| s.count >= c.max_stack(s)))
|
||||
};
|
||||
if !is_full {
|
||||
changed |= self.hopper_suck(pos, &view);
|
||||
}
|
||||
if changed {
|
||||
@@ -995,19 +1084,20 @@ impl Game<'_> {
|
||||
/// `tryMoveItems(…, () -> addItem(hopper, itemEntity))`; true when the
|
||||
/// item entity was taken whole.
|
||||
fn hopper_take_item(&mut self, pos: Pos, i: usize) -> bool {
|
||||
if self.hoppers.get(&pos).is_some_and(|h| h.cooldown > 0) {
|
||||
return false;
|
||||
}
|
||||
let state = self.world.block(pos.0, pos.1, pos.2);
|
||||
let c = self.containers();
|
||||
if c.prop(state, "enabled") == Some("false") {
|
||||
return false;
|
||||
}
|
||||
let Some(view) = c.view(pos, false) else {
|
||||
return false;
|
||||
};
|
||||
if c.prop(state, "enabled") == Some("false")
|
||||
|| self.hoppers.get(&pos).is_some_and(|h| h.cooldown > 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
let mut changed = false;
|
||||
if !c.is_empty(&view) {
|
||||
changed = self.hopper_eject(pos, &view);
|
||||
changed = self.hopper_eject_with_state(pos, state, &view);
|
||||
}
|
||||
let c = self.containers();
|
||||
let full =
|
||||
@@ -1043,8 +1133,7 @@ impl Game<'_> {
|
||||
}
|
||||
/// `HopperBlockEntity.ejectItems`: into the block container in front, else
|
||||
/// a container entity there (`getContainerAt`).
|
||||
fn hopper_eject(&mut self, pos: Pos, hopper: &View) -> bool {
|
||||
let state = self.world.block(pos.0, pos.1, pos.2);
|
||||
fn hopper_eject_with_state(&mut self, pos: Pos, state: BlockStateId, hopper: &View) -> bool {
|
||||
let c = self.containers();
|
||||
let Some(facing) = c.prop(state, "facing").and_then(dir_from_name) else {
|
||||
return false;
|
||||
@@ -1156,6 +1245,9 @@ impl Game<'_> {
|
||||
if blocked {
|
||||
return false;
|
||||
}
|
||||
if self.dropped.is_empty() {
|
||||
return false;
|
||||
}
|
||||
// Hopper.SUCK_AABB: the hopper bowl and the block above.
|
||||
let (min, max) = (
|
||||
[
|
||||
@@ -1214,7 +1306,7 @@ impl Game<'_> {
|
||||
// second setChanged after the move or, when the hopper has no
|
||||
// room, the setItem putting the bone meal back into the detached
|
||||
// container (whose bone meal is lost with it).
|
||||
let bone_meal = c.bone_meal.clone();
|
||||
let bone_meal = bone_meal_stack().clone();
|
||||
if !horizon_client::mod_api::mods().is_empty() {
|
||||
let mut cancel = false;
|
||||
self.native_inventory_move_item(
|
||||
@@ -1244,13 +1336,15 @@ impl Game<'_> {
|
||||
source: &View,
|
||||
hopper: &View,
|
||||
) -> bool {
|
||||
for slot in source.slots_for_face(Some(Direction::Down)) {
|
||||
let c = self.containers();
|
||||
let Some(stack) = c.get(source, slot) else {
|
||||
continue;
|
||||
};
|
||||
if !c.can_take(source, slot, stack, Direction::Down) {
|
||||
continue;
|
||||
for slot in source.slots_for_face_iter(Some(Direction::Down)) {
|
||||
{
|
||||
let c = self.containers();
|
||||
let Some(stack) = c.get(source, slot) else {
|
||||
continue;
|
||||
};
|
||||
if !c.can_take(source, slot, stack, Direction::Down) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if self.hopper_move_canceled(source, slot, source, hopper) {
|
||||
continue;
|
||||
@@ -1269,7 +1363,9 @@ impl Game<'_> {
|
||||
}
|
||||
return true;
|
||||
}
|
||||
Some(rest) => self.put_back(source, slot, rest),
|
||||
Some(rest) => {
|
||||
self.put_back(source, slot, rest);
|
||||
}
|
||||
}
|
||||
}
|
||||
false
|
||||
@@ -1279,6 +1375,9 @@ impl Game<'_> {
|
||||
/// minecarts, chest boats) whose box meets the 1x1x1 box around `at`,
|
||||
/// picked at random.
|
||||
fn entity_container_at(&mut self, at: [f64; 3]) -> Option<u32> {
|
||||
if self.carts.is_empty() && self.boats.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let meets = |b: horizon_core::geometry::Aabb| {
|
||||
let (lo, hi) = (b.min_position(), b.max_position());
|
||||
lo.x < at[0] + 0.5
|
||||
|
||||
@@ -899,4 +899,32 @@ impl Game<'_> {
|
||||
f.input_clicked(world_recipes!(self), before);
|
||||
}
|
||||
}
|
||||
|
||||
/// `AbstractContainerScreen` keeps `isQuickCrafting` and the drag it
|
||||
/// belongs to on the screen. Horizon keeps both on `Game` (`inv_clicks`,
|
||||
/// `inv_drag`, `last_inv_click`), so they are dropped when the pointer
|
||||
/// changes hands: a screen that opens or closes starts with its own
|
||||
/// state, and a press whose release never reached the screen -- the
|
||||
/// pointer left the window, or the screen closed first -- would
|
||||
/// otherwise leave every later press on every later screen swallowed.
|
||||
pub(super) fn reset_inventory_clicks(&mut self) {
|
||||
self.inv_clicks.cancel_drag();
|
||||
self.inv_drag = None;
|
||||
self.last_inv_click = None;
|
||||
}
|
||||
|
||||
/// A sound a menu click made the player play (a bundle's insert or
|
||||
/// remove): `Entity.playSound` at the player.
|
||||
pub(super) fn player_menu_sound(&mut self, sound: &str) {
|
||||
let Some(p) = self.player.as_ref() else {
|
||||
return;
|
||||
};
|
||||
let at = [p.position.x, p.position.y, p.position.z];
|
||||
let (volume, pitch) = if sound == horizon_client::bundle::INSERT_FAIL {
|
||||
(1.0, 1.0)
|
||||
} else {
|
||||
(0.8, 0.8 + self.world.random.next_float() * 0.4)
|
||||
};
|
||||
self.audio.play(sound, Some(at), volume, pitch);
|
||||
}
|
||||
}
|
||||
@@ -28,6 +28,10 @@ use horizon_core::{
|
||||
};
|
||||
use horizon_world::registry::BlockStateId;
|
||||
use std::{collections::HashMap, sync::Arc};
|
||||
mod index;
|
||||
mod nbt;
|
||||
pub(super) use index::CartIndex;
|
||||
use index::cart_at;
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
|
||||
pub(super) enum CartKind {
|
||||
@@ -187,102 +191,6 @@ pub(super) const TNT_PRIME_EVENT: i8 = 70;
|
||||
mod display;
|
||||
use display::{cart_render_bounds, display_offset, shown_unless_air};
|
||||
pub(super) use display::{default_display_offset, forget_unreadable_display};
|
||||
/// The carts' and mobs' boxes for one tick's cart pushes (`push_from_cart`,
|
||||
/// `pickup_from_cart`), so a cart reads its neighbours instead of every cart and
|
||||
/// every mob of the level. Both answer in slot order, as the scans did.
|
||||
///
|
||||
/// The cart grid follows each ticked cart ([`CartIndex::moved`]); it is
|
||||
/// rebuilt once the list's length changed (a cart gone or added), and dropped
|
||||
/// after a command, which can move or replace any cart
|
||||
/// ([`CartIndex::forget_carts`]). The mob grid holds until the mob
|
||||
/// list's next write ([`ListStamp`]). A native mod's hook can move either kind
|
||||
/// unseen, so with mods loaded both scans stay.
|
||||
#[derive(Default)]
|
||||
pub(super) struct CartIndex {
|
||||
carts: Option<(SeenGrid, Vec<u32>)>,
|
||||
/// The grid, and the carts a mob rides.
|
||||
mobs: Option<(ListStamp, SeenGrid, FxHashSet<u32>)>,
|
||||
near: Vec<u32>,
|
||||
}
|
||||
impl CartIndex {
|
||||
fn usable() -> bool {
|
||||
horizon_client::mod_api::mods().is_empty()
|
||||
}
|
||||
/// The cart in slot `i` has these bounds now.
|
||||
fn moved(&mut self, i: usize, bounds: Aabb) {
|
||||
if let Some((grid, ids)) = &mut self.carts
|
||||
&& i < ids.len()
|
||||
{
|
||||
grid.update(i, bounds);
|
||||
}
|
||||
}
|
||||
fn forget_carts(&mut self) {
|
||||
self.carts = None;
|
||||
}
|
||||
/// The slots of the carts whose box may meet `area`, for the cart in slot
|
||||
/// `i` (just moved); false: scan them all.
|
||||
fn carts_near(&mut self, carts: &[Minecart], i: usize, area: Aabb, out: &mut Vec<u32>) -> bool {
|
||||
if !Self::usable() {
|
||||
return false;
|
||||
}
|
||||
let fresh = |ids: &[u32]| ids.len() == carts.len() && ids[i] == carts[i].id;
|
||||
if !self.carts.as_ref().is_some_and(|(_, ids)| fresh(ids)) {
|
||||
self.carts = Some((
|
||||
SeenGrid::new(carts.iter().map(Minecart::bounds)),
|
||||
carts.iter().map(|c| c.id).collect(),
|
||||
));
|
||||
}
|
||||
let Some((grid, ids)) = &mut self.carts else {
|
||||
return false;
|
||||
};
|
||||
grid.update(i, carts[i].bounds());
|
||||
if !grid.candidates(area, out) {
|
||||
return false;
|
||||
}
|
||||
if out.iter().any(|&j| ids[j as usize] != carts[j as usize].id) {
|
||||
// A cart was replaced in place: start over next time.
|
||||
self.carts = None;
|
||||
return false;
|
||||
}
|
||||
true
|
||||
}
|
||||
/// The slots of the mobs whose box may meet `area` into `out`, and
|
||||
/// whether a mob rides cart `cart`; None: scan them all.
|
||||
fn mobs_near(
|
||||
&mut self,
|
||||
mobs: &JoinList<Mob>,
|
||||
cart: u32,
|
||||
area: Aabb,
|
||||
out: &mut Vec<u32>,
|
||||
) -> Option<bool> {
|
||||
if !Self::usable() {
|
||||
return None;
|
||||
}
|
||||
if !self
|
||||
.mobs
|
||||
.as_ref()
|
||||
.is_some_and(|(stamp, ..)| *stamp == mobs.stamp())
|
||||
{
|
||||
self.mobs = Some((
|
||||
mobs.stamp(),
|
||||
SeenGrid::new(mobs.iter().map(Mob::bounds)),
|
||||
mobs.iter().filter_map(|m| m.extra.mount).collect(),
|
||||
));
|
||||
}
|
||||
let (_, grid, mounted) = self.mobs.as_ref()?;
|
||||
grid.candidates(area, out).then(|| mounted.contains(&cart))
|
||||
}
|
||||
}
|
||||
/// Where the cart `id` is in `carts`: at `hint` (where it was a moment ago,
|
||||
/// the usual case while carts tick in order) or found by a scan. A world
|
||||
/// with hundreds of mineshaft chest carts asked this of every cart several
|
||||
/// times a tick.
|
||||
fn cart_at(carts: &[Minecart], id: u32, hint: usize) -> Option<usize> {
|
||||
if carts.get(hint).is_some_and(|c| c.id == id) {
|
||||
return Some(hint);
|
||||
}
|
||||
carts.iter().position(|c| c.id == id)
|
||||
}
|
||||
/// `AbstractMinecart.push(entity)` for a non-cart `entity` at `feet`:
|
||||
/// the push the cart loses (the entity gets a quarter of it back).
|
||||
pub(super) fn cart_push_from(feet: Vec3, cart: Vec3) -> Option<(f64, f64)> {
|
||||
@@ -2984,177 +2892,6 @@ impl Game<'_> {
|
||||
.render_state(&scene, state.0, m, light, out);
|
||||
}
|
||||
}
|
||||
/// Saving and loading.
|
||||
impl Minecart {
|
||||
pub fn to_nbt(
|
||||
&self,
|
||||
uuid: [i32; 4],
|
||||
raw: Option<&horizon_world::nbt::Tag>,
|
||||
) -> horizon_world::nbt::Tag {
|
||||
use horizon_world::nbt::{JavaString, Tag};
|
||||
let mut tag = horizon_client::entity_nbt::vehicle_to_nbt(
|
||||
self.kind.id(),
|
||||
self.position,
|
||||
self.velocity,
|
||||
[self.yaw, self.pitch],
|
||||
(self.on_ground, self.fall_distance),
|
||||
uuid,
|
||||
raw,
|
||||
);
|
||||
horizon_client::entity_nbt::write_saved_flags(&mut tag, self.on_ground, self.no_gravity);
|
||||
if let Tag::Compound(map) = &mut tag {
|
||||
// ContainerEntity.addChestVehicleSaveData belongs only to the
|
||||
// container subclasses, never to a plain or specialized cart.
|
||||
if self.kind.slots() == 0 {
|
||||
map.remove(&JavaString::from("Items"));
|
||||
}
|
||||
if self.kind == CartKind::CommandBlock {
|
||||
for key in ["LastOutput", "LastExecution"] {
|
||||
map.remove(&JavaString::from(key));
|
||||
}
|
||||
}
|
||||
map.remove(&JavaString::from("DisplayOffset"));
|
||||
map.remove(&JavaString::from("DisplayState"));
|
||||
let mut put = |k: &str, v: Tag| {
|
||||
map.insert(JavaString::from(k), v);
|
||||
};
|
||||
put("FlippedRotation", Tag::Byte(i8::from(self.flipped)));
|
||||
// AbstractMinecart saves firstTick as HasTicked. A cart never burns:
|
||||
// Entity.move sets -getFireImmuneTicks(), 0 for all but players.
|
||||
put("HasTicked", Tag::Byte(i8::from(self.first_tick)));
|
||||
put("Fire", Tag::Short(0));
|
||||
// addAdditionalSaveData: the offset only off its default, the state only when set.
|
||||
if let Some(offset) = self.custom_offset
|
||||
&& f64::from(offset) != default_display_offset(self.kind)
|
||||
{
|
||||
put("DisplayOffset", Tag::Int(offset));
|
||||
}
|
||||
if let Some(state) = &self.custom_display {
|
||||
put("DisplayState", state.clone());
|
||||
}
|
||||
match self.kind {
|
||||
CartKind::Chest | CartKind::Hopper => {
|
||||
put("Items", horizon_client::save::encode_items(&self.items));
|
||||
if self.kind == CartKind::Hopper {
|
||||
put("Enabled", Tag::Byte(i8::from(self.enabled)));
|
||||
}
|
||||
}
|
||||
CartKind::Furnace => {
|
||||
put("PushX", Tag::Double(self.push.x.to_bits()));
|
||||
put("PushZ", Tag::Double(self.push.z.to_bits()));
|
||||
put("Fuel", Tag::Short(self.fuel as i16));
|
||||
}
|
||||
CartKind::Tnt => put("fuse", Tag::Int(self.fuse)),
|
||||
// BaseCommandBlock.save (its CustomName shares the entity's key).
|
||||
CartKind::CommandBlock => {
|
||||
for (key, value) in self.command.save() {
|
||||
if !matches!(key, "CustomName" | "powered" | "conditionMet" | "auto") {
|
||||
put(key, value);
|
||||
}
|
||||
}
|
||||
}
|
||||
// BaseSpawner.save: the delays, ranges, SpawnData and SpawnPotentials.
|
||||
CartKind::Spawner => {
|
||||
if let Some(spawner) = &self.spawner {
|
||||
for (key, value) in
|
||||
horizon_client::mobs::spawner::SpawnerExt::save(&**spawner)
|
||||
{
|
||||
put(key, value);
|
||||
}
|
||||
}
|
||||
}
|
||||
CartKind::Rideable => {}
|
||||
}
|
||||
// A loaded loot table goes back while the items are still its roll;
|
||||
// otherwise the items replace it, as vanilla's unpackLootTable does.
|
||||
if self.loot.as_ref().is_some_and(|l| l.restore(map)) {
|
||||
if let Some(table) = horizon_client::save::text(map.get(&"LootTable".into()))
|
||||
.and_then(|id| horizon_client_core::identifier::normalize_id(&id))
|
||||
{
|
||||
map.insert("LootTable".into(), Tag::String(table.as_str().into()));
|
||||
}
|
||||
} else {
|
||||
map.remove(&JavaString::from("LootTable"));
|
||||
map.remove(&JavaString::from("LootTableSeed"));
|
||||
}
|
||||
}
|
||||
tag
|
||||
}
|
||||
pub fn from_nbt(tag: &horizon_world::nbt::Tag, id: u32, kind: CartKind) -> Option<Self> {
|
||||
let (position, _, _) = horizon_client::entity_nbt::boat_from_nbt(tag)?;
|
||||
let mut c = Self::new(id, kind, position);
|
||||
c.load(tag)?;
|
||||
Some(c)
|
||||
}
|
||||
/// `Entity.load` on this cart: the saved fields are read over the live
|
||||
/// ones, the rest (riders, lerp, hurt state) stays; its kind never changes.
|
||||
/// `None`, with nothing changed, when the compound has no position.
|
||||
pub fn load(&mut self, tag: &horizon_world::nbt::Tag) -> Option<()> {
|
||||
use horizon_world::nbt::Tag;
|
||||
let (position, velocity, yaw) = horizon_client::entity_nbt::boat_from_nbt(tag)?;
|
||||
let kind = self.kind;
|
||||
let c = self;
|
||||
c.position = position;
|
||||
c.previous = position;
|
||||
c.velocity = velocity;
|
||||
c.yaw = yaw;
|
||||
c.previous_yaw = yaw;
|
||||
c.no_gravity = horizon_client::entity_nbt::flag(tag, "NoGravity");
|
||||
c.on_ground = horizon_client::entity_nbt::flag(tag, "OnGround");
|
||||
c.pitch = horizon_client::entity_nbt::rotation(tag)[1];
|
||||
c.previous_pitch = c.pitch;
|
||||
// ValueInput's numeric getters take any NumericTag: getIntOr is its
|
||||
// intValue, getShortOr the low 16 bits of it, getBooleanOr
|
||||
// `byteValue() != 0` (the low 8), getDoubleOr its doubleValue.
|
||||
let int = |k: &str| {
|
||||
tag.get(k)
|
||||
.and_then(horizon_client_core::nbt_path::int_value)
|
||||
};
|
||||
let flag = |k: &str, default: bool| int(k).map_or(default, |v| v as i8 != 0);
|
||||
let double = |k: &str| match tag.get(k) {
|
||||
Some(Tag::Byte(v)) => f64::from(*v),
|
||||
Some(Tag::Short(v)) => f64::from(*v),
|
||||
Some(Tag::Int(v)) => f64::from(*v),
|
||||
Some(Tag::Long(v)) => *v as f64,
|
||||
Some(Tag::Float(v)) => f64::from(f32::from_bits(*v)),
|
||||
Some(Tag::Double(v)) => f64::from_bits(*v),
|
||||
_ => 0.0,
|
||||
};
|
||||
// Entity.load: OnGround and fall_distance, as saved.
|
||||
(c.on_ground, c.fall_distance) = horizon_client::entity_nbt::ground_from_nbt(tag);
|
||||
c.flipped = flag("FlippedRotation", false);
|
||||
c.first_tick = flag("HasTicked", false);
|
||||
if kind.slots() > 0 {
|
||||
c.items = horizon_client::save::decode_items(tag.get("Items"), kind.slots());
|
||||
}
|
||||
c.enabled = flag("Enabled", true);
|
||||
c.fuel = int("Fuel").map_or(0, |v| i32::from(v as i16));
|
||||
// readAdditionalSaveData: the saved push has no y.
|
||||
c.push = Vec3::new(double("PushX"), 0.0, double("PushZ"));
|
||||
c.fuse = int("fuse").unwrap_or(-1);
|
||||
// readAdditionalSaveData: DisplayState, DisplayOffset (default offset when absent).
|
||||
c.custom_display = tag.get("DisplayState").cloned();
|
||||
c.custom_offset = int("DisplayOffset");
|
||||
if kind == CartKind::CommandBlock {
|
||||
c.command = horizon_client::command_block::CommandBlock::load(tag);
|
||||
// MinecartCommandBlock.readAdditionalSaveData sets the synched command.
|
||||
// A changed value invokes onSyncedDataUpdated -> setCommand, resetting successes.
|
||||
if c.synced_command != c.command.command {
|
||||
c.synced_command.clone_from(&c.command.command);
|
||||
c.command.set_command(&c.synced_command);
|
||||
}
|
||||
c.synced_output = c.command.last_output_string();
|
||||
}
|
||||
// MinecartSpawner.readAdditionalSaveData: BaseSpawner.load.
|
||||
if kind == CartKind::Spawner {
|
||||
c.spawner = Some(Box::new(horizon_client::mobs::spawner::SpawnerExt::load(
|
||||
tag,
|
||||
)));
|
||||
}
|
||||
Some(())
|
||||
}
|
||||
}
|
||||
|
||||
/// `VehicleEntity.hurtServer`'s destroy test: a creative player, damage past
|
||||
/// 40, or (`MinecartTNT.shouldSourceDestroy`) fire or an explosion hitting a
|
||||
/// TNT cart.
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
//! The carts' and mobs' boxes for a tick's cart pushes ([`CartIndex`]).
|
||||
use super::*;
|
||||
|
||||
/// The carts' and mobs' boxes for one tick's cart pushes (`push_from_cart`,
|
||||
/// `pickup_from_cart`), so a cart reads its neighbours instead of every cart and
|
||||
/// every mob of the level. Both answer in slot order, as the scans did.
|
||||
///
|
||||
/// The cart grid follows each ticked cart ([`CartIndex::moved`]); it is
|
||||
/// rebuilt once the list's length changed (a cart gone or added), and dropped
|
||||
/// after a command, which can move or replace any cart
|
||||
/// ([`CartIndex::forget_carts`]). The mob grid holds until the mob
|
||||
/// list's next write ([`ListStamp`]). A native mod's hook can move either kind
|
||||
/// unseen, so with mods loaded both scans stay.
|
||||
#[derive(Default)]
|
||||
pub(in crate::game) struct CartIndex {
|
||||
pub(super) carts: Option<(SeenGrid, Vec<u32>)>,
|
||||
/// The grid, and the carts a mob rides.
|
||||
pub(super) mobs: Option<(ListStamp, SeenGrid, FxHashSet<u32>)>,
|
||||
pub(super) near: Vec<u32>,
|
||||
}
|
||||
impl CartIndex {
|
||||
pub(super) fn usable() -> bool {
|
||||
horizon_client::mod_api::mods().is_empty()
|
||||
}
|
||||
/// The cart in slot `i` has these bounds now.
|
||||
pub(super) fn moved(&mut self, i: usize, bounds: Aabb) {
|
||||
if let Some((grid, ids)) = &mut self.carts
|
||||
&& i < ids.len()
|
||||
{
|
||||
grid.update(i, bounds);
|
||||
}
|
||||
}
|
||||
pub(super) fn forget_carts(&mut self) {
|
||||
self.carts = None;
|
||||
}
|
||||
/// The slots of the carts whose box may meet `area`, for the cart in slot
|
||||
/// `i` (just moved); false: scan them all.
|
||||
pub(super) fn carts_near(
|
||||
&mut self,
|
||||
carts: &[Minecart],
|
||||
i: usize,
|
||||
area: Aabb,
|
||||
out: &mut Vec<u32>,
|
||||
) -> bool {
|
||||
if !Self::usable() {
|
||||
return false;
|
||||
}
|
||||
let fresh = |ids: &[u32]| ids.len() == carts.len() && ids[i] == carts[i].id;
|
||||
if !self.carts.as_ref().is_some_and(|(_, ids)| fresh(ids)) {
|
||||
self.carts = Some((
|
||||
SeenGrid::new(carts.iter().map(Minecart::bounds)),
|
||||
carts.iter().map(|c| c.id).collect(),
|
||||
));
|
||||
}
|
||||
let Some((grid, ids)) = &mut self.carts else {
|
||||
return false;
|
||||
};
|
||||
grid.update(i, carts[i].bounds());
|
||||
if !grid.candidates(area, out) {
|
||||
return false;
|
||||
}
|
||||
if out.iter().any(|&j| ids[j as usize] != carts[j as usize].id) {
|
||||
// A cart was replaced in place: start over next time.
|
||||
self.carts = None;
|
||||
return false;
|
||||
}
|
||||
true
|
||||
}
|
||||
/// The slots of the mobs whose box may meet `area` into `out`, and
|
||||
/// whether a mob rides cart `cart`; None: scan them all.
|
||||
pub(super) fn mobs_near(
|
||||
&mut self,
|
||||
mobs: &JoinList<Mob>,
|
||||
cart: u32,
|
||||
area: Aabb,
|
||||
out: &mut Vec<u32>,
|
||||
) -> Option<bool> {
|
||||
if !Self::usable() {
|
||||
return None;
|
||||
}
|
||||
if !self
|
||||
.mobs
|
||||
.as_ref()
|
||||
.is_some_and(|(stamp, ..)| *stamp == mobs.stamp())
|
||||
{
|
||||
self.mobs = Some((
|
||||
mobs.stamp(),
|
||||
SeenGrid::new(mobs.iter().map(Mob::bounds)),
|
||||
mobs.iter().filter_map(|m| m.extra.mount).collect(),
|
||||
));
|
||||
}
|
||||
let (_, grid, mounted) = self.mobs.as_ref()?;
|
||||
grid.candidates(area, out).then(|| mounted.contains(&cart))
|
||||
}
|
||||
}
|
||||
/// Where the cart `id` is in `carts`: at `hint` (where it was a moment ago,
|
||||
/// the usual case while carts tick in order) or found by a scan. A world
|
||||
/// with hundreds of mineshaft chest carts asked this of every cart several
|
||||
/// times a tick.
|
||||
pub(super) fn cart_at(carts: &[Minecart], id: u32, hint: usize) -> Option<usize> {
|
||||
if carts.get(hint).is_some_and(|c| c.id == id) {
|
||||
return Some(hint);
|
||||
}
|
||||
carts.iter().position(|c| c.id == id)
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
//! Saving and loading a minecart.
|
||||
use super::*;
|
||||
|
||||
/// Saving and loading.
|
||||
impl Minecart {
|
||||
pub fn to_nbt(
|
||||
&self,
|
||||
uuid: [i32; 4],
|
||||
raw: Option<&horizon_world::nbt::Tag>,
|
||||
) -> horizon_world::nbt::Tag {
|
||||
use horizon_world::nbt::{JavaString, Tag};
|
||||
let mut tag = horizon_client::entity_nbt::vehicle_to_nbt(
|
||||
self.kind.id(),
|
||||
self.position,
|
||||
self.velocity,
|
||||
[self.yaw, self.pitch],
|
||||
(self.on_ground, self.fall_distance),
|
||||
uuid,
|
||||
raw,
|
||||
);
|
||||
horizon_client::entity_nbt::write_saved_flags(&mut tag, self.on_ground, self.no_gravity);
|
||||
if let Tag::Compound(map) = &mut tag {
|
||||
// ContainerEntity.addChestVehicleSaveData belongs only to the
|
||||
// container subclasses, never to a plain or specialized cart.
|
||||
if self.kind.slots() == 0 {
|
||||
map.remove(&JavaString::from("Items"));
|
||||
}
|
||||
if self.kind == CartKind::CommandBlock {
|
||||
for key in ["LastOutput", "LastExecution"] {
|
||||
map.remove(&JavaString::from(key));
|
||||
}
|
||||
}
|
||||
map.remove(&JavaString::from("DisplayOffset"));
|
||||
map.remove(&JavaString::from("DisplayState"));
|
||||
let mut put = |k: &str, v: Tag| {
|
||||
map.insert(JavaString::from(k), v);
|
||||
};
|
||||
put("FlippedRotation", Tag::Byte(i8::from(self.flipped)));
|
||||
// AbstractMinecart saves firstTick as HasTicked. A cart never burns:
|
||||
// Entity.move sets -getFireImmuneTicks(), 0 for all but players.
|
||||
put("HasTicked", Tag::Byte(i8::from(self.first_tick)));
|
||||
put("Fire", Tag::Short(0));
|
||||
// addAdditionalSaveData: the offset only off its default, the state only when set.
|
||||
if let Some(offset) = self.custom_offset
|
||||
&& f64::from(offset) != default_display_offset(self.kind)
|
||||
{
|
||||
put("DisplayOffset", Tag::Int(offset));
|
||||
}
|
||||
if let Some(state) = &self.custom_display {
|
||||
put("DisplayState", state.clone());
|
||||
}
|
||||
match self.kind {
|
||||
CartKind::Chest | CartKind::Hopper => {
|
||||
put("Items", horizon_client::save::encode_items(&self.items));
|
||||
if self.kind == CartKind::Hopper {
|
||||
put("Enabled", Tag::Byte(i8::from(self.enabled)));
|
||||
}
|
||||
}
|
||||
CartKind::Furnace => {
|
||||
put("PushX", Tag::Double(self.push.x.to_bits()));
|
||||
put("PushZ", Tag::Double(self.push.z.to_bits()));
|
||||
put("Fuel", Tag::Short(self.fuel as i16));
|
||||
}
|
||||
CartKind::Tnt => put("fuse", Tag::Int(self.fuse)),
|
||||
// BaseCommandBlock.save (its CustomName shares the entity's key).
|
||||
CartKind::CommandBlock => {
|
||||
for (key, value) in self.command.save() {
|
||||
if !matches!(key, "CustomName" | "powered" | "conditionMet" | "auto") {
|
||||
put(key, value);
|
||||
}
|
||||
}
|
||||
}
|
||||
// BaseSpawner.save: the delays, ranges, SpawnData and SpawnPotentials.
|
||||
CartKind::Spawner => {
|
||||
if let Some(spawner) = &self.spawner {
|
||||
for (key, value) in
|
||||
horizon_client::mobs::spawner::SpawnerExt::save(&**spawner)
|
||||
{
|
||||
put(key, value);
|
||||
}
|
||||
}
|
||||
}
|
||||
CartKind::Rideable => {}
|
||||
}
|
||||
// A loaded loot table goes back while the items are still its roll;
|
||||
// otherwise the items replace it, as vanilla's unpackLootTable does.
|
||||
if self.loot.as_ref().is_some_and(|l| l.restore(map)) {
|
||||
if let Some(table) = horizon_client::save::text(map.get(&"LootTable".into()))
|
||||
.and_then(|id| horizon_client_core::identifier::normalize_id(&id))
|
||||
{
|
||||
map.insert("LootTable".into(), Tag::String(table.as_str().into()));
|
||||
}
|
||||
} else {
|
||||
map.remove(&JavaString::from("LootTable"));
|
||||
map.remove(&JavaString::from("LootTableSeed"));
|
||||
}
|
||||
}
|
||||
tag
|
||||
}
|
||||
pub fn from_nbt(tag: &horizon_world::nbt::Tag, id: u32, kind: CartKind) -> Option<Self> {
|
||||
let (position, _, _) = horizon_client::entity_nbt::boat_from_nbt(tag)?;
|
||||
let mut c = Self::new(id, kind, position);
|
||||
c.load(tag)?;
|
||||
Some(c)
|
||||
}
|
||||
/// `Entity.load` on this cart: the saved fields are read over the live
|
||||
/// ones, the rest (riders, lerp, hurt state) stays; its kind never changes.
|
||||
/// `None`, with nothing changed, when the compound has no position.
|
||||
pub fn load(&mut self, tag: &horizon_world::nbt::Tag) -> Option<()> {
|
||||
use horizon_world::nbt::Tag;
|
||||
let (position, velocity, yaw) = horizon_client::entity_nbt::boat_from_nbt(tag)?;
|
||||
let kind = self.kind;
|
||||
let c = self;
|
||||
c.position = position;
|
||||
c.previous = position;
|
||||
c.velocity = velocity;
|
||||
c.yaw = yaw;
|
||||
c.previous_yaw = yaw;
|
||||
c.no_gravity = horizon_client::entity_nbt::flag(tag, "NoGravity");
|
||||
c.on_ground = horizon_client::entity_nbt::flag(tag, "OnGround");
|
||||
c.pitch = horizon_client::entity_nbt::rotation(tag)[1];
|
||||
c.previous_pitch = c.pitch;
|
||||
// ValueInput's numeric getters take any NumericTag: getIntOr is its
|
||||
// intValue, getShortOr the low 16 bits of it, getBooleanOr
|
||||
// `byteValue() != 0` (the low 8), getDoubleOr its doubleValue.
|
||||
let int = |k: &str| {
|
||||
tag.get(k)
|
||||
.and_then(horizon_client_core::nbt_path::int_value)
|
||||
};
|
||||
let flag = |k: &str, default: bool| int(k).map_or(default, |v| v as i8 != 0);
|
||||
let double = |k: &str| match tag.get(k) {
|
||||
Some(Tag::Byte(v)) => f64::from(*v),
|
||||
Some(Tag::Short(v)) => f64::from(*v),
|
||||
Some(Tag::Int(v)) => f64::from(*v),
|
||||
Some(Tag::Long(v)) => *v as f64,
|
||||
Some(Tag::Float(v)) => f64::from(f32::from_bits(*v)),
|
||||
Some(Tag::Double(v)) => f64::from_bits(*v),
|
||||
_ => 0.0,
|
||||
};
|
||||
// Entity.load: OnGround and fall_distance, as saved.
|
||||
(c.on_ground, c.fall_distance) = horizon_client::entity_nbt::ground_from_nbt(tag);
|
||||
c.flipped = flag("FlippedRotation", false);
|
||||
c.first_tick = flag("HasTicked", false);
|
||||
if kind.slots() > 0 {
|
||||
c.items = horizon_client::save::decode_items(tag.get("Items"), kind.slots());
|
||||
}
|
||||
c.enabled = flag("Enabled", true);
|
||||
c.fuel = int("Fuel").map_or(0, |v| i32::from(v as i16));
|
||||
// readAdditionalSaveData: the saved push has no y.
|
||||
c.push = Vec3::new(double("PushX"), 0.0, double("PushZ"));
|
||||
c.fuse = int("fuse").unwrap_or(-1);
|
||||
// readAdditionalSaveData: DisplayState, DisplayOffset (default offset when absent).
|
||||
c.custom_display = tag.get("DisplayState").cloned();
|
||||
c.custom_offset = int("DisplayOffset");
|
||||
if kind == CartKind::CommandBlock {
|
||||
c.command = horizon_client::command_block::CommandBlock::load(tag);
|
||||
// MinecartCommandBlock.readAdditionalSaveData sets the synched command.
|
||||
// A changed value invokes onSyncedDataUpdated -> setCommand, resetting successes.
|
||||
if c.synced_command != c.command.command {
|
||||
c.synced_command.clone_from(&c.command.command);
|
||||
c.command.set_command(&c.synced_command);
|
||||
}
|
||||
c.synced_output = c.command.last_output_string();
|
||||
}
|
||||
// MinecartSpawner.readAdditionalSaveData: BaseSpawner.load.
|
||||
if kind == CartKind::Spawner {
|
||||
c.spawner = Some(Box::new(horizon_client::mobs::spawner::SpawnerExt::load(
|
||||
tag,
|
||||
)));
|
||||
}
|
||||
Some(())
|
||||
}
|
||||
}
|
||||
@@ -61,46 +61,49 @@ impl Game<'_> {
|
||||
/// that are gone, and read what the instances follow.
|
||||
pub(super) fn start_mob_sounds(&mut self) -> Vec<MobSound> {
|
||||
let game_time = self.world.game_time;
|
||||
let mobs: Vec<MobSound> = self
|
||||
.mobs
|
||||
.iter()
|
||||
.filter(|m| {
|
||||
!m.removed
|
||||
&& matches!(
|
||||
m.species(),
|
||||
Species::Bee
|
||||
| Species::Guardian
|
||||
| Species::ElderGuardian
|
||||
| Species::Sniffer
|
||||
)
|
||||
})
|
||||
.map(|m| MobSound {
|
||||
let mut mobs = Vec::new();
|
||||
let mut bee_indices = Vec::new();
|
||||
|
||||
for m in &self.mobs {
|
||||
if m.removed {
|
||||
continue;
|
||||
}
|
||||
let species = m.species();
|
||||
if !matches!(
|
||||
species,
|
||||
Species::Bee | Species::Guardian | Species::ElderGuardian | Species::Sniffer
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
let is_bee = species == Species::Bee;
|
||||
let sound = MobSound {
|
||||
id: m.id,
|
||||
position: [m.position.x, m.position.y, m.position.z],
|
||||
// Vec3.horizontalDistance.
|
||||
speed: m.velocity.x.hypot(m.velocity.z),
|
||||
baby: m.is_baby(),
|
||||
// NeutralMob.isAngry: the anger end time is ahead.
|
||||
angry: m.species() == Species::Bee
|
||||
angry: is_bee
|
||||
&& bee::get(m).is_some_and(|b| b.anger_end > 0 && b.anger_end - game_time > 0),
|
||||
silent: m.silent,
|
||||
attack_scale: guardian::attack_scale(m),
|
||||
// SnifferSoundInstance.tick.
|
||||
sniffer_digging: m.species() == Species::Sniffer
|
||||
sniffer_digging: species == Species::Sniffer
|
||||
&& m.ai.target.is_none()
|
||||
&& sniffer::can_play_digging_sound(m),
|
||||
})
|
||||
.collect();
|
||||
};
|
||||
if is_bee {
|
||||
bee_indices.push(mobs.len());
|
||||
}
|
||||
mobs.push(sound);
|
||||
}
|
||||
self.guardian_sounds
|
||||
.retain(|id| mobs.iter().any(|m| m.id == *id));
|
||||
self.bee_sounds
|
||||
.retain(|id, _| mobs.iter().any(|m| m.id == *id));
|
||||
for sound in &mobs {
|
||||
let is_bee = self
|
||||
.mobs
|
||||
.iter()
|
||||
.any(|m| m.id == sound.id && m.species() == Species::Bee);
|
||||
if is_bee && self.bee_sounds.get(&sound.id) != Some(&sound.angry) {
|
||||
for &idx in &bee_indices {
|
||||
let sound = &mobs[idx];
|
||||
if self.bee_sounds.get(&sound.id) != Some(&sound.angry) {
|
||||
self.bee_sounds.insert(sound.id, sound.angry);
|
||||
let instance = SoundInstance::bee(sound, sound.angry, random_source::create());
|
||||
self.audio.play_instance(instance, false);
|
||||
|
||||
@@ -471,6 +471,7 @@ pub(crate) mod tests {
|
||||
dim: game.dim,
|
||||
pos: [1, 2, 3],
|
||||
yaw: 0.0,
|
||||
pitch: 0.0,
|
||||
forced: false,
|
||||
};
|
||||
let spawn = Vec3::new(1.0, 2.0, 3.0);
|
||||
|
||||
@@ -46,6 +46,8 @@ pub(super) struct SpawnScratch {
|
||||
/// dx/dz order, that no earlier player's square holds.
|
||||
segments: Vec<Vec<(i32, i32)>>,
|
||||
stale: Vec<bool>,
|
||||
/// Reusable buffer for spawn players across ticks to avoid heap allocation.
|
||||
pub(super) spawn_players: Vec<SpawnPlayer>,
|
||||
}
|
||||
impl SpawnScratch {
|
||||
fn chunk(p: &LevelPlayer) -> (i32, i32) {
|
||||
@@ -128,6 +130,20 @@ impl SpawnScratch {
|
||||
self.prepare(players);
|
||||
self.candidates.clear();
|
||||
let mut count = 0;
|
||||
if players.len() == 1 {
|
||||
let p = &players[0];
|
||||
count = self.segments.first().map_or(0, |s| s.len());
|
||||
if !p.spectator
|
||||
&& let Some(segment) = self.segments.first()
|
||||
{
|
||||
for &c in segment {
|
||||
if close_enough_for_spawning(p, c) && keep(c) {
|
||||
self.candidates.push(c);
|
||||
}
|
||||
}
|
||||
}
|
||||
return count as i32;
|
||||
}
|
||||
for (i, segment) in self.segments.iter().enumerate() {
|
||||
count += segment.len();
|
||||
let near = &self.neighbours[i];
|
||||
@@ -650,8 +666,10 @@ fn spawn_cost_types(biomes: &horizon_client::biome::Biomes) -> Vec<&str> {
|
||||
types
|
||||
}
|
||||
impl<'a> Game<'a> {
|
||||
pub(super) fn natural_level(&self) -> GameLevel<'_, 'a> {
|
||||
let players = self.level_players();
|
||||
pub(super) fn natural_level_with_players(
|
||||
&self,
|
||||
players: Vec<LevelPlayer>,
|
||||
) -> GameLevel<'_, 'a> {
|
||||
let nearest = players
|
||||
.iter()
|
||||
.filter(|p| !p.spectator)
|
||||
@@ -670,6 +688,9 @@ impl<'a> Game<'a> {
|
||||
costed: spawn_cost_types(world_biomes!(self)),
|
||||
}
|
||||
}
|
||||
pub(super) fn natural_level(&self) -> GameLevel<'_, 'a> {
|
||||
self.natural_level_with_players(self.level_players())
|
||||
}
|
||||
/// Creates a mob the spawner accepted, finalized the game's way; the
|
||||
/// caller adds it with [`Game::add_spawned`].
|
||||
fn finalize_spawned(&self, r: &SpawnRequest, pending: &mut Vec<Mob>) -> Option<Spawned> {
|
||||
@@ -773,6 +794,8 @@ impl<'a> Game<'a> {
|
||||
scratch: &mut SpawnScratch,
|
||||
) -> i32 {
|
||||
let levels = self.tickets.levels();
|
||||
// Recomputed each pass: a chunk's ticket level and entity loading change without the
|
||||
// players moving or the chunk count changing.
|
||||
scratch.collect(players, |(cx, cz)| {
|
||||
levels.loaded_ticking(cx, cz) && super::ticking_chunk(&self.world, cx, cz)
|
||||
})
|
||||
@@ -819,31 +842,28 @@ impl<'a> Game<'a> {
|
||||
{
|
||||
let level = self.natural_level();
|
||||
laps.lap("sn_level");
|
||||
let counted: Vec<Counted> = self
|
||||
.mobs
|
||||
.iter()
|
||||
.map(|m| Counted {
|
||||
entity: &m.kind.id,
|
||||
category: Category::from_name(&m.kind.category),
|
||||
block: [
|
||||
m.position.x.floor() as i32,
|
||||
m.position.y.floor() as i32,
|
||||
m.position.z.floor() as i32,
|
||||
],
|
||||
is_mob: true,
|
||||
persistent: m.persistent
|
||||
|| horizon_client::mobs::ai::requires_custom_persistence(m),
|
||||
})
|
||||
.collect();
|
||||
let players = players
|
||||
.iter()
|
||||
.map(|p| SpawnPlayer {
|
||||
let counted = self.mobs.iter().map(|m| Counted {
|
||||
entity: &m.kind.id,
|
||||
category: Category::from_name(&m.kind.category),
|
||||
block: [
|
||||
m.position.x.floor() as i32,
|
||||
m.position.y.floor() as i32,
|
||||
m.position.z.floor() as i32,
|
||||
],
|
||||
is_mob: true,
|
||||
persistent: m.persistent
|
||||
|| horizon_client::mobs::ai::requires_custom_persistence(m),
|
||||
});
|
||||
scratch.spawn_players.clear();
|
||||
scratch
|
||||
.spawn_players
|
||||
.extend(players.iter().map(|p| SpawnPlayer {
|
||||
position: [p.position.x, p.position.y, p.position.z],
|
||||
spectator: p.spectator,
|
||||
})
|
||||
.collect();
|
||||
}));
|
||||
let spawn_players = std::mem::take(&mut scratch.spawn_players);
|
||||
laps.lap("sn_materialize");
|
||||
let mut state = SpawnState::new(&level, spawnable, players, &counted, |cx, cz| {
|
||||
let mut state = SpawnState::new(&level, spawnable, spawn_players, counted, |cx, cz| {
|
||||
self.world.chunks.contains_key(&(cx, cz))
|
||||
});
|
||||
laps.lap("sn_state");
|
||||
@@ -854,7 +874,8 @@ impl<'a> Game<'a> {
|
||||
Vec::new()
|
||||
};
|
||||
laps.lap("sn_categories");
|
||||
// Util.shuffle.
|
||||
// Util.shuffle, every pass: it draws from the level's random even when no
|
||||
// category may spawn.
|
||||
for i in (1..scratch.candidates.len()).rev() {
|
||||
let j = random.next_int_bound(i as i32 + 1).unwrap_or(0) as usize;
|
||||
scratch.candidates.swap(i, j);
|
||||
@@ -885,6 +906,7 @@ impl<'a> Game<'a> {
|
||||
);
|
||||
}
|
||||
}
|
||||
scratch.spawn_players = state.into_players();
|
||||
}
|
||||
spawn_selection::attempt_counters(true);
|
||||
laps.lap("sn_attempts");
|
||||
|
||||
@@ -185,7 +185,7 @@ impl Game<'_> {
|
||||
let output = self
|
||||
.renderer
|
||||
.as_ref()
|
||||
.map_or(Default::default(), |r| *r.output());
|
||||
.map_or(Default::default(), |r| r.output());
|
||||
ui.draw(&self.settings, output, batch, &self.gui.atlas, self.cursor);
|
||||
self.ui.options = ui;
|
||||
self.show_calibration((batch.width, batch.height));
|
||||
|
||||
@@ -679,26 +679,6 @@ impl Game<'_> {
|
||||
gamepad::move_vector(self.pad.state.left, settings.move_deadzone)
|
||||
}
|
||||
|
||||
/// Whether `key` is held, a movement key by the left stick too: as the keys the stick holds
|
||||
/// (`eight_direction_keys`), whatever moves the player. For readers of the keys that aren't
|
||||
/// the movement itself.
|
||||
pub(super) fn movement_key_held(&self, key: KeyCode) -> bool {
|
||||
if self.keys.raw(&key) {
|
||||
return true;
|
||||
}
|
||||
let Some(stick) = self.pad_move() else {
|
||||
return false;
|
||||
};
|
||||
let [forward, backward, left, right] = gamepad::eight_direction_keys(stick);
|
||||
match key {
|
||||
KeyCode::KeyW => forward,
|
||||
KeyCode::KeyS => backward,
|
||||
KeyCode::KeyA => left,
|
||||
KeyCode::KeyD => right,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// The right stick turns the view, once per frame (`Entity.turn`).
|
||||
pub(super) fn pad_look(&mut self, seconds: f32) {
|
||||
let settings = &self.settings.controller;
|
||||
|
||||
@@ -280,11 +280,11 @@ impl Game<'_> {
|
||||
/// front of) it, pulled in where visual shapes are in the way. Returns the
|
||||
/// position, yaw and pitch to render from.
|
||||
pub(super) fn camera_view(&mut self, partial: f32) -> Option<(glam::DVec3, f32, f32)> {
|
||||
if self.studio_freecam.active {
|
||||
if self.studio.freecam.active {
|
||||
return Some((
|
||||
self.studio_freecam.position,
|
||||
self.studio_freecam.yaw,
|
||||
self.studio_freecam.pitch,
|
||||
self.studio.freecam.position,
|
||||
self.studio.freecam.yaw,
|
||||
self.studio.freecam.pitch,
|
||||
));
|
||||
}
|
||||
// Minecraft.cameraEntity: a server's spectator camera on a mob or
|
||||
@@ -643,8 +643,9 @@ impl Game<'_> {
|
||||
/// The model's old pitch is the player's `xRotO`, which the turn moves
|
||||
/// with `xRot` (`turn_rotation`).
|
||||
pub(super) fn turn_player(&mut self, xo: f64, yo: f64) {
|
||||
if self.studio_freecam.active {
|
||||
self.studio_freecam
|
||||
if self.studio.freecam.active {
|
||||
self.studio
|
||||
.freecam
|
||||
.turn((xo * 0.15) as f32, (yo * 0.15) as f32);
|
||||
return;
|
||||
}
|
||||
@@ -697,7 +698,7 @@ impl Game<'_> {
|
||||
/// `LevelExtractor`'s skip of the camera entity: the player's own body
|
||||
/// is drawn unless the camera is in first person, or while it sleeps.
|
||||
pub(super) fn own_body_in_view(&self) -> bool {
|
||||
!self.camera_type.first_person() || self.sleeping.is_some() || self.studio_freecam.active
|
||||
!self.camera_type.first_person() || self.sleeping.is_some() || self.studio.freecam.active
|
||||
}
|
||||
/// Whether the player's own model shows in the world view (its body or,
|
||||
/// while invisible, only its armour and held items).
|
||||
|
||||
@@ -101,7 +101,7 @@ impl Game<'_> {
|
||||
};
|
||||
// A controller's stick click acts as the key without setting the modifier: it is held
|
||||
// while the pad holds it (or its release waits for a tick), not when the keyboard says.
|
||||
let pad = self.pad.holds_action(&self.settings.controller, mapping);
|
||||
let pad = self.input_holds(mapping);
|
||||
if !held && !pad {
|
||||
self.toggles.0[slot].set_down(false, needs_toggle);
|
||||
self.sync_toggle(slot);
|
||||
@@ -180,7 +180,7 @@ impl Game<'_> {
|
||||
raw: KeyCode,
|
||||
source: Source,
|
||||
) -> Result<()> {
|
||||
if self.screen == Screen::Playing && self.studio_replay.is_some() {
|
||||
if self.screen == Screen::Playing && self.studio.replay.is_some() {
|
||||
if !pressed {
|
||||
self.keys.release(&key, source);
|
||||
return Ok(());
|
||||
@@ -191,7 +191,7 @@ impl Game<'_> {
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
if !studio_freecam_movement_key(key) || !self.studio_freecam.active {
|
||||
if !studio_freecam_movement_key(key) || !self.studio.freecam.active {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
@@ -352,9 +352,7 @@ impl Game<'_> {
|
||||
_ => return false,
|
||||
};
|
||||
match source {
|
||||
Source::Keyboard | Source::Agent => {
|
||||
self.pad.holds_action(&self.settings.controller, mapping)
|
||||
}
|
||||
Source::Keyboard | Source::Agent => self.input_holds(mapping),
|
||||
Source::Pad => {
|
||||
let keys = &self.settings.keys;
|
||||
self.physical_buttons
|
||||
@@ -450,6 +448,79 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// The input source (docs/layers.md, "Controllers, splitscreen, replays and UI pages"): what
|
||||
/// gameplay asks of a device beyond the keyboard and mouse, and tells it. Gameplay calls these,
|
||||
/// never the controller modules; today `pad` answers.
|
||||
impl Game<'_> {
|
||||
/// The analog move vector (`[left, forward]`, -1..1) while a device moves the player.
|
||||
pub(super) fn input_move(&self) -> Option<[f32; 2]> {
|
||||
self.pad_move()
|
||||
}
|
||||
|
||||
/// Whether a device holds the key mapping named `mapping` (`key.use`).
|
||||
pub(super) fn input_holds(&self, mapping: &str) -> bool {
|
||||
self.pad.holds_action(&self.settings.controller, mapping)
|
||||
}
|
||||
|
||||
/// Turns the view by what the devices give this frame (`Entity.turn`).
|
||||
pub(super) fn input_look(&mut self, seconds: f32) {
|
||||
self.pad_look(seconds);
|
||||
}
|
||||
|
||||
/// The keyboard or mouse acted in play (a key or button pressed): it has control again, though
|
||||
/// only moving the mouse takes a pad out of use.
|
||||
pub(super) fn input_keyboard_used(&mut self) {
|
||||
self.pad.keyboard_took_over = true;
|
||||
}
|
||||
|
||||
/// Whether the pointer event being handled is one a device made (a pad's menu cursor
|
||||
/// clicking), not the mouse's.
|
||||
pub(super) fn input_made_pointer_event(&self) -> bool {
|
||||
self.pad.menu.synthetic
|
||||
}
|
||||
|
||||
/// A new player entity took over (spawn, respawn, a server joined, a dimension change).
|
||||
pub(super) fn input_player_replaced(&mut self) {
|
||||
self.pad.player_replaced();
|
||||
}
|
||||
|
||||
/// Whether the drop about to happen is a device's whole-stack drop (it has no Control key to
|
||||
/// hold). Asking answers once.
|
||||
pub(super) fn input_takes_stack_drop(&mut self) -> bool {
|
||||
std::mem::take(&mut self.pad.drop_stack)
|
||||
}
|
||||
|
||||
/// The HUD starts a frame: what a device drew on the last one is gone.
|
||||
pub(super) fn input_hud_begin(&mut self) {
|
||||
self.pad.guide_shown.clear();
|
||||
}
|
||||
|
||||
/// Something happened to the player that a device may let them feel.
|
||||
pub(super) fn input_feedback(&mut self, event: horizon_client::input_source::Feedback) {
|
||||
self.pad_rumble(event.into());
|
||||
}
|
||||
|
||||
/// Whether `key` is held, a movement key by the analog move vector too: as the keys it holds
|
||||
/// (`eight_direction_keys`), whatever moves the player. For readers of the keys that aren't
|
||||
/// the movement itself.
|
||||
pub(super) fn movement_key_held(&self, key: KeyCode) -> bool {
|
||||
if self.keys.raw(&key) {
|
||||
return true;
|
||||
}
|
||||
let Some(stick) = self.input_move() else {
|
||||
return false;
|
||||
};
|
||||
let [forward, backward, left, right] = horizon_client::gamepad::eight_direction_keys(stick);
|
||||
match key {
|
||||
KeyCode::KeyW => forward,
|
||||
KeyCode::KeyS => backward,
|
||||
KeyCode::KeyA => left,
|
||||
KeyCode::KeyD => right,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{studio_freecam_movement_key, studio_replay_shortcut};
|
||||
|
||||
@@ -10,7 +10,7 @@ impl Game<'_> {
|
||||
/// the container's hotbar keys are now (`checkHotbarKeyPressed`).
|
||||
pub(super) fn key_pressed(&mut self, key: KeyCode, raw: KeyCode) -> Result<()> {
|
||||
self.direct_craft_stop();
|
||||
if self.screen == Screen::Playing && self.studio_replay.is_some() {
|
||||
if self.screen == Screen::Playing && self.studio.replay.is_some() {
|
||||
match key {
|
||||
KeyCode::Escape => {
|
||||
self.close_studio_replay();
|
||||
@@ -25,19 +25,19 @@ impl Game<'_> {
|
||||
KeyCode::KeyF => self.handle_studio_timeline_action(
|
||||
horizon_client::studio::TimelineAction::ToggleFreecam,
|
||||
),
|
||||
KeyCode::KeyT => self.studio_timeline.visible = !self.studio_timeline.visible,
|
||||
KeyCode::KeyT => self.studio.timeline.visible = !self.studio.timeline.visible,
|
||||
KeyCode::KeyM => self.add_studio_marker(),
|
||||
KeyCode::Home => self.handle_studio_topbar_action(
|
||||
horizon_client::studio::TopBarAction::ResetCameraToPlayer,
|
||||
),
|
||||
KeyCode::KeyL => {
|
||||
self.studio_freecam.locked_to_path = !self.studio_freecam.locked_to_path;
|
||||
self.studio.freecam.locked_to_path = !self.studio.freecam.locked_to_path;
|
||||
}
|
||||
KeyCode::KeyK => self.handle_studio_timeline_action(
|
||||
horizon_client::studio::TimelineAction::AddCameraKeyframe,
|
||||
),
|
||||
KeyCode::Delete => {
|
||||
if let Some(index) = self.studio_timeline.selected_keyframe {
|
||||
if let Some(index) = self.studio.timeline.selected_keyframe {
|
||||
self.handle_studio_timeline_action(
|
||||
horizon_client::studio::TimelineAction::RemoveCameraKeyframe(index),
|
||||
);
|
||||
@@ -78,7 +78,7 @@ impl Game<'_> {
|
||||
KeyCode::Escape => match self.screen {
|
||||
Screen::Options => self.options_key(KeyCode::Escape, None)?,
|
||||
Screen::Playing => {
|
||||
if self.studio_replay.is_some() && self.captured {
|
||||
if self.studio.replay.is_some() && self.captured {
|
||||
self.capture(false);
|
||||
return Ok(());
|
||||
}
|
||||
@@ -213,7 +213,7 @@ impl Game<'_> {
|
||||
// key.toggleGui is a KeyMapping: it clicks only with no screen open.
|
||||
KeyCode::F1 if self.screen == Screen::Playing => self.toggle_hide_gui(),
|
||||
KeyCode::F11 => self.toggle_fullscreen(),
|
||||
KeyCode::F5 if self.screen == Screen::Playing && self.studio_replay.is_none() => {
|
||||
KeyCode::F5 if self.screen == Screen::Playing && self.studio.replay.is_none() => {
|
||||
self.cycle_camera()
|
||||
}
|
||||
KeyCode::KeyE
|
||||
@@ -279,8 +279,7 @@ impl Game<'_> {
|
||||
KeyCode::KeyQ if self.screen == Screen::Playing => {
|
||||
// handleKeybinds: the number keys select before key.drop.
|
||||
self.select_queued_hotbar();
|
||||
let all =
|
||||
drop_whole_stack(self.modifiers) || std::mem::take(&mut self.pad.drop_stack);
|
||||
let all = drop_whole_stack(self.modifiers) || self.input_takes_stack_drop();
|
||||
self.drop_held(all);
|
||||
}
|
||||
KeyCode::Digit1
|
||||
|
||||
@@ -111,7 +111,7 @@ impl Game<'_> {
|
||||
self.stop_sounds_for_new_level();
|
||||
}
|
||||
self.player = Some(player);
|
||||
self.pad.player_replaced();
|
||||
self.input_player_replaced();
|
||||
self.sway = Default::default();
|
||||
self.apply_equipment();
|
||||
self.eye = 1.62;
|
||||
|
||||
@@ -26,7 +26,7 @@ impl Game<'_> {
|
||||
let pointer = self.captured || self.agent_pointer;
|
||||
let grabbed = pointer
|
||||
&& self.screen == Screen::Playing
|
||||
&& (self.player.is_some() || self.studio_freecam.active);
|
||||
&& (self.player.is_some() || self.studio.freecam.active);
|
||||
let active = self.focused || self.agent_pointer;
|
||||
if let Some((xo, yo)) = self
|
||||
.mouse
|
||||
@@ -76,8 +76,8 @@ impl Game<'_> {
|
||||
&& self.control.is_none()
|
||||
&& self.screen == Screen::Playing
|
||||
&& !studio_suppresses_focus_pause(
|
||||
self.studio_replay.is_some(),
|
||||
self.studio_freecam.active,
|
||||
self.studio.replay.is_some(),
|
||||
self.studio.freecam.active,
|
||||
)
|
||||
&& self.player.is_some()
|
||||
&& !self.options.no_pause
|
||||
@@ -149,7 +149,7 @@ impl Game<'_> {
|
||||
self.reset_inventory_clicks();
|
||||
if !capture {
|
||||
self.stop_mining();
|
||||
self.studio_rmb_down = false;
|
||||
self.studio.rmb_down = false;
|
||||
}
|
||||
// The real pointer takes over from an agent's.
|
||||
self.agent_pointer = false;
|
||||
@@ -353,9 +353,9 @@ impl Game<'_> {
|
||||
/// click. A screen that is open keeps it free, as releasing the pointer
|
||||
/// for that screen did: it is the screen, not the focus, that shows it.
|
||||
pub(super) fn regrab_on_refocus(&mut self) {
|
||||
if self.studio_replay.is_some() || self.studio_freecam.active {
|
||||
if self.studio.replay.is_some() || self.studio.freecam.active {
|
||||
self.regrab_on_refocus = false;
|
||||
self.studio_rmb_down = false;
|
||||
self.studio.rmb_down = false;
|
||||
self.capture(false);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -2433,7 +2433,7 @@ impl Game<'_> {
|
||||
/// view centre, `calculateStorageRange` (the server's radius, at least
|
||||
/// two, plus three) chunks each way.
|
||||
pub(super) fn remote_chunk_in_range(&self, x: i32, z: i32) -> bool {
|
||||
if self.studio_replay.is_some() {
|
||||
if self.studio.replay.is_some() {
|
||||
return true;
|
||||
}
|
||||
self.remote
|
||||
@@ -3110,33 +3110,6 @@ impl Game<'_> {
|
||||
index.get(mobs, id as u32).map(mob_tracking_body)
|
||||
});
|
||||
}
|
||||
fn remove_entity(&mut self, id: i32) {
|
||||
self.detach_remote_tracking_entity(id);
|
||||
self.audio.remove_sound_entity(id as u32);
|
||||
// Entity.setRemoved detaches every passenger, even when no empty
|
||||
// SetPassengers packet preceded the vehicle's removal. Otherwise
|
||||
// mobs and other players keep their passenger pose after it is gone.
|
||||
self.remote_set_passengers(id, &[]);
|
||||
self.mobs.retain(|m| m.id != id as u32);
|
||||
self.orbs.retain(|o| o.id != id as u32);
|
||||
self.dropped.retain(|d| d.id != id as u32);
|
||||
// Before the crystals go: the dragon's beam index follows them.
|
||||
self.remove_remote_dragon(id);
|
||||
self.remove_remote_object(id as u32);
|
||||
self.remove_remote_projectile(id as u32);
|
||||
self.persist.uuids.remove(&(id as u32));
|
||||
if let Some(r) = self.remote.as_mut() {
|
||||
r.glowing.remove(&id);
|
||||
r.entities.remove(&id);
|
||||
r.passenger_vehicles.remove(&(id as u32));
|
||||
r.passenger_vehicles.retain(|_, v| *v != id as u32);
|
||||
r.lerps.remove(&id);
|
||||
r.cart_lerps.remove(&id);
|
||||
r.stepped.remove(&id);
|
||||
r.head_lerps.remove(&id);
|
||||
r.pending_items.remove(&id);
|
||||
}
|
||||
}
|
||||
/// Map a menu slot to Horizon's inventory screen slot ids.
|
||||
pub(super) fn menu_slot(menu: &str, slot: i32) -> Option<usize> {
|
||||
let s = usize::try_from(slot).ok()?;
|
||||
@@ -3707,7 +3680,7 @@ impl Game<'_> {
|
||||
self.move_remote_interaction(p.id, p.position);
|
||||
}
|
||||
C::RemoveEntities(p) => {
|
||||
for id in p.ids {
|
||||
for &id in &p.ids {
|
||||
if let Some(r) = self.remote.as_mut() {
|
||||
if let Some(uuid) = r.level_players.remove(&id) {
|
||||
r.attachments.remove(&Holder::Player(uuid));
|
||||
@@ -3716,8 +3689,8 @@ impl Game<'_> {
|
||||
r.level_player_textures.remove(&id);
|
||||
r.attachments.remove(&Holder::Entity(id as u32));
|
||||
}
|
||||
self.remove_entity(id);
|
||||
}
|
||||
self.remove_entities(&p.ids);
|
||||
}
|
||||
C::MoveEntityPos(play::MoveEntityPos(m))
|
||||
| C::MoveEntityPosRot(play::MoveEntityPosRot(m))
|
||||
@@ -6176,7 +6149,7 @@ impl Game<'_> {
|
||||
player.spectator = self.spectator();
|
||||
player.velocity = delta;
|
||||
self.player = Some(player);
|
||||
self.pad.player_replaced();
|
||||
self.input_player_replaced();
|
||||
self.sway = Default::default();
|
||||
self.eye = 1.62;
|
||||
self.previous_eye = 1.62;
|
||||
@@ -6185,7 +6158,7 @@ impl Game<'_> {
|
||||
self.apply_remote_abilities();
|
||||
}
|
||||
}
|
||||
if self.studio_replay.is_none() {
|
||||
if self.studio.replay.is_none() {
|
||||
self.previous_position = position;
|
||||
}
|
||||
self.yaw = yaw;
|
||||
@@ -6441,7 +6414,7 @@ impl Game<'_> {
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
let stick = self.pad_move();
|
||||
let stick = self.input_move();
|
||||
if self.player.is_none() {
|
||||
return Ok(());
|
||||
}
|
||||
@@ -6521,7 +6494,7 @@ impl Game<'_> {
|
||||
return Ok(());
|
||||
};
|
||||
player.fluids.boat = boat;
|
||||
if !riding && self.studio_replay.is_none() {
|
||||
if !riding && self.studio.replay.is_none() {
|
||||
self.previous_position = player.position;
|
||||
}
|
||||
let before = (
|
||||
@@ -6728,7 +6701,7 @@ impl Game<'_> {
|
||||
self.snap_player_model_pitch();
|
||||
}
|
||||
// The player's own model (third person, the inventory's preview).
|
||||
if self.studio_replay.is_none() {
|
||||
if self.studio.replay.is_none() {
|
||||
self.tick_player_model();
|
||||
}
|
||||
self.tick_emotes();
|
||||
@@ -9525,7 +9498,7 @@ impl Game<'_> {
|
||||
KeyCode::KeyQ if !self.spectator() => {
|
||||
// Minecraft.hasControlDown: the physical keys, so a toggled
|
||||
// sprint on Left Control does not make Q drop the stack.
|
||||
let all = self.has_control_down() || std::mem::take(&mut self.pad.drop_stack);
|
||||
let all = self.has_control_down() || self.input_takes_stack_drop();
|
||||
self.remote_drop(all);
|
||||
}
|
||||
KeyCode::KeyE
|
||||
@@ -10528,7 +10501,7 @@ impl Game<'_> {
|
||||
/// A connection's screen is over the world: connecting, reconfiguring, or
|
||||
/// the level loading screen, a portal's included (`remote_overlay`).
|
||||
pub(super) fn remote_screen_up(&self) -> bool {
|
||||
self.studio_replay.is_none()
|
||||
self.studio.replay.is_none()
|
||||
&& self
|
||||
.remote
|
||||
.as_ref()
|
||||
@@ -10612,7 +10585,7 @@ impl Game<'_> {
|
||||
/// Connected or connecting with no player yet: `ConnectScreen`, then
|
||||
/// `LevelLoadingScreen`, over the panorama with its blur.
|
||||
pub(super) fn remote_loading(&self) -> bool {
|
||||
if self.studio_replay.is_some() {
|
||||
if self.studio.replay.is_some() {
|
||||
return false;
|
||||
}
|
||||
use horizon_client::level_loading::Reason;
|
||||
@@ -11642,6 +11615,9 @@ fn in_storage_range(view_distance: i32, center: (i32, i32), x: i32, z: i32) -> b
|
||||
x.abs_diff(center.0) <= range && z.abs_diff(center.1) <= range
|
||||
}
|
||||
|
||||
// A packet's removals (`RemoveEntities`), apart: this file may not grow.
|
||||
#[path = "remote_removal.rs"]
|
||||
mod removal;
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
|
||||
@@ -793,13 +793,17 @@ impl Game<'_> {
|
||||
}
|
||||
|
||||
pub(super) fn remove_remote_projectile(&mut self, id: u32) {
|
||||
self.fangs.list.retain(|f| f.id != id);
|
||||
self.tnts.retain(|t| t.id != id);
|
||||
self.falling.retain(|f| f.id != id);
|
||||
self.crystals.retain(|c| c.id != id);
|
||||
self.remove_remote_small_projectiles(id);
|
||||
}
|
||||
|
||||
pub(super) fn remove_remote_small_projectiles(&mut self, id: u32) {
|
||||
self.fangs.list.retain(|f| f.id != id);
|
||||
self.creatures.projectiles.retain(|p| p.id != id);
|
||||
self.tridents.retain(|t| t.id != id);
|
||||
self.rockets.retain(|r| r.id != id);
|
||||
self.crystals.retain(|c| c.id != id);
|
||||
self.potions.remove_remote_thrown(id);
|
||||
self.potions.clouds.retain(|c| c.id != id);
|
||||
self.end.remove_remote_eye(id);
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
//! Removing entities a server dropped (`RemoveEntities`), out of remote.rs,
|
||||
//! which may not grow.
|
||||
use super::super::Game;
|
||||
|
||||
impl Game<'_> {
|
||||
pub(super) fn remove_entity(&mut self, id: i32) {
|
||||
self.remove_entities(&[id]);
|
||||
}
|
||||
|
||||
/// `ClientboundRemoveEntitiesPacket`: a packet's ids go together, the big
|
||||
/// entity lists filtered once (#13007), each id's own cleanup in order.
|
||||
pub(super) fn remove_entities(&mut self, ids: &[i32]) {
|
||||
for &id in ids {
|
||||
self.detach_remote_tracking_entity(id);
|
||||
self.audio.remove_sound_entity(id as u32);
|
||||
// Entity.setRemoved detaches every passenger, even when no empty
|
||||
// SetPassengers packet preceded the vehicle's removal. Otherwise
|
||||
// mobs and other players keep their passenger pose after it is gone.
|
||||
self.remote_set_passengers(id, &[]);
|
||||
// Before the crystals go: the dragon's beam index follows them.
|
||||
self.remove_remote_dragon(id);
|
||||
self.crystals.retain(|c| c.id != id as u32);
|
||||
self.remove_remote_object(id as u32);
|
||||
self.remove_remote_small_projectiles(id as u32);
|
||||
self.persist.uuids.remove(&(id as u32));
|
||||
if let Some(r) = self.remote.as_mut() {
|
||||
r.glowing.remove(&id);
|
||||
r.entities.remove(&id);
|
||||
r.passenger_vehicles.remove(&(id as u32));
|
||||
r.lerps.remove(&id);
|
||||
r.cart_lerps.remove(&id);
|
||||
r.stepped.remove(&id);
|
||||
r.head_lerps.remove(&id);
|
||||
r.pending_items.remove(&id);
|
||||
}
|
||||
}
|
||||
let removed: horizon_client::fast_hash::FxHashSet<u32> = if ids.len() > 1 {
|
||||
ids.iter().map(|&id| id as u32).collect()
|
||||
} else {
|
||||
Default::default()
|
||||
};
|
||||
let keep = |id: u32| {
|
||||
if ids.len() > 1 {
|
||||
!removed.contains(&id)
|
||||
} else {
|
||||
ids.first().is_none_or(|&removed| id != removed as u32)
|
||||
}
|
||||
};
|
||||
self.mobs.retain(|m| keep(m.id));
|
||||
self.orbs.retain(|o| keep(o.id));
|
||||
self.dropped.retain(|d| keep(d.id));
|
||||
self.falling.retain(|f| keep(f.id));
|
||||
self.tnts.retain(|t| keep(t.id));
|
||||
if let Some(r) = self.remote.as_mut() {
|
||||
r.passenger_vehicles.retain(|_, v| keep(*v));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES and scratch homes/cwd"]
|
||||
fn remove_entities_filters_mixed_entity_lists() {
|
||||
use crate::game::{
|
||||
Audio, Game, Options, Progress, Settings, execution_role::ExecutionRole, load_resources,
|
||||
};
|
||||
use horizon_protocol::packets::play::{self, Clientbound as C};
|
||||
|
||||
let root = std::path::PathBuf::from(std::env::var_os("HORIZON_TEST_RESOURCES").unwrap());
|
||||
let settings = Settings::default();
|
||||
let loaded = load_resources(
|
||||
root.clone(),
|
||||
None,
|
||||
&root,
|
||||
&settings,
|
||||
std::time::Instant::now(),
|
||||
false,
|
||||
true,
|
||||
&Progress::new(),
|
||||
&mut |_, _| unreachable!(),
|
||||
)
|
||||
.unwrap();
|
||||
let mut options = Options::parse(std::iter::empty()).unwrap().unwrap();
|
||||
options.resources = root.clone();
|
||||
options.execution_role = ExecutionRole::Client;
|
||||
let mut game = Game::new(
|
||||
&loaded.resources,
|
||||
loaded.pack,
|
||||
loaded.gui.unwrap(),
|
||||
options,
|
||||
settings,
|
||||
Audio::silent(&root),
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
let (client, _server) = horizon_protocol::connection::Pipe::pair();
|
||||
game.connect_local(client, "RemovalTest").unwrap();
|
||||
for (id, kind) in [
|
||||
(40, "minecraft:falling_block"),
|
||||
(41, "minecraft:falling_block"),
|
||||
(42, "minecraft:falling_block"),
|
||||
(43, "minecraft:tnt"),
|
||||
(44, "minecraft:tnt"),
|
||||
(45, "minecraft:experience_orb"),
|
||||
(46, "minecraft:experience_orb"),
|
||||
(47, "minecraft:zombie"),
|
||||
(48, "minecraft:zombie"),
|
||||
] {
|
||||
if matches!(kind, "minecraft:falling_block" | "minecraft:tnt") {
|
||||
assert!(game.spawn_remote_projectile(
|
||||
id,
|
||||
kind,
|
||||
[0.0, 64.0, 0.0],
|
||||
(0.0, 0.0),
|
||||
[0.0; 3],
|
||||
0
|
||||
));
|
||||
} else {
|
||||
game.spawn_entity(id, kind, [0.0, 64.0, 0.0], [0.0; 3]);
|
||||
}
|
||||
}
|
||||
for id in [49, 50] {
|
||||
game.dropped.push(horizon_client::entities::ItemEntity::new(
|
||||
id,
|
||||
horizon_client::items::ItemStack::new("minecraft:sand", 1),
|
||||
horizon_core::geometry::Vec3::new(0.0, 64.0, 0.0),
|
||||
horizon_core::geometry::Vec3::ZERO,
|
||||
0.0,
|
||||
));
|
||||
}
|
||||
game.handle_packet(C::RemoveEntities(play::RemoveEntities {
|
||||
ids: vec![40, 42, 43, 45, 47, 49],
|
||||
}))
|
||||
.unwrap();
|
||||
assert_eq!(game.falling.iter().map(|e| e.id).collect::<Vec<_>>(), [41]);
|
||||
assert_eq!(game.tnts.iter().map(|e| e.id).collect::<Vec<_>>(), [44]);
|
||||
assert_eq!(game.orbs.iter().map(|e| e.id).collect::<Vec<_>>(), [46]);
|
||||
assert_eq!(game.mobs.iter().map(|e| e.id).collect::<Vec<_>>(), [48]);
|
||||
assert_eq!(game.dropped.iter().map(|e| e.id).collect::<Vec<_>>(), [50]);
|
||||
}
|
||||
}
|
||||
@@ -168,6 +168,7 @@ impl Game<'_> {
|
||||
full_water_vision: self.settings.extras.pvp.tweaks.clear_water,
|
||||
night_vision: self.fog_night_vision(),
|
||||
environment: self.pack.polytone_colors.env,
|
||||
fluid_fog: self.pack.polytone_colors.fluid_fog,
|
||||
};
|
||||
let mut fog = horizon_client::fog::setup_fog(tables, &self.fog, &input);
|
||||
if !self.settings.horizon.fog {
|
||||
|
||||
@@ -292,10 +292,8 @@ fn pack_particles(
|
||||
}
|
||||
|
||||
/// The packs' custom colours; their dye and sheep colours go to the draw paths' table.
|
||||
fn read_colors(root: &std::path::Path) -> horizon_client::polytone_colors::PackColors {
|
||||
let colors = horizon_client::polytone_colors::PackColors::read(root);
|
||||
horizon_client::pack_palette::set(&colors);
|
||||
horizon_client::pack_palette::set_item_bars(root);
|
||||
horizon_client::brewing::set_pack_colors(colors.effects());
|
||||
fn read_colors(root: &std::path::Path) -> horizon_client::pack_palette::PackColors {
|
||||
let colors = horizon_client::pack_palette::read(root);
|
||||
horizon_client::brewing::set_pack_colors(colors.effects.clone());
|
||||
colors
|
||||
}
|
||||
@@ -29,12 +29,13 @@ pub(super) struct RespawnConfig {
|
||||
pub dim: Dim,
|
||||
pub pos: [i32; 3],
|
||||
pub yaw: f32,
|
||||
pub pitch: f32,
|
||||
pub forced: bool,
|
||||
}
|
||||
|
||||
/// A respawn point as a player's save keeps it (`respawn`): dimension,
|
||||
/// block, yaw and whether it is forced.
|
||||
pub(super) type SavedRespawn = (String, [i32; 3], f32, bool);
|
||||
/// block, yaw, pitch and whether it is forced.
|
||||
pub(super) type SavedRespawn = (String, [i32; 3], f32, f32, bool);
|
||||
|
||||
impl RespawnConfig {
|
||||
/// `RespawnData.of`: the yaw wrapped to [-180, 180).
|
||||
@@ -43,19 +44,37 @@ impl RespawnConfig {
|
||||
dim,
|
||||
pos,
|
||||
yaw: wrap_degrees(f64::from(yaw)) as f32,
|
||||
pitch: 0.0,
|
||||
forced,
|
||||
}
|
||||
}
|
||||
pub(super) fn from_saved((dim, pos, yaw, forced): &SavedRespawn) -> Self {
|
||||
/// `SetSpawnCommand`: the forced literal, with its already parsed angles.
|
||||
pub(super) fn forced(dim: Dim, pos: [i32; 3], yaw: f32, pitch: f32) -> Self {
|
||||
Self {
|
||||
dim,
|
||||
pos,
|
||||
yaw,
|
||||
pitch,
|
||||
forced: true,
|
||||
}
|
||||
}
|
||||
pub(super) fn from_saved((dim, pos, yaw, pitch, forced): &SavedRespawn) -> Self {
|
||||
Self {
|
||||
dim: Dim::from_id(dim),
|
||||
pos: *pos,
|
||||
yaw: *yaw,
|
||||
pitch: *pitch,
|
||||
forced: *forced,
|
||||
}
|
||||
}
|
||||
pub(super) fn saved(&self) -> SavedRespawn {
|
||||
(self.dim.id().to_owned(), self.pos, self.yaw, self.forced)
|
||||
(
|
||||
self.dim.id().to_owned(),
|
||||
self.pos,
|
||||
self.yaw,
|
||||
self.pitch,
|
||||
self.forced,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -180,6 +199,7 @@ impl Game<'_> {
|
||||
dim: self.dim,
|
||||
pos,
|
||||
yaw: 0.0,
|
||||
pitch: 0.0,
|
||||
forced: false,
|
||||
};
|
||||
// RespawnConfig.isSamePosition: dimension and block.
|
||||
@@ -478,14 +498,14 @@ impl Game<'_> {
|
||||
rule.can_set_spawn.test(self.dark_outside())
|
||||
}
|
||||
/// `ServerPlayer.findRespawnAndUseSpawnBlock` for a config in the loaded
|
||||
/// dimension: the position and yaw, using an anchor's charge; a bed's
|
||||
/// dimension: position and angles, using an anchor's charge; a bed's
|
||||
/// stand-up spot. A forced point (`spawnpoint`) that is no anchor or bed
|
||||
/// holds if a player fits in it; anything else is gone.
|
||||
pub(super) fn find_respawn_and_use(
|
||||
&mut self,
|
||||
c: RespawnConfig,
|
||||
consume: bool,
|
||||
) -> Result<Option<(Vec3, f32)>> {
|
||||
) -> Result<Option<(Vec3, f32, f32)>> {
|
||||
let state = self.world.block(c.pos[0], c.pos[1], c.pos[2]);
|
||||
if !(self.is_anchor(state)
|
||||
&& (c.forced || self.anchor_charges(state) > 0)
|
||||
@@ -495,7 +515,7 @@ impl Game<'_> {
|
||||
if self.is_bed(state) && self.bed_sets_spawn(state) {
|
||||
let facing = self.bed_forward(state);
|
||||
let stand = self.bed_stand_up(c.pos, facing, c.yaw);
|
||||
return Ok(stand.map(|p| (p, look_at_yaw(p, c.pos))));
|
||||
return Ok(stand.map(|p| (p, look_at_yaw(p, c.pos), 0.0)));
|
||||
}
|
||||
let [x, y, z] = c.pos;
|
||||
let free = c.forced
|
||||
@@ -503,7 +523,7 @@ impl Game<'_> {
|
||||
&& self.possible_to_respawn_in(state)
|
||||
&& self.possible_to_respawn_in(self.world.block(x, y + 1, z));
|
||||
let at = Vec3::new(f64::from(x) + 0.5, f64::from(y) + 0.1, f64::from(z) + 0.5);
|
||||
return Ok(free.then_some((at, c.yaw)));
|
||||
return Ok(free.then_some((at, c.yaw, c.pitch)));
|
||||
}
|
||||
let stand = self.anchor_stand_up(c.pos);
|
||||
if !c.forced
|
||||
@@ -513,7 +533,7 @@ impl Game<'_> {
|
||||
{
|
||||
self.level_set(c.pos, next)?;
|
||||
}
|
||||
Ok(stand.map(|p| (p, look_at_yaw(p, c.pos))))
|
||||
Ok(stand.map(|p| (p, look_at_yaw(p, c.pos), 0.0)))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -560,3 +580,354 @@ mod tests {
|
||||
assert!((look_at_yaw(east, head) - 90.0).abs() < 1.0e-4);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod connected_spawnpoint_rotation_tests {
|
||||
use crate::game::{Game, lan::LanSettings};
|
||||
use horizon_client::{
|
||||
chunk::Chunk,
|
||||
lan::{Joined, NetConfig, Out, PlayerSender, guest::Guest, hub::HubConfig, playerdata},
|
||||
};
|
||||
use horizon_protocol::packets::{
|
||||
configuration::ClientInformation,
|
||||
play::{self, Clientbound as C, Serverbound as S},
|
||||
};
|
||||
use std::sync::{Arc, atomic::AtomicI32, mpsc};
|
||||
|
||||
fn command(game: &mut Game<'_>, text: &str) {
|
||||
game.chat_capture = Some(Vec::new());
|
||||
game.ui.registries.permission = 4;
|
||||
let dispatcher = horizon_client::command::vanilla::build_for(false, true);
|
||||
let parsed = dispatcher.parse(text, &game.ui.registries);
|
||||
let (ctx, _) = parsed
|
||||
.executable()
|
||||
.expect("actual registered dedicated command");
|
||||
let (root, ctx) = (parsed.context.clone(), ctx.clone());
|
||||
assert_eq!(game.run_chain(&root, &ctx).unwrap(), 1, "{text}");
|
||||
assert!(
|
||||
!game.chat_capture.take().unwrap().is_empty(),
|
||||
"actual command feedback {text}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "prepared resources and isolated scratch homes/cwd"]
|
||||
fn command_forced_free_point_survives_real_guest_death_and_respawn_packet() {
|
||||
let mut game = crate::game::native_machines::tests::server_game();
|
||||
game.set_allow_commands(true);
|
||||
let scratch = std::env::temp_dir().join(format!(
|
||||
"horizon-forced-spawn-{}-{}",
|
||||
std::process::id(),
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos()
|
||||
));
|
||||
std::fs::create_dir_all(&scratch).unwrap();
|
||||
game.world_root = scratch.clone();
|
||||
// Fixture setup through the existing column insertion API; admit every lit column below.
|
||||
for x in -2..=2 {
|
||||
for z in -2..=2 {
|
||||
game.streaming
|
||||
.insert_remote(&mut game.world, Chunk::empty(x, z));
|
||||
assert!(game.streaming.is_lit(x, z));
|
||||
}
|
||||
}
|
||||
let stone = game
|
||||
.pack
|
||||
.assets
|
||||
.registry
|
||||
.default_state("minecraft:stone")
|
||||
.unwrap();
|
||||
let air = game
|
||||
.pack
|
||||
.assets
|
||||
.registry
|
||||
.default_state("minecraft:air")
|
||||
.unwrap();
|
||||
game.world.set(12, 99, 12, stone).unwrap();
|
||||
for y in [100, 101] {
|
||||
game.world.set(12, y, 12, air).unwrap();
|
||||
}
|
||||
assert_eq!(game.world.block(12, 99, 12), stone);
|
||||
assert!(game.world.loaded(12, 12));
|
||||
game.level.world_spawn = Some([4, 100, 4]);
|
||||
game.level.world_spawn_angle = [-30.0, -5.0];
|
||||
game.lan_host(
|
||||
LanSettings {
|
||||
port: 0,
|
||||
guest_commands: true,
|
||||
force_game_mode: false,
|
||||
},
|
||||
NetConfig {
|
||||
listen: false,
|
||||
announce: false,
|
||||
..NetConfig::loopback(0, String::new(), 2)
|
||||
},
|
||||
HubConfig {
|
||||
default_game_mode: 0,
|
||||
guest_commands: true,
|
||||
force_game_mode: false,
|
||||
hardcore: false,
|
||||
view_distance: 2,
|
||||
simulation_distance: 2,
|
||||
dimension: "minecraft:overworld".into(),
|
||||
allow_flight: false,
|
||||
seed: 0,
|
||||
max_players: 2,
|
||||
integrated: false,
|
||||
levels: Default::default(),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
let (tx, rx) = mpsc::channel();
|
||||
let mut guest = Guest::new(
|
||||
Joined {
|
||||
conn: 7,
|
||||
uuid: 77,
|
||||
name: "Alice".into(),
|
||||
properties: Vec::new(),
|
||||
info: ClientInformation::new(2),
|
||||
brand: "test".into(),
|
||||
sender: PlayerSender::new(tx, Arc::default()),
|
||||
latency: Arc::new(AtomicI32::new(0)),
|
||||
address: "127.0.0.1:7".parse().unwrap(),
|
||||
expected: false,
|
||||
configured: Default::default(),
|
||||
},
|
||||
7,
|
||||
[4.5, 100.0, 4.5],
|
||||
2,
|
||||
);
|
||||
guest.dimension = "minecraft:overworld".into();
|
||||
guest.placed = true;
|
||||
guest.loaded = true;
|
||||
guest.health = 20.0;
|
||||
guest.game_mode = 0;
|
||||
guest.yaw = 0.0;
|
||||
guest.pitch = 0.0;
|
||||
game.lan.as_mut().unwrap().hub.guests.push(guest);
|
||||
let clock = game.world.game_time;
|
||||
command(&mut game, "spawnpoint Alice 12 100 12 45 10");
|
||||
let host = game.lan.as_ref().unwrap();
|
||||
assert!(host.owner.is_none());
|
||||
let guest = &host.hub.guests[0];
|
||||
assert_eq!(
|
||||
(guest.uuid, guest.health, guest.game_mode, guest.position),
|
||||
(77, 20.0, 0, [4.5, 100.0, 4.5])
|
||||
);
|
||||
let tag = playerdata::player_tag(guest);
|
||||
let respawn = tag
|
||||
.get("respawn")
|
||||
.expect("actual serialized personal configuration")
|
||||
.clone();
|
||||
assert_eq!(
|
||||
respawn.get("yaw"),
|
||||
Some(&horizon_world::nbt::Tag::Float(45f32.to_bits()))
|
||||
);
|
||||
assert_eq!(
|
||||
respawn.get("pos"),
|
||||
Some(&horizon_world::nbt::Tag::IntArray(vec![12, 100, 12]))
|
||||
);
|
||||
command(&mut game, "kill Alice");
|
||||
game.tick_lan(false);
|
||||
let mut host = game.lan.take().unwrap();
|
||||
assert_eq!(host.hub.guests[0].uuid, 77);
|
||||
assert_eq!(host.hub.guests[0].health, 0.0);
|
||||
assert!(
|
||||
host.hub.guests[0].dead,
|
||||
"actual command death before request"
|
||||
);
|
||||
for _ in rx.try_iter() {}
|
||||
game.lan_guest_packet(
|
||||
&mut host,
|
||||
7,
|
||||
S::ClientCommand(play::ClientCommand { action: 0 }),
|
||||
);
|
||||
game.tick_server_travel(&mut host);
|
||||
assert!(
|
||||
!game.on_server_trip(7),
|
||||
"admitted lit columns permit actual arrival"
|
||||
);
|
||||
let guest = &host.hub.guests[0];
|
||||
assert_eq!(guest.uuid, 77);
|
||||
assert_eq!(guest.health, 20.0);
|
||||
assert!(!guest.dead);
|
||||
assert_eq!(guest.position, [12.5, 100.1, 12.5]);
|
||||
assert_eq!(game.level.world_spawn, Some([4, 100, 4]));
|
||||
assert_eq!(game.level.world_spawn_angle, [-30.0, -5.0]);
|
||||
assert_eq!(game.world.game_time, clock);
|
||||
let physical = [guest.yaw.to_bits(), guest.pitch.to_bits()];
|
||||
let awaiting_id = guest
|
||||
.awaiting_teleport
|
||||
.expect("actual final arrival awaits its acknowledgement")
|
||||
.0;
|
||||
let mut packets = Vec::new();
|
||||
for out in rx.try_iter() {
|
||||
match out {
|
||||
Out::Packet(p) => packets.push(p),
|
||||
Out::Packets(p) => packets.extend(p),
|
||||
Out::Disconnect(_) => panic!("unexpected disconnect"),
|
||||
}
|
||||
}
|
||||
let mut positions = Vec::new();
|
||||
let mut respawns = 0;
|
||||
for p in packets {
|
||||
println!("complete actual Hub frame {p:?}");
|
||||
let mut r = horizon_protocol::buf::Reader::new(&p);
|
||||
let id = r.varint().unwrap();
|
||||
match C::decode(id, r.rest()).unwrap() {
|
||||
C::PlayerPosition(p) => positions.push(p),
|
||||
C::Respawn(_) => respawns += 1,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
assert_eq!(respawns, 1);
|
||||
assert!(!positions.is_empty());
|
||||
println!("all actual arrival positions {positions:?}");
|
||||
let selected: Vec<_> = positions
|
||||
.iter()
|
||||
.filter(|p| p.teleport == awaiting_id)
|
||||
.collect();
|
||||
assert_eq!(
|
||||
selected.len(),
|
||||
1,
|
||||
"select by actual authoritative pending id, never expected position/pitch"
|
||||
);
|
||||
let p = selected[0];
|
||||
assert_eq!(p.position, [12.5, 100.1, 12.5]);
|
||||
// All successful command/death/arrival/identity/world controls precede the intended regression.
|
||||
assert_eq!(
|
||||
(
|
||||
respawn.get("pitch"),
|
||||
physical,
|
||||
[p.yaw.to_bits(), p.pitch.to_bits()]
|
||||
),
|
||||
(
|
||||
Some(&horizon_world::nbt::Tag::Float(10f32.to_bits())),
|
||||
[45f32.to_bits(), 10f32.to_bits()],
|
||||
[45f32.to_bits(), 10f32.to_bits()]
|
||||
)
|
||||
);
|
||||
drop(host);
|
||||
drop(game);
|
||||
std::fs::remove_dir_all(scratch).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod pitch_preservation_controls {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn wrapped_existing_callers_default_to_positive_zero_pitch() {
|
||||
let c = RespawnConfig::wrapped(Dim::Overworld, [5, 100, 5], 45.0, false);
|
||||
assert_eq!(c.yaw.to_bits(), 45f32.to_bits());
|
||||
assert_eq!(c.pitch.to_bits(), 0.0f32.to_bits());
|
||||
assert!(!c.forced);
|
||||
}
|
||||
|
||||
/// Added source/model controls; these do not add a connected bed/anchor runtime claim.
|
||||
#[test]
|
||||
#[ignore = "prepared resources and isolated scratch homes/cwd"]
|
||||
fn bed_anchor_facing_and_invalid_personal_fallback_ignore_stored_pitch() {
|
||||
let mut game = crate::game::native_machines::tests::server_game();
|
||||
let stone = game
|
||||
.pack
|
||||
.assets
|
||||
.registry
|
||||
.default_state("minecraft:stone")
|
||||
.unwrap();
|
||||
for x in 0..16 {
|
||||
for z in 0..16 {
|
||||
game.world.set(x, 99, z, stone).unwrap();
|
||||
}
|
||||
}
|
||||
let state = |game: &Game<'_>, name: &str, properties: &[(&str, &str)]| {
|
||||
let registry = game.pack.assets.registry;
|
||||
let id = registry.default_state(name).unwrap();
|
||||
let mut value = registry.state(id).unwrap().clone();
|
||||
for (key, value_) in properties {
|
||||
value.properties.insert((*key).into(), (*value_).into());
|
||||
}
|
||||
registry.resolve(&value).unwrap()
|
||||
};
|
||||
let head = state(
|
||||
&game,
|
||||
"minecraft:red_bed",
|
||||
&[("facing", "north"), ("part", "head")],
|
||||
);
|
||||
let foot = state(
|
||||
&game,
|
||||
"minecraft:red_bed",
|
||||
&[("facing", "north"), ("part", "foot")],
|
||||
);
|
||||
game.world.set(5, 100, 5, head).unwrap();
|
||||
game.world.set(5, 100, 6, foot).unwrap();
|
||||
assert!(game.is_bed(head) && game.bed_sets_spawn(head));
|
||||
let bed = RespawnConfig {
|
||||
dim: Dim::Overworld,
|
||||
pos: [5, 100, 5],
|
||||
yaw: 45.0,
|
||||
pitch: 0.0,
|
||||
forced: true,
|
||||
};
|
||||
let zero = game
|
||||
.find_respawn_and_use(bed, false)
|
||||
.unwrap()
|
||||
.expect("valid bed geometry");
|
||||
let stored = game
|
||||
.find_respawn_and_use(RespawnConfig { pitch: 75.0, ..bed }, false)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(stored, zero);
|
||||
assert_eq!(stored.2.to_bits(), 0.0f32.to_bits());
|
||||
assert_eq!(stored.1.to_bits(), look_at_yaw(stored.0, bed.pos).to_bits());
|
||||
|
||||
// Existing built-in dimension facts select the anchor rule; no new world or actor API.
|
||||
game.dim = Dim::Nether;
|
||||
let anchor = state(&game, "minecraft:respawn_anchor", &[("charges", "2")]);
|
||||
game.world.set(10, 100, 10, anchor).unwrap();
|
||||
assert!(game.anchor_works() && game.is_anchor(anchor));
|
||||
let c = RespawnConfig {
|
||||
dim: Dim::Nether,
|
||||
pos: [10, 100, 10],
|
||||
yaw: 45.0,
|
||||
pitch: 0.0,
|
||||
forced: true,
|
||||
};
|
||||
let zero = game
|
||||
.find_respawn_and_use(c, false)
|
||||
.unwrap()
|
||||
.expect("valid anchor geometry");
|
||||
let stored = game
|
||||
.find_respawn_and_use(RespawnConfig { pitch: 75.0, ..c }, true)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(stored, zero);
|
||||
assert_eq!(stored.2.to_bits(), 0.0f32.to_bits());
|
||||
assert_eq!(stored.1.to_bits(), look_at_yaw(stored.0, c.pos).to_bits());
|
||||
assert_eq!(
|
||||
game.world.block(10, 100, 10),
|
||||
anchor,
|
||||
"existing forced-charge behavior"
|
||||
);
|
||||
|
||||
game.dim = Dim::Overworld;
|
||||
game.level.world_spawn = Some([4, 100, 4]);
|
||||
game.level.world_spawn_angle = [-30.0, -5.0];
|
||||
game.world.set(12, 100, 12, stone).unwrap();
|
||||
let invalid = RespawnConfig {
|
||||
dim: Dim::Overworld,
|
||||
pos: [12, 100, 12],
|
||||
yaw: 45.0,
|
||||
pitch: 10.0,
|
||||
forced: true,
|
||||
};
|
||||
game.respawn_config = Some(invalid);
|
||||
assert!(game.resolve_server_respawn(invalid).is_none());
|
||||
assert!(game.respawn_config.is_none());
|
||||
assert_eq!(game.level.world_spawn, Some([4, 100, 4]));
|
||||
assert_eq!(game.level.world_spawn_angle, [-30.0, -5.0]);
|
||||
}
|
||||
}
|
||||
@@ -359,12 +359,19 @@ impl Game<'_> {
|
||||
self.sculk.tracker = WardenSpawnTracker::from_nbt(tag);
|
||||
}
|
||||
}
|
||||
/// Write the tracker into the player data being saved.
|
||||
/// The tracker as saved, once it has been loaded.
|
||||
pub(super) fn warden_tracker_tag(&self) -> Option<horizon_world::nbt::Tag> {
|
||||
self.sculk
|
||||
.tracker_loaded
|
||||
.then(|| self.sculk.tracker.to_nbt())
|
||||
}
|
||||
/// Write the tracker into the player data being saved. With a LAN owner,
|
||||
/// `store_owner` rewrites that from the owner's guest, so it puts the
|
||||
/// tracker there as well (`playerdata::with_warden`).
|
||||
pub(super) fn save_warden_tracker(&mut self) {
|
||||
if !self.sculk.tracker_loaded {
|
||||
let Some(tag) = self.warden_tracker_tag() else {
|
||||
return;
|
||||
}
|
||||
let tag = self.sculk.tracker.to_nbt();
|
||||
};
|
||||
let extra = self
|
||||
.level
|
||||
.player_extra
|
||||
|
||||
@@ -644,11 +644,14 @@ impl Game<'_> {
|
||||
/// in the level in place: where it stands up, facing the anchor (with
|
||||
/// the deplete sound) or the bed; else the point is forgotten, and the
|
||||
/// caller tells the player (`Hub::missing_respawn_block`).
|
||||
pub(super) fn resolve_server_respawn(&mut self, config: RespawnConfig) -> Option<(Vec3, f32)> {
|
||||
pub(super) fn resolve_server_respawn(
|
||||
&mut self,
|
||||
config: RespawnConfig,
|
||||
) -> Option<(Vec3, f32, f32)> {
|
||||
let [x, y, z] = config.pos;
|
||||
let anchor = self.is_anchor(self.world.block(x, y, z));
|
||||
match self.find_respawn_and_use(config, true) {
|
||||
Ok(Some((at, yaw))) => {
|
||||
Ok(Some((at, yaw, pitch))) => {
|
||||
if !config.forced && anchor {
|
||||
let [x, y, z] = config.pos.map(f64::from);
|
||||
self.audio.play(
|
||||
@@ -658,7 +661,7 @@ impl Game<'_> {
|
||||
1.0,
|
||||
);
|
||||
}
|
||||
Some((at, yaw))
|
||||
Some((at, yaw, pitch))
|
||||
}
|
||||
Ok(None) | Err(_) => {
|
||||
let mut cancel = false;
|
||||
@@ -796,7 +799,10 @@ impl Game<'_> {
|
||||
let yaw = host.hub.guests[i].yaw;
|
||||
let (position, yaw) = if let Some(config) = trip.respawn {
|
||||
match self.as_guest(host, trip.conn, |g| g.resolve_server_respawn(config)) {
|
||||
Some(Some(found)) => found,
|
||||
Some(Some((at, yaw, pitch))) => {
|
||||
host.hub.guests[i].pitch = pitch;
|
||||
(at, yaw)
|
||||
}
|
||||
_ => {
|
||||
host.hub.missing_respawn_block(trip.conn);
|
||||
self.respawn_at_world_spawn(host, trip.conn);
|
||||
|
||||
@@ -1268,7 +1268,7 @@ impl Loading {
|
||||
let output = self
|
||||
.renderer
|
||||
.as_ref()
|
||||
.map_or(Default::default(), |r| *r.output());
|
||||
.map_or(Default::default(), |r| r.output());
|
||||
self.menu
|
||||
.options
|
||||
.draw(&self.settings, output, batch, &gui.atlas, self.menu.cursor);
|
||||
|
||||
@@ -15,6 +15,8 @@ pub(super) fn rename_box() -> horizon_client::ui::EditBox {
|
||||
edit.text_color = u32::MAX;
|
||||
// AnvilScreen also sets `setTextColorUneditable(-1)`: white either way.
|
||||
edit.text_color_uneditable = u32::MAX;
|
||||
// AnvilScreen sets `setInvertHighlightedTextColor(false)`.
|
||||
edit.invert_highlighted_text_color = false;
|
||||
edit.focused = true;
|
||||
edit.editable = false;
|
||||
edit
|
||||
@@ -40,6 +42,7 @@ mod tests {
|
||||
// AnvilScreen.init: no input yet, so the box is read-only but focused.
|
||||
let station_box = rename_box();
|
||||
assert!(station_box.focused && !station_box.editable);
|
||||
assert!(!station_box.invert_highlighted_text_color);
|
||||
|
||||
// slotChanged with an item: editable, and it shows the item's name.
|
||||
let mut station = Station::new_kind(Kind::Anvil, [0; 3]);
|
||||
|
||||
@@ -15,6 +15,26 @@ use std::{
|
||||
time::{Duration, SystemTime, UNIX_EPOCH},
|
||||
};
|
||||
|
||||
/// Studio's state in a `Game`: the replay playing, its free camera, timeline and top bar. Studio is
|
||||
/// a Horizon extra (docs/layers.md: the replay editor); the engine only asks it whether a replay
|
||||
/// plays or the free camera flies.
|
||||
#[derive(Default)]
|
||||
pub(super) struct Studio {
|
||||
/// The packet replay playing, if any.
|
||||
pub(super) replay: Option<ReplayDriver>,
|
||||
pub(super) freecam: horizon_client::studio::StudioFreecam,
|
||||
pub(super) timeline: horizon_client::studio::TimelineUi,
|
||||
pub(super) topbar: horizon_client::studio::TopBarUi,
|
||||
pub(super) rmb_down: bool,
|
||||
pub(super) camera_track_dirty: bool,
|
||||
/// Camera perspective to restore after leaving replay playback.
|
||||
pub(super) replay_previous_camera_type: Option<super::perspective::CameraType>,
|
||||
/// Replay opened from the title screen owns the synthetic remote session.
|
||||
pub(super) replay_standalone: bool,
|
||||
pub(super) last_replay_tick: Option<std::time::Instant>,
|
||||
pub(super) virtual_tick_ms: f32,
|
||||
}
|
||||
|
||||
pub const HELP: &[&str] = &[
|
||||
"studio record start [NAME] start recording clientbound packets into .mcpr archive",
|
||||
"studio record stop stop recording and finalize .mcpr archive",
|
||||
@@ -109,13 +129,13 @@ impl Game<'_> {
|
||||
match words[0] {
|
||||
"freecam" => match words.get(1).copied() {
|
||||
Some("on") => {
|
||||
if self.studio_replay.is_none() {
|
||||
if self.studio.replay.is_none() {
|
||||
self.system_message(Text::translate(
|
||||
"horizon.studio.freecam.replay_required",
|
||||
&[],
|
||||
));
|
||||
} else if let Some((position, yaw, pitch)) = self.camera_view(1.0) {
|
||||
self.studio_freecam.activate(
|
||||
self.studio.freecam.activate(
|
||||
position,
|
||||
yaw,
|
||||
pitch,
|
||||
@@ -131,24 +151,24 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
Some("off") => {
|
||||
self.studio_freecam.deactivate();
|
||||
self.studio.freecam.deactivate();
|
||||
self.capture(false);
|
||||
self.system_message(Text::translate("horizon.studio.freecam.disabled", &[]));
|
||||
}
|
||||
_ => self.system_message(Text::translate("horizon.studio.freecam.usage", &[])),
|
||||
},
|
||||
"keyframe" => {
|
||||
let Some(driver) = self.studio_replay.as_ref() else {
|
||||
let Some(driver) = self.studio.replay.as_ref() else {
|
||||
self.system_message(Text::translate("horizon.studio.replay.inactive", &[]));
|
||||
return true;
|
||||
};
|
||||
let time_ms = driver.position_ms();
|
||||
match words.get(1).copied() {
|
||||
Some("add") => {
|
||||
if !self.studio_freecam.active
|
||||
if !self.studio.freecam.active
|
||||
&& let Some((position, yaw, pitch)) = self.camera_view(1.0)
|
||||
{
|
||||
self.studio_freecam.activate(
|
||||
self.studio.freecam.activate(
|
||||
position,
|
||||
yaw,
|
||||
pitch,
|
||||
@@ -156,16 +176,16 @@ impl Game<'_> {
|
||||
);
|
||||
}
|
||||
self.capture(true);
|
||||
self.studio_freecam.add_keyframe_at_current(time_ms);
|
||||
self.studio_camera_track_dirty = true;
|
||||
self.studio.freecam.add_keyframe_at_current(time_ms);
|
||||
self.studio.camera_track_dirty = true;
|
||||
self.system_message(Text::tr(
|
||||
"horizon.studio.keyframe.added",
|
||||
&[&time_ms.to_string()],
|
||||
));
|
||||
}
|
||||
Some("clear") => {
|
||||
self.studio_freecam.track.clear();
|
||||
self.studio_camera_track_dirty = true;
|
||||
self.studio.freecam.track.clear();
|
||||
self.studio.camera_track_dirty = true;
|
||||
self.system_message(Text::translate(
|
||||
"horizon.studio.keyframe.cleared",
|
||||
&[],
|
||||
@@ -181,15 +201,15 @@ impl Game<'_> {
|
||||
));
|
||||
return true;
|
||||
};
|
||||
if index >= self.studio_freecam.track.keyframes.len() {
|
||||
if index >= self.studio.freecam.track.keyframes.len() {
|
||||
self.system_message(Text::translate(
|
||||
"horizon.studio.keyframe.index_invalid",
|
||||
&[],
|
||||
));
|
||||
return true;
|
||||
}
|
||||
self.studio_freecam.track.keyframes.remove(index);
|
||||
self.studio_camera_track_dirty = true;
|
||||
self.studio.freecam.track.keyframes.remove(index);
|
||||
self.studio.camera_track_dirty = true;
|
||||
self.system_message(Text::tr(
|
||||
"horizon.studio.keyframe.removed",
|
||||
&[&index.to_string()],
|
||||
@@ -199,14 +219,14 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
"path" => match words.get(1).copied() {
|
||||
Some("lock") if !self.studio_freecam.track.is_empty() => {
|
||||
self.studio_freecam.active = true;
|
||||
self.studio_freecam.locked_to_path = true;
|
||||
Some("lock") if !self.studio.freecam.track.is_empty() => {
|
||||
self.studio.freecam.active = true;
|
||||
self.studio.freecam.locked_to_path = true;
|
||||
self.capture(true);
|
||||
self.system_message(Text::translate("horizon.studio.path.locked", &[]));
|
||||
}
|
||||
Some("unlock") => {
|
||||
self.studio_freecam.locked_to_path = false;
|
||||
self.studio.freecam.locked_to_path = false;
|
||||
self.system_message(Text::translate("horizon.studio.path.unlocked", &[]));
|
||||
}
|
||||
_ => self.system_message(Text::translate("horizon.studio.path.usage", &[])),
|
||||
@@ -299,7 +319,7 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
Some("pause") => {
|
||||
if let Some(r) = self.studio_replay.as_mut() {
|
||||
if let Some(r) = self.studio.replay.as_mut() {
|
||||
r.set_paused(true);
|
||||
self.system_message(Text::translate("horizon.studio.replay.paused", &[]));
|
||||
} else {
|
||||
@@ -307,7 +327,7 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
Some("resume") => {
|
||||
if let Some(r) = self.studio_replay.as_mut() {
|
||||
if let Some(r) = self.studio.replay.as_mut() {
|
||||
r.set_paused(false);
|
||||
self.system_message(Text::translate("horizon.studio.replay.resumed", &[]));
|
||||
} else {
|
||||
@@ -323,7 +343,7 @@ impl Game<'_> {
|
||||
return true;
|
||||
};
|
||||
if let Ok(spd) = spd_str.parse::<f64>() {
|
||||
let new_speed = if let Some(r) = self.studio_replay.as_mut() {
|
||||
let new_speed = if let Some(r) = self.studio.replay.as_mut() {
|
||||
r.set_speed(spd);
|
||||
Some(r.speed())
|
||||
} else {
|
||||
@@ -357,11 +377,12 @@ impl Game<'_> {
|
||||
return true;
|
||||
};
|
||||
if let Ok(ms) = ms_str.parse::<u32>() {
|
||||
if self.studio_replay.is_some() {
|
||||
if self.studio.replay.is_some() {
|
||||
match self.seek_studio_replay(ms) {
|
||||
Ok(()) => {
|
||||
let actual_ms = self
|
||||
.studio_replay
|
||||
.studio
|
||||
.replay
|
||||
.as_ref()
|
||||
.map_or(ms, |replay| ms.min(replay.duration_ms()));
|
||||
let ms_text = actual_ms.to_string();
|
||||
@@ -400,7 +421,7 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
Some("status") => {
|
||||
if let Some(r) = self.studio_replay.as_ref() {
|
||||
if let Some(r) = self.studio.replay.as_ref() {
|
||||
let position = r.position_ms().to_string();
|
||||
let duration = r.duration_ms().to_string();
|
||||
let progress = format!("{:.1}", r.progress() * 100.0);
|
||||
@@ -435,7 +456,7 @@ impl Game<'_> {
|
||||
|
||||
/// Reset playback tick accumulator and align positions on seek.
|
||||
pub(super) fn reset_studio_playback_ticks(&mut self) {
|
||||
self.studio_virtual_tick_ms = 0.0;
|
||||
self.studio.virtual_tick_ms = 0.0;
|
||||
if let Some(player) = &self.player {
|
||||
self.previous_position = player.position;
|
||||
if let Some(m) = self.player_model.as_mut() {
|
||||
@@ -452,12 +473,14 @@ impl Game<'_> {
|
||||
/// forward because its pre-replay world must remain intact.
|
||||
pub(super) fn seek_studio_replay(&mut self, target_ms: u32) -> std::io::Result<()> {
|
||||
let current_ms = self
|
||||
.studio_replay
|
||||
.studio
|
||||
.replay
|
||||
.as_ref()
|
||||
.map_or(0, ReplayDriver::position_ms);
|
||||
let packets = if self.studio_replay_standalone {
|
||||
let packets = if self.studio.replay_standalone {
|
||||
let packets = self
|
||||
.studio_replay
|
||||
.studio
|
||||
.replay
|
||||
.as_mut()
|
||||
.expect("replay checked by caller")
|
||||
.seek_and_collect(target_ms)?;
|
||||
@@ -471,7 +494,8 @@ impl Game<'_> {
|
||||
"backward seek requires a standalone replay",
|
||||
));
|
||||
}
|
||||
self.studio_replay
|
||||
self.studio
|
||||
.replay
|
||||
.as_mut()
|
||||
.expect("replay checked by caller")
|
||||
.seek_forward(target_ms)?
|
||||
@@ -479,7 +503,7 @@ impl Game<'_> {
|
||||
|
||||
self.dispatch_studio_replay_packets(packets);
|
||||
self.reset_studio_playback_ticks();
|
||||
self.last_replay_tick = Some(std::time::Instant::now());
|
||||
self.studio.last_replay_tick = Some(std::time::Instant::now());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -510,19 +534,19 @@ impl Game<'_> {
|
||||
|
||||
/// Advance active replay playback by elapsed frame time and dispatch ready packets.
|
||||
pub(super) fn advance_studio_replay(&mut self) -> Result<()> {
|
||||
let Some(driver) = self.studio_replay.as_mut() else {
|
||||
self.last_replay_tick = None;
|
||||
let Some(driver) = self.studio.replay.as_mut() else {
|
||||
self.studio.last_replay_tick = None;
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
let now = std::time::Instant::now();
|
||||
let delta = match self.last_replay_tick {
|
||||
let delta = match self.studio.last_replay_tick {
|
||||
Some(last) => now
|
||||
.saturating_duration_since(last)
|
||||
.min(Duration::from_millis(250)),
|
||||
None => Duration::from_millis(16),
|
||||
};
|
||||
self.last_replay_tick = Some(now);
|
||||
self.studio.last_replay_tick = Some(now);
|
||||
|
||||
let ready_packets = match driver.advance(delta) {
|
||||
Ok(pkts) => pkts,
|
||||
@@ -533,16 +557,18 @@ impl Game<'_> {
|
||||
};
|
||||
|
||||
self.dispatch_studio_replay_packets(ready_packets);
|
||||
if self.studio_freecam.locked_to_path
|
||||
if self.studio.freecam.locked_to_path
|
||||
&& let Some(position) = self
|
||||
.studio_replay
|
||||
.studio
|
||||
.replay
|
||||
.as_ref()
|
||||
.map(|replay| replay.position_ms())
|
||||
{
|
||||
self.studio_freecam.update_spline_playback(position);
|
||||
self.studio.freecam.update_spline_playback(position);
|
||||
}
|
||||
let paused = self
|
||||
.studio_replay
|
||||
.studio
|
||||
.replay
|
||||
.as_ref()
|
||||
.is_some_and(|replay| replay.is_paused());
|
||||
if self.player_model.is_none() {
|
||||
@@ -550,13 +576,14 @@ impl Game<'_> {
|
||||
}
|
||||
if !paused {
|
||||
let speed = self
|
||||
.studio_replay
|
||||
.studio
|
||||
.replay
|
||||
.as_ref()
|
||||
.map_or(1.0, |r| r.speed() as f32)
|
||||
.max(0.1);
|
||||
self.studio_virtual_tick_ms += delta.as_secs_f32() * 1000.0 * speed;
|
||||
while self.studio_virtual_tick_ms >= 50.0 {
|
||||
self.studio_virtual_tick_ms -= 50.0;
|
||||
self.studio.virtual_tick_ms += delta.as_secs_f32() * 1000.0 * speed;
|
||||
while self.studio.virtual_tick_ms >= 50.0 {
|
||||
self.studio.virtual_tick_ms -= 50.0;
|
||||
if let Some(player) = &self.player {
|
||||
let current_pos = player.position;
|
||||
let (dx, dz) = (
|
||||
@@ -607,7 +634,7 @@ impl Game<'_> {
|
||||
m.previous_limb_speed = m.limb_speed;
|
||||
}
|
||||
if let Some((position, duration, paused, markers)) =
|
||||
self.studio_replay.as_ref().map(|replay| {
|
||||
self.studio.replay.as_ref().map(|replay| {
|
||||
(
|
||||
replay.position_ms(),
|
||||
replay.duration_ms(),
|
||||
@@ -616,10 +643,10 @@ impl Game<'_> {
|
||||
)
|
||||
})
|
||||
{
|
||||
self.studio_timeline.current_time_ms = u64::from(position);
|
||||
self.studio_timeline.duration_ms = u64::from(duration);
|
||||
self.studio_timeline.playing = !paused;
|
||||
self.studio_timeline.markers = markers;
|
||||
self.studio.timeline.current_time_ms = u64::from(position);
|
||||
self.studio.timeline.duration_ms = u64::from(duration);
|
||||
self.studio.timeline.playing = !paused;
|
||||
self.studio.timeline.markers = markers;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -639,10 +666,10 @@ impl Game<'_> {
|
||||
self.capture(false);
|
||||
}
|
||||
TopBarAction::ExitStudio => {
|
||||
if self.studio_replay.is_some() {
|
||||
if self.studio.replay.is_some() {
|
||||
self.close_studio_replay();
|
||||
} else {
|
||||
self.studio_freecam.deactivate();
|
||||
self.studio.freecam.deactivate();
|
||||
self.capture(false);
|
||||
}
|
||||
}
|
||||
@@ -668,42 +695,46 @@ impl Game<'_> {
|
||||
} else {
|
||||
(glam::DVec3::ZERO, 0.0, 0.0)
|
||||
};
|
||||
self.studio_freecam
|
||||
self.studio
|
||||
.freecam
|
||||
.activate(position, yaw, pitch, self.settings.fov);
|
||||
}
|
||||
TopBarAction::ToggleLockToPath => {
|
||||
self.studio_freecam.locked_to_path = !self.studio_freecam.locked_to_path;
|
||||
self.studio.freecam.locked_to_path = !self.studio.freecam.locked_to_path;
|
||||
}
|
||||
TopBarAction::ToggleFreecam => {
|
||||
if self.studio_freecam.active {
|
||||
self.studio_freecam.deactivate();
|
||||
if self.studio.freecam.active {
|
||||
self.studio.freecam.deactivate();
|
||||
self.capture(false);
|
||||
} else {
|
||||
let (position, yaw, pitch) =
|
||||
self.camera_view(0.0)
|
||||
.unwrap_or((glam::DVec3::ZERO, 0.0, 0.0));
|
||||
self.studio_freecam
|
||||
self.studio
|
||||
.freecam
|
||||
.activate(position, yaw, pitch, self.settings.fov);
|
||||
self.capture(true);
|
||||
}
|
||||
}
|
||||
TopBarAction::AddKeyframe => {
|
||||
self.studio_freecam
|
||||
.add_keyframe_at_current(self.studio_timeline.current_time_ms as u32);
|
||||
self.studio_camera_track_dirty = true;
|
||||
self.studio
|
||||
.freecam
|
||||
.add_keyframe_at_current(self.studio.timeline.current_time_ms as u32);
|
||||
self.studio.camera_track_dirty = true;
|
||||
}
|
||||
TopBarAction::AddMarker => self.add_studio_marker(),
|
||||
TopBarAction::ClearKeyframes => {
|
||||
self.studio_freecam.track.keyframes.clear();
|
||||
self.studio_timeline.selected_keyframe = None;
|
||||
self.studio_camera_track_dirty = true;
|
||||
self.studio.freecam.track.keyframes.clear();
|
||||
self.studio.timeline.selected_keyframe = None;
|
||||
self.studio.camera_track_dirty = true;
|
||||
}
|
||||
TopBarAction::ToggleTimeline => {
|
||||
self.studio_timeline.visible = !self.studio_timeline.visible;
|
||||
self.studio.timeline.visible = !self.studio.timeline.visible;
|
||||
}
|
||||
TopBarAction::SplitClip => {
|
||||
self.studio_timeline
|
||||
.split_at(self.studio_timeline.current_time_ms);
|
||||
self.studio
|
||||
.timeline
|
||||
.split_at(self.studio.timeline.current_time_ms);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -716,93 +747,97 @@ impl Game<'_> {
|
||||
use horizon_client::studio::TimelineAction;
|
||||
match action {
|
||||
TimelineAction::PauseForSeek => {
|
||||
self.studio_timeline.playing = false;
|
||||
if let Some(replay) = self.studio_replay.as_mut() {
|
||||
self.studio.timeline.playing = false;
|
||||
if let Some(replay) = self.studio.replay.as_mut() {
|
||||
replay.set_paused(true);
|
||||
}
|
||||
}
|
||||
TimelineAction::Seek(time_ms) => {
|
||||
let time_ms = if self.studio_replay_standalone {
|
||||
let time_ms = if self.studio.replay_standalone {
|
||||
time_ms
|
||||
} else {
|
||||
let current =
|
||||
self.studio_replay
|
||||
self.studio
|
||||
.replay
|
||||
.as_ref()
|
||||
.map_or(0, ReplayDriver::position_ms) as u64;
|
||||
time_ms.max(current)
|
||||
};
|
||||
self.studio_timeline.current_time_ms = time_ms;
|
||||
self.studio.timeline.current_time_ms = time_ms;
|
||||
if let Err(error) = self.seek_studio_replay(time_ms.min(u32::MAX as u64) as u32) {
|
||||
log::warn!("Studio timeline seek failed: {error}");
|
||||
}
|
||||
}
|
||||
TimelineAction::TogglePlay => {
|
||||
self.studio_timeline.playing = !self.studio_timeline.playing;
|
||||
if let Some(replay) = self.studio_replay.as_mut() {
|
||||
replay.set_paused(!self.studio_timeline.playing);
|
||||
self.studio.timeline.playing = !self.studio.timeline.playing;
|
||||
if let Some(replay) = self.studio.replay.as_mut() {
|
||||
replay.set_paused(!self.studio.timeline.playing);
|
||||
}
|
||||
}
|
||||
TimelineAction::StepBack => {
|
||||
let time_ms = self.studio_timeline.current_time_ms.saturating_sub(1000);
|
||||
let time_ms = self.studio.timeline.current_time_ms.saturating_sub(1000);
|
||||
self.handle_studio_timeline_action(TimelineAction::Seek(time_ms));
|
||||
}
|
||||
TimelineAction::StepForward => {
|
||||
let time_ms = self
|
||||
.studio_timeline
|
||||
.studio
|
||||
.timeline
|
||||
.current_time_ms
|
||||
.saturating_add(1000)
|
||||
.min(self.studio_timeline.duration_ms);
|
||||
.min(self.studio.timeline.duration_ms);
|
||||
self.handle_studio_timeline_action(TimelineAction::Seek(time_ms));
|
||||
}
|
||||
TimelineAction::SeekStart => {
|
||||
self.handle_studio_timeline_action(TimelineAction::Seek(0))
|
||||
}
|
||||
TimelineAction::SeekEnd => self.handle_studio_timeline_action(TimelineAction::Seek(
|
||||
self.studio_timeline.duration_ms,
|
||||
self.studio.timeline.duration_ms,
|
||||
)),
|
||||
TimelineAction::SetSpeed(speed) => {
|
||||
self.studio_timeline.speed = speed;
|
||||
if let Some(replay) = self.studio_replay.as_mut() {
|
||||
self.studio.timeline.speed = speed;
|
||||
if let Some(replay) = self.studio.replay.as_mut() {
|
||||
replay.set_speed(speed);
|
||||
}
|
||||
}
|
||||
TimelineAction::ToggleFreecam => {
|
||||
if self.studio_freecam.active {
|
||||
self.studio_freecam.deactivate();
|
||||
if self.studio.freecam.active {
|
||||
self.studio.freecam.deactivate();
|
||||
} else {
|
||||
let (position, yaw, pitch) =
|
||||
self.camera_view(0.0)
|
||||
.unwrap_or((glam::DVec3::ZERO, 0.0, 0.0));
|
||||
self.studio_freecam
|
||||
self.studio
|
||||
.freecam
|
||||
.activate(position, yaw, pitch, self.settings.fov);
|
||||
}
|
||||
}
|
||||
TimelineAction::AddCameraKeyframe => {
|
||||
self.studio_freecam
|
||||
.add_keyframe_at_current(self.studio_timeline.current_time_ms as u32);
|
||||
self.studio_camera_track_dirty = true;
|
||||
self.studio
|
||||
.freecam
|
||||
.add_keyframe_at_current(self.studio.timeline.current_time_ms as u32);
|
||||
self.studio.camera_track_dirty = true;
|
||||
}
|
||||
TimelineAction::RemoveCameraKeyframe(index) => {
|
||||
if index < self.studio_freecam.track.keyframes.len() {
|
||||
self.studio_freecam.track.keyframes.remove(index);
|
||||
self.studio_camera_track_dirty = true;
|
||||
if index < self.studio.freecam.track.keyframes.len() {
|
||||
self.studio.freecam.track.keyframes.remove(index);
|
||||
self.studio.camera_track_dirty = true;
|
||||
}
|
||||
self.studio_timeline.selected_keyframe = None;
|
||||
self.studio.timeline.selected_keyframe = None;
|
||||
}
|
||||
TimelineAction::SelectKeyframe(index) => {
|
||||
self.studio_timeline.selected_keyframe = Some(index);
|
||||
self.studio.timeline.selected_keyframe = Some(index);
|
||||
}
|
||||
TimelineAction::KeyframeMoved => {
|
||||
self.studio_camera_track_dirty = true;
|
||||
self.studio.camera_track_dirty = true;
|
||||
}
|
||||
TimelineAction::SplitClip(time_ms) => self.studio_timeline.split_at(time_ms),
|
||||
TimelineAction::SplitClip(time_ms) => self.studio.timeline.split_at(time_ms),
|
||||
TimelineAction::None => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// Add a bookmark at the replay playhead and save it in the `.mcpr` archive.
|
||||
pub(super) fn add_studio_marker(&mut self) {
|
||||
let Some(replay) = self.studio_replay.as_mut() else {
|
||||
let Some(replay) = self.studio.replay.as_mut() else {
|
||||
return;
|
||||
};
|
||||
let label = Text::translate(
|
||||
@@ -811,7 +846,7 @@ impl Game<'_> {
|
||||
)
|
||||
.plain();
|
||||
match replay.add_marker(label) {
|
||||
Ok(_) => self.studio_timeline.markers = replay.markers().to_vec(),
|
||||
Ok(_) => self.studio.timeline.markers = replay.markers().to_vec(),
|
||||
Err(error) => log::warn!("Studio: could not save replay marker: {error}"),
|
||||
}
|
||||
}
|
||||
@@ -845,7 +880,7 @@ impl Game<'_> {
|
||||
Ok(Some(res))
|
||||
}
|
||||
Some("pause") => {
|
||||
if let Some(r) = self.studio_replay.as_mut() {
|
||||
if let Some(r) = self.studio.replay.as_mut() {
|
||||
r.set_paused(true);
|
||||
Ok(Some(json!({ "ok": true, "paused": true })))
|
||||
} else {
|
||||
@@ -853,7 +888,7 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
Some("resume") => {
|
||||
if let Some(r) = self.studio_replay.as_mut() {
|
||||
if let Some(r) = self.studio.replay.as_mut() {
|
||||
r.set_paused(false);
|
||||
Ok(Some(json!({ "ok": true, "paused": false })))
|
||||
} else {
|
||||
@@ -863,7 +898,7 @@ impl Game<'_> {
|
||||
Some("speed") => {
|
||||
let spd_str = words.get(3).ok_or("missing speed value")?;
|
||||
let spd: f64 = spd_str.parse().map_err(|_| "invalid speed")?;
|
||||
if let Some(r) = self.studio_replay.as_mut() {
|
||||
if let Some(r) = self.studio.replay.as_mut() {
|
||||
r.set_speed(spd);
|
||||
Ok(Some(json!({ "ok": true, "speed": r.speed() })))
|
||||
} else {
|
||||
@@ -873,7 +908,7 @@ impl Game<'_> {
|
||||
Some("seek") => {
|
||||
let ms_str = words.get(3).ok_or("missing seek timestamp")?;
|
||||
let ms: u32 = ms_str.parse().map_err(|_| "invalid timestamp")?;
|
||||
let pos = if let Some(r) = self.studio_replay.as_mut() {
|
||||
let pos = if let Some(r) = self.studio.replay.as_mut() {
|
||||
r.seek(ms).map_err(|e| format!("{e}"))?;
|
||||
Some(r.position_ms())
|
||||
} else {
|
||||
@@ -887,7 +922,7 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
Some("status") => {
|
||||
if let Some(r) = self.studio_replay.as_ref() {
|
||||
if let Some(r) = self.studio.replay.as_ref() {
|
||||
Ok(Some(json!({
|
||||
"ok": true,
|
||||
"active": true,
|
||||
@@ -999,13 +1034,13 @@ impl Game<'_> {
|
||||
}
|
||||
|
||||
pub(super) fn open_studio_replay(&mut self, path: &str) -> Result<Value> {
|
||||
if self.studio_camera_track_dirty
|
||||
&& let Some(active) = self.studio_replay.as_mut()
|
||||
if self.studio.camera_track_dirty
|
||||
&& let Some(active) = self.studio.replay.as_mut()
|
||||
{
|
||||
if let Err(error) = active.save_camera_track(&self.studio_freecam.track) {
|
||||
if let Err(error) = active.save_camera_track(&self.studio.freecam.track) {
|
||||
return Ok(json!({ "ok": false, "error": error.to_string() }));
|
||||
}
|
||||
self.studio_camera_track_dirty = false;
|
||||
self.studio.camera_track_dirty = false;
|
||||
}
|
||||
// The recordings browser can be opened while the title screen is
|
||||
// available but the slower world tables are still loading. Replay
|
||||
@@ -1028,7 +1063,7 @@ impl Game<'_> {
|
||||
}
|
||||
};
|
||||
|
||||
let standalone = self.studio_replay.is_none() && self.remote.is_none();
|
||||
let standalone = self.studio.replay.is_none() && self.remote.is_none();
|
||||
if self.remote.is_none()
|
||||
&& let Err(error) = self.init_remote_for_replay()
|
||||
{
|
||||
@@ -1038,17 +1073,17 @@ impl Game<'_> {
|
||||
|
||||
let duration_ms = driver.duration_ms();
|
||||
let packets = driver.packet_count();
|
||||
self.studio_timeline.duration_ms = u64::from(duration_ms);
|
||||
self.studio_timeline.current_time_ms = 0;
|
||||
self.studio_timeline.playing = !driver.is_paused();
|
||||
self.studio_timeline.markers = driver.markers().to_vec();
|
||||
self.studio_freecam.track = driver.camera_track().cloned().unwrap_or_default();
|
||||
self.studio_camera_track_dirty = false;
|
||||
if self.studio_replay.is_none() {
|
||||
self.studio_replay_previous_camera_type = Some(self.camera_type);
|
||||
self.studio_replay_standalone = standalone;
|
||||
self.studio.timeline.duration_ms = u64::from(duration_ms);
|
||||
self.studio.timeline.current_time_ms = 0;
|
||||
self.studio.timeline.playing = !driver.is_paused();
|
||||
self.studio.timeline.markers = driver.markers().to_vec();
|
||||
self.studio.freecam.track = driver.camera_track().cloned().unwrap_or_default();
|
||||
self.studio.camera_track_dirty = false;
|
||||
if self.studio.replay.is_none() {
|
||||
self.studio.replay_previous_camera_type = Some(self.camera_type);
|
||||
self.studio.replay_standalone = standalone;
|
||||
}
|
||||
self.studio_replay = Some(driver);
|
||||
self.studio.replay = Some(driver);
|
||||
self.camera_type = super::perspective::CameraType::FirstPerson;
|
||||
self.title = None;
|
||||
self.screen = Screen::Playing;
|
||||
@@ -1066,14 +1101,14 @@ impl Game<'_> {
|
||||
}))
|
||||
}
|
||||
pub(super) fn close_studio_replay(&mut self) -> bool {
|
||||
if self.studio_camera_track_dirty
|
||||
&& let Some(driver) = self.studio_replay.as_mut()
|
||||
&& let Err(error) = driver.save_camera_track(&self.studio_freecam.track)
|
||||
if self.studio.camera_track_dirty
|
||||
&& let Some(driver) = self.studio.replay.as_mut()
|
||||
&& let Err(error) = driver.save_camera_track(&self.studio.freecam.track)
|
||||
{
|
||||
log::warn!("Studio: could not save camera path: {error}");
|
||||
}
|
||||
self.studio_camera_track_dirty = false;
|
||||
let closed = self.studio_replay.take().is_some();
|
||||
self.studio.camera_track_dirty = false;
|
||||
let closed = self.studio.replay.take().is_some();
|
||||
if !closed {
|
||||
return false;
|
||||
}
|
||||
@@ -1081,23 +1116,23 @@ impl Game<'_> {
|
||||
// A standalone replay owns a synthetic Remote connection. Treat that
|
||||
// connection as replay state even if the saved origin flag was lost;
|
||||
// sending it through leave_server() opens the Multiplayer screen.
|
||||
let standalone = self.studio_replay_standalone
|
||||
let standalone = self.studio.replay_standalone
|
||||
|| self
|
||||
.remote
|
||||
.as_ref()
|
||||
.is_some_and(|remote| remote.brand == "horizon-studio");
|
||||
self.studio_replay_standalone = false;
|
||||
if let Some(camera_type) = self.studio_replay_previous_camera_type.take() {
|
||||
self.studio.replay_standalone = false;
|
||||
if let Some(camera_type) = self.studio.replay_previous_camera_type.take() {
|
||||
self.camera_type = camera_type;
|
||||
}
|
||||
self.last_replay_tick = None;
|
||||
self.studio_timeline.current_time_ms = 0;
|
||||
self.studio_timeline.playing = false;
|
||||
self.studio_timeline.markers.clear();
|
||||
self.studio_timeline.split_points.clear();
|
||||
self.studio_timeline.selected_keyframe = None;
|
||||
self.studio_freecam.deactivate();
|
||||
self.studio_virtual_tick_ms = 0.0;
|
||||
self.studio.last_replay_tick = None;
|
||||
self.studio.timeline.current_time_ms = 0;
|
||||
self.studio.timeline.playing = false;
|
||||
self.studio.timeline.markers.clear();
|
||||
self.studio.timeline.split_points.clear();
|
||||
self.studio.timeline.selected_keyframe = None;
|
||||
self.studio.freecam.deactivate();
|
||||
self.studio.virtual_tick_ms = 0.0;
|
||||
self.capture(false);
|
||||
if standalone {
|
||||
if let Some(mut remote) = self.remote.take() {
|
||||
|
||||
@@ -149,7 +149,7 @@ impl Game<'_> {
|
||||
self.audio
|
||||
.play_unshared("minecraft:item.totem.use", Some(body.bounds.feet), 1.0, 1.0);
|
||||
if target == TrackingTarget::LocalPlayer {
|
||||
self.pad_rumble(horizon_client::gamepad::Rumble::Totem);
|
||||
self.input_feedback(horizon_client::input_source::Feedback::Totem);
|
||||
// findTotem, then LocalPlayer.displayItemActivation. The offsets
|
||||
// come from the player's own random in vanilla, not the level's:
|
||||
// the client-only particle random stands in for it. The server
|
||||
|
||||
@@ -298,7 +298,7 @@ impl super::Game<'_> {
|
||||
.into_iter()
|
||||
.any(|action| self.keys.held(action))
|
||||
|| self
|
||||
.pad_move()
|
||||
.input_move()
|
||||
.is_some_and(|[left, forward]| left.abs() > 0.3 || forward.abs() > 0.3))
|
||||
{
|
||||
self.tutorial.input();
|
||||
|
||||
@@ -107,9 +107,9 @@ impl Game<'_> {
|
||||
state: ElementState::Pressed,
|
||||
..
|
||||
}
|
||||
) && !self.pad.menu.synthetic
|
||||
) && !self.input_made_pointer_event()
|
||||
{
|
||||
self.pad.keyboard_took_over = true;
|
||||
self.input_keyboard_used();
|
||||
}
|
||||
if let WindowEvent::MouseInput { state, button, .. } = &event {
|
||||
if *state == ElementState::Pressed {
|
||||
|
||||
@@ -245,7 +245,7 @@ impl Game<'_> {
|
||||
// Pause-menu and death-screen exits use the normal world-close action.
|
||||
// A standalone replay owns a synthetic remote session, so it must go
|
||||
// through replay cleanup instead of being treated like a server.
|
||||
if self.studio_replay.is_some() {
|
||||
if self.studio.replay.is_some() {
|
||||
self.close_studio_replay();
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
@@ -165,6 +165,22 @@ impl<T: Joined> JoinList<T> {
|
||||
self.push(entity);
|
||||
}
|
||||
}
|
||||
/// An entity that never left its level keeps its place in
|
||||
/// `EntityTickList`: one a pass took out and puts back (an arrow bouncing
|
||||
/// off) goes back before the first entity that joined after it. One that
|
||||
/// has not joined yet is appended and numbered, as by `push`.
|
||||
pub fn put_back(&mut self, entity: T) {
|
||||
let seq = entity.join_seq();
|
||||
let at = if seq.is_joined() {
|
||||
self.list
|
||||
.iter()
|
||||
.position(|q| q.join_seq() > seq)
|
||||
.unwrap_or(self.list.len())
|
||||
} else {
|
||||
self.list.len()
|
||||
};
|
||||
self.insert(at, entity);
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a, T> IntoIterator for &'a JoinList<T> {
|
||||
@@ -193,6 +209,33 @@ impl<T> IntoIterator for JoinList<T> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[derive(Debug)]
|
||||
struct E {
|
||||
join: JoinSeq,
|
||||
}
|
||||
crate::impl_joined!(E);
|
||||
|
||||
/// A numbered entity put back lands by its join number; a new one goes last.
|
||||
#[test]
|
||||
fn put_back_keeps_the_join_order() {
|
||||
let mut list = JoinList::default();
|
||||
for _ in 0..3 {
|
||||
list.push(E {
|
||||
join: JoinSeq::UNJOINED,
|
||||
});
|
||||
}
|
||||
let first = list.remove(0);
|
||||
let first_seq = first.join;
|
||||
list.put_back(first);
|
||||
assert_eq!(list[0].join, first_seq, "back before the later joins");
|
||||
list.put_back(E {
|
||||
join: JoinSeq::UNJOINED,
|
||||
});
|
||||
let seqs: Vec<_> = list.iter().map(|e| e.join).collect();
|
||||
assert!(seqs.windows(2).all(|w| w[0] < w[1]), "{seqs:?}");
|
||||
assert_eq!(seqs.len(), 4);
|
||||
}
|
||||
|
||||
/// Whatever can change a list changes its stamp; only reading leaves it.
|
||||
#[test]
|
||||
fn a_stamp_holds_until_the_list_can_have_changed() {
|
||||
|
||||
@@ -173,10 +173,10 @@ pub struct Guest {
|
||||
/// Where the player respawns (a bed's spot; `ServerPlayer.respawnConfig`),
|
||||
/// None for the world spawn.
|
||||
pub spawn: Option<[f64; 3]>,
|
||||
/// A respawn point outside the Overworld (a respawn anchor, or one
|
||||
/// `spawnpoint` set there): its dimension, block, yaw and whether it is
|
||||
/// forced, as the player's save keeps it (`respawn`).
|
||||
pub respawn_elsewhere: Option<(String, [i32; 3], f32, bool)>,
|
||||
/// A block-resolved personal respawn point: its dimension, block, yaw,
|
||||
/// pitch and whether it is forced, as the player's save keeps it
|
||||
/// (`respawn`).
|
||||
pub respawn_elsewhere: Option<(String, [i32; 3], f32, f32, bool)>,
|
||||
/// Pending ClientboundPlayerPositionPacket id; movement is ignored until
|
||||
/// the client accepts it (ServerGamePacketListenerImpl.awaitingPositionFromClient).
|
||||
pub awaiting_teleport: Option<(i32, [f64; 3])>,
|
||||
|
||||
@@ -343,7 +343,7 @@ struct Kept {
|
||||
effects: Option<crate::effects::PlayerEffects>,
|
||||
game_mode: Option<(i32, bool, f32)>,
|
||||
position: Option<([f64; 3], [f64; 3], f32, f32, f32, bool, Option<[i32; 4]>)>,
|
||||
spawn: Option<(Option<[f64; 3]>, Option<(String, [i32; 3], f32, bool)>)>,
|
||||
spawn: Option<(Option<[f64; 3]>, Option<(String, [i32; 3], f32, f32, bool)>)>,
|
||||
recipes: Option<crate::recipe_book::Settings>,
|
||||
/// The body's values: its fall goes with the position, its air and fire with the
|
||||
/// health.
|
||||
|
||||
@@ -53,6 +53,27 @@ pub fn exists(world: &Path, uuid: u128) -> bool {
|
||||
path(world, uuid).is_file()
|
||||
}
|
||||
|
||||
/// The key `ServerPlayer.addAdditionalSaveData` stores the `WardenSpawnTracker` under.
|
||||
pub const WARDEN_TRACKER_KEY: &str = "warden_spawn_tracker";
|
||||
|
||||
/// Put a player's `warden_spawn_tracker` into the compound `store` writes from
|
||||
/// (`guest.saved`), so a save keeps it rather than the older one loaded with
|
||||
/// the file (the LAN owner's tracker lives in `Game`, `save_warden_tracker`).
|
||||
/// Other saved keys are left as they are; no tracker (not loaded yet) changes
|
||||
/// nothing.
|
||||
pub fn with_warden(guest: &mut Guest, tracker: Option<Tag>) -> &mut Guest {
|
||||
let Some(tracker) = tracker else {
|
||||
return guest;
|
||||
};
|
||||
let saved = guest
|
||||
.saved
|
||||
.get_or_insert_with(|| Tag::Compound(Default::default()));
|
||||
if let Tag::Compound(map) = saved {
|
||||
map.insert(WARDEN_TRACKER_KEY.into(), tracker);
|
||||
}
|
||||
guest
|
||||
}
|
||||
|
||||
/// `PlayerDataStorage.load`: a guest's saved state, if they have been here
|
||||
/// before. The player's file, else its `.dat_old` (what a save cut off
|
||||
/// between its two renames leaves, `write_dat`).
|
||||
@@ -651,6 +672,42 @@ mod tests {
|
||||
)
|
||||
}
|
||||
|
||||
/// #9965: the owner's warden warning level and cooldown reach the file on
|
||||
/// a save, instead of the ones the file was loaded with.
|
||||
#[test]
|
||||
fn a_stored_warden_tracker_replaces_the_loaded_one() {
|
||||
let mut g = guest();
|
||||
g.saved = Some(Tag::compound(vec![
|
||||
(
|
||||
"warden_spawn_tracker",
|
||||
Tag::compound(vec![
|
||||
("warning_level", Tag::Int(2)),
|
||||
("cooldown_ticks", Tag::Int(77)),
|
||||
("ticks_since_last_warning", Tag::Int(5)),
|
||||
]),
|
||||
),
|
||||
("unrelated_key", Tag::Int(9)),
|
||||
]));
|
||||
let tracker = crate::sculk::WardenSpawnTracker {
|
||||
ticks_since_last_warning: 0,
|
||||
warning_level: 3,
|
||||
cooldown_ticks: 200,
|
||||
};
|
||||
with_warden(&mut g, Some(tracker.to_nbt()));
|
||||
let (tag, _) = round_trip(&g);
|
||||
let mut back = guest();
|
||||
let mut level = Level::default();
|
||||
level.read_player(&tag);
|
||||
level.player_extra = Some(tag);
|
||||
apply(&mut back, &level);
|
||||
let saved = back.saved.as_ref().unwrap();
|
||||
let read =
|
||||
crate::sculk::WardenSpawnTracker::from_nbt(saved.get("warden_spawn_tracker").unwrap());
|
||||
assert_eq!((read.warning_level, read.cooldown_ticks), (3, 200));
|
||||
assert_eq!(read.ticks_since_last_warning, 0);
|
||||
assert!(matches!(saved.get("unrelated_key"), Some(Tag::Int(9))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_new_player_faces_the_world_spawn_angle() {
|
||||
let mut g = guest();
|
||||
|
||||
@@ -246,7 +246,9 @@ impl Goal for DefendVillage {
|
||||
// wider, as the box test is strict).
|
||||
let near = b.inflate(Vec3::new(1.0, 1.0, 1.0));
|
||||
// Villager.getPlayerReputation of the player this golem sees: the
|
||||
// villager's gossips about that player's own UUID.
|
||||
// villager's gossips about that player's own UUID. The villagers come
|
||||
// through the same `attackTargeting` (`forCombat().range(64)`), so the
|
||||
// golem must see them too.
|
||||
let bad = cx.w.others_near(near).any(|s| {
|
||||
s.species == Species::Villager
|
||||
&& s.alive
|
||||
@@ -255,6 +257,7 @@ impl Goal for DefendVillage {
|
||||
.is_some_and(|v| v.gossips.reputation(p.uuid) <= -100)
|
||||
&& b.contains(s.position)
|
||||
&& s.position.distance_squared(m.position) <= 64.0 * 64.0
|
||||
&& m.sees(cx.w, Who::Mob(s.id))
|
||||
});
|
||||
bad && m.sees(cx.w, Who::Player)
|
||||
}
|
||||
@@ -322,6 +325,39 @@ impl Goal for MoveTowardsTarget {
|
||||
m.nav_stop();
|
||||
}
|
||||
}
|
||||
impl Mob {
|
||||
/// `IronGolem.mobInteract`: the repair sound's pitch comes from the
|
||||
/// golem's own random, not the level's.
|
||||
pub fn golem_repair_pitch(&mut self) -> f32 {
|
||||
1.0 + (self.random.next_float() - self.random.next_float()) * 0.2
|
||||
}
|
||||
}
|
||||
/// `Level.isBrightOutside`: a fixed-time dimension (the End) is never bright.
|
||||
fn bright_outside(w: &MobWorld) -> bool {
|
||||
w.daylight && !w.dimension_type.has_fixed_time
|
||||
}
|
||||
/// `OfferFlowerGoal.getNearestEntity`: the nearest of
|
||||
/// #candidate_for_iron_golem_gift (villagers and copper golems) in the golem's
|
||||
/// box inflated by (6, 2, 6) that passes `forNonCombat().range(6.0)`: within
|
||||
/// six blocks and in sight.
|
||||
fn gift_candidate(m: &mut Mob, w: &MobWorld) -> Option<u32> {
|
||||
let area = m.bounds().inflate(Vec3::new(6.0, 2.0, 6.0));
|
||||
let mut best: Option<(f64, u32)> = None;
|
||||
for s in w.others_near(area) {
|
||||
if !matches!(s.species, Species::Villager | Species::CopperGolem)
|
||||
|| !s.alive
|
||||
|| !area.intersects(s.bounds)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let d = s.position.distance_squared(m.position);
|
||||
if d > 36.0 || best.is_some_and(|(b, _)| d >= b) || !m.sees(w, Who::Mob(s.id)) {
|
||||
continue;
|
||||
}
|
||||
best = Some((d, s.id));
|
||||
}
|
||||
best.map(|(_, id)| id)
|
||||
}
|
||||
/// `OfferFlowerGoal`: by day, now and then, hold out a poppy to a villager.
|
||||
#[derive(Clone, Debug)]
|
||||
struct OfferFlower {
|
||||
@@ -334,16 +370,10 @@ impl Goal for OfferFlower {
|
||||
MOVE | LOOK
|
||||
}
|
||||
fn can_use(&mut self, m: &mut Mob, cx: &mut Cx) -> bool {
|
||||
if !cx.w.daylight || m.random.next_int_bound(8000).unwrap_or(1) != 0 {
|
||||
if !bright_outside(cx.w) || m.random.next_int_bound(8000).unwrap_or(1) != 0 {
|
||||
return false;
|
||||
}
|
||||
// The nearest of #candidate_for_iron_golem_gift: villagers and copper golems.
|
||||
self.villager =
|
||||
cx.w.nearby(m.position, 6.0, |s| {
|
||||
matches!(s.species, Species::Villager | Species::CopperGolem) && s.alive
|
||||
})
|
||||
.map(|s| s.id)
|
||||
.next();
|
||||
self.villager = gift_candidate(m, cx.w);
|
||||
self.villager.is_some()
|
||||
}
|
||||
fn can_continue(&mut self, _: &mut Mob, _: &mut Cx) -> bool {
|
||||
@@ -612,6 +642,78 @@ mod tests {
|
||||
Mob::new(id, Arc::new(kind), at, 0.0)
|
||||
}
|
||||
|
||||
fn villager_at(id: u32, at: Vec3) -> crate::mobs::Seen {
|
||||
mob(id, Species::Villager, at).seen(None)
|
||||
}
|
||||
|
||||
fn pick(w: &MobWorld) -> Option<u32> {
|
||||
let mut golem = mob(1, Species::IronGolem, Vec3::new(0.5, 64.0, 0.5));
|
||||
gift_candidate(&mut golem, w)
|
||||
}
|
||||
|
||||
fn with_chunks(world: &mut crate::world::World, _: &horizon_world::registry::BlockRegistry) {
|
||||
for cx in -2..2 {
|
||||
for cz in -2..2 {
|
||||
world.chunks.insert(
|
||||
(cx, cz),
|
||||
std::sync::Arc::new(crate::chunk::Chunk::empty(cx, cz)),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `OfferFlowerGoal`: the search box is the golem's box inflated by
|
||||
/// (6, 2, 6) and the candidate must be within six blocks (Euclidean).
|
||||
#[test]
|
||||
fn the_poppy_goes_to_a_villager_in_the_box_within_six_blocks() {
|
||||
let cases = [
|
||||
(Vec3::new(4.5, 64.0, 0.5), true),
|
||||
// 6.5 blocks on one axis: outside the range.
|
||||
(Vec3::new(7.0, 64.0, 0.5), false),
|
||||
// About 8.5 blocks away, inside the per-axis box of six.
|
||||
(Vec3::new(6.0, 64.0, 6.0), false),
|
||||
// Five blocks above: outside the box's two-block rise.
|
||||
(Vec3::new(0.5, 69.0, 0.5), false),
|
||||
];
|
||||
for (at, offered) in cases {
|
||||
let villager = villager_at(2, at);
|
||||
crate::mobs::world::with_built_test_world(&[villager], with_chunks, |w, _| {
|
||||
assert_eq!(pick(w).is_some(), offered, "villager at {at:?}");
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// The candidate must also be in sight, and a fixed-time dimension is
|
||||
/// never bright outside.
|
||||
#[test]
|
||||
fn the_poppy_needs_sight_and_a_dimension_with_a_day() {
|
||||
let villager = villager_at(2, Vec3::new(4.5, 64.0, 0.5));
|
||||
let wall = |world: &mut crate::world::World, r: &horizon_world::registry::BlockRegistry| {
|
||||
with_chunks(world, r);
|
||||
let stone = r.default_state("minecraft:stone").unwrap();
|
||||
for y in 62..70 {
|
||||
for z in -6..8 {
|
||||
world.set(2, y, z, stone).unwrap();
|
||||
}
|
||||
}
|
||||
};
|
||||
crate::mobs::world::with_built_test_world(std::slice::from_ref(&villager), wall, |w, _| {
|
||||
assert_eq!(pick(w), None, "a villager behind a wall");
|
||||
});
|
||||
crate::mobs::world::with_built_test_world(&[villager], with_chunks, |w, _| {
|
||||
assert!(bright_outside(w));
|
||||
assert_eq!(pick(w), Some(2));
|
||||
let mut end = w.clone();
|
||||
end.dimension_type =
|
||||
horizon_world::dimension_type::DimensionFacts::vanilla("minecraft:the_end")
|
||||
.unwrap();
|
||||
assert!(!bright_outside(&end));
|
||||
let mut night = w.clone();
|
||||
night.daylight = false;
|
||||
assert!(!bright_outside(&night));
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snow_trail_posts_block_place_with_golem_and_snow_state() {
|
||||
let ran = crate::mobs::world::with_built_test_world(
|
||||
|
||||
@@ -855,9 +855,11 @@ pub fn texture(m: &Mob) -> String {
|
||||
"red",
|
||||
"black",
|
||||
];
|
||||
match COLORS.get(m.extra.color as usize) {
|
||||
Some(c) => format!("entity/shulker/shulker_{c}"),
|
||||
None => "entity/shulker/shulker".into(),
|
||||
// Shulker.getColor: the saved byte is signed; 16 and above are undyed, and
|
||||
// DyeColor.byId sends a negative id to white (OutOfBoundsStrategy.ZERO).
|
||||
match m.extra.color as i8 {
|
||||
16.. => "entity/shulker/shulker".into(),
|
||||
c => format!("entity/shulker/shulker_{}", COLORS[c.max(0) as usize]),
|
||||
}
|
||||
}
|
||||
/// `attachFace.getOpposite().getRotation()` by attach face: the open
|
||||
@@ -1144,3 +1146,46 @@ mod tests {
|
||||
assert!((h - 1.0).abs() < 1e-9, "{h}");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod saved_bytes_tests {
|
||||
use super::*;
|
||||
|
||||
/// `Shulker.readAdditionalSaveData`: `AttachFace` is
|
||||
/// `Direction.LEGACY_ID_CODEC` (`from3DDataValue` wraps the id through
|
||||
/// `abs(id % 6)`), while `Peek` and `Color` are raw `getByteOr` bytes. The
|
||||
/// load clamped all three to their setters' ranges instead.
|
||||
#[test]
|
||||
fn a_shulker_reads_its_face_and_lid_bytes_like_vanilla() {
|
||||
use crate::entity_nbt::mob_from_nbt;
|
||||
use horizon_world::nbt::Tag;
|
||||
let mut kind = crate::mobs::sensor_scan_tests::kind();
|
||||
let k = std::sync::Arc::make_mut(&mut kind);
|
||||
k.id = "minecraft:shulker".into();
|
||||
k.species = Species::Shulker;
|
||||
let types = [("minecraft:shulker".into(), kind)].into();
|
||||
let tag = Tag::compound([
|
||||
("id", Tag::String("minecraft:shulker".into())),
|
||||
(
|
||||
"Pos",
|
||||
Tag::List(vec![
|
||||
Tag::Double(0.0f64.to_bits()),
|
||||
Tag::Double(64.0f64.to_bits()),
|
||||
Tag::Double(0.0f64.to_bits()),
|
||||
]),
|
||||
),
|
||||
("AttachFace", Tag::Byte(7)),
|
||||
("Peek", Tag::Byte(-1)),
|
||||
("Color", Tag::Byte(-1)),
|
||||
]);
|
||||
let m = mob_from_nbt(&tag, &types, 1, None).unwrap();
|
||||
assert_eq!(m.extra.variant3, 1, "abs(7 % 6) is up");
|
||||
assert_eq!(m.extra.counter, -1, "the peek byte is kept");
|
||||
assert_eq!(m.extra.color, 255, "the color byte is kept");
|
||||
assert_eq!(
|
||||
texture(&m),
|
||||
"entity/shulker/shulker_white",
|
||||
"DyeColor.byId(-1)"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -21,7 +21,12 @@ use horizon_core::{
|
||||
random::RandomSource,
|
||||
};
|
||||
use serde_json::Value;
|
||||
use std::{borrow::Cow, collections::HashMap, fs, path::Path};
|
||||
use std::{
|
||||
borrow::{Borrow, Cow},
|
||||
collections::HashMap,
|
||||
fs,
|
||||
path::Path,
|
||||
};
|
||||
|
||||
pub use crate::biome::{SpawnCost, parse_costs, parse_entries};
|
||||
|
||||
@@ -879,13 +884,17 @@ impl SpawnState {
|
||||
/// `NaturalSpawner.createState`: every non-persistent entity of a
|
||||
/// spawning category in a loaded chunk adds its biome's spawn charge, its
|
||||
/// category count and (for mobs) the local caps of the players near it.
|
||||
pub fn new<L: NaturalLevel>(
|
||||
pub fn new<'a, L: NaturalLevel, I>(
|
||||
l: &L,
|
||||
spawnable_chunks: i32,
|
||||
players: Vec<SpawnPlayer>,
|
||||
entities: &[Counted],
|
||||
entities: I,
|
||||
chunk_loaded: impl Fn(i32, i32) -> bool,
|
||||
) -> Self {
|
||||
) -> Self
|
||||
where
|
||||
I: IntoIterator,
|
||||
I::Item: std::borrow::Borrow<Counted<'a>>,
|
||||
{
|
||||
let mut state = Self {
|
||||
spawnable_chunks,
|
||||
counts: [0; 8],
|
||||
@@ -898,6 +907,7 @@ impl SpawnState {
|
||||
last_checked: None,
|
||||
};
|
||||
for e in entities {
|
||||
let e = e.borrow();
|
||||
if e.is_mob && e.persistent || e.category == Category::Misc {
|
||||
continue;
|
||||
}
|
||||
@@ -915,6 +925,10 @@ impl SpawnState {
|
||||
}
|
||||
state
|
||||
}
|
||||
/// Reclaims the player buffer for reuse across passes.
|
||||
pub fn into_players(self) -> Vec<SpawnPlayer> {
|
||||
self.players
|
||||
}
|
||||
fn add_charge(&mut self, pos: [i32; 3], charge: f64) {
|
||||
if charge != 0.0 {
|
||||
self.charges.push((pos, charge));
|
||||
|
||||
@@ -2148,18 +2148,16 @@ impl MotionWorld<'_, '_, '_> {
|
||||
let gravity = living_motion::effective_gravity(self)?;
|
||||
self.player.velocity = fall_flying_movement(v, self.player.pitch, self.player.yaw, gravity);
|
||||
let movement = self.player.velocity;
|
||||
let before = self.player.position;
|
||||
self.move_self(movement)?;
|
||||
// LivingEntity.handleFallFlyingCollisions: the fall-damage sound plays
|
||||
// on both sides (a glider's own travel is client-side, and the sound
|
||||
// must reach its own ears, #8906), only the server hurts, and it
|
||||
// compares the delta before the move with the one after it, which
|
||||
// `Entity.move` leaves as the movement it resolved. `move_entity` does
|
||||
// not write that back, so the distance actually travelled stands in.
|
||||
// compares the horizontal speed before `updateFallFlyingMovement`
|
||||
// with the delta `Entity.move` leaves (the collided axes zeroed).
|
||||
if self.player.collision.horizontal {
|
||||
let travelled = self.player.position.subtract(before);
|
||||
let new_h = (travelled.x * travelled.x + travelled.z * travelled.z).sqrt();
|
||||
let dmg = fly_into_wall_damage(move_h, new_h);
|
||||
let v = self.player.velocity;
|
||||
let after_h = (v.x * v.x + v.z * v.z).sqrt();
|
||||
let dmg = fly_into_wall_damage(move_h, after_h);
|
||||
if dmg > 0.0 {
|
||||
let first = self.player.events.len();
|
||||
// getFallDamageSound((int) dmg)
|
||||
@@ -3148,6 +3146,7 @@ mod tests {
|
||||
assert_eq!(fly_into_wall_damage(0.3, 0.0), 0.0);
|
||||
assert!((fly_into_wall_damage(0.5, 0.1) - 1.0).abs() < 1e-5);
|
||||
assert!((fly_into_wall_damage(1.2, 0.0) - 9.0).abs() < 1e-5);
|
||||
assert!(fly_into_wall_damage(0.01, 0.0) < 0.0);
|
||||
}
|
||||
|
||||
use super::*;
|
||||
@@ -4195,17 +4194,16 @@ mod tests {
|
||||
health: &resources.health,
|
||||
};
|
||||
w.travel_fall_flying().unwrap();
|
||||
assert!(
|
||||
w.player.events.iter().any(|e| matches!(
|
||||
let is_fall = |e: &_| {
|
||||
matches!(
|
||||
e,
|
||||
MotionEvent::Fall(crate::landing::FallEvent::Sound {
|
||||
sound: crate::landing::SceneSound::SmallFall,
|
||||
..
|
||||
})
|
||||
)),
|
||||
"{:?}",
|
||||
w.player.events
|
||||
);
|
||||
MotionEvent::Fall(crate::landing::FallEvent::Sound { .. })
|
||||
)
|
||||
};
|
||||
assert_eq!(w.player.events.iter().filter(|e| is_fall(e)).count(), 1);
|
||||
// The sound must play only once on impact, not repeat on subsequent ticks (#12304):
|
||||
w.travel_fall_flying().unwrap();
|
||||
assert_eq!(w.player.events.iter().filter(|e| is_fall(e)).count(), 1);
|
||||
assert!(
|
||||
!w.player
|
||||
.hurt_log
|
||||
|
||||
@@ -13,6 +13,8 @@
|
||||
//! game rules) is kept from the loaded files and written back unchanged.
|
||||
//! Worlds saved by older Horizon builds (`region/`, `DIM-1/`, `Player` in
|
||||
//! `level.dat`) are migrated on open.
|
||||
mod respawn;
|
||||
|
||||
use crate::{
|
||||
Result,
|
||||
beehive::BeehiveExt,
|
||||
@@ -3945,8 +3947,8 @@ pub struct Level {
|
||||
pub spawn: Option<[f64; 3]>,
|
||||
/// A respawn point resolved through its block when the player respawns
|
||||
/// (a bed, a respawn anchor, a forced point in any dimension):
|
||||
/// dimension, block, yaw and `forced`.
|
||||
pub respawn_elsewhere: Option<(String, [i32; 3], f32, bool)>,
|
||||
/// dimension, block, yaw, pitch and `forced`.
|
||||
pub respawn_elsewhere: Option<(String, [i32; 3], f32, f32, bool)>,
|
||||
pub position: Option<[f64; 3]>,
|
||||
pub rotation: [f32; 2],
|
||||
pub health: f32,
|
||||
@@ -4292,20 +4294,8 @@ impl Level {
|
||||
if let Some(d) = text(p.get("Dimension")) {
|
||||
self.dimension = d;
|
||||
}
|
||||
if let Some(respawn) = p.get("respawn")
|
||||
&& let Some(Tag::IntArray(pos)) = respawn.get("pos")
|
||||
&& pos.len() == 3
|
||||
{
|
||||
let dimension =
|
||||
text(respawn.get("dimension")).unwrap_or_else(|| "minecraft:overworld".into());
|
||||
let forced = number(respawn.get("forced")).is_some_and(|v| v != 0);
|
||||
// Forced points, including Overworld ones, are validated through
|
||||
// their blocks on death, just like beds and respawn anchors.
|
||||
let yaw = match respawn.get("yaw") {
|
||||
Some(Tag::Float(b)) => f32::from_bits(*b),
|
||||
_ => 0.0,
|
||||
};
|
||||
self.respawn_elsewhere = Some((dimension, [pos[0], pos[1], pos[2]], yaw, forced));
|
||||
if let Some(respawn) = respawn::read(p) {
|
||||
self.respawn_elsewhere = Some(respawn);
|
||||
}
|
||||
let mut inventory = Vec::new();
|
||||
let mut inventory_components = HashMap::new();
|
||||
@@ -4536,17 +4526,8 @@ impl Level {
|
||||
));
|
||||
}
|
||||
// A respawn point other than the world spawn: an anchor elsewhere, or a bed.
|
||||
if let Some((dimension, block, yaw, forced)) = &self.respawn_elsewhere {
|
||||
let mut respawn = vec![
|
||||
("dimension", string(dimension)),
|
||||
("pos", Tag::IntArray(block.to_vec())),
|
||||
("yaw", Tag::Float(yaw.to_bits())),
|
||||
("pitch", Tag::Float(0f32.to_bits())),
|
||||
];
|
||||
if *forced {
|
||||
respawn.push(("forced", Tag::Byte(1)));
|
||||
}
|
||||
entries.push(("respawn", Tag::compound(respawn)));
|
||||
if let Some(saved) = &self.respawn_elsewhere {
|
||||
entries.push(("respawn", respawn::write(saved)));
|
||||
} else if let Some(s) = self.spawn {
|
||||
let block = [
|
||||
s[0].floor() as i32,
|
||||
@@ -7698,7 +7679,7 @@ mod tests {
|
||||
assert_eq!(back.spawn, Some([8.0, 64.0, 8.0]));
|
||||
assert_eq!(
|
||||
back.respawn_elsewhere,
|
||||
Some(("minecraft:overworld".into(), [20, 70, 20], 0.0, true))
|
||||
Some(("minecraft:overworld".into(), [20, 70, 20], 0.0, 0.0, true))
|
||||
);
|
||||
assert!(back.difficulty_locked);
|
||||
assert!(back.weather.raining);
|
||||
@@ -7969,7 +7950,7 @@ mod tests {
|
||||
// A personal point coinciding with the world spawn must still keep
|
||||
// its forced flag and yaw, rather than turning into a world fallback.
|
||||
for pos in [[5, 64, 5], [0, 80, 0]] {
|
||||
let point = ("minecraft:overworld".to_owned(), pos, 37.0, true);
|
||||
let point = ("minecraft:overworld".to_owned(), pos, 37.0, 0.0, true);
|
||||
let level = Level {
|
||||
respawn_elsewhere: Some(point.clone()),
|
||||
..Level::default()
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
//! Personal respawn record leaves from Level, using its existing typed NBT rules.
|
||||
use super::{Tag, number, string, text};
|
||||
|
||||
pub(super) fn read(p: &Tag) -> Option<(String, [i32; 3], f32, f32, bool)> {
|
||||
let mut value = None;
|
||||
if let Some(respawn) = p.get("respawn")
|
||||
&& let Some(Tag::IntArray(pos)) = respawn.get("pos")
|
||||
&& pos.len() == 3
|
||||
{
|
||||
let dimension =
|
||||
text(respawn.get("dimension")).unwrap_or_else(|| "minecraft:overworld".into());
|
||||
let forced = number(respawn.get("forced")).is_some_and(|v| v != 0);
|
||||
// Forced points, including Overworld ones, are validated through
|
||||
// their blocks on death, just like beds and respawn anchors.
|
||||
let yaw = match respawn.get("yaw") {
|
||||
Some(Tag::Float(b)) => f32::from_bits(*b),
|
||||
_ => 0.0,
|
||||
};
|
||||
let pitch = match respawn.get("pitch") {
|
||||
Some(Tag::Float(b)) => f32::from_bits(*b),
|
||||
_ => 0.0,
|
||||
};
|
||||
value = Some((dimension, [pos[0], pos[1], pos[2]], yaw, pitch, forced));
|
||||
}
|
||||
value
|
||||
}
|
||||
|
||||
pub(super) fn write(
|
||||
(dimension, block, yaw, pitch, forced): &(String, [i32; 3], f32, f32, bool),
|
||||
) -> Tag {
|
||||
let mut respawn = vec![
|
||||
("dimension", string(dimension)),
|
||||
("pos", Tag::IntArray(block.to_vec())),
|
||||
("yaw", Tag::Float(yaw.to_bits())),
|
||||
("pitch", Tag::Float(pitch.to_bits())),
|
||||
];
|
||||
if *forced {
|
||||
respawn.push(("forced", Tag::Byte(1)));
|
||||
}
|
||||
Tag::compound(respawn)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::super::{Level, read_dat, write_dat};
|
||||
use super::*;
|
||||
|
||||
/// Added model control: old records without pitch retain the existing positive-zero default.
|
||||
#[test]
|
||||
fn personal_respawn_missing_pitch_loads_positive_zero() {
|
||||
let tag = Tag::compound(vec![(
|
||||
"respawn",
|
||||
Tag::compound(vec![
|
||||
("dimension", string("minecraft:overworld")),
|
||||
("pos", Tag::IntArray(vec![12, 100, 12])),
|
||||
("yaw", Tag::Float(45f32.to_bits())),
|
||||
("forced", Tag::Byte(1)),
|
||||
]),
|
||||
)]);
|
||||
let mut level = Level::default();
|
||||
level.read_player(&tag);
|
||||
let (_, pos, yaw, pitch, forced) = level.respawn_elsewhere.unwrap();
|
||||
assert_eq!(pos, [12, 100, 12]);
|
||||
assert_eq!(yaw.to_bits(), 45f32.to_bits());
|
||||
assert_eq!(pitch.to_bits(), 0.0f32.to_bits());
|
||||
assert!(forced);
|
||||
}
|
||||
|
||||
/// Added model/I/O control, separate from the connected runtime proof.
|
||||
#[test]
|
||||
fn personal_respawn_nonzero_pitch_survives_actual_player_file_write_and_read() {
|
||||
let root = std::env::temp_dir().join(format!(
|
||||
"horizon-personal-pitch-{}-{}",
|
||||
std::process::id(),
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos()
|
||||
));
|
||||
std::fs::create_dir_all(&root).unwrap();
|
||||
let file = root.join("player.dat");
|
||||
let original = Level {
|
||||
respawn_elsewhere: Some((
|
||||
"minecraft:overworld".into(),
|
||||
[12, 100, 12],
|
||||
45.0,
|
||||
10.0,
|
||||
true,
|
||||
)),
|
||||
..Level::default()
|
||||
};
|
||||
write_dat(&file, original.player_tag()).unwrap();
|
||||
let tag = read_dat(&file).expect("actual written NBT player file");
|
||||
assert_eq!(
|
||||
tag.get("respawn").unwrap().get("pitch"),
|
||||
Some(&Tag::Float(10f32.to_bits()))
|
||||
);
|
||||
let mut loaded = Level::default();
|
||||
loaded.read_player(&tag);
|
||||
assert_eq!(loaded.respawn_elsewhere, original.respawn_elsewhere);
|
||||
println!("actual saved personal respawn {:?}", tag.get("respawn"));
|
||||
std::fs::remove_dir_all(root).unwrap();
|
||||
}
|
||||
}
|
||||
@@ -852,14 +852,24 @@ fn parse_recipe_sources<'a>(
|
||||
"minecraft:stonecutting" => {
|
||||
// A result count outside 1..=99 fails vanilla's codec: no recipe.
|
||||
if let Some(count) = crate::crafting::template_count(&v["result"]) {
|
||||
stonecutting.push(Stonecutting {
|
||||
id,
|
||||
input: ingredient(&v["ingredient"], items),
|
||||
result: ItemStack::new(
|
||||
let result = if v["result"].get("components").is_some() {
|
||||
match SmithingResult::parse(&v["result"], defaults)
|
||||
.and_then(|result| result.create())
|
||||
{
|
||||
Some(result) => result,
|
||||
None => continue,
|
||||
}
|
||||
} else {
|
||||
ItemStack::new(
|
||||
horizon_world::loot::recipe_result_id(&v["result"])
|
||||
.unwrap_or("minecraft:air"),
|
||||
count,
|
||||
),
|
||||
)
|
||||
};
|
||||
stonecutting.push(Stonecutting {
|
||||
id,
|
||||
input: ingredient(&v["ingredient"], items),
|
||||
result,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -2216,6 +2226,27 @@ mod tests {
|
||||
vec!["minecraft:diamond_sword", "minecraft:diamond_axe"]
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn stonecutter_result_keeps_its_components() {
|
||||
let root = std::env::temp_dir().join(format!(
|
||||
"horizon-stonecutter-components-{}",
|
||||
std::process::id()
|
||||
));
|
||||
let dir = root.join("data/minecraft/recipe");
|
||||
fs::create_dir_all(&dir).unwrap();
|
||||
fs::write(root.join("items.json"), r#"{"minecraft:stone":{}}"#).unwrap();
|
||||
fs::write(
|
||||
dir.join("cut.json"),
|
||||
r#"{"type":"minecraft:stonecutting","ingredient":"minecraft:stone","result":{"id":"minecraft:stone","components":{"minecraft:custom_name":"Cut Stone"}}}"#,
|
||||
)
|
||||
.unwrap();
|
||||
let recipes = StationRecipes::load(&root, &sample_items(), &PackOverlay::NONE);
|
||||
let _ = fs::remove_dir_all(&root);
|
||||
let listed = recipes.stonecutter(&ItemStack::new("minecraft:stone", 1));
|
||||
assert_eq!(listed.len(), 1);
|
||||
assert_eq!(listed[0].result.custom_name().as_deref(), Some("Cut Stone"));
|
||||
}
|
||||
|
||||
/// A world pack's stonecutting recipe takes its place in registration
|
||||
/// order (path, then namespace), not a place after the game's own.
|
||||
#[test]
|
||||
|
||||
@@ -47,6 +47,15 @@ pub fn open_vulkan_device(
|
||||
Some(unsafe { adapter.create_device_from_hal(open, desc) })
|
||||
}
|
||||
|
||||
/// wgpu's opt-in to its `EXPERIMENTAL_*` features, which the crates that forbid unsafe code
|
||||
/// can't give: hardware ray queries, asked for only when the player turns ray tracing on
|
||||
/// (`gfx/ray_query.rs` in horizon-client-render).
|
||||
pub fn experimental_features() -> wgpu::ExperimentalFeatures {
|
||||
// Safety: wgpu warns that its experimental features may have bugs that reach undefined
|
||||
// behaviour through safe calls. Horizon accepts that only on request, for ray queries.
|
||||
unsafe { wgpu::ExperimentalFeatures::enabled() }
|
||||
}
|
||||
|
||||
/// Tells the compositor, through `VK_EXT_hdr_metadata`, that the swapchain's
|
||||
/// content is BT.709, peaks at `peak` nits and is no darker than `black`. Needs a
|
||||
/// device from `open_vulkan_device` and a configured surface; does nothing otherwise.
|
||||
|
||||
@@ -50,12 +50,111 @@ pub enum BiomeColor {
|
||||
Colormap(String),
|
||||
}
|
||||
|
||||
/// A fluid modifier's fog (`fluid_modifiers`): `fog_colormap` as a flat colour (RGB), and the
|
||||
/// `fog_radius` and `fog_fade` multipliers (1 is vanilla).
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct FluidFog {
|
||||
pub color: Option<u32>,
|
||||
pub radius: f32,
|
||||
pub fade: f32,
|
||||
}
|
||||
|
||||
/// The fog the packs' fluid modifiers give water and lava, read with the colours.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq)]
|
||||
pub struct FluidFogs {
|
||||
pub water: Option<FluidFog>,
|
||||
pub lava: Option<FluidFog>,
|
||||
}
|
||||
|
||||
impl FluidFogs {
|
||||
/// Every `assets/<namespace>/polytone/fluid_modifiers/**.json` under a pack overlay root that
|
||||
/// sets a fog field and targets `minecraft:water` or `minecraft:lava` (still or flowing). A
|
||||
/// colormap id or an expression isn't read (a warning names it).
|
||||
pub fn read(root: &Path) -> Self {
|
||||
let mut fogs = Self::default();
|
||||
for namespace in std::fs::read_dir(root.join("assets"))
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.flatten()
|
||||
{
|
||||
let mut stack = vec![namespace.path().join("polytone/fluid_modifiers")];
|
||||
while let Some(dir) = stack.pop() {
|
||||
for entry in std::fs::read_dir(&dir).into_iter().flatten().flatten() {
|
||||
let path = entry.path();
|
||||
if path.is_dir() {
|
||||
stack.push(path);
|
||||
} else if path.extension().is_some_and(|e| e == "json") {
|
||||
fogs.add(root, &path);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
fogs
|
||||
}
|
||||
|
||||
fn add(&mut self, root: &Path, path: &Path) {
|
||||
let Ok(text) = std::fs::read_to_string(path) else {
|
||||
return;
|
||||
};
|
||||
let Ok(json) = serde_json::from_str::<Json>(&text) else {
|
||||
return;
|
||||
};
|
||||
let relative = path.strip_prefix(root).unwrap_or(path).to_string_lossy();
|
||||
let number = |key: &str| match json.get(key) {
|
||||
None => Some(1.0),
|
||||
Some(Json::Number(n)) => n.as_f64().map(|v| v as f32),
|
||||
Some(Json::String(s)) => s.trim().parse().ok().or_else(|| {
|
||||
log::warn!("{relative}: {key} expressions aren't read yet");
|
||||
None
|
||||
}),
|
||||
_ => None,
|
||||
};
|
||||
let color = match json.get("fog_colormap") {
|
||||
None => None,
|
||||
Some(value) => match parse_color(value) {
|
||||
Some(c) => Some(c & 0xFF_FFFF),
|
||||
None => {
|
||||
log::warn!("{relative}: only flat fog colours are read");
|
||||
None
|
||||
}
|
||||
},
|
||||
};
|
||||
let fog = FluidFog {
|
||||
color,
|
||||
radius: number("fog_radius").unwrap_or(1.0),
|
||||
fade: number("fog_fade").unwrap_or(1.0),
|
||||
};
|
||||
if fog
|
||||
== (FluidFog {
|
||||
color: None,
|
||||
radius: 1.0,
|
||||
fade: 1.0,
|
||||
})
|
||||
{
|
||||
return;
|
||||
}
|
||||
let implicit = crate::implicit_id(&relative, "fluid_modifiers");
|
||||
let Ok(targets) = crate::Targets::parse(json.get("targets"), implicit.as_deref()) else {
|
||||
return;
|
||||
};
|
||||
let aims = |id: &str| targets.matches(id, |_| false);
|
||||
if aims("minecraft:water") || aims("minecraft:flowing_water") {
|
||||
self.water = Some(fog);
|
||||
}
|
||||
if aims("minecraft:lava") || aims("minecraft:flowing_lava") {
|
||||
self.lava = Some(fog);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Every pack's custom colours, by dotted key.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct PackColors {
|
||||
entries: BTreeMap<String, Json>,
|
||||
/// [`Self::environment`], read once with the packs: the fog reads it every frame.
|
||||
pub env: Environment,
|
||||
/// The packs' fluid modifiers' fog.
|
||||
pub fluid_fog: FluidFogs,
|
||||
}
|
||||
|
||||
impl PackColors {
|
||||
@@ -83,6 +182,7 @@ impl PackColors {
|
||||
}
|
||||
}
|
||||
colors.env = colors.environment();
|
||||
colors.fluid_fog = FluidFogs::read(root);
|
||||
colors
|
||||
}
|
||||
|
||||
@@ -376,6 +476,31 @@ mod tests {
|
||||
assert_eq!(c.biome_color("dry_foliage"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fluid_modifiers_give_water_and_lava_their_fog() {
|
||||
let root = std::env::temp_dir().join(format!("fluid-fog-{}", std::process::id()));
|
||||
let dir = root.join("assets/minecraft/polytone/fluid_modifiers");
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
std::fs::write(
|
||||
dir.join("water.json"),
|
||||
r#"{"targets": ["minecraft:water", "minecraft:flowing_water"], "fog_colormap": "ff0000", "fog_radius": 0.3}"#,
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::write(dir.join("lava.json"), r#"{"fog_fade": "0"}"#).unwrap();
|
||||
std::fs::write(dir.join("honey.json"), r#"{"colormap": "foliage_color"}"#).unwrap();
|
||||
let fogs = FluidFogs::read(&root);
|
||||
let fog = |color, radius, fade| {
|
||||
Some(FluidFog {
|
||||
color,
|
||||
radius,
|
||||
fade,
|
||||
})
|
||||
};
|
||||
assert_eq!(fogs.water, fog(Some(0xff0000), 0.3, 1.0));
|
||||
assert_eq!(fogs.lava, fog(None, 1.0, 0.0));
|
||||
std::fs::remove_dir_all(&root).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dyes_name_their_colour_kind() {
|
||||
let mut c = PackColors::default();
|
||||
|
||||
@@ -0,0 +1,267 @@
|
||||
//! The numeric fast path: a program of one expression whose every part is a number (constants,
|
||||
//! arithmetic, comparisons of numbers, math functions, ternaries and `root.method(...)` calls) is
|
||||
//! compiled again into a tree of `f64`s, which runs without the interpreter's `Value`s and
|
||||
//! `Result`s. It answers exactly what the interpreter would: anything that could tell them apart
|
||||
//! (strings, objects, `==` on a method's value, locals, game functions) keeps the interpreter.
|
||||
|
||||
use super::parse::{BinOp, Expr, Stmt};
|
||||
use super::{Field, Host, MathFn, Root, Value};
|
||||
use std::sync::Arc;
|
||||
|
||||
/// What a node gives: always a number, always a boolean, or whatever a host method answers.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
enum Kind {
|
||||
Num,
|
||||
Bool,
|
||||
Unknown,
|
||||
}
|
||||
|
||||
/// A method argument, handed to the host as the interpreter would: a constant of any kind
|
||||
/// (`o.custom('glow')`), another method's own value, or a number or boolean.
|
||||
#[derive(Debug)]
|
||||
pub(super) enum Arg {
|
||||
Value(Value),
|
||||
Method(Num),
|
||||
Num(Num, bool),
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(super) enum Num {
|
||||
Const(f64),
|
||||
Neg(Box<Num>),
|
||||
Not(Box<Num>),
|
||||
Bin(BinOp, Box<Num>, Box<Num>),
|
||||
And(Box<Num>, Box<Num>),
|
||||
Or(Box<Num>, Box<Num>),
|
||||
Cond(Box<Num>, Box<Num>, Box<Num>),
|
||||
Call(MathFn, Vec<Num>),
|
||||
Method(Root, Arc<str>, Vec<Arg>),
|
||||
/// A method without arguments the host may answer by [`Field`] (its name otherwise).
|
||||
Field(Root, Field, Arc<str>),
|
||||
}
|
||||
|
||||
/// The fast form of a whole program, with the kind of value it gives.
|
||||
#[derive(Debug)]
|
||||
pub(super) struct Fast {
|
||||
root: Num,
|
||||
kind: Kind,
|
||||
}
|
||||
|
||||
impl Fast {
|
||||
/// The fast form of a program, when it is one expression the fast path covers.
|
||||
pub(super) fn of(body: &[Stmt], locals: usize) -> Option<Self> {
|
||||
let [Stmt::Expr(e)] = body else {
|
||||
return None;
|
||||
};
|
||||
if locals != 0 {
|
||||
return None;
|
||||
}
|
||||
let root = Num::of(e)?;
|
||||
let kind = root.kind();
|
||||
// A method at the top hands back its own value; elsewhere the kind must be known.
|
||||
(kind != Kind::Unknown || matches!(root, Num::Method(..) | Num::Field(..)))
|
||||
.then_some(Self { root, kind })
|
||||
}
|
||||
|
||||
pub(super) fn eval(&self, host: &mut dyn Host) -> Result<Value, String> {
|
||||
match &self.root {
|
||||
Num::Method(root, name, args) => return call(host, *root, name, args),
|
||||
Num::Field(root, field, name) => return field_value(host, *root, *field, name),
|
||||
_ => {}
|
||||
}
|
||||
let n = self.root.eval(host)?;
|
||||
Ok(match self.kind {
|
||||
Kind::Bool => Value::Bool(n != 0.0),
|
||||
_ => Value::Num(n),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn field_value(host: &mut dyn Host, root: Root, field: Field, name: &str) -> Result<Value, String> {
|
||||
match host.field(root, field) {
|
||||
Some(value) => Ok(value),
|
||||
None => call(host, root, name, &[]),
|
||||
}
|
||||
}
|
||||
|
||||
fn call(host: &mut dyn Host, root: Root, name: &str, args: &[Arg]) -> Result<Value, String> {
|
||||
const FEW: usize = 4;
|
||||
let target = host.root(root);
|
||||
if args.is_empty() {
|
||||
return host.method(&target, name, &[]);
|
||||
}
|
||||
let mut values: [Value; FEW] = std::array::from_fn(|_| Value::Null);
|
||||
if args.len() > FEW {
|
||||
let all = args
|
||||
.iter()
|
||||
.map(|a| a.value(host))
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
return host.method(&target, name, &all);
|
||||
}
|
||||
for (slot, arg) in values.iter_mut().zip(args) {
|
||||
*slot = arg.value(host)?;
|
||||
}
|
||||
host.method(&target, name, &values[..args.len()])
|
||||
}
|
||||
|
||||
impl Arg {
|
||||
fn value(&self, host: &mut dyn Host) -> Result<Value, String> {
|
||||
Ok(match self {
|
||||
Self::Value(v) => v.clone(),
|
||||
Self::Method(Num::Method(root, name, args)) => call(host, *root, name, args)?,
|
||||
Self::Method(Num::Field(root, field, name)) => field_value(host, *root, *field, name)?,
|
||||
Self::Method(n) | Self::Num(n, false) => Value::Num(n.eval(host)?),
|
||||
Self::Num(n, true) => Value::Bool(n.eval(host)? != 0.0),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a subtree calls the host, whose answer may be no number.
|
||||
fn has_method(e: &Expr) -> bool {
|
||||
match e {
|
||||
Expr::Method(..) => true,
|
||||
Expr::Neg(a) | Expr::Not(a) => has_method(a),
|
||||
Expr::Binary(_, a, b) | Expr::And(a, b) | Expr::Or(a, b) => has_method(a) || has_method(b),
|
||||
Expr::Cond(c, a, b) => has_method(c) || has_method(a) || has_method(b),
|
||||
Expr::Call(_, args) | Expr::Host(_, args) => args.iter().any(has_method),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
impl Num {
|
||||
fn of(e: &Expr) -> Option<Self> {
|
||||
let boxed = |e: &Expr| Self::of(e).map(Box::new);
|
||||
Some(match e {
|
||||
Expr::Const(Value::Num(n)) => Self::Const(*n),
|
||||
Expr::Const(_) | Expr::Local(_) | Expr::Root(_) | Expr::Host(..) => return None,
|
||||
Expr::Neg(a) => Self::Neg(boxed(a)?),
|
||||
Expr::Not(a) => Self::Not(boxed(a)?),
|
||||
Expr::Binary(op, a, b) => {
|
||||
// `==` and `!=` also compare strings and objects: only between plain numbers.
|
||||
if matches!(op, BinOp::Eq | BinOp::Ne) && (has_method(a) || has_method(b)) {
|
||||
return None;
|
||||
}
|
||||
Self::Bin(*op, boxed(a)?, boxed(b)?)
|
||||
}
|
||||
Expr::And(a, b) => Self::And(boxed(a)?, boxed(b)?),
|
||||
Expr::Or(a, b) => Self::Or(boxed(a)?, boxed(b)?),
|
||||
Expr::Cond(c, a, b) => Self::Cond(boxed(c)?, boxed(a)?, boxed(b)?),
|
||||
Expr::Call(f, args) => {
|
||||
Self::Call(*f, args.iter().map(Self::of).collect::<Option<_>>()?)
|
||||
}
|
||||
Expr::Method(target, name, args) => {
|
||||
let Expr::Root(root) = **target else {
|
||||
return None;
|
||||
};
|
||||
if let (true, Some(field)) = (args.is_empty(), Field::of(name)) {
|
||||
return Some(Self::Field(root, field, name.clone()));
|
||||
}
|
||||
let args = args
|
||||
.iter()
|
||||
.map(|a| match a {
|
||||
Expr::Const(v) => Some(Arg::Value(v.clone())),
|
||||
other => {
|
||||
let n = Self::of(other)?;
|
||||
match n.kind() {
|
||||
Kind::Unknown
|
||||
if matches!(n, Self::Method(..) | Self::Field(..)) =>
|
||||
{
|
||||
Some(Arg::Method(n))
|
||||
}
|
||||
// A ternary between method values: the interpreter's own value.
|
||||
Kind::Unknown => None,
|
||||
kind => Some(Arg::Num(n, kind == Kind::Bool)),
|
||||
}
|
||||
}
|
||||
})
|
||||
.collect::<Option<_>>()?;
|
||||
Self::Method(root, name.clone(), args)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn kind(&self) -> Kind {
|
||||
match self {
|
||||
Self::Const(_) | Self::Neg(_) | Self::Call(..) => Kind::Num,
|
||||
Self::Bin(op, ..) => match op {
|
||||
BinOp::Eq | BinOp::Ne | BinOp::Lt | BinOp::Le | BinOp::Gt | BinOp::Ge => Kind::Bool,
|
||||
_ => Kind::Num,
|
||||
},
|
||||
Self::Not(_) | Self::And(..) | Self::Or(..) => Kind::Bool,
|
||||
Self::Cond(_, a, b) => match (a.kind(), b.kind()) {
|
||||
(x, y) if x == y => x,
|
||||
_ => Kind::Unknown,
|
||||
},
|
||||
Self::Method(..) | Self::Field(..) => Kind::Unknown,
|
||||
}
|
||||
}
|
||||
|
||||
/// The value as a number, as the interpreter's `Value::num` reads it.
|
||||
fn eval(&self, host: &mut dyn Host) -> Result<f64, String> {
|
||||
Ok(match self {
|
||||
Self::Const(n) => *n,
|
||||
Self::Neg(a) => -a.eval(host)?,
|
||||
Self::Not(a) => f64::from(u8::from(!a.truthy(host)?)),
|
||||
Self::Bin(op, a, b) => {
|
||||
let (x, y) = (a.eval(host)?, b.eval(host)?);
|
||||
let t = |b: bool| f64::from(u8::from(b));
|
||||
match op {
|
||||
BinOp::Add => x + y,
|
||||
BinOp::Sub => x - y,
|
||||
BinOp::Mul => x * y,
|
||||
BinOp::Div => x / y,
|
||||
BinOp::Rem => x % y,
|
||||
BinOp::Pow => x.powf(y),
|
||||
BinOp::Eq => t(x == y),
|
||||
BinOp::Ne => t(x != y),
|
||||
BinOp::Lt => t(x < y),
|
||||
BinOp::Le => t(x <= y),
|
||||
BinOp::Gt => t(x > y),
|
||||
BinOp::Ge => t(x >= y),
|
||||
}
|
||||
}
|
||||
Self::And(a, b) => f64::from(u8::from(a.truthy(host)? && b.truthy(host)?)),
|
||||
Self::Or(a, b) => f64::from(u8::from(a.truthy(host)? || b.truthy(host)?)),
|
||||
Self::Cond(c, a, b) => {
|
||||
if c.truthy(host)? {
|
||||
a.eval(host)?
|
||||
} else {
|
||||
b.eval(host)?
|
||||
}
|
||||
}
|
||||
Self::Call(f, args) if args.len() <= 4 => {
|
||||
let mut nums = [0.0; 4];
|
||||
for (slot, arg) in nums.iter_mut().zip(args) {
|
||||
*slot = arg.eval(host)?;
|
||||
}
|
||||
f(&nums[..args.len()])
|
||||
}
|
||||
Self::Call(f, args) => {
|
||||
let nums = args
|
||||
.iter()
|
||||
.map(|a| a.eval(host))
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
f(&nums)
|
||||
}
|
||||
Self::Method(root, name, args) => call(host, *root, name, args)?.num(),
|
||||
Self::Field(root, field, name) => field_value(host, *root, *field, name)?.num(),
|
||||
})
|
||||
}
|
||||
|
||||
/// The value's truth, as the interpreter's `Value::truthy` reads it: a method's own answer
|
||||
/// (a string or an object is true), else whether the number isn't 0.
|
||||
fn truthy(&self, host: &mut dyn Host) -> Result<bool, String> {
|
||||
match self {
|
||||
Self::Method(root, name, args) => Ok(call(host, *root, name, args)?.truthy()),
|
||||
Self::Field(root, field, name) => Ok(field_value(host, *root, *field, name)?.truthy()),
|
||||
Self::Cond(c, a, b) => {
|
||||
if c.truthy(host)? {
|
||||
a.truthy(host)
|
||||
} else {
|
||||
b.truthy(host)
|
||||
}
|
||||
}
|
||||
other => Ok(other.eval(host)? != 0.0),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
//! loads, with every name resolved, and then evaluated against a [`Host`], the game side that
|
||||
//! answers context objects (`o`, `p`, `g`...) and their methods.
|
||||
mod colors;
|
||||
mod fast;
|
||||
mod legacy;
|
||||
mod lex;
|
||||
mod parse;
|
||||
@@ -68,6 +69,36 @@ impl Root {
|
||||
}
|
||||
}
|
||||
|
||||
/// A method without arguments that expressions read most, resolved when the program compiles so
|
||||
/// a host can answer it without comparing names ([`Host::field`]).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Field {
|
||||
Age,
|
||||
Life,
|
||||
Custom,
|
||||
X,
|
||||
Y,
|
||||
Z,
|
||||
Temperature,
|
||||
Downfall,
|
||||
}
|
||||
|
||||
impl Field {
|
||||
pub(crate) fn of(name: &str) -> Option<Self> {
|
||||
Some(match name {
|
||||
"age" => Self::Age,
|
||||
"life" => Self::Life,
|
||||
"custom" => Self::Custom,
|
||||
"x" => Self::X,
|
||||
"y" => Self::Y,
|
||||
"z" => Self::Z,
|
||||
"temperature" => Self::Temperature,
|
||||
"downfall" => Self::Downfall,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// What the game answers while an expression runs.
|
||||
pub trait Host {
|
||||
fn root(&mut self, root: Root) -> Value;
|
||||
@@ -76,6 +107,12 @@ pub trait Host {
|
||||
fn method(&mut self, target: &Value, name: &str, args: &[Value]) -> Result<Value, String>;
|
||||
/// A global function of the game's (`config`, `colormap`, `modOn`, `date*`).
|
||||
fn function(&mut self, name: &str, args: &[Value]) -> Result<Value, String>;
|
||||
/// `root.field()` answered without its name, or `None` to answer it through [`Self::method`]:
|
||||
/// a host that answers must give what `method` would.
|
||||
fn field(&mut self, root: Root, field: Field) -> Option<Value> {
|
||||
let _ = (root, field);
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
type MathFn = fn(&[f64]) -> f64;
|
||||
@@ -139,13 +176,16 @@ pub(crate) const MATH: &[(&str, usize, usize, MathFn)] = &[
|
||||
pub struct Program {
|
||||
body: Vec<Stmt>,
|
||||
locals: usize,
|
||||
/// The same program on the numeric fast path, when it is one (`fast.rs`).
|
||||
fast: Option<fast::Fast>,
|
||||
}
|
||||
|
||||
impl Program {
|
||||
/// Parses `src`; a syntax error or an unknown name fails here.
|
||||
pub fn compile(src: &str) -> Result<Self, String> {
|
||||
let (body, locals) = parse::parse(src)?;
|
||||
Ok(Self { body, locals })
|
||||
let fast = fast::Fast::of(&body, locals);
|
||||
Ok(Self { body, locals, fast })
|
||||
}
|
||||
|
||||
/// Whether the program reads nothing of its context but the `root.name()` methods `allowed`
|
||||
@@ -185,6 +225,15 @@ impl Program {
|
||||
/// Runs the statements; the result is the last statement's value (an assignment's is the
|
||||
/// value it stored).
|
||||
pub fn eval(&self, host: &mut dyn Host) -> Result<Value, String> {
|
||||
if let Some(fast) = &self.fast {
|
||||
return fast.eval(host);
|
||||
}
|
||||
self.interpret(host)
|
||||
}
|
||||
|
||||
/// Runs the statements through the interpreter (what [`Self::eval`] does for a program the
|
||||
/// fast path doesn't cover).
|
||||
fn interpret(&self, host: &mut dyn Host) -> Result<Value, String> {
|
||||
let mut run = Run {
|
||||
host,
|
||||
locals: vec![Value::Null; self.locals],
|
||||
|
||||
@@ -231,3 +231,114 @@ fn reads_only_names_what_a_program_may_read() {
|
||||
assert!(!ok("r.noise(o.y, 0)"));
|
||||
assert!(!ok("config('pack:option')"));
|
||||
}
|
||||
|
||||
/// A host whose methods answer every kind of value, and record the arguments they get.
|
||||
struct Kinds {
|
||||
age: f64,
|
||||
seen: Vec<String>,
|
||||
/// Answers `o.age` and `o.life` by field too, as the particles' host does.
|
||||
fields: bool,
|
||||
}
|
||||
impl Host for Kinds {
|
||||
fn root(&mut self, root: Root) -> Value {
|
||||
match root {
|
||||
Root::Object => Value::Object(1),
|
||||
Root::Random => Value::Object(2),
|
||||
_ => Value::Object(3),
|
||||
}
|
||||
}
|
||||
fn method(&mut self, _: &Value, name: &str, args: &[Value]) -> Result<Value, String> {
|
||||
self.seen.push(format!("{name}{args:?}"));
|
||||
Ok(match name {
|
||||
"age" => Value::Num(self.age),
|
||||
"life" => Value::Num(140.0),
|
||||
"custom" => Value::Num(if args.is_empty() { 0.3 } else { 2.0 }),
|
||||
"inWater" => Value::Bool(self.age > 50.0),
|
||||
"biome" => Value::Str("minecraft:plains".into()),
|
||||
"empty" => Value::Str("".into()),
|
||||
"me" => Value::Object(7),
|
||||
"noise" => Value::Num(args.iter().map(Value::num).sum::<f64>().sin()),
|
||||
"fail" => return Err("no such thing".into()),
|
||||
_ => Value::Null,
|
||||
})
|
||||
}
|
||||
fn function(&mut self, _: &str, _: &[Value]) -> Result<Value, String> {
|
||||
Ok(Value::Null)
|
||||
}
|
||||
fn field(&mut self, root: Root, field: Field) -> Option<Value> {
|
||||
if !self.fields || root != Root::Object {
|
||||
return None;
|
||||
}
|
||||
match field {
|
||||
Field::Age => Some(Value::Num(self.age)),
|
||||
Field::Life => Some(Value::Num(140.0)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_fast_path_answers_what_the_interpreter_does() {
|
||||
let corpus = [
|
||||
"(0.7 + 0.3*sin(o.age*0.18 + o.custom*13000)) * min(o.age/(o.life*0.15),1)",
|
||||
"0.5+(r.noise(o.age/60,5+o.age/60)*0.55)",
|
||||
"o.age > 40",
|
||||
"o.age >= 40 && o.age < 90",
|
||||
"!(o.age > 3)",
|
||||
"o.age > 10 || o.fail",
|
||||
"o.age > 70 ? 1 : 0.25",
|
||||
"o.age > 70 ? o.age : o.life",
|
||||
"o.age > 70 ? o.biome : o.life",
|
||||
"o.inWater",
|
||||
"o.inWater ? 2 : 3",
|
||||
"!o.inWater",
|
||||
"o.biome ? 1 : 0",
|
||||
"o.empty ? 1 : 0",
|
||||
"o.me ? 1 : 0",
|
||||
"o.biome + 1",
|
||||
"-o.inWater",
|
||||
"o.custom('glow') + 1",
|
||||
"o.custom(o.age > 3)",
|
||||
"r.noise(o.inWater, 2)",
|
||||
"o.fail + 1",
|
||||
"o.age % 7 - 2 ^ 3",
|
||||
"clamp(o.age, 10, 20) / 0",
|
||||
"o.age == 3",
|
||||
"2 == 2",
|
||||
"1 != 2 ? o.age : 0",
|
||||
"max(o.age, o.life) - min(o.age, 1)",
|
||||
"o.age > 3 ? o.age > 5 : o.inWater",
|
||||
];
|
||||
for src in corpus {
|
||||
let program = Program::compile(src).unwrap();
|
||||
let cases = [0.0, 3.0, 45.0, 75.0, 139.0]
|
||||
.into_iter()
|
||||
.flat_map(|a| [(a, false), (a, true)]);
|
||||
for (age, fields) in cases {
|
||||
let mut fast_host = Kinds {
|
||||
age,
|
||||
seen: Vec::new(),
|
||||
fields,
|
||||
};
|
||||
let mut slow_host = Kinds {
|
||||
age,
|
||||
seen: Vec::new(),
|
||||
fields,
|
||||
};
|
||||
let fast = program.eval(&mut fast_host);
|
||||
let slow = program.interpret(&mut slow_host);
|
||||
let same = match (&fast, &slow) {
|
||||
(Ok(Value::Num(a)), Ok(Value::Num(b))) => a.to_bits() == b.to_bits(),
|
||||
(a, b) => a == b,
|
||||
};
|
||||
assert!(same, "{src} at {age}: fast {fast:?}, interpreter {slow:?}");
|
||||
// A field the host answers skips its call; the interpreter makes it by name.
|
||||
if !fields {
|
||||
assert_eq!(fast_host.seen, slow_host.seen, "{src} at {age}: the calls");
|
||||
}
|
||||
}
|
||||
}
|
||||
// The fast path covers the particles' and colormaps' plain arithmetic.
|
||||
assert!(Program::compile(corpus[0]).unwrap().fast.is_some());
|
||||
assert!(Program::compile(corpus[1]).unwrap().fast.is_some());
|
||||
}
|
||||
+2
-2
@@ -45,11 +45,11 @@ Every setting applies at once, except worker threads, the graphics API and trans
|
||||
|
||||
**Translucent batching** (Advanced, after Graphics API; on by default, `batchTranslucent` in `horizon-graphics.txt`, a new key so that the `translucentBatching:false` earlier builds saved for everyone doesn't keep it off; it applies at the next start or resource reload, and is no part of a preset) gives water, glass, ice and other translucent quads a vertex buffer of their own, with pages up to 2 GiB, and their sorted indices the same: the back-to-front list then stays in one page and goes out in a few draw calls instead of switching pages every few sections. The picture is the same and frames come faster, most over water and at high render distances (RTX 4090 at 1080p: 4 to 16% more FPS at render distance 32; over the ocean at 64, 354 draw calls a frame instead of 3,832 and 43% more FPS); it costs a little video memory, as solid and translucent quads no longer share free space (terrain meshes take 1 to 15% more at render distance 32, 5 to 10% more at 64). The large pages shrink once the view has settled (a second after terrain stops arriving): one much larger than what it holds, as after a teleport or a lower render distance, moves into a page of the right size, about 32 MiB a frame. It needs indirect multi-draw that isn't OpenGL's (which loops over the entries on the CPU), and devices whose vanilla frames draw flat (Metal, Intel Gen11 and Gen12 on Vulkan) don't offer it: there the switch is greyed out and reads Off, and macOS doesn't show it. A `shaders/` pack's terrain and shadow passes, and the `[shadow]` pass of one of Horizon's own packs, draw flat where the device can, reading translucent quads beside their section's solid ones: while such a pack runs they go back there. Such a pack picked at the start has the world laid out for it from the first; switching to or from one later meshes the world again.
|
||||
|
||||
**HDR** has its own page and three choices: **Off** (the default) keeps SDR; **On** uses HDR wherever the display path offers it; **Auto** uses HDR only while the system reports HDR active for the window's display and an HDR output is offered, otherwise SDR. On is unavailable where no HDR output is offered; Auto stays selectable before the system is queried, including on an SDR display. The controls below work only while HDR is active. The existing `hdr` key in `horizon-graphics.txt` saves On as `true`, Off as `false`, and Auto as `auto`; old boolean files keep their meaning, and older builds ignore `auto` and keep their Off default. It presents to an HDR10 swapchain (`Rgb10a2Unorm`, BT.2020 with the PQ curve) or, where only that is offered, an scRGB one (`Rgba16Float`, extended linear sRGB), on displays and compositors that offer either; `HORIZON_HDR_OUTPUT=pq` or `scrgb` allows only one, and offers it to an `--offscreen` run, which has no display to ask: there HDR is drawn and screenshotted (the `-hdr.png` below) but not presented, and automatic brightness takes 203 and 1000 nits, so HDR can be checked without an HDR display. The frame is drawn exactly as in SDR, in vanilla's gamma space (into a 16-bit target (`Rgba16Unorm`, which clips at white after every pass as the 8-bit one does) when HDR is on as the renderer is made, at the start or a resource reload, with **Smooth Gradients** on (the default, `hdrSmoothGradients`; off, the 8-bit frame) and where the device can draw into it, else an 8-bit one; HDR switched off after that keeps the 16-bit frame and copies it to the 8-bit swapchain, without frame generation, until the next start; with `HORIZON_HDR_FRAME=float` the frame follows HDR, Smooth Gradients and the picked pack unless it was refused, is missing or its owner mod is off (the renderer is made again on the same device before the next frame, and the world meshed again), and with no shader pack or a 0.2 one, where the device can keep the HUD on a layer of its own, a float one (`Rgba16Float`) takes the 16-bit one's place whatever Smooth Gradients is: the world's shaders write light past white into it themselves, and it holds no marks (below)), and a last pass (`hdr.wgsl`) decodes it to linear light at the **Paper White** and lifts light sources (blocks that emit light, lava, the sun and the moon) towards the **Peak Brightness**; everything else stays as in SDR. The mesher puts the light a block's face emits in its vertex colour's alpha (`EMISSION_BITS`), the frame's alpha carries the mark, and only pixels that still show the world before the hand and HUD are lifted. On Vulkan, `horizon-hdr` sends HDR metadata (MaxCLL and mastering luminance at the peak, MaxFALL as 0, unknown, since a frame's average isn't known ahead of it), which wgpu doesn't: without it a compositor may clip everything above SDR white. Paper White and Peak Brightness are **Auto** by default: they follow what the system reports for the display. Set by hand, on the slider and in calibration alike, Paper White runs from 10 to 480 nits by 10, and Peak Brightness from 200 to 2000 nits by 50, where most displays' peaks are, then by 500 up to PQ's 10 000, for displays that are brighter or tone-map the signal themselves; `hdrPeak` keeps any value from 200 to 10 000. On Wayland that's the compositor's preferred image description of the window (`wp_color_management_v1`, in `horizon-hdr`'s `wayland.rs`), watched while HDR is On or Auto (Off does not ask the system); the preferred target peak above reference white reports HDR headroom, so a move to another output or a recalibrated desktop applies at once. KWin maps the content's reference white (203 nits) onto the desktop's SDR white, so Auto puts Paper White at 203 and Peak Brightness at the display's peak over that ratio; Hyprland (0.57) doesn't: it reports the standard 203 as reference white, so Auto puts Paper White at 203, and it shows HDR windows below its own SDR white (`sdr_max_luminance`, which it doesn't report): in a test, a white browser page was brighter than both the game's white and a 1000-nit patch in mpv. Other compositors are unchecked. On Windows Auto takes what wgpu reads from the OS (`Surface::display_hdr_info`: DXGI's `MaxLuminance` and the SDR white level of Windows' SDR content brightness slider, both absolute nits), read again every 2 seconds since no event says they changed; the same reading's current output colour-space state drives Auto (an offered Direct3D 12 HDR10 swapchain alone does not). macOS reports potential EDR headroom, fixed for a display rather than following desktop brightness: Auto uses it above 1, or stays SDR if it is missing or invalid. macOS reports no absolute nits or separate HDR-mode flag. X11, OpenGL, other paths without a report and offscreen runs keep Auto in SDR. Windows' luminance readings often come from the display's EDID and may run optimistic, and this is untested so far. **Calibrate...** (`hdr_calibration.rs`) sets Black Level, Peak Line truncated
|
||||
**HDR** has its own page and three choices: **Off** (the default) keeps SDR; **On** uses HDR wherever the display path offers it; **Auto** uses HDR only while the system reports HDR active for the window's display and an HDR output is offered, otherwise SDR. On is unavailable where no HDR output is offered; Auto stays selectable before the system is queried, including on an SDR display. The controls below work only while HDR is active. The existing `hdr` key in `horizon-graphics.txt` saves On as `true`, Off as `false`, and Auto as `auto`; old boolean files keep their meaning, and older builds ignore `auto` and keep their Off default. It presents to an HDR10 swapchain (`Rgb10a2Unorm`, BT.2020 with the PQ curve) or, where only that is offered, an scRGB one (`Rgba16Float`, extended linear sRGB), on displays and compositors that offer either; `HORIZON_HDR_OUTPUT=pq` or `scrgb` allows only one, and offers it to an `--offscreen` run, which has no display to ask: there HDR is drawn and screenshotted (the `-hdr.png` below) but not presented, and automatic brightness takes 203 and 1000 nits, so HDR can be checked without an HDR display. The frame is drawn exactly as in SDR, in vanilla's gamma space (into a 16-bit target (`Rgba16Unorm`, which clips at white after every pass as the 8-bit one does) when HDR is on as the renderer is made, at the start or a resource reload, with **Smooth Gradients** on (the default, `hdrSmoothGradients`; off, the 8-bit frame) and where the device can draw into it, else an 8-bit one; HDR switched off after that keeps the 16-bit frame and copies it to the 8-bit swapchain, without frame generation, until the next start; with `HORIZON_HDR_FRAME=float` the frame follows HDR, Smooth Gradients and the picked pack unless it was refused, is missing or its owner mod is off (the renderer is made again on the same device before the next frame, and the world meshed again), and with no shader pack or a 0.2 one, where the device can keep the HUD on a layer of its own, a float one (`Rgba16Float`) takes the 16-bit one's place whatever Smooth Gradients is: the world's shaders write light past white into it themselves, and it holds no marks (below)), and a last pass (`hdr.wgsl`) decodes it to linear light at the **Paper White** and lifts light sources (blocks that emit light, lava, the sun and the moon) towards the **Peak Brightness**; everything else stays as in SDR. The mesher puts the light a block's face emits in its vertex colour's alpha (`EMISSION_BITS`), the frame's alpha carries the mark, and only pixels that still show the world before the hand and HUD are lifted. On Vulkan, `horizon-hdr` sends HDR metadata (MaxCLL and mastering luminance at the peak, MaxFALL as 0, unknown, since a frame's average isn't known ahead of it), which wgpu doesn't: without it a compositor may clip everything above SDR white. Paper White and Peak Brightness are **Auto** by default: they follow what the system reports for the display. Set by hand, on the slider and in calibration alike, Paper White runs from 10 to 480 nits by 10, and Peak Brightness from 200 to 2000 nits by 50, where most displays' peaks are, then by 500 up to PQ's 10 000, for displays that are brighter or tone-map the signal themselves; `hdrPeak` keeps any value from 200 to 10 000. On Wayland that's the compositor's preferred image description of the window (`wp_color_management_v1`, in `horizon-hdr`'s `wayland.rs`), watched while HDR is On or Auto (Off does not ask the system); the preferred target peak above reference white reports HDR headroom, so a move to another output or a recalibrated desktop applies at once. KWin maps the content's reference white (203 nits) onto the desktop's SDR white, so Auto puts Paper White at 203 and Peak Brightness at the display's peak over that ratio; Hyprland (0.57) doesn't: it reports the standard 203 as reference white, so Auto puts Paper White at 203, and it shows HDR windows below its own SDR white (`sdr_max_luminance`, which it doesn't report): in a test, a white browser page was brighter than both the game's white and a 1000-nit patch in mpv. Other compositors are unchecked. On Windows Auto takes what wgpu reads from the OS (`Surface::display_hdr_info`: DXGI's `MaxLuminance` and the SDR white level of Windows' SDR content brightness slider, both absolute nits), read again every 2 seconds since no event says they changed; the same reading's current output colour-space state drives Auto (an offered Direct3D 12 HDR10 swapchain alone does not). macOS reports potential EDR headroom, fixed for a display rather than following desktop brightness: Auto uses it above 1, or stays SDR if it is missing or invalid. macOS reports no absolute nits or separate HDR-mode flag. X11, OpenGL, other paths without a report and offscreen runs keep Auto in SDR. Windows' SDR white is the player's own slider, but its peak is mostly the display's EDID figure, which often runs high: Auto still uses it, over the 1000-nit default, but the HDR Line truncated
|
||||
|
||||
With `HORIZON_HDR_FRAME=float`, HDR on and no shader pack or a 0.2 one, devices with independent blending draw the frame in layers ([hdr-linear-light.md](design/hdr-linear-light.md), The HUD and menus). The world's shaders write their own light into the frame instead of a mark (`hdr_written`, `hdr_light.wgsl`), a whole frame at a time, so glass, water, clouds and particles each lay their own light over the light behind them. Improved Transparency's layers write their own light too, before they are weighted and composited. No float frame holds marks: under a 0.2 pack Horizon's own draws keep their colour, alpha 1 where opaque and coverage where blended, HDR output lifts nothing, and the pack's `[tonemap]` takes the tone curve's place below (one or the other runs, never both); without the HUD layer (HDR off, or a device without independent blending) its float frame finishes on the swapchain's 8 bits before the camera overlays, which then draw with the HUD as in SDR, and HDR output stays off. The camera overlays (vignette, pumpkin, powder snow, portal, nausea), post effects and entity outlines draw on light. After the post effects and before the camera overlays, the tone curve eases that light into the peak (`Hdr::tonemap`) in place of HDR lighting's own easing and the light sources' cap at the peak: up to 60% of the peak light stays as it is, and above it ITU-R BT.2390's EETF in PQ rolls it off, on its brightest channel so it keeps its hue. The HUD and menus then draw on an 8-bit target of their own, the world clipped to white, as they found the frame before (each draw clips at white as there), and write how much of the world they hide into an R8 coverage target. The last pass lays the HUD over the world's light by that coverage, in gamma as the HUD blends: light behind see-through HUD shows as vanilla weights the HUD over it, and none shows through what covers it. The menu blur blurs that picture, light above white included, and the menu draws over the result. UI Brightness scales the HUD's own part, not the world under it. A screenshot maps the world's light into SDR, unchanged up to 0.6 of paper white in linear light and smoothly rolled towards white above it, with one scale for all RGB channels to keep their hue (where Horizon lifts nothing, with HDR Lighting off or under a 0.2 pack, the world is clipped at white as SDR draws it); the HUD's own part stays as drawn, whatever UI Brightness is, over the mapped world by its coverage. The world's icon uses the same mapping without the HUD; the `-hdr.png` beside a screenshot keeps the whole HDR picture. `HORIZON_HDR_DEBUG=coverage` tints the coverage magenta for inspection.
|
||||
|
||||
HDR also lights the world past paper white: surfaces at block or sky light levels 11 to 15 rise above it, up to 4 times for the strongest light, sky light by how a face meets the sun, easing into the peak from 60% of it so bright textures keep their detail. The frame's alpha between 0.76 and 1 carries it to the last pass, where it amounts to the lightmap going past 1. Darker light stays as in SDR. It's off while a shader pack's post or material stage runs, and `HORIZON_HDR_LIGHTING=0` turns it off to compare.
|
||||
HDR also lights the world past paper white: surfaces at block or sky light levels 11 to 15 rise above it, up to 4 times for the strongest light, sky light by how a face meets the sun, easing into the peak from 60% of it so bright textures keep their detail. The frame's alpha between 0.76 and 1 carries it to the last pass, where it amounts to the lightmap going past 1. Darker light stays as in SDR. It's off while a shader pack runs. **HDR Lighting** (`hdrLighting`, on by default) is the whole of this lift: light sources, the sun and the lit surfaces above; off, HDR shows SDR's picture. Under a `shaders/` pack or a 0.2 one Horizon lifts nothing, so the row is greyed and says why; under a 0.1 pack it still lifts the light sources and the sun.
|
||||
|
||||
HDR's light sources shine by the kind of light rather than its level (`mesher::hdr_tier`): the sun most, then glowstone, campfires and the soul fires, then flames and lava, torch and candle flames among them (particles too), then faint glowers such as a redstone torch or magma; glow lichen stays as in SDR. Each tier has its share of the headroom and the brightness its lift starts and ends at, the texel's own before lighting, so a light source shines alike at any light level and on every face (`gfx/hdr.rs` `TIERS`); `HORIZON_HDR_DEBUG=1` greys what isn't lifted and tints what is magenta.
|
||||
|
||||
|
||||
+1
-1
@@ -20,7 +20,7 @@ plan are in [server.md](server.md).
|
||||
takes the account's UUID when someone owns that name, as vanilla does, and that player is then refused.
|
||||
- **An offline server** (`online_mode = false`) never checks accounts: anyone who reaches it can join under
|
||||
any name. Keep it on your LAN or a tailnet (Tailscale, ZeroTier), or share it only with friends you trust.
|
||||
A Horizon player under an offline name can join only such a server.
|
||||
A Horizon game started without a session (no network at Play) can join only such a server.
|
||||
- **Secure chat** works as on vanilla's server: in online mode, vanilla clients and Horizon's (Nightly
|
||||
2026-11-01 and newer) sign their chat, and with `enforce-secure-profile=true` (the default) unsigned chat is
|
||||
refused. On an offline server, or for a player without a chat session, chat stays unsigned and shows as not
|
||||
|
||||
@@ -188,7 +188,7 @@ MCP: `account_sign_out`, needs `confirm: true`.
|
||||
|
||||
## `horizon-launcher account select`
|
||||
|
||||
Play as this account, or as one of your offline names.
|
||||
Play as this account.
|
||||
|
||||
horizon-launcher account select <WHO>
|
||||
|
||||
@@ -196,41 +196,6 @@ Play as this account, or as one of your offline names.
|
||||
|
||||
MCP: `account_select`.
|
||||
|
||||
## `horizon-launcher account offline`
|
||||
|
||||
Play offline (LAN, offline-mode servers) beside a signed-in account that owns the game: as NAME (kept to switch back to; 3 to 16 letters, digits or _), or as the name the game made up.
|
||||
|
||||
horizon-launcher account offline [NAME]
|
||||
|
||||
- `NAME`
|
||||
|
||||
MCP: `account_offline`.
|
||||
|
||||
## `horizon-launcher account forget-offline`
|
||||
|
||||
Forget one of your offline names; its skin and cape go with it.
|
||||
|
||||
horizon-launcher account forget-offline <NAME>
|
||||
|
||||
- `NAME`
|
||||
|
||||
MCP: `account_forget_offline`.
|
||||
|
||||
## `horizon-launcher account skin`
|
||||
|
||||
Show or set an offline name's skin and cape: a 64x64 (or 64x32) skin PNG, a 64x32 cape PNG, the model. Kept by the launcher and given to the game when that name plays.
|
||||
|
||||
horizon-launcher account skin <NAME> [FILE]
|
||||
|
||||
- `NAME`
|
||||
- `FILE`: The skin's PNG
|
||||
- `--model`: classic or slim arms
|
||||
- `--cape`: The cape's PNG
|
||||
- `--no-skin`: Back to the default skin
|
||||
- `--no-cape`: Take the cape off
|
||||
|
||||
MCP: `account_skin`.
|
||||
|
||||
## `horizon-launcher settings`
|
||||
|
||||
Show the launcher's settings, or set one (the Settings page): selected-version TAG, game-architecture auto|x64|arm64, minecraft-dir PATH, server-dir PATH ("default" resets a folder), install-new-versions on|off, prune-old-versions on|off (delete old versions after an install, keeping the selected one, the ones instances use and the newest few), keep-versions N (how many of the newest to keep; "default" is 2), opening-animation on|off, proxy http://host:port|socks5://host:port ("default" removes it), while-playing stay|minimize|hide (what the window does while the game runs), language system|en|fr|ru|de|zh|es|uk|pl (the window's language; system follows the computer's), accent violet|blue|teal|green|amber|red|pink|#rrggbb|DEGREES (a named accent, any colour, or any hue), background PATH (a picture behind Home; "default" shows Horizon's landscape), native-cache-gib N (combined native compilation cache limit in GiB, default 30; 0 disables automatic cleanup), native-profile performance|dev|release (the profile native mods build with: performance is incremental, dev rebuilds a mod quickest for a slow game, release is slowest to build and slightly faster to play).
|
||||
|
||||
@@ -423,6 +423,78 @@ pub trait Layer: Sync {
|
||||
- Sprites, items and the player's state come with their platform API (M6). They are not in this first
|
||||
version.
|
||||
|
||||
### Look (`horizon_platform::look`)
|
||||
|
||||
What the engine draws differently for a crate above, as plain data: PvP & HUD's knobs today (#14217).
|
||||
|
||||
```rust
|
||||
pub struct Look { // #[non_exhaustive]; Default is vanilla's
|
||||
pub legacy_pose: bool, pub hide_attack_indicator: bool,
|
||||
pub mute_attack_sounds: bool, pub hide_attack_particles: bool,
|
||||
pub frame: Frame, // outline, hurt overlay and glint colours
|
||||
pub shield_size: u8, pub totem_scale: f32, pub fire_lower: f32,
|
||||
pub clear_lava: bool, pub clear_water: bool, pub chat: Chat,
|
||||
pub hide_effect_icons: bool, pub time_of_day: Option<i64>, pub weather: Option<(f32, f32)>,
|
||||
}
|
||||
pub fn update(change: impl FnOnce(&mut Look)); // the player whose turn it is (`seat()`)
|
||||
```
|
||||
|
||||
- Pushed, not asked: a crate changes its own fields when its settings load or change (several crates share the
|
||||
look, each keeping to its fields), and the engine reads it where it draws (`game/look.rs`, the only reader).
|
||||
Nothing is called per frame, and a seat nothing changed sees vanilla's.
|
||||
- A knob is named by what it changes in the picture or the sound, never by the feature that uses it. A new knob
|
||||
is a field added with vanilla's value as its default.
|
||||
|
||||
### Container screens (`horizon_platform::containers`)
|
||||
|
||||
Input and drawing on an open container screen (the inventory, chests, furnaces, crafting tables...), for the
|
||||
extras that act on one: Mouse Tweaks and sorting (M5).
|
||||
|
||||
```rust
|
||||
/// The open screen's menu, for the length of one call. The engine implements it.
|
||||
pub trait Container {
|
||||
fn menu(&self) -> &str; // "inventory", "minecraft:generic_9x3", "minecraft:furnace"...
|
||||
fn slots(&self) -> Vec<Slot>; // every slot drawn, in the screen's order
|
||||
fn stack(&mut self, slot: SlotId) -> Option<Stack>;
|
||||
fn carried(&self) -> Option<Stack>; // the stack on the cursor
|
||||
fn takes_one(&mut self, slot: SlotId, of: &Stack) -> bool; // the menu's rules: mayPlace, the slot's limit
|
||||
fn landing(&mut self, slot: SlotId) -> Vec<SlotId>; // where quickMoveStack would put its items
|
||||
fn click(&mut self, slot: SlotId, click: Click) -> bool; // one ordinary click, as vanilla sends it
|
||||
fn spread(&self) -> usize; // the slots vanilla's quick craft drag covers now
|
||||
fn cursor(&self) -> (f32, f32); // GUI pixels, from the panel's corner
|
||||
fn shift(&self) -> bool;
|
||||
fn order(&mut self, slot: SlotId) -> (u32, String); // the creative tabs' order and the shown name
|
||||
}
|
||||
pub struct SlotId(pub u32); // opaque, stable while the screen is open
|
||||
pub struct Slot { pub id: SlotId, pub role: Role, pub x: f32, pub y: f32 } // top left, from the panel's corner
|
||||
pub enum Role { Hotbar, Inventory, Storage, Input, Result, Equipment, Other } // a crafting grid is Input
|
||||
pub struct Stack { pub item: String, pub count: u32, pub max: u32, pub kind: u64 } // kind: equal for the same item and components
|
||||
pub enum Click { Pick { right: bool }, QuickMove, Put { one: bool } } // Put: a one-slot QUICK_CRAFT drag
|
||||
|
||||
// In `Hooks`, each `false` (or nothing) by default, so vanilla's handling runs:
|
||||
fn container_press(&self, c: &mut dyn Container, slot: Option<SlotId>, right: bool) -> bool;
|
||||
fn container_drag(&self, c: &mut dyn Container, slot: Option<SlotId>) -> bool; // the cursor moved, a button held
|
||||
fn container_release(&self, c: &mut dyn Container);
|
||||
fn container_wheel(&self, c: &mut dyn Container, slot: Option<SlotId>, steps: f32) -> bool;
|
||||
fn container_key(&self, c: &mut dyn Container, slot: Option<SlotId>, key: &str) -> bool; // `key.keyboard.r`, `key.mouse.middle`
|
||||
fn container_draw(&self, c: &dyn Container, canvas: &mut dyn hud::Canvas); // over the panel, from its corner
|
||||
```
|
||||
|
||||
- `true` means the hook took the input: vanilla's press, drag or wheel doesn't run, and a press or drag taken
|
||||
ends vanilla's quick craft drag, as Mouse Tweaks' right-drag replaces it. Every hook hears `release`. The
|
||||
first hook that returns `true` wins.
|
||||
- Each `click` is an ordinary slot click: the menu engine's on the player's own world, a `ContainerClick` on a
|
||||
server. So an extra built on it works wherever vanilla's clicks do, and anticheats see vanilla's packets.
|
||||
- The engine offers every menu screen's slots; the crate decides which menus it acts in, by `menu()` (Mouse
|
||||
Tweaks stays out of those whose slots cost or trigger something: anvils, enchanting, merchants, beacons,
|
||||
crafters). Creative's tabs are no container screen.
|
||||
- The reads that ask the menu's rules take `&mut self`, as the engine's do; drawing gets `&dyn Container`,
|
||||
so it reads the rest only.
|
||||
- The engine builds a `Container` only when a hook is registered and a container screen gets input or draws.
|
||||
Without one there is no cost, and vanilla's handling is unchanged.
|
||||
- `order` is the only text: Sorting ranks by it. The rest is ids and numbers, so the API names no engine type
|
||||
(`ItemStack` stays in the engine).
|
||||
|
||||
## Extras that may leave the game
|
||||
|
||||
Horizon's extras are Horizon-layer code. Each one is a candidate to become an optional native mod that players add
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
|
||||
Horizon signs players in with their Microsoft account in the launcher and joins online-mode
|
||||
(authenticated) servers like vanilla 26.3 does. The launcher plays only with an account that owns
|
||||
Minecraft: Java Edition ([the owner gate](#the-owner-gate)); offline names, LAN and offline-mode
|
||||
servers play behind it. This note is the design, the status of each part and what Ludovic has to
|
||||
register.
|
||||
Minecraft: Java Edition ([the owner gate](#the-owner-gate)), as that account, on LAN and
|
||||
offline-mode servers too. There are no offline names. This note is the design, the status of each
|
||||
part and what Ludovic has to register.
|
||||
|
||||
## What Ludovic registers (needs his accounts)
|
||||
|
||||
@@ -87,7 +87,7 @@ possible; the launcher just doesn't offer it on its own.
|
||||
says "Sign in with a Microsoft account that owns Minecraft: Java Edition to play", with Sign in.
|
||||
- **The window says it once.** A player without an owner (one who played offline until now) sees
|
||||
the gate's screen once when the launcher opens: why Horizon needs the account ("like other
|
||||
launchers"), that worlds, offline names and settings stay where they are, and Sign in, which shows
|
||||
launchers"), and Sign in, which shows
|
||||
the device code on the same screen. It comes back whenever Play is refused; Not now closes it,
|
||||
and Home keeps a Sign in row (`launcher/src/owner_page.rs`).
|
||||
- **The answer lives with the tokens.** Each account's keychain item keeps the last answer
|
||||
@@ -96,8 +96,13 @@ possible; the launcher just doesn't offer it on its own.
|
||||
`secret-tool` or Keychain Access. With no network (or a sign-in that can't be renewed) the
|
||||
last answer stands: being offline never blocks a player who passed before, and the game starts
|
||||
without a session, offline. An answer of "doesn't own" (or no profile) blocks at once.
|
||||
- **Offline names play behind an owner.** They can be added and played only while an owning
|
||||
account is signed in. Signing the last one out keeps them, and they play again once one is back.
|
||||
- **The account that plays owns the game.** The game plays as the selected account (else the
|
||||
first), which must own the game itself: another account's ownership doesn't let it play
|
||||
(`account::launch_for_game`). The project decided on 2026-11-09 that Horizon plays only with a
|
||||
Microsoft account that owns Minecraft: Java Edition: the offline names, their skins and capes,
|
||||
and 0.2.58's hidden developer option are gone. Files from older launchers still load; the next
|
||||
save drops them (`offline`, `offline_selected`, `looks` and `skins/` in accounts.json's folder,
|
||||
`dev_offline` in settings.json).
|
||||
- **Who isn't affected:** the game binary itself (`horizon-client`, `horizon-ctl launch`, the
|
||||
harnesses, CI), the dedicated server and `horizon-server`, the website. There is no bypass flag:
|
||||
the launcher's tests use accounts in a memory store (`MemoryStore`, and `HORIZON_NO_KEYCHAIN`
|
||||
@@ -230,7 +235,7 @@ Progress is public metadata and does not expose credentials through the UI or MC
|
||||
name the expected player. Signing in to a different account is rejected before storing its
|
||||
credentials or adding/selecting that identity. Retry keeps the same target; Cancel leaves the
|
||||
saved account alone. Successful re-sign-in restores that account without changing the player
|
||||
or offline name currently selected for Play. Adding a new account remains a separate action.
|
||||
currently selected for Play. Adding a new account remains a separate action.
|
||||
|
||||
## Handing the session to the game
|
||||
|
||||
@@ -287,7 +292,8 @@ Code: `horizon-protocol` (`rsa.rs`, `crypto::server_hash`), `horizon-client` (`a
|
||||
|
||||
Horizon's dedicated server verifies players when `online-mode=true`. A LAN or Play with Friends world
|
||||
verifies its guests when the host is signed in, as vanilla's `IntegratedServer` does
|
||||
(`OnlineMode::lan`); under an offline name it stays offline-mode. The host's own player joins through
|
||||
(`OnlineMode::lan`); started without a session (the services couldn't be reached at Play) it
|
||||
stays offline-mode. The host's own player joins through
|
||||
memory and is never asked to authenticate.
|
||||
|
||||
`HORIZON_API_DISCOVERY` overrides the discovery URL, the counterpart of authlib's
|
||||
|
||||
Loaded 100 of 227 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user