Compare commits

..
Author SHA1 Message Date
Horizon agentandClaude Opus 5.5 3548e4d802 horizon-ctl friends answers through Hooks::control (spec #14167 step 3a)
Hooks gains control_commands (a group name and its help lines) and control
(words + the world's ShareView, answering JSON or an error). The control
socket asks the hooks after its own groups; hello and help list the groups
crates add. Play with Friends answers `friends ...` through it, so the
engine no longer calls into friends.rs and game/friends_off.rs goes.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 22:02:26 +01:00
Horizon agentandClaude Opus 5.5 cb3983803e Screen::Friends becomes Screen::AddedPage; the engine names it from the page (spec #14167 step 2b)
The screen the pause menu's Friends icon opens is the page registered at
Place::PauseFriends, so the engine stops naming it after Play with Friends:
Screen::AddedPage (its control name stays "friends"), and the icon's widget
name, tooltip and screen title come from the page's title (vanilla's
gui.friends.open when none is registered). The engine no longer reads
friends::TITLE or friends::title().

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 21:52:12 +01:00
Horizon agentandClaude Opus 5.5 ee60188af7 Play with Friends' screen becomes a page at Place::PauseFriends (spec #14167 step 2a)
The Friends screen is now Play with Friends' page, registered at a new
Place::PauseFriends (vanilla's pause-menu Friends icon) and drawn by the
engine as its own screen (game/page_screen.rs) with today's layout: grey and
white text, the code broken between its groups, Copy and Stop side by side,
Troubleshooting beside Done. Its rows are read when drawn (the code is polled
then, as before); a press runs on the next Hooks::share tick, which has the
game's view. horizon_hooks::tr gives crates the player's language.
game/friends/screen.rs goes; Screen::Friends stays until step 2b.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 21:06:46 +01:00
Horizon agent f518ef4c07 Merge branch 'split/pages-plain-rows' into split/friends-page 2026-11-09 20:56:42 +01:00
Horizon agentandClaude Opus 5.5 2719b22258 Friends reads its code while the screen is drawn, as before
The invite code and the host's events are polled while the Friends screen
is drawn or horizon-ctl friends status asks, as on main: Hooks::share stays
dispatched for the screen's page (#14167 step 2a), which is its first user.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 20:50:04 +01:00
Horizon agentandClaude Opus 5.5 3da374ef18 Play with Friends hosts through Hooks::share and Hooks::lan_closed (spec #14167 step 1)
The engine shows the window's world to crates that share it past the LAN
through horizon_hooks::share::ShareView, ticks them next to social::tick()
(also while paused) and tells them when the world closes to LAN. Only the
window's own player dispatches: never the serving Game or a splitscreen guest.
game/friends.rs keeps its logic but goes through the view, its state in its
own static instead of a Game field, ready to move to horizon-friends.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 20:40:22 +01:00
255 changed files with 6983 additions and 6581 deletions

No files matched your search

+1
View File
@@ -13,3 +13,4 @@
87152661 rdbtCVS # added by Ludovic, 2026-10-27
84875247 Online5277 # added by Ludovic, 2026-10-27 (his top three by merged pull requests)
86072227 levzzz5154 # added by Ludovic, 2026-10-28 (Horizon Team; author of horizon-mixins)
169198624 itarqos5 # added by Ludovic, 2026-11-08 (Discord Literal; Horizon Team)
Generated
-10
View File
@@ -1796,21 +1796,11 @@ dependencies = [
"horizon-friends",
"horizon-game",
"horizon-heartbeat",
"horizon-official-servers",
"horizon-platform",
"log",
"winresource",
]
[[package]]
name = "horizon-official-servers"
version = "0.1.0"
dependencies = [
"horizon-addresses",
"horizon-platform",
"log",
]
[[package]]
name = "horizon-optifine"
version = "0.1.0"
-1
View File
@@ -34,7 +34,6 @@ members = [
"crates/horizon-game",
"crates/horizon-official",
"crates/horizon-heartbeat",
"crates/horizon-official-servers",
"crates/horizon-bench",
"crates/horizon-friends",
"crates/horizon-relay",
-4
View File
@@ -15,10 +15,6 @@ pub const OFFICIAL_SERVER: &str = "play.playhorizon.dev";
/// `official_server_previous` in `launcher/links.json`).
pub const OFFICIAL_SERVER_PREVIOUS: &[&str] = &["horizon.bunchy7s.dev"];
/// Afri-Network, a partner server the game added once below the official server (#9299) until
/// the partnership ended: kept so the game can take back out the entry it added.
pub const WITHDRAWN_PARTNER_SERVER: &str = "afri-network.co.za";
/// [`OFFICIAL_SERVER`] with the port its SRV record gives, for askers that don't look SRV up
/// (the telemetry server's status checks).
pub const OFFICIAL_SERVER_STATUS: &str = "play.playhorizon.dev:24454";
+37 -172
View File
@@ -74,70 +74,11 @@ pub trait ResourceSource {
vec!["minecraft".into()]
}
}
/// The bundled file a language reads, and whether it drops `ß`: a regional variant reads its
/// language's file (Swiss German writes "ss" for "ß", as vanilla's de_ch does).
fn bundled_code(code: &str) -> (&str, bool) {
match code {
"ko" => ("ko_kr", false),
"hr" => ("hr_hr", false),
"de_at" => ("de_de", false),
"de_ch" => ("de_de", true),
"fr_ca" | "fr_ch" => ("fr_fr", false),
"es_ar" | "es_cl" | "es_ec" | "es_mx" | "es_uy" | "es_ve" => ("es_es", false),
code => (code, false),
}
}
/// Lang files crates above the engine ship (`horizon_hooks::lang`), each a list of
/// (code, JSON) pairs. The game's entry installs them once, before any language loads.
static CRATE_FILES: std::sync::OnceLock<&'static [&'static [(&'static str, &'static str)]]> =
std::sync::OnceLock::new();
/// Installs the crates' lang files (`horizon_hooks::lang::engine::all()`). Only the first call
/// counts.
pub fn install_crate_files(files: &'static [&'static [(&'static str, &'static str)]]) {
let _ = CRATE_FILES.set(files);
}
/// The crates' entries for language `code` (through [`bundled_code`]'s variants) that the
/// engine's own `horizon` file for it doesn't have: on a key both have, the engine wins.
fn read_crate_files(files: &[&[(&str, &str)]], code: &str, map: &mut HashMap<String, String>) {
if files.is_empty() {
return;
}
let (file, swiss) = bundled_code(code);
let mut own = HashMap::new();
if let Some(bytes) = Bundled.read(&format!("assets/horizon/lang/{code}.json")) {
read_lang_file(&bytes, &mut own);
}
for (c, json) in files.iter().flat_map(|f| f.iter()) {
if *c != file {
continue;
}
let mut theirs = HashMap::new();
if swiss {
read_lang_file(json.replace('ß', "ss").as_bytes(), &mut theirs);
} else {
read_lang_file(json.as_bytes(), &mut theirs);
}
for (key, value) in theirs {
if !own.contains_key(&key) {
map.insert(key, value);
}
}
}
}
/// Horizon's own strings, compiled in so they need no prepared resources.
struct Bundled;
impl ResourceSource for Bundled {
fn read(&self, path: &str) -> Option<Vec<u8>> {
let code = path
.strip_prefix("assets/horizon/lang/")?
.strip_suffix(".json")?;
let (file, swiss) = bundled_code(code);
let path = format!("assets/horizon/lang/{file}.json");
let body = match path.as_str() {
let body = match path {
"assets/horizon/lang/en_us.json" => {
include_str!("../../horizon-client/assets/horizon/lang/en_us.json")
}
@@ -162,10 +103,10 @@ impl ResourceSource for Bundled {
"assets/horizon/lang/tr_tr.json" => {
include_str!("../../horizon-client/assets/horizon/lang/tr_tr.json")
}
"assets/horizon/lang/ko_kr.json" => {
"assets/horizon/lang/ko_kr.json" | "assets/horizon/lang/ko.json" => {
include_str!("../../horizon-client/assets/horizon/lang/ko_kr.json")
}
"assets/horizon/lang/hr_hr.json" => {
"assets/horizon/lang/hr_hr.json" | "assets/horizon/lang/hr.json" => {
include_str!("../../horizon-client/assets/horizon/lang/hr_hr.json")
}
"assets/horizon/lang/pt_pt.json" => {
@@ -390,20 +331,30 @@ impl ResourceSource for Bundled {
"assets/horizon/lang/vec_it.json" => {
include_str!("../../horizon-client/assets/horizon/lang/vec_it.json")
}
"assets/horizon/lang/de_de.json" => {
"assets/horizon/lang/de_de.json" | "assets/horizon/lang/de_at.json" => {
include_str!("../../horizon-client/assets/horizon/lang/de_de.json")
}
"assets/horizon/lang/fr_fr.json" => {
// Swiss German writes "ss" for "ß", as vanilla's de_ch does.
"assets/horizon/lang/de_ch.json" => {
let de = include_str!("../../horizon-client/assets/horizon/lang/de_de.json");
return Some(de.replace('ß', "ss").into_bytes());
}
"assets/horizon/lang/fr_fr.json"
| "assets/horizon/lang/fr_ca.json"
| "assets/horizon/lang/fr_ch.json" => {
include_str!("../../horizon-client/assets/horizon/lang/fr_fr.json")
}
"assets/horizon/lang/es_es.json" => {
"assets/horizon/lang/es_es.json"
| "assets/horizon/lang/es_ar.json"
| "assets/horizon/lang/es_cl.json"
| "assets/horizon/lang/es_ec.json"
| "assets/horizon/lang/es_mx.json"
| "assets/horizon/lang/es_uy.json"
| "assets/horizon/lang/es_ve.json" => {
include_str!("../../horizon-client/assets/horizon/lang/es_es.json")
}
_ => return None,
};
if swiss {
return Some(body.replace('ß', "ss").into_bytes());
}
Some(body.as_bytes().to_vec())
}
fn namespaces(&self) -> Vec<String> {
@@ -487,16 +438,6 @@ impl Language {
/// from every namespace and every source in priority order (later sources
/// override).
pub fn load(sources: &[&dyn ResourceSource], code: &str) -> Self {
let crate_files = CRATE_FILES.get().copied().unwrap_or_default();
Self::load_with(sources, code, crate_files)
}
/// [`Language::load`] with these crates' lang files.
fn load_with(
sources: &[&dyn ResourceSource],
code: &str,
crate_files: &[&[(&str, &str)]],
) -> Self {
let mut map = HashMap::new();
let mut codes = vec![DEFAULT];
if code != DEFAULT {
@@ -512,14 +453,12 @@ impl Language {
sources.iter().flat_map(|s| s.namespaces()).collect();
for c in codes {
for namespace in &namespaces {
for (i, source) in sources.iter().enumerate() {
if let Some(bytes) = source.read(&format!("assets/{namespace}/lang/{c}.json")) {
read_lang_file(&bytes, &mut map);
}
// The crates' text right after the engine's own (`Bundled`, the first source).
if i == 0 && namespace == "horizon" {
read_crate_files(crate_files, c, &mut map);
}
for source in sources {
let Some(bytes) = source.read(&format!("assets/{namespace}/lang/{c}.json"))
else {
continue;
};
read_lang_file(&bytes, &mut map);
}
}
}
@@ -749,43 +688,6 @@ mod tests {
);
}
#[test]
fn a_crates_text_loads_after_the_engines_and_before_packs() {
static CRATE: &[(&str, &str)] = &[
(
"en_us",
r#"{"test.crate.hello": "Hello", "horizon.osk.close": "Shut", "test.crate.packed": "Crate"}"#,
),
(
"de_de",
r#"{"test.crate.hello": "Grüß dich", "horizon.osk.close": "Zu"}"#,
),
("fr_fr", r#"{"test.crate.hello": "Bonjour"}"#),
];
let pack = Mem(HashMap::from([(
"assets/horizon/lang/en_us.json".to_owned(),
br#"{"test.crate.packed": "Pack"}"#.to_vec(),
)]));
let load = |code| Language::load_with(&[&pack], code, &[CRATE]);
let en = load("en_us");
assert_eq!(en.get("test.crate.hello"), Some("Hello"));
// The engine's own file wins on a key both have; a pack wins over both.
assert_eq!(en.get("horizon.osk.close"), Some("Close"));
assert_eq!(en.get("test.crate.packed"), Some("Pack"));
let de = load("de_de");
assert_eq!(de.get("test.crate.hello"), Some("Grüß dich"));
assert_eq!(de.get("horizon.osk.close"), Some("Schließen"));
// Regional variants read their language's file, as the engine's do.
assert_eq!(load("de_ch").get("test.crate.hello"), Some("Grüss dich"));
assert_eq!(load("fr_ca").get("test.crate.hello"), Some("Bonjour"));
// A language the crate lacks falls back to its English.
assert_eq!(load("pl_pl").get("test.crate.hello"), Some("Hello"));
// Without crate files, nothing changes.
let plain = Language::load_with(&[&pack], "de_de", &[]);
assert_eq!(plain.get("test.crate.hello"), None);
assert_eq!(plain.len() + 1, de.len());
}
#[test]
fn a_scoped_language_is_the_calling_threads_and_reports_what_it_lacks() {
let empty = Mem(HashMap::new());
@@ -837,20 +739,6 @@ mod tests {
std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../horizon-client/assets/horizon/lang")
}
/// The crates whose own lang files the game loads (`crates/<name>/assets/lang`,
/// registered through `horizon_hooks::lang`), with that folder.
fn crate_lang_dirs() -> Vec<(String, std::path::PathBuf)> {
let crates = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("..");
let mut dirs: Vec<_> = std::fs::read_dir(&crates)
.unwrap()
.flatten()
.map(|e| (e.file_name().into_string().unwrap(), e.path()))
.map(|(name, path)| (name, path.join("assets/lang")))
.filter(|(_, dir)| dir.join("en_us.json").is_file())
.collect();
dirs.sort();
dirs
}
/// The keys of a flat bundled file in file order, duplicates included (a map
/// would keep only the last of two equal keys). One `"key": "value"` per line.
fn file_keys(body: &str) -> Vec<String> {
@@ -888,33 +776,11 @@ mod tests {
#[test]
fn bundled_files_are_served_and_have_no_stale_or_repeated_keys() {
let engine = check_lang_dir(&bundled_dir(), true);
assert!(
engine.1 >= 7,
"found {} bundled lang files in {}",
engine.1,
bundled_dir().display()
);
// A crate's files pass the same checks. Its keys are its own: on a key the engine's
// files also have, the engine's text would win and hide the crate's.
for (name, dir) in crate_lang_dirs() {
let (en, _) = check_lang_dir(&dir, false);
for key in en.keys() {
assert!(
!engine.0.contains_key(key),
"{name}: {key} is also the engine's"
);
}
}
}
/// The checks every lang folder passes: flat maps, no repeated key, no key en_us lacks, the
/// same placeholders as English, no empty text. Its en_us and how many files it has.
fn check_lang_dir(dir: &std::path::Path, bundled: bool) -> (HashMap<String, String>, usize) {
let dir = bundled_dir();
let en_body = std::fs::read_to_string(dir.join("en_us.json")).unwrap();
let en: HashMap<String, String> = serde_json::from_str(&en_body).unwrap();
let mut seen = 0;
for entry in std::fs::read_dir(dir).unwrap().flatten() {
for entry in std::fs::read_dir(&dir).unwrap().flatten() {
let name = entry.file_name().into_string().unwrap();
let Some(code) = name.strip_suffix(".json") else {
continue;
@@ -923,7 +789,7 @@ mod tests {
// A file that Bundled::read does not name is never loaded.
let path = format!("assets/horizon/lang/{name}");
assert!(
!bundled || Bundled.read(&path).is_some(),
Bundled.read(&path).is_some(),
"{name} is not served by Bundled"
);
let body = std::fs::read_to_string(entry.path()).unwrap();
@@ -949,7 +815,11 @@ mod tests {
assert!(!value.trim().is_empty(), "{code}: {key} is empty");
}
}
(en, seen)
assert!(
seen >= 7,
"found {seen} bundled lang files in {}",
dir.display()
);
}
#[test]
@@ -964,10 +834,10 @@ mod tests {
}
}
}
let english = |dir: &std::path::Path| -> HashMap<String, String> {
serde_json::from_str(&std::fs::read_to_string(dir.join("en_us.json")).unwrap()).unwrap()
};
let mut en = english(&bundled_dir());
let en: HashMap<String, String> = serde_json::from_str(
&std::fs::read_to_string(bundled_dir().join("en_us.json")).unwrap(),
)
.unwrap();
let crates = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("..");
let mut files = Vec::new();
for krate in [
@@ -977,11 +847,6 @@ mod tests {
] {
rust_files(&crates.join(krate).join("src"), &mut files);
}
// A crate that ships lang files names its keys in its own sources.
for (name, dir) in crate_lang_dirs() {
en.extend(english(&dir));
rust_files(&crates.join(name).join("src"), &mut files);
}
assert!(files.len() > 100, "found only {} sources", files.len());
let mut found = 0;
for file in files {
@@ -1441,16 +1441,6 @@ 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)]
@@ -1,15 +0,0 @@
//! 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,
}
-1
View File
@@ -9,7 +9,6 @@ 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;
+2 -1
View File
@@ -115,7 +115,8 @@ default = ["audio", "controllers", "horizon-extras"]
audio = ["horizon-client-render/audio"]
# Controllers: gilrs, and the DualSense's own output. Without it only `horizon-ctl pad`'s virtual pad.
controllers = ["dep:gilrs", "horizon-client-input/controllers"]
# Horizon's own parts still in this crate (docs/forking.md, docs/layers.md): the features below.
# Horizon's own parts (docs/forking.md, docs/layers.md): the features below, and the official
# server in the list (`official_servers`).
horizon-extras = ["discord"]
# "Playing Horizon" on the player's Discord profile (`discord_presence`).
discord = []
@@ -1,158 +0,0 @@
//! 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
}
}
@@ -386,7 +386,6 @@ fn replay(window: Arc<Window>) -> Result<(), Box<dyn std::error::Error>> {
path: path.clone(),
size: None,
scaled: false,
background: false,
});
for _ in 0..32 {
draw(&mut renderer)?;
@@ -207,7 +207,6 @@ fn fixture(window: Arc<Window>) -> Result<(), Box<dyn std::error::Error>> {
path: out.join("cpu.png"),
size: None,
scaled: false,
background: false,
});
renderer.render(&frame)?;
let expected = read(&out.join("cpu.png"));
@@ -252,7 +251,6 @@ fn fixture(window: Arc<Window>) -> Result<(), Box<dyn std::error::Error>> {
path: out.join("gpu.png"),
size: None,
scaled: false,
background: false,
});
renderer.render(&frame)?;
assert_eq!(
@@ -167,7 +167,6 @@ fn fixture(window: Arc<Window>) -> Result<(), Box<dyn std::error::Error>> {
path: path.clone(),
size: None,
scaled: false,
background: false,
});
// twice: the first frame of a new warp builds it
renderer.render(&frame).unwrap();
@@ -337,7 +336,6 @@ fn bench(renderer: &mut Renderer, out: &Path) -> Result<(), Box<dyn std::error::
path: out.join("bench.png"),
size: None,
scaled: false,
background: false,
});
renderer.render(&frame).unwrap();
renderer.render(&frame).unwrap();
+52 -166
View File
@@ -478,7 +478,6 @@ 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,
@@ -723,29 +722,6 @@ 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,
@@ -846,24 +822,18 @@ 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_with_length(0.0, 0.0, 1.0, 1.0);
return stereo_gains(0.0, 0.0, 1.0);
}
let u = (up / length).clamp(-1.0, 1.0);
let cos_elev = (1.0 - u * u).max(0.0).sqrt();
let elevation = (up / length).clamp(-1.0, 1.0).asin();
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 (sin_az, cos_az) = azimuth.sin_cos();
let side = sin_az * cos_elev;
let front = cos_az * cos_elev * 0.095_660_8;
let side = azimuth.sin() * elevation.cos();
let front = azimuth.cos() * elevation.cos() * 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
@@ -883,10 +853,16 @@ 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 (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 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],
@@ -920,7 +896,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 &mut mixer.channels {
for (id, c) in &mixer.channels {
let Some((_, source_rate, source_channels)) = &c.samples else {
continue;
};
@@ -931,7 +907,6 @@ 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]| {
[
@@ -940,56 +915,45 @@ fn mix(mixer: &mut Mixer, data: &mut [f32], channels: usize, rate: u32) {
at[2] - listener[2],
]
};
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 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 (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 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;
}
}
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)
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 {
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;
distance_gain(c.linear, distance)
}
(gain, [left * gain, right * gain])
} 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]
};
let step = f64::from(*source_rate) / f64::from(rate) * f64::from(c.pitch.max(0.0));
voices.push((*id, [left, right, gain], step));
@@ -1010,40 +974,6 @@ 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 {
@@ -1062,8 +992,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] + (samples[(i + 1) * 2] - samples[i * 2]) * t;
let r = samples[i * 2 + 1] + (samples[(i + 1) * 2 + 1] - samples[i * 2 + 1]) * t;
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;
if channels >= 2 {
frame[0] += l * left;
frame[1] += r * right;
@@ -1071,7 +1001,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] + (samples[i + 1] - samples[i]) * t;
let s = samples[i] * (1.0 - t) + samples[i + 1] * t;
if channels >= 2 {
frame[0] += s * left;
frame[1] += s * right;
@@ -1482,7 +1412,6 @@ 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,
@@ -1606,12 +1535,8 @@ 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;
@@ -1651,7 +1576,6 @@ 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,
@@ -2185,25 +2109,6 @@ 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 {
@@ -2708,25 +2613,6 @@ 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,25 +569,6 @@ 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 {
-53
View File
@@ -233,8 +233,6 @@ pub struct FogInputs<'a> {
pub night_vision: f32,
/// 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`.
@@ -399,24 +397,6 @@ 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;
@@ -515,7 +495,6 @@ mod tests {
full_water_vision: false,
night_vision: 0.0,
environment: Default::default(),
fluid_fog: Default::default(),
}
}
#[test]
@@ -632,38 +611,6 @@ 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: crate::pack_palette::Environment,
+107 -40
View File
@@ -201,7 +201,6 @@ pub use hdr::{Calibration as HdrCalibration, Output as HdrOutput};
mod hold;
pub use hold::WorldHold;
pub use output::{DisplayReading, EffectiveOutput, FrameConfiguration, FrameRequest};
pub use screenshot_save::Saved as SavedScreenshot;
pub mod block_light;
mod custom_sky;
mod displace;
@@ -237,7 +236,6 @@ pub mod panorama;
mod readback;
mod resident;
mod reuse;
mod screenshot_save;
mod stars;
mod surface_setup;
use surface_setup::{AdapterChoice, AdapterFailure, open_adapter, pick_adapter, set_up_surface};
@@ -1086,9 +1084,6 @@ pub struct ScreenshotRequest {
/// F2's: drawn at the game's resolution (Resolution Scale above 100%) times
/// `Renderer::screenshot_quality`, not the window's.
pub scaled: bool,
/// Written on a thread of its own (F2's, the world icon's): its outcome comes back a
/// frame or a few later in `Renderer::saved_screenshots`, not `screenshot_result`.
pub background: bool,
}
/// A solid terrain pipeline without HDR output's lighting (terrain.wgsl's `HDR_LIGHTING`),
/// for the frames HDR is off, and with it where the display offers HDR output: the
@@ -1583,9 +1578,6 @@ pub struct Renderer {
pub screenshot_result: Option<std::result::Result<[u32; 2], String>>,
/// The HDR copy the last screenshot wrote beside it, if it wrote one.
pub screenshot_hdr: Option<std::path::PathBuf>,
/// `background` screenshots written since the caller last took them.
pub saved_screenshots: Vec<SavedScreenshot>,
screenshot_saves: screenshot_save::Saves,
/// Screenshot Quality (Horizon setting): a `scaled` screenshot's frame is this many
/// times the game's resolution (1, 2, 4 or 8).
pub screenshot_quality: u32,
@@ -5085,7 +5077,6 @@ impl Renderer {
));
}
let world_primitives = world_primitives::WorldPrimitives::new(&device, frame_format);
let screenshot_saves = screenshot_save::Saves::new(&device);
Ok(Self {
display: None,
reading: display,
@@ -5357,8 +5348,6 @@ impl Renderer {
screenshot: None,
screenshot_result: None,
screenshot_hdr: None,
saved_screenshots: Vec::new(),
screenshot_saves,
screenshot_quality: 1,
screenshot_halvings: 0,
gamma: 0.5,
@@ -7052,10 +7041,8 @@ impl Renderer {
}
}
/// This renderer's output as applied, with what its destination offers and reports.
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)
pub fn output(&self) -> &output::EffectiveOutput {
&self.output
}
/// What this renderer's display last reported: a renderer made again for the window
/// starts from it (`Renderer::new`'s `display`).
@@ -7988,8 +7975,6 @@ impl Renderer {
if self.device_lost() {
return Err("the graphics device was lost".into());
}
let saved = self.screenshot_saves.finished();
self.saved_screenshots.extend(saved);
if let Some(request) = self.capture.take() {
let result = self.write_capture(&request, frame);
self.capture_result = Some(result.map_err(|e| e.to_string()));
@@ -8334,7 +8319,9 @@ 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.shader_pack_kind().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");
if let Some(hdr) = &mut self.hdr {
hdr.lighting = hdr_lighting && hdr.look;
}
@@ -12057,18 +12044,24 @@ impl Renderer {
self.screenshot = Some(request);
} else {
self.screenshot_halvings = 0;
let size = [self.config.width, self.config.height];
let hdr = hdr_readback
.filter(|_| self.hdr.is_some())
.map(|b| (hdr_screenshot_path(&request.path), b));
let shot = screenshot_save::Shot::new(&request, size, self.config.format, hdr);
if request.background {
self.screenshot_saves.start(shot, buffer);
} else {
let saved = screenshot_save::save_now(&self.device, shot, &buffer);
self.screenshot_hdr = saved.hdr;
self.screenshot_result = Some(saved.result);
self.screenshot_hdr = None;
if let Some(hdr_buffer) = hdr_readback {
let hdr_path = hdr_screenshot_path(&request.path);
match self.write_hdr_screenshot(&hdr_path, &hdr_buffer) {
Ok(()) => {
log::info!("Saved HDR screenshot to {}", hdr_path.display());
self.screenshot_hdr = Some(hdr_path);
}
Err(e) => log::warn!(
"Horizon renderer: HDR screenshot {}: {e}",
hdr_path.display()
),
}
}
self.screenshot_result = Some(
self.write_screenshot(&request, &buffer)
.map_err(|e| e.to_string()),
);
}
}
// The frame's own waits for the submit thread (its queue writes and passes).
@@ -12671,22 +12664,96 @@ impl Renderer {
let texture = copy.as_ref().unwrap_or(texture);
Some((request, copy_frame(&self.device, encoder, texture, size)))
} else {
let error = format!(
self.screenshot_result = Some(Err(format!(
"screenshots need a 32-bit surface format, not {:?}",
self.config.format
);
if request.background {
self.saved_screenshots.push(screenshot_save::Saved {
path: request.path,
result: Err(error),
hdr: None,
});
} else {
self.screenshot_result = Some(Err(error));
}
)));
None
}
}
/// The pixels `copy_frame` read back into `buffer`, 4 bytes each, row after row.
fn read_frame(&self, buffer: &wgpu::Buffer) -> Result<Vec<[u8; 4]>> {
let (sender, receiver) = std::sync::mpsc::channel();
buffer.map_async(wgpu::MapMode::Read, .., move |r| {
let _ = sender.send(r);
});
self.device
.poll(wgpu::PollType::wait_indefinitely())
.map_err(|e| e.to_string())?;
receiver
.recv()
.map_err(|e| e.to_string())?
.map_err(|e| e.to_string())?;
let (width, height, row) = (
self.config.width as usize,
self.config.height as usize,
padded_row(self.config.width) as usize,
);
let mut pixels = Vec::with_capacity(width * height);
{
let data = buffer.get_mapped_range(..).map_err(|e| e.to_string())?;
for y in 0..height {
pixels.extend_from_slice(data[y * row..y * row + width * 4].as_chunks::<4>().0);
}
}
buffer.unmap();
Ok(pixels)
}
fn write_screenshot(
&self,
request: &ScreenshotRequest,
buffer: &wgpu::Buffer,
) -> Result<[u32; 2]> {
let bgra = matches!(
self.config.format.remove_srgb_suffix(),
wgpu::TextureFormat::Bgra8Unorm
);
let mut pixels = Vec::with_capacity((self.config.width * self.config.height * 4) as usize);
for px in self.read_frame(buffer)? {
let (r, b) = if bgra { (px[2], px[0]) } else { (px[0], px[2]) };
pixels.extend_from_slice(&[r, px[1], b, 255]);
}
// NativeImage.resizeSubRectTo on takeAutoScreenshot's grab: the icon
// is the frame's centre square averaged down to its size.
let (width, height, pixels) = match request.size {
Some(size) => (
size[0],
size[1],
square_downscale(&pixels, self.config.width, self.config.height, size),
),
None => (self.config.width, self.config.height, pixels),
};
let mut encoder = png::Encoder::new(
std::io::BufWriter::new(std::fs::File::create(&request.path)?),
width,
height,
);
encoder.set_color(png::ColorType::Rgba);
encoder.set_depth(png::BitDepth::Eight);
encoder.write_header()?.write_image_data(&pixels)?;
Ok([width, height])
}
/// The HDR10 copy (`Hdr::capture`) as a 16-bit PNG tagged BT.2020 with PQ (cICP, PNG's
/// third edition), with the content light levels its pixels hold (cLLI), and
/// an ICC profile for the programs that don't read cICP (`hdr_icc`).
fn write_hdr_screenshot(&self, path: &std::path::Path, buffer: &wgpu::Buffer) -> Result<()> {
if self.hdr.is_none() {
return Ok(());
}
let pixels = hdr10_to_rgb16(&self.read_frame(buffer)?);
let mut info = png::Info::with_size(self.config.width, self.config.height);
info.color_type = png::ColorType::Rgb;
info.bit_depth = png::BitDepth::Sixteen;
info.icc_profile = Some(hdr_icc::profile().into());
let encoder =
png::Encoder::with_info(std::io::BufWriter::new(std::fs::File::create(path)?), info)?;
let mut writer = encoder.write_header()?;
// BT.2020 primaries, PQ, RGB, full range.
writer.write_chunk(png::chunk::cICP, &[9, 16, 0, 1])?;
writer.write_chunk(png::chunk::cLLI, &hdr_icc::content_light_levels(&pixels))?;
writer.write_image_data(&pixels)?;
Ok(())
}
}
/// `screenshots/2026-10-28_12.00.00.png` -> `screenshots/2026-10-28_12.00.00-hdr.png`.
fn hdr_screenshot_path(path: &std::path::Path) -> PathBuf {
+1 -1
View File
@@ -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),
/// and with HDR Lighting (`look`) off.
/// with HDR Lighting (`look`) off, and with `HORIZON_HDR_LIGHTING=0`, to compare.
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.
@@ -144,9 +144,6 @@ 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 {
@@ -169,22 +166,7 @@ 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()
@@ -291,7 +273,6 @@ 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,
}
}
@@ -1,321 +0,0 @@
//! Writing a screenshot's PNG once its frame is read back: on the render thread for the
//! requests that want the result with the frame (an agent's, a `screenshot-sequence`'s),
//! on a thread of its own for F2's and the world icon's (`ScreenshotRequest::background`).
//! The render thread then neither waits for the GPU to finish the frame nor encodes the
//! PNG, which at 4K or a Screenshot Quality above 1x froze the game for a second or more.
use super::{ScreenshotRequest, hdr_icc, hdr10_to_rgb16, padded_row, square_downscale};
use std::path::PathBuf;
use std::sync::mpsc::{Receiver, Sender, channel};
use std::time::Duration;
/// What a frame's readback holds and where its PNG goes.
pub(super) struct Shot {
pub path: PathBuf,
/// `ScreenshotRequest::size`: the world icon's square.
pub size: Option<[u32; 2]>,
/// The frame's size, which the readback's rows hold (padded to `padded_row`).
pub frame: [u32; 2],
/// Blue first (`Bgra8Unorm`), swapped to PNG's red first.
pub bgra: bool,
/// The HDR10 copy's readback and its PNG's path, beside the SDR one.
pub hdr: Option<(PathBuf, wgpu::Buffer)>,
}
impl Shot {
pub fn new(
request: &ScreenshotRequest,
frame: [u32; 2],
format: wgpu::TextureFormat,
hdr: Option<(PathBuf, wgpu::Buffer)>,
) -> Self {
Self {
path: request.path.clone(),
size: request.size,
frame,
bgra: format.remove_srgb_suffix() == wgpu::TextureFormat::Bgra8Unorm,
hdr,
}
}
}
/// A written screenshot: its path, its size or the error, and the HDR copy written beside it.
pub struct Saved {
pub path: PathBuf,
pub result: Result<[u32; 2], String>,
pub hdr: Option<PathBuf>,
}
/// Waits for `buffer`'s mapping on the render thread and writes the PNG there.
pub(super) fn save_now(device: &wgpu::Device, shot: Shot, buffer: &wgpu::Buffer) -> Saved {
let (sender, receiver) = channel();
map(buffer, &sender);
if let Some((_, hdr)) = &shot.hdr {
map(hdr, &sender);
}
let waited = device
.poll(wgpu::PollType::wait_indefinitely())
.map_err(|e| e.to_string());
let count = 1 + usize::from(shot.hdr.is_some());
let mapped = waited.and_then(|_| wait_mapped(&receiver, count));
write(shot, buffer, mapped)
}
/// Screenshots being written on their thread.
pub(super) struct Saves {
device: wgpu::Device,
sender: Sender<Saved>,
receiver: Receiver<Saved>,
in_flight: usize,
}
impl Saves {
pub fn new(device: &wgpu::Device) -> Self {
let (sender, receiver) = channel();
Self {
device: device.clone(),
sender,
receiver,
in_flight: 0,
}
}
/// Maps `buffer` (and the HDR copy's), whose copy was just submitted, and writes the
/// PNG on a thread of its own once the GPU has finished the frame.
pub fn start(&mut self, shot: Shot, buffer: wgpu::Buffer) {
let (mapped, on_mapped) = channel();
map(&buffer, &mapped);
if let Some((_, hdr)) = &shot.hdr {
map(hdr, &mapped);
}
let done = self.sender.clone();
let path = shot.path.clone();
let spawned = std::thread::Builder::new()
.name("Screenshot writer".into())
.spawn(move || {
let count = 1 + usize::from(shot.hdr.is_some());
let ready = wait_mapped(&on_mapped, count);
let _ = done.send(write(shot, &buffer, ready));
});
match spawned {
Ok(_) => {}
Err(e) => {
let _ = self.sender.send(Saved {
path,
result: Err(format!("no thread to write it: {e}")),
hdr: None,
});
}
}
self.in_flight += 1;
}
/// The screenshots written since the last call. Polls the device so a finished frame's
/// mapping reaches its writer: the render thread never waits for it.
pub fn finished(&mut self) -> Vec<Saved> {
if self.in_flight == 0 {
return Vec::new();
}
let _ = self.device.poll(wgpu::PollType::Poll);
let saved: Vec<Saved> = self.receiver.try_iter().collect();
self.in_flight -= saved.len();
saved
}
}
impl Drop for Saves {
/// A screenshot taken right before quitting is still written whole.
fn drop(&mut self) {
let deadline = std::time::Instant::now() + Duration::from_secs(10);
while self.in_flight > 0 && std::time::Instant::now() < deadline {
let _ = self.device.poll(wgpu::PollType::Poll);
if let Ok(saved) = self.receiver.recv_timeout(Duration::from_millis(20)) {
log_saved(&saved);
self.in_flight -= 1;
}
}
}
}
fn log_saved(saved: &Saved) {
match &saved.result {
Ok(_) => log::info!("Saved screenshot to {}", saved.path.display()),
Err(e) => log::warn!("Horizon renderer: screenshot {}: {e}", saved.path.display()),
}
}
fn map(buffer: &wgpu::Buffer, mapped: &Sender<Result<(), String>>) {
let mapped = mapped.clone();
buffer.map_async(wgpu::MapMode::Read, .., move |r| {
let _ = mapped.send(r.map_err(|e| e.to_string()));
});
}
/// `count` mappings done, or the first one that failed. A frame the GPU never finishes
/// (a lost device) gives up after a while instead of keeping its thread.
fn wait_mapped(mapped: &Receiver<Result<(), String>>, count: usize) -> Result<(), String> {
for _ in 0..count {
mapped
.recv_timeout(Duration::from_secs(30))
.map_err(|_| "the frame never came back from the GPU".to_string())??;
}
Ok(())
}
/// The PNG (and the HDR copy's beside it) from the mapped readbacks.
fn write(shot: Shot, buffer: &wgpu::Buffer, mapped: Result<(), String>) -> Saved {
let mut hdr_written = None;
let result = mapped.and_then(|()| {
if let Some((hdr_path, hdr_buffer)) = &shot.hdr {
let pixels = read_rows(hdr_buffer, shot.frame);
match pixels.and_then(|p| write_hdr_png(hdr_path, shot.frame, &p)) {
Ok(()) => {
log::info!("Saved HDR screenshot to {}", hdr_path.display());
hdr_written = Some(hdr_path.clone());
}
Err(e) => log::warn!(
"Horizon renderer: HDR screenshot {}: {e}",
hdr_path.display()
),
}
}
let pixels = read_rows(buffer, shot.frame)?;
write_png(&shot, &pixels)
});
Saved {
path: shot.path,
result,
hdr: hdr_written,
}
}
/// The pixels `copy_frame` read back into the mapped `buffer`, 4 bytes each, row after
/// row; unmaps it.
fn read_rows(buffer: &wgpu::Buffer, [width, height]: [u32; 2]) -> Result<Vec<[u8; 4]>, String> {
let (width, height, row) = (width as usize, height as usize, padded_row(width) as usize);
let mut pixels = Vec::with_capacity(width * height);
{
let data = buffer.get_mapped_range(..).map_err(|e| e.to_string())?;
for y in 0..height {
pixels.extend_from_slice(data[y * row..y * row + width * 4].as_chunks::<4>().0);
}
}
buffer.unmap();
Ok(pixels)
}
fn write_png(shot: &Shot, frame: &[[u8; 4]]) -> Result<[u32; 2], String> {
let [frame_w, frame_h] = shot.frame;
let mut pixels = Vec::with_capacity(frame.len() * 4);
for px in frame {
let (r, b) = if shot.bgra {
(px[2], px[0])
} else {
(px[0], px[2])
};
pixels.extend_from_slice(&[r, px[1], b, 255]);
}
// NativeImage.resizeSubRectTo on takeAutoScreenshot's grab: the icon
// is the frame's centre square averaged down to its size.
let (width, height, pixels) = match shot.size {
Some(size) => (
size[0],
size[1],
square_downscale(&pixels, frame_w, frame_h, size),
),
None => (frame_w, frame_h, pixels),
};
let file = std::fs::File::create(&shot.path).map_err(|e| e.to_string())?;
let mut encoder = png::Encoder::new(std::io::BufWriter::new(file), width, height);
encoder.set_color(png::ColorType::Rgba);
encoder.set_depth(png::BitDepth::Eight);
encoder
.write_header()
.and_then(|mut w| w.write_image_data(&pixels))
.map_err(|e| e.to_string())?;
Ok([width, height])
}
/// The HDR10 copy (`Hdr::capture`) as a 16-bit PNG tagged BT.2020 with PQ (cICP, PNG's
/// third edition), with the content light levels its pixels hold (cLLI), and
/// an ICC profile for the programs that don't read cICP (`hdr_icc`).
fn write_hdr_png(
path: &std::path::Path,
[width, height]: [u32; 2],
frame: &[[u8; 4]],
) -> Result<(), String> {
let pixels = hdr10_to_rgb16(frame);
let mut info = png::Info::with_size(width, height);
info.color_type = png::ColorType::Rgb;
info.bit_depth = png::BitDepth::Sixteen;
info.icc_profile = Some(hdr_icc::profile().into());
let file = std::fs::File::create(path).map_err(|e| e.to_string())?;
let written = png::Encoder::with_info(std::io::BufWriter::new(file), info)
.and_then(|encoder| encoder.write_header())
.and_then(|mut writer| {
// BT.2020 primaries, PQ, RGB, full range.
writer.write_chunk(png::chunk::cICP, &[9, 16, 0, 1])?;
writer.write_chunk(png::chunk::cLLI, &hdr_icc::content_light_levels(&pixels))?;
writer.write_image_data(&pixels)
});
written.map_err(|e| e.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_background_save_writes_the_png_off_the_render_thread() {
let instance = crate::gfx::test_gpu::instance();
let Ok(adapter) = pollster::block_on(instance.request_adapter(&Default::default())) else {
return;
};
let Ok((device, queue)) = pollster::block_on(adapter.request_device(&Default::default()))
else {
return;
};
let dir = std::env::temp_dir().join(format!("horizon-shot-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("shot.png");
let [w, h] = [3u32, 2];
// A frame of blue-first pixels, each row padded as `copy_frame` reads them back.
let row = padded_row(w) as usize;
let mut bytes = vec![0u8; row * h as usize];
for y in 0..h as usize {
for x in 0..w as usize {
bytes[y * row + x * 4..][..4].copy_from_slice(&[10 * x as u8, 20, 200, 0]);
}
}
let source = wgpu::util::DeviceExt::create_buffer_init(
&device,
&wgpu::util::BufferInitDescriptor {
label: None,
contents: &bytes,
usage: wgpu::BufferUsages::COPY_SRC,
},
);
let buffer = device.create_buffer(&wgpu::BufferDescriptor {
label: None,
size: bytes.len() as u64,
usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
mapped_at_creation: false,
});
let mut encoder = device.create_command_encoder(&Default::default());
encoder.copy_buffer_to_buffer(&source, 0, &buffer, 0, bytes.len() as u64);
queue.submit([encoder.finish()]);
let request = ScreenshotRequest {
path: path.clone(),
size: None,
scaled: false,
background: true,
};
let mut saves = Saves::new(&device);
saves.start(
Shot::new(&request, [w, h], wgpu::TextureFormat::Bgra8Unorm, None),
buffer,
);
let deadline = std::time::Instant::now() + Duration::from_secs(10);
let saved = loop {
if let Some(saved) = saves.finished().pop() {
break saved;
}
assert!(std::time::Instant::now() < deadline, "never written");
std::thread::sleep(Duration::from_millis(5));
};
assert_eq!(saved.path, path);
assert_eq!(saved.result, Ok([w, h]));
let decoder =
png::Decoder::new(std::io::BufReader::new(std::fs::File::open(&path).unwrap()));
let mut reader = decoder.read_info().unwrap();
let mut out = vec![0; reader.output_buffer_size().unwrap()];
reader.next_frame(&mut out).unwrap();
// Red first, opaque: the last pixel of the first row was [20, 20, 200] blue-first.
assert_eq!(&out[8..12], &[200, 20, 20, 255]);
std::fs::remove_dir_all(&dir).unwrap();
}
}
@@ -1110,8 +1110,7 @@ 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. HDR
/// Lighting has nothing to switch where the running shader pack lights the world itself.
/// only where it is offered (`graphics::gpu_culling_offer`), with the reason it isn't.
fn blocked(self, s: &Settings, out: &EffectiveOutput) -> Option<&'static str> {
if !self.available() {
return Some("This feature is not yet available in Horizon.");
@@ -1142,9 +1141,6 @@ 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,49 +31,6 @@ 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();
@@ -7,14 +7,6 @@ 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);
@@ -37,10 +29,6 @@ pub fn read(root: &Path) -> pack_palette::PackColors {
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),
},
}
}
@@ -59,24 +59,6 @@ pub struct PackColors {
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 {
@@ -90,7 +72,6 @@ impl Default for PackColors {
entity_shadows: true,
xp_bar: [None; 2],
env: Environment::default(),
fluid_fog: FluidFogs::default(),
}
}
}
@@ -801,26 +801,6 @@ 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> {
@@ -337,13 +337,6 @@ impl Settings {
.and_then(|mode| self.video_modes.iter().position(|m| *m == mode))
.map_or(-1, |i| i as i32)
}
/// Loads only the crates' settings stores (`horizon_hooks::settings`) from the files next to
/// `path`, into the seat whose turn it is (`horizon_hooks::seat`): a splitscreen player whose
/// settings were made from P1's rather than loaded.
pub fn load_stores(path: &Path) {
let dir = path.parent();
horizon_hooks::settings::engine::load(&mut |file| fs::read_to_string(dir?.join(file)).ok());
}
pub fn load(path: &Path) -> Self {
let mut horizon = path
.parent()
@@ -913,20 +913,6 @@ 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) {}
@@ -1508,14 +1494,13 @@ impl SoundEngine {
.iter()
.filter_map(|(h, handle)| handle.channel.map(|c| (*h, c))),
);
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);
for (h, c) in live.drain(..) {
if backend.stopped(c) {
self.release_handle(h, backend);
}
}
// Released handles nobody maps any more.
let mut mapped = stopped;
let mut mapped = std::mem::take(&mut self.mapped_scratch);
mapped.clear();
mapped.extend(self.instance_to_channel.iter().map(|(_, h)| *h));
self.handles
@@ -1565,7 +1550,9 @@ impl SoundEngine {
h.pitch_edited = pitch_edited;
}
if let Some(c) = self.handles.get(&handle).and_then(handle_channel) {
backend.update_channel(c, volume, pitch, position);
backend.set_volume(c, volume);
backend.set_pitch(c, pitch);
backend.set_position(c, position);
}
let instance = self.instances.get_mut(&id).expect("present");
if instance.looping
+1 -23
View File
@@ -1251,9 +1251,6 @@ 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]>,
@@ -1546,7 +1543,6 @@ impl EditBox {
text_color_uneditable: rgba(112, 112, 112, 255),
editable: true,
message: String::new(),
invert_highlighted_text_color: true,
frame: None,
}
}
@@ -1922,11 +1918,7 @@ 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),
self.invert_highlighted_text_color,
);
batch.text_highlight(atlas, (a, ty - 1.0, b - a, 11.0), true);
}
if show_cursor {
if insert {
@@ -2636,20 +2628,6 @@ 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,
@@ -287,7 +287,6 @@
"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.",
+2 -1
View File
@@ -102,6 +102,8 @@ pub mod native_data;
pub mod net;
pub mod nether_portal;
pub mod no_resources;
#[cfg(feature = "horizon-extras")]
pub mod official_servers;
pub mod offline_name;
pub mod old_world;
pub mod osk;
@@ -169,7 +171,6 @@ 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;
+9 -5
View File
@@ -23,12 +23,16 @@ pub fn main_with(mods: crate::mod_api::Mods) {
horizon_client::telemetry::mark_start();
// Opt-in telemetry keeps each crash report (docs/telemetry.md), whatever the mode.
crash::on_report(horizon_client::telemetry::keep_crash);
// Crates above the engine registered their hooks before this (horizon-official's `main`),
// then the Horizon parts still in this crate; none registers after this point.
game::player_hooks::register();
// Crates' text in the player's language (`horizon_hooks::tr`).
horizon_hooks::engine::set_language(|key| {
horizon_client::lang::has(key).then(|| horizon_client::lang::get(key))
});
// Play with Friends' host side, until it moves to horizon-friends (spec #14167).
#[cfg(feature = "friends")]
game::register_friends();
// Crates above the engine registered their hooks before this (horizon-official's `main`);
// none registers after this point.
horizon_hooks::engine::seal();
// The text crates ship (`horizon_hooks::lang`), loaded with the game's own languages.
horizon_client::lang::install_crate_files(horizon_hooks::lang::engine::all());
horizon_client::spans::startup::init(std::time::Instant::now());
horizon_client::spans::trace::start_from_env();
let args: Vec<String> = std::env::args().skip(1).collect();
+220 -58
View File
@@ -143,6 +143,8 @@ mod enderman_hurt_teleport_tests;
mod enderman_placement_tests;
mod entity;
mod entity_culling;
#[cfg(test)]
mod entity_inside_tests;
mod entity_sounds;
mod event_loop;
mod events;
@@ -162,15 +164,6 @@ mod frame_geometry;
mod frames;
#[cfg(feature = "friends")]
mod friends;
mod held_items;
#[cfg(test)]
mod setblock_order_tests;
// Without Play with Friends: nothing shared, no Friends button (game/friends_off.rs).
#[cfg(test)]
mod entity_inside_tests;
#[cfg(not(feature = "friends"))]
#[path = "game/friends_off.rs"]
mod friends;
mod functions;
mod game_mode;
mod game_tests;
@@ -181,6 +174,7 @@ mod golems;
mod growth;
mod hand;
mod headless;
mod held_items;
mod hud_editor;
mod hud_room;
mod ime;
@@ -357,6 +351,7 @@ mod pad;
mod pad_actions;
mod pad_craft;
mod pad_menu;
mod page_screen;
mod paintings;
mod pandas;
mod particle_culling;
@@ -373,7 +368,6 @@ mod place_feature_tests;
mod play_input;
mod play_keys;
mod player_equip;
pub(crate) mod player_hooks;
mod player_spawn;
mod pointer_capture;
#[cfg(test)]
@@ -403,7 +397,6 @@ mod remote_vehicles;
mod render_control;
mod render_settings;
mod render_tick;
mod replay;
mod repro;
mod resource_load;
mod resource_loader;
@@ -422,7 +415,10 @@ mod seats;
mod server_links;
mod server_packs;
mod server_travel;
#[cfg(test)]
mod setblock_order_tests;
mod shader_packs;
mod share_view;
mod sheep_dye;
mod shelf;
mod shield;
@@ -599,6 +595,8 @@ use resource_loader::{
EntityModels, GameCpuResources, Loaded, LoaderMsg, PendingWorldEntry, WorldRestLoader,
run_loading, write_report,
};
#[cfg(feature = "friends")]
pub(crate) use share_view::register_friends;
use std::{
cell::RefCell,
collections::{HashMap, HashSet},
@@ -651,9 +649,9 @@ enum Screen {
Advancements,
/// WorldOptionsScreen (game/lan_screen.rs).
WorldOptions,
/// Play with Friends, from the pause menu's Friends button
/// (game/friends.rs, game/friends/screen.rs).
Friends,
/// The page a crate registered at `Place::PauseFriends` (Play with Friends in the official
/// build), from the pause menu's Friends icon, drawn as its own screen (game/page_screen.rs).
AddedPage,
/// `SocialInteractionsScreen` (`social_screen`, game/social.rs).
Social,
/// CommandBlockEditScreen (game/command_blocks.rs).
@@ -697,7 +695,7 @@ impl Screen {
Screen::Stats => "stats",
Screen::Advancements => "advancements",
Screen::WorldOptions => "world_options",
Screen::Friends => "friends",
Screen::AddedPage => "friends",
Screen::Social => "social",
Screen::CommandBlock => "command_block",
Screen::BlockEditor => "block_editor",
@@ -2884,9 +2882,19 @@ struct Game<'a> {
/// The open connection joined from Realms: `ServerData.isRealm` for the friends
/// service's presence status.
realm_session: bool,
/// Replay playback (game/replay.rs), and Horizon Studio's editor on it (game/studio.rs).
pub(super) replay: replay::Replay,
pub(super) studio: studio::Studio,
/// 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,
/// This world is open to the network (game/lan.rs).
lan: Option<lan::LanHost>,
/// Single player's server thread, which `remote` is connected to.
@@ -2895,9 +2903,6 @@ struct Game<'a> {
level_shapes: horizon_client::chunk::LevelShapes,
/// This game is that server thread's (game/integrated.rs).
serving: Option<integrated::Serving>,
/// The open world is shared behind an invite code (game/friends.rs).
#[cfg(feature = "friends")]
friends: Option<friends::Sharing>,
/// The Social Interactions screen and the players whose chat is hidden (game/social.rs).
social: social::Social,
world_options: Option<lan_screen::WorldOptions>,
@@ -5018,7 +5023,7 @@ impl Game<'_> {
if canceled {
return Ok(false);
}
self.input_feedback(horizon_client::input_source::Feedback::BlockBroken);
self.pad_rumble(horizon_client::gamepad::Rumble::BlockBroken);
let broken = state;
let facts = self.res.facts().get(state).clone();
let state = self.crack_mined_pot(pos, state);
@@ -5729,7 +5734,6 @@ impl Game<'_> {
if let Some(log) = self.world.block_log.as_mut() {
log.extend(held);
}
self.tick_friends();
horizon_client::spans::time("tick_hand", || self.tick_hand());
horizon_client::spans::time("tick_sleep", || self.tick_sleep());
self.announce_sleep_status();
@@ -5837,7 +5841,7 @@ impl Game<'_> {
batch
}
fn hud_layers(&mut self, size: [u32; 2]) -> HudBatch {
self.input_hud_begin();
self.pad.guide_shown.clear();
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.
@@ -5934,7 +5938,8 @@ 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 show_hud = !self.ui.hide_gui && !self.replay.playing();
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
// rest of the HUD.
@@ -6232,7 +6237,7 @@ impl Game<'_> {
Screen::Advancements => self.draw_advancements(&mut batch),
Screen::Mod => {}
Screen::WorldOptions => self.draw_world_options(&mut batch),
Screen::Friends => self.draw_friends_screen(&mut batch),
Screen::AddedPage => self.draw_added_page(&mut batch),
Screen::Social => self.draw_social_screen(&mut batch),
Screen::ServerLinks => self.draw_server_links(&mut batch),
Screen::ConfirmLink => self.draw_confirm_link(&mut batch),
@@ -6273,7 +6278,23 @@ impl Game<'_> {
self.draw_sign_editor(&mut batch);
self.draw_book(&mut batch);
}
self.draw_studio_bars(&mut batch);
if !self.ui.hide_gui
&& self.screen == Screen::Playing
&& (self.studio_replay.is_some() || self.studio_freecam.active)
{
self.studio_topbar.render(
&mut batch,
&self.gui.atlas,
self.cursor,
self.studio_freecam.locked_to_path,
);
self.studio_timeline.render(
&mut batch,
&self.gui.atlas,
self.cursor,
&self.studio_freecam,
);
}
if show_hud {
self.draw_pad_guide(&mut batch, guide_from);
}
@@ -6496,12 +6517,12 @@ impl Game<'_> {
/// `Camera.setRotation`: the camera's orientation, turned round in
/// third-person front view.
pub(super) fn camera_orientation(&self) -> glam::Quat {
if self.studio.flying() {
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.
@@ -6708,7 +6729,11 @@ 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 = self.own_player_alpha();
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()
};
self.player_model_geometry(camera, player_alpha, &mut out);
// A native pack's shadow pass alone gets the first-person model (no per-entity draws).
if self.pack_ids.is_none() && !self.own_body_in_view() {
@@ -7437,6 +7462,7 @@ impl Game<'_> {
self.spawn_phantoms();
self.tick_advancements();
self.tick_player_model();
self.tick_emotes();
Ok(())
}
@@ -9206,7 +9232,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.flying() {
if self.shows_panorama() || self.studio_freecam.active {
view
} else {
self.native_camera_pose_now(camera_alpha as f32, view)
@@ -9384,7 +9410,14 @@ 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());
self.studio_camera_path(&mut g);
if self.studio_freecam.active || self.studio_replay.is_some() {
horizon_client::studio::render_camera_path(
&self.studio_freecam.track,
&mut g,
None,
100,
);
}
lines.extend(g.lines);
let on_top = g.on_top;
pr.lap("pr_debug");
@@ -9406,7 +9439,7 @@ impl Game<'_> {
Vec::new()
};
pr.lap("pr_native_visuals");
let view = if self.studio.flying() {
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)
@@ -9532,7 +9565,7 @@ impl Game<'_> {
} else {
self.fov_degrees(camera_alpha as f32, camera.to_array())
};
let camera_effects = fov::camera_effects_enabled(title, self.studio.flying());
let camera_effects = fov::camera_effects_enabled(title, self.studio_freecam.active);
let bob = if !camera_effects {
glam::Mat4::IDENTITY
} else {
@@ -10037,7 +10070,7 @@ impl Game<'_> {
)
)
{
self.input_keyboard_used();
self.pad.keyboard_took_over = true;
}
if let WindowEvent::KeyboardInput { event, .. } = &event
&& event.state == ElementState::Pressed
@@ -10425,7 +10458,21 @@ impl Game<'_> {
let b = HudBatch::new([r.config.width, r.config.height]);
let previous = self.cursor;
self.cursor = (position.x as f32 / b.scale, position.y as f32 / b.scale);
if self.studio_mouse_dragged(b.width) {
if self.screen == Screen::Playing
&& (self.studio_timeline.dragging_playhead
|| self.studio_timeline.dragging_keyframe.is_some())
{
let action = self.studio_timeline.handle_mouse_drag(
self.cursor,
b.width,
&mut self.studio_freecam,
);
if matches!(
action,
horizon_client::studio::TimelineAction::KeyframeMoved
) {
self.studio_camera_track_dirty = true;
}
return Ok(());
}
if self.screen_mouse_dragged(previous) {
@@ -10476,7 +10523,7 @@ impl Game<'_> {
}
}
WindowEvent::MouseWheel { .. }
if self.screen == Screen::Playing && self.replay.playing() => {}
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)),
@@ -10753,7 +10800,90 @@ impl Game<'_> {
}
WindowEvent::MouseInput { state, button, .. } => {
let pressed = state == ElementState::Pressed;
if self.studio_mouse_input(pressed, button) {
if !pressed && button == MouseButton::Left {
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())
&& 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 pressed
&& let Some(index) = self.studio_timeline.keyframe_at_cursor(
self.cursor,
batch.width,
batch.height,
&self.studio_freecam,
)
{
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;
}
return Ok(());
}
}
if pressed {
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;
self.capture(false);
return Ok(());
}
}
if pressed
&& button == MouseButton::Left
&& self.screen == Screen::Playing
&& (self.studio_replay.is_some() || self.studio_freecam.active)
{
let size = self
.renderer
.as_ref()
.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(
self.cursor,
batch.width,
batch.height,
);
if !matches!(topbar_action, horizon_client::studio::TopBarAction::None) {
self.handle_studio_topbar_action(topbar_action);
return Ok(());
}
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(
self.cursor,
batch.width,
batch.height,
&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() {
return Ok(());
}
}
}
if self.screen == Screen::Playing && self.studio_replay.is_some() {
return Ok(());
}
if matches!(self.screen, Screen::Inventory | Screen::Creative)
@@ -10880,8 +11010,8 @@ impl Game<'_> {
Screen::WorldOptions if button == MouseButton::Left => {
self.world_options_release()
}
Screen::Friends if pressed && button == MouseButton::Left => {
self.friends_screen_click()
Screen::AddedPage if pressed && button == MouseButton::Left => {
self.added_page_click()
}
Screen::Social if pressed && button == MouseButton::Left => {
self.social_click()
@@ -11223,7 +11353,24 @@ impl Game<'_> {
let between = now.saturating_duration_since(self.frame_end);
self.frame_ticks = elapsed.as_secs_f32() * 20.0;
self.previous = now;
self.studio_fly(elapsed);
if self.studio_freecam.active
&& self.screen == Screen::Playing
&& !self.dialog_open()
{
let held = |key| f64::from(u8::from(self.keys.raw(&key)));
let forward = held(KeyCode::KeyW) - held(KeyCode::KeyS);
let strafe = held(KeyCode::KeyD) - held(KeyCode::KeyA);
let vertical =
held(KeyCode::KeyE) - held(KeyCode::KeyQ) + held(KeyCode::Space);
let fast = self.keys.raw(&KeyCode::ControlRight);
self.studio_freecam.update_flight_motion(
forward,
strafe,
vertical,
fast,
elapsed.as_secs_f64().min(0.1),
);
}
// Arrow-key camera controls belong to gameplay; chat and
// other screens use the same keys for their own navigation.
if self.screen == Screen::Playing
@@ -11262,6 +11409,7 @@ impl Game<'_> {
// the world's own ticks.
horizon_client::social::set_presence_status(self.presence_status());
horizon_client::social::tick();
self.share_tick();
// Minecraft.tick: ClientTickEvent.Pre before any of it.
self.native_client_tick_pre();
if self.title.is_none() && !self.pause {
@@ -11296,7 +11444,7 @@ impl Game<'_> {
self.extras_frame(elapsed);
self.extras_retry()?;
self.apply_mouse_look();
self.input_look(elapsed.as_secs_f32());
self.pad_look(elapsed.as_secs_f32());
self.advance_flight();
self.tick_world_icon();
let ticked = Instant::now();
@@ -11408,18 +11556,17 @@ impl Game<'_> {
server.set_legacy_rules(self.settings.extras.pvp.combat.rules);
}
self.clock.end_frame(self.pause);
let saved = self
.renderer
.as_mut()
.map(|r| std::mem::take(&mut r.saved_screenshots))
.unwrap_or_default();
for saved in saved {
self.finish_screenshot(saved);
}
let mut consumed = false;
let finished = self
.renderer
.as_mut()
.and_then(|r| r.screenshot_result.take());
if let Some(result) = &finished
&& self.ui.screenshot_message.is_some()
{
self.finish_screenshot(result);
consumed = true;
}
if let Some(result) = &finished
&& let Some(reply) = self.pending_screenshot.take()
{
@@ -11432,6 +11579,7 @@ impl Game<'_> {
}
.to_string(),
);
consumed = true;
}
if let Some(result) =
self.renderer.as_mut().and_then(|r| r.capture_result.take())
@@ -11440,6 +11588,12 @@ impl Game<'_> {
let value = result.unwrap_or_else(|e| serde_json::json!({ "error": e }));
let _ = reply.send(value.to_string());
}
// A result nobody asked for was the world icon's
// (`tick_world_icon`): a failure tries again a second
// later, as `tryTakeScreenshotIfNeeded` does.
if !consumed && let Some(Err(error)) = finished {
log::warn!("Horizon: couldn't save the world icon: {error}");
}
if rendered {
self.frame_stats.record(elapsed.as_secs_f32() * 1000.0);
self.debug_charts.frame_times.log(elapsed.as_nanos() as i64);
@@ -12000,8 +12154,16 @@ impl<'a> Game<'a> {
entity_attachments: Default::default(),
remote: None,
realm_session: false,
replay: Default::default(),
studio: Default::default(),
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,
lan: None,
integrated: None,
level_shapes: Default::default(),
@@ -12015,8 +12177,6 @@ impl<'a> Game<'a> {
test_defs_snapshot: false,
test_highlights: HashMap::new(),
game_tests: game_tests::GameTests::default(),
#[cfg(feature = "friends")]
friends: None,
social: Default::default(),
debug_keys: Default::default(),
debug_charts: Default::default(),
@@ -12500,8 +12660,10 @@ pub fn run_with() -> Result<()> {
// horizon-ctl drives: those start in a source checkout and wrote the list there.
if !options.offscreen && !synchronous_startup(&options) {
let version = horizon_client::telemetry::version();
let game_dir = horizon_client::logging::player_dir();
horizon_hooks::engine::started(&horizon_hooks::Started::new(&version, &game_dir));
horizon_hooks::engine::started(&horizon_hooks::Started::new(&version));
// horizon-extras: the official server is Horizon's (docs/forking.md).
#[cfg(feature = "horizon-extras")]
horizon_client::official_servers::update(&horizon_client::logging::player_dir());
}
mods::register_components(&vanilla_root)?;
mods::register_standalone_models();
+1 -1
View File
@@ -479,7 +479,7 @@ impl Game<'_> {
Screen::Stats => "stats",
Screen::Advancements => "advancements",
Screen::WorldOptions => "world-options",
Screen::Friends => "friends",
Screen::AddedPage => "friends",
Screen::Social => "social",
Screen::ServerLinks => "server-links",
Screen::ConfirmLink => "confirm-link",
+29 -15
View File
@@ -848,20 +848,34 @@ impl Game<'_> {
self.armor_stands[i].last_hit = time;
return true;
}
// brokenByPlayer: the stand item, with the stand's name, pops at its
// block, then brokenByAnything: the loot table and the equipment.
let (width, height) = stand_box(&self.armor_stands[i]);
let p = self.armor_stands[i].position;
let pos = [p.x.floor() as i32, p.y.floor() as i32, p.z.floor() as i32];
let mut stand = ItemStack::new("minecraft:armor_stand", 1);
if let Some(name) = &self.armor_stands[i].custom_name {
stand.data_mut().custom_name =
Some(horizon_client::scoreboard::component_tag(&name.component()));
}
self.pop_resource(pos, stand);
// ArmorStand.kill(level, the causing entity), after the drops.
self.break_armor_stand_with_source(i, by);
// brokenByPlayer: the stand item, then brokenByAnything: the equipment.
let a = self.armor_stands.remove(i);
let (width, height) = stand_box(&a);
self.audio
.play("minecraft:entity.armor_stand.break", Some(at), 1.0, 1.0);
self.show_breaking_particles(at, f64::from(width), f64::from(height));
let center = [at[0], at[1] + if a.small { 0.4875 } else { 0.9875 }, at[2]];
let stand = ItemStack {
item: Arc::from("minecraft:armor_stand"),
count: 1,
damage: 0,
extra: None,
};
self.spawn_item(stand, center, [0.0; 3], 10);
for stack in a.equipment.into_iter().flatten() {
let r = &mut self.world.random;
let v = [
f64::from(r.next_float()) * 0.2 - 0.1,
0.2,
f64::from(r.next_float()) * 0.2 - 0.1,
];
self.spawn_item(stack, center, v, 10);
}
// ArmorStand.kill(level, the causing entity), after the drops.
self.stand_game_event("minecraft:entity_die", at, by);
self.world
.modified
.insert(((at[0].floor() as i32) >> 4, (at[2].floor() as i32) >> 4));
true
}
/// `Entity.gameEvent(event, attributedTo)` for a stand at `at`: the
@@ -1188,8 +1202,6 @@ impl Game<'_> {
let a = self.armor_stands.remove(i);
let at = [a.position.x, a.position.y, a.position.z];
let a_id = a.id;
self.audio
.play("minecraft:entity.armor_stand.break", Some(at), 1.0, 1.0);
// brokenByAnything's dropAllDeathLoot, before the equipment.
if !a.small && self.ui.rules.bool("mob_drops") {
self.armor_stand_death_loot(at, a.fire_ticks > 0);
@@ -1198,6 +1210,8 @@ impl Game<'_> {
for stack in drops {
self.pop_resource(pos, stack);
}
self.audio
.play("minecraft:entity.armor_stand.break", Some(at), 1.0, 1.0);
self.entity_killed_event_by(at, source.or(Some(super::commands::Target::Stand(a_id))));
self.world.modified.insert((pos[0] >> 4, pos[2] >> 4));
}
@@ -42,25 +42,12 @@ impl Game<'_> {
self.trial_spawner_tick_positions.clear();
self.item_columns = Some(Default::default());
self.pass_mobs = Some(Default::default());
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() {
for slot in self.world.block_entity_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;
@@ -223,14 +210,8 @@ impl Game<'_> {
/// (`ServerLevel.areEntitiesLoaded`).
pub(super) fn block_ticking_area(&self) -> impl Fn(i32, i32) -> bool + use<> {
let levels = self.tickets.levels();
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)))
}
let pending = self.world.entities_pending.clone();
move |x, z| levels.block_ticking_at(x, z) && !pending.contains(&(x >> 4, z >> 4))
}
/// `DaylightDetectorBlock.tickEntity` of the detector at `pos`.
fn tick_daylight_detector(&mut self, pos: Pos) {
+10 -43
View File
@@ -255,8 +255,8 @@ pub struct UiState {
/// What this frame's HUD shows that Horizon's overlays keep clear of (`Game::hud_room`),
/// set by `hud_layers` before anything draws.
pub room: horizon_client::hud_kit::room::Room,
/// F2 screenshots not written yet: each reports in chat once its PNG is.
pub screenshot_messages: Vec<std::path::PathBuf>,
/// Path of an F2 screenshot waiting for its frame.
pub screenshot_message: Option<std::path::PathBuf>,
/// The options screens.
pub options: horizon_client::options_ui::OptionsUi,
/// The prepared resources before resource packs are layered on.
@@ -1982,17 +1982,15 @@ impl Game<'_> {
if std::fs::create_dir_all(&dir).is_err() {
return None;
}
// A screenshot still being written has no file yet: its name is taken all the same.
let name = screenshot_name(&dir, &self.ui.screenshot_messages);
let name = screenshot_name(&dir);
let renderer = self.renderer.as_mut()?;
let path = dir.join(name);
renderer.screenshot = Some(horizon_client::gfx::ScreenshotRequest {
path: path.clone(),
size: None,
scaled: true,
background: true,
});
self.ui.screenshot_messages.push(path.clone());
self.ui.screenshot_message = Some(path.clone());
Some(path)
}
/// `Minecraft.handleGlobalKeyPress`'s screenshot branch: the
@@ -2016,23 +2014,12 @@ impl Game<'_> {
self.take_screenshot();
true
}
/// A screenshot written on its thread: F2's reports in chat. Any other was the world
/// icon's (`tick_world_icon`): a failure tries again a second later, as
/// `tryTakeScreenshotIfNeeded` does.
pub(super) fn finish_screenshot(&mut self, saved: horizon_client::gfx::SavedScreenshot) {
let Some(at) = self
.ui
.screenshot_messages
.iter()
.position(|p| *p == saved.path)
else {
if let Err(error) = saved.result {
log::warn!("Horizon: couldn't save the world icon: {error}");
}
/// After a frame: report a finished F2 screenshot in chat.
pub(super) fn finish_screenshot(&mut self, result: &std::result::Result<[u32; 2], String>) {
let Some(path) = self.ui.screenshot_message.take() else {
return;
};
let path = self.ui.screenshot_messages.remove(at);
let message = match &saved.result {
let message = match result {
Ok(_) => {
log::info!("Saved screenshot to {}", path.display());
let name = path.file_name().unwrap_or_default().to_string_lossy();
@@ -2068,7 +2055,7 @@ fn chat_box_text(text: &str) -> String {
.filter(|&ch| ch >= ' ' && ch != '\u{7f}' && ch != '§')
.collect()
}
pub fn screenshot_name(dir: &std::path::Path, pending: &[std::path::PathBuf]) -> String {
pub fn screenshot_name(dir: &std::path::Path) -> String {
// Util.getFilenameFormattedDateTime: the local date and time.
let base = chrono::Local::now().format("%Y-%m-%d_%H.%M.%S").to_string();
let mut n = 1;
@@ -2078,8 +2065,7 @@ pub fn screenshot_name(dir: &std::path::Path, pending: &[std::path::PathBuf]) ->
} else {
format!("{base}_{n}.png")
};
let path = dir.join(&name);
if !path.exists() && !pending.contains(&path) {
if !dir.join(&name).exists() {
return name;
}
n += 1;
@@ -2121,25 +2107,6 @@ mod tests {
assert!(should_discard_draft(ChatExit::Done, true));
}
/// `Screenshot.getFile` picks the first free name of the second; an F2
/// screenshot still being written on its thread has no file yet, so a
/// second press in the same second takes the next name, not the same one.
#[test]
fn a_screenshot_being_written_keeps_its_name() {
let dir = std::env::temp_dir().join(format!("horizon-shots-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let first = screenshot_name(&dir, &[]);
let second = screenshot_name(&dir, &[dir.join(&first)]);
assert_ne!(first, second);
// Unless the clock turned to the next second in between.
let same_second = second.starts_with(first.trim_end_matches(".png"));
assert!(
!same_second || second == first.replace(".png", "_2.png"),
"{second}"
);
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn resource_ids_are_listed_once_per_root() {
let root = std::env::temp_dir().join(format!("horizon-ids-{}", std::process::id()));
+6 -3
View File
@@ -3831,9 +3831,12 @@ impl Game<'_> {
return;
}
g.spawn = None;
g.respawn_config = Some(super::respawn_anchor::RespawnConfig::forced(
level, pos, yaw, pitch,
));
g.respawn_config = Some(super::respawn_anchor::RespawnConfig {
dim: level,
pos,
yaw,
forced: true,
});
});
}
let dim = Text::literal(self.dim.id());
+12 -10
View File
@@ -17,16 +17,19 @@ const PROTOCOL: &str = "0.1.0";
/// The command groups the game serves, as `help` chains them: what `hello` reports as its features.
const FEATURES: &[&str] = &[
"game", "station", "blocks", "lan", "friends", "render", "sculk", "agent", "ui", "options",
"events", "saves", "pad", "studio",
"game", "station", "blocks", "lan", "render", "sculk", "agent", "ui", "options", "events",
"saves", "pad", "studio",
];
/// `hello`: the protocol the socket speaks, the game's release and the command groups it serves.
/// `hello`: the protocol the socket speaks, the game's release and the command groups it serves
/// (the engine's, then those of the crates that answer commands, `Hooks::control_commands`).
fn hello() -> Value {
let groups = horizon_hooks::engine::control_commands().map(|(group, _)| group);
let features: Vec<&str> = FEATURES.iter().copied().chain(groups).collect();
json!({
"protocol": PROTOCOL,
"release": horizon_core::release::VERSION,
"features": FEATURES,
"features": features,
})
}
@@ -400,7 +403,7 @@ impl Game<'_> {
if let Some(value) = self.lan_control(&words)? {
return Ok(Some(value));
}
if let Some(value) = self.friends_control(&words)? {
if let Some(value) = self.hooks_control(&words)? {
return Ok(Some(value));
}
if let Some(value) = self.studio_control(&words)? {
@@ -417,7 +420,7 @@ impl Game<'_> {
.chain(super::station_control::HELP)
.chain(super::blockctl::HELP)
.chain(super::lan::HELP)
.chain(super::friends::HELP)
.chain(horizon_hooks::engine::control_commands().flat_map(|(_, help)| help))
.chain(super::render_control::HELP)
.chain(super::sculk::HELP)
.chain(super::agent::HELP)
@@ -644,7 +647,7 @@ impl Game<'_> {
.into());
}
if arg(2)? != "up" {
self.input_keyboard_used();
self.pad.keyboard_took_over = true;
}
// The command confirmation takes the keys; none go past it.
if self.command_prompt.is_some() {
@@ -800,7 +803,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.input_holds(name) {
if !self.pad.holds_action(&self.settings.controller, name) {
self.toggles.key(
slot,
horizon_client::keymapping::KeyAction::Release,
@@ -1039,7 +1042,6 @@ impl Game<'_> {
path: path.trim().into(),
size: None,
scaled: false,
background: false,
});
self.pending_screenshot = Some(reply.clone());
Ok(None)
@@ -1371,7 +1373,7 @@ impl Game<'_> {
Screen::Stats => "stats",
Screen::Advancements => "advancements",
Screen::WorldOptions => "world-options",
Screen::Friends => "friends",
Screen::AddedPage => "friends",
Screen::Social => "social",
Screen::ServerLinks => "server-links",
Screen::ConfirmLink => "confirm-link",
+1 -1
View File
@@ -123,7 +123,7 @@ fn screen_ids_are_distinct_and_stable() {
Screen::Stats,
Screen::Advancements,
Screen::WorldOptions,
Screen::Friends,
Screen::AddedPage,
Screen::Social,
Screen::CommandBlock,
Screen::BlockEditor,
+285 -497
View File
@@ -20,17 +20,13 @@ use horizon_cosmetics::follow::Follow;
use horizon_cosmetics::prefs::{self, Others};
use horizon_cosmetics::trail::{Budget, Emitter};
use horizon_cosmetics::{Item, Slot};
use horizon_hooks::players::{
Badge, CameraOverride, CameraView, Frame, Gear, Kind, Mark, NameDecoration, PlayerModel,
PlayerModels, SpriteStore, Sprites,
};
use std::cell::{Cell, RefCell};
use std::sync::Arc;
use std::sync::mpsc::{Receiver, channel};
use std::sync::{Arc, Mutex, PoisonError};
use std::time::{Duration, Instant};
/// Horizon's mark before a Horizon player's name, over their name tag and in the player list.
const MARK: Mark = &horizon_cosmetics::mark::MARK;
pub(super) const MARK: horizon_client::tab_list::NameMark = &horizon_cosmetics::mark::MARK;
/// An item as the renderer takes it.
struct Baked {
@@ -59,8 +55,7 @@ impl Baked {
#[derive(Default)]
pub(super) struct Cosmetics {
/// The hooks' client (`Seat`), taken once it started.
client: Option<Arc<horizon_cosmetics::Cosmetics>>,
client: Option<horizon_cosmetics::Cosmetics>,
/// By the item's address; each entry holds its item, so the address isn't reused.
baked: RefCell<FxHashMap<usize, Baked>>,
/// Items drawn whose texture isn't in the atlas yet.
@@ -70,6 +65,16 @@ pub(super) struct Cosmetics {
poses: RefCell<(Poses, Poses)>,
/// Item files from a folder (`HORIZON_COSMETICS_LOCAL`): mannequins wear them too.
local: bool,
/// Show my cosmetics on its way to the service.
mine: AccountSync,
/// Show me as a Horizon player: sent at start, on each change and every hour while on.
listed: AccountSync,
/// Whether the signed-in account owns a cosmetic.
owner: OwnerCheck,
/// Which other players play an emote (`horizon_cosmetics::Feed`), started with the client.
feed: Option<horizon_cosmetics::Feed>,
/// My emote on its way to the service.
emote_out: EmoteOut,
/// Companions, trails and auras.
fx: RefCell<Fx>,
/// Emotes: those the wheel offers and the one playing (`cosmetics/emotes.rs`).
@@ -400,13 +405,17 @@ fn slot_image(item: &Item) -> Vec<u8> {
/// Which slots an avatar's gear covers: a helmet or head item hides a hat, an elytra or a cape
/// what is worn on the back. A name tag style is never drawn on the body.
fn covered(gear: Gear, slot: Slot) -> bool {
fn covered(m: &Mob, slot: Slot) -> bool {
match slot {
Slot::Head => gear.head,
Slot::Back => gear.back,
Slot::Head => m.extra.head.is_some(),
Slot::Back => m.cape.is_some() || m.extra.chest.as_deref() == Some("minecraft:elytra"),
Slot::Shoulder => false,
// A chestplate, not an elytra.
Slot::Torso => gear.chest,
Slot::Torso => m
.extra
.chest
.as_deref()
.is_some_and(|c| c != "minecraft:elytra"),
Slot::NameTag | Slot::Emote => true,
// On no bone: drawn by a pass of their own, never on the body.
Slot::Companion | Slot::Trail | Slot::Aura => true,
@@ -418,12 +427,12 @@ fn covered(gear: Gear, slot: Slot) -> bool {
/// (`worn` is None), another player's at "name tags only" or hidden, or every item `covered` (a
/// helmet over a hat). One max over at most five items, read when the item was parsed.
fn body_top(
others: Others,
extras: &horizon_client::extras::Extras,
own: bool,
worn: Option<&horizon_cosmetics::Worn>,
covered: impl Fn(Slot) -> bool,
) -> Option<f32> {
if !own && others != Others::Shown {
if !own && extras.effective_others() != Others::Shown {
return None;
}
worn?
@@ -440,424 +449,115 @@ pub(super) fn enabled() -> bool {
cfg!(feature = "horizon-extras") && horizon_cosmetics::enabled()
}
/// What the cosmetics' hooks keep for one splitscreen player (`horizon_hooks::seat`): the
/// service's client, the emote feed and what the account tells the service.
#[derive(Default)]
struct Seat {
client: Option<Arc<horizon_cosmetics::Cosmetics>>,
/// Item files from a folder (`HORIZON_COSMETICS_LOCAL`).
local: bool,
/// Show my cosmetics on its way to the service.
mine: AccountSync,
/// Show me as a Horizon player: sent at start, on each change and every hour while on.
listed: AccountSync,
/// Whether the signed-in account owns a cosmetic.
owner: OwnerCheck,
/// Which other players play an emote (`horizon_cosmetics::Feed`), started with the client.
feed: Option<horizon_cosmetics::Feed>,
/// My emote on its way to the service.
emote_out: EmoteOut,
/// What the game's settings and the player's emote say (`Game::cosmetics_say`).
said: Said,
/// Kept between ticks so a tick allocates nothing: the player models in view.
models: Vec<PlayerModel>,
/// The world is the window's own single-player one (`Frame::solo`).
solo: bool,
/// Name tag badges' sprites, by the item's address: their id (never reused) and the item,
/// held so the address isn't reused while it is there.
sprites: FxHashMap<usize, (u64, Arc<Item>)>,
last_sprite: u64,
/// Sprites drawing missed, put in the atlas next frame.
wanted: Vec<u64>,
}
/// What the game tells the hooks before each call, until the settings move here (spec #14091
/// step 5a) and the player's emote with them (step 4).
#[derive(Default)]
struct Said {
mine: bool,
listed: bool,
/// Other players' cosmetics are shown (not "name tags only", not hidden).
others_shown: bool,
/// Other players' cosmetics are hidden (not even name tag styles).
others_hidden: bool,
/// Horizon's mark by Horizon players' names.
marks: bool,
/// Cosmetics in single player: the signed-in account's on the player's own model.
solo: bool,
/// My emote pulled the camera back: when it started (the own model's age).
pull: Option<f32>,
/// My emote while it plays: its id and whether it loops.
playing: Option<(String, bool)>,
/// A folder's first emote, which mannequins play.
first: Option<Arc<Item>>,
}
/// One [`Seat`] per splitscreen player.
static SERVICES: Mutex<Vec<Seat>> = Mutex::new(Vec::new());
/// Runs `f` on this seat's [`Seat`]. Never called inside itself.
fn service<R>(f: impl FnOnce(&mut Seat) -> R) -> R {
let seat = horizon_hooks::seat();
let mut all = SERVICES.lock().unwrap_or_else(PoisonError::into_inner);
if all.len() <= seat {
all.resize_with(seat + 1, Seat::default);
}
f(&mut all[seat])
}
/// The cosmetics' hooks, registered when [`enabled`].
pub(super) struct Hooks;
pub(super) static HOOKS: Hooks = Hooks;
impl horizon_hooks::Hooks for Hooks {
fn frame(&self, frame: &Frame, sprites: &mut dyn SpriteStore) {
service(|s| {
s.solo = frame.solo;
s.frame();
s.put_sprites(sprites);
});
}
fn tick(&self, models: &mut dyn PlayerModels) {
service(|s| s.tick(models));
}
fn name_decoration(
&self,
model: &PlayerModel,
sprites: &dyn Sprites,
) -> Option<NameDecoration> {
service(|s| s.name_decoration(model, sprites))
}
fn player_mark(&self, uuid: u128) -> Option<Mark> {
service(|s| {
let client = s.client.as_ref().filter(|_| s.said.marks)?;
client.frame().horizon(uuid).then_some(MARK)
})
}
/// An emote pulls the camera back: it eases out over the emote's first quarter second.
fn camera(&self, own: &CameraView) -> Option<CameraOverride> {
let start = service(|s| s.said.pull)?;
let t = ((own.age - start) / 5.0).clamp(0.0, 1.0);
Some(CameraOverride::new(0.35 + 0.65 * t * t * (3.0 - 2.0 * t)))
}
}
impl Seat {
/// Once a frame: starts the service's client on first use, asks whether the account owns a
/// cosmetic, and tells the service what the account shows.
fn frame(&mut self) {
if self.client.is_none() {
let (client, local) = Cosmetics::client();
self.client = Some(Arc::new(client));
self.local = local;
// Others' emotes come from the service only (a local folder has no one playing).
if !local {
self.feed = Some(horizon_cosmetics::Feed::new(http()));
}
}
// Item files from a folder (the art, the bench): shown as to an owner, nothing asked.
if self.local {
self.owner.owns = true;
return;
}
// Whether the account owns a cosmetic: what the last run learnt, then the service.
let o = &mut self.owner;
if !o.started {
o.started = true;
o.owns = account()
.is_some_and(|(_, uuid, home)| prefs::owned(&home, &uuid).unwrap_or(false));
}
if let Some(done) = o.asking.as_ref().and_then(|rx| rx.try_recv().ok()) {
o.asking = None;
match done {
Ok(owns) => (o.owns, o.known, o.retry) = (owns, true, None),
Err(_) => o.retry = backoff(o.retry),
}
}
// One sign-in at a time: Mojang keeps one joined server id per account.
let due = !o.known && o.retry.is_none_or(|(at, _)| Instant::now() >= at);
if due && o.asking.is_none() && self.mine.sending.is_none() && self.listed.sending.is_none()
{
let (tx, rx) = channel();
o.asking = Some(rx);
std::thread::Builder::new()
.name("Cosmetics account".into())
.spawn(move || {
let _ = tx.send(check_owner());
})
.ok();
}
// Show my cosmetics: sent once per change of the wish, then retried until confirmed.
// Show me as a Horizon player: also said again every hour while on.
let every = horizon_cosmetics::account::PRESENCE_EVERY;
self.mine.settle(None);
self.listed.settle(Some(every));
// One sign-in at a time, as for the ownership check above.
let busy = self.owner.asking.is_some()
|| self.mine.sending.is_some()
|| self.listed.sending.is_some();
let said = &self.said;
if let Some(done) = self
.emote_out
.sending
.as_ref()
.and_then(|rx| rx.try_recv().ok())
{
self.emote_out.sending = None;
match done {
Ok(()) => self.emote_out.retry = None,
Err(_) => {
self.emote_out.retry = backoff(self.emote_out.retry);
self.emote_out.sent = None;
}
}
}
// My emote: only while I show myself as a Horizon player (playing one says so).
let playing = said.playing.as_ref();
let looping = playing.is_some_and(|(_, looping)| *looping);
let wish = playing.map(|(id, _)| id.as_str()).filter(|_| said.listed);
let o = &mut self.emote_out;
let changed = o.sent.as_ref().map(Option::as_deref) != Some(wish);
let again = looping && o.again.is_some_and(|at| Instant::now() >= at);
let waited = o.retry.is_none_or(|(at, _)| Instant::now() >= at);
// Nothing sent yet and nothing playing: nothing to say (a failed send forgets what
// was sent: the service ends a one-shot by itself, a loop within a minute).
let quiet = wish.is_none() && o.sent.is_none();
let emote_due = !quiet && waited && (changed || again);
// One sign-in at a time, the emote's included.
if busy || o.sending.is_some() {
} else if emote_due {
let wish = wish.map(str::to_owned);
o.sent = Some(wish.clone());
o.again = looping.then(|| Instant::now() + LOOP_AGAIN);
let (tx, rx) = channel();
o.sending = Some(rx);
let kept = o.kept.clone();
std::thread::Builder::new()
.name("Cosmetics emote".into())
.spawn(move || {
let _ = tx.send(send_emote(&kept, wish.as_deref()));
})
.ok();
} else if self.mine.due(said.mine) {
self.mine.start(said.mine, send_mine);
} else if self.listed.due(said.listed) {
self.listed.start(said.listed, send_listed);
}
}
/// What goes by `m`'s name: Horizon's mark if they play Horizon (and the mark is on), the
/// name tag style they wear if cosmetics are shown for them (name tags only counts), and how
/// high what is drawn on their body gets (`body_top`). One look in the cache for all three.
fn name_decoration(
&mut self,
m: &PlayerModel,
sprites: &dyn Sprites,
) -> Option<NameDecoration> {
let said = &self.said;
let own = m.kind == Kind::Own;
let styles = if own { said.mine } else { !said.others_hidden };
if !(styles || said.marks) {
return None;
}
let uuid = match m.kind {
Kind::Own => own_uuid(m.uuid, self.solo, said.solo, || {
horizon_client::auth::current().map(|s| s.uuid)
}),
Kind::Mannequin if self.local => Some(u128::from(m.id)),
Kind::Player => m.uuid,
_ => None,
}?;
let mut frame = self.client.as_ref()?.frame();
let mark = (said.marks && frame.horizon(uuid)).then_some(MARK);
let worn = if styles { frame.worn(uuid) } else { None };
drop(frame);
let others = if said.others_shown {
Others::Shown
} else {
Others::NameTags
};
let top = body_top(others, own, worn.as_ref(), |slot| covered(m.gear, slot))
.filter(|_| !m.invisible);
let badge = worn.and_then(|w| self.badge(&w, sprites));
Some(NameDecoration::new(mark, badge, top))
}
/// The name tag style in `worn`: its badge's place in the atlas and its line's colour. None
/// until the badge is in the atlas (it is put there next frame).
fn badge(&mut self, worn: &horizon_cosmetics::Worn, sprites: &dyn Sprites) -> Option<Badge> {
let item = worn.iter().find(|i| i.slot == Slot::NameTag)?;
let [r, g, b] = item.tag?.line;
let next = self.last_sprite + 1;
let (id, _) = *self
.sprites
.entry(Arc::as_ptr(item) as usize)
.or_insert_with(|| (next, item.clone()));
self.last_sprite = self.last_sprite.max(id);
let Some([u0, v0, u1, v1]) = sprites.get(id) else {
if !self.wanted.contains(&id) {
self.wanted.push(id);
}
return None;
};
// The mark sits in the slot's top left corner.
let u1 = u0 + (u1 - u0) * item.texture.width as f32 / 64.0;
let v1 = v0 + (v1 - v0) * item.texture.height as f32 / 64.0;
Some(Badge::new(
[u0, v0, u1, v1],
u32::from_be_bytes([0, r, g, b]),
))
}
/// Puts the sprites drawing missed in the atlas; forgets those of items no one wears.
fn put_sprites(&mut self, store: &mut dyn SpriteStore) {
for id in self.wanted.drain(..) {
let item = self.sprites.values().find(|(i, _)| *i == id);
if let Some((_, item)) = item.filter(|_| !store.has(id)) {
store.put(id, slot_image(item));
}
}
self.sprites
.retain(|_, (_, item)| Arc::strong_count(item) > 1);
}
/// Once a tick: names the Horizon players in view to the emote feed and plays what it says
/// they play on their models; one that moved ends theirs here at once. Others' emotes show
/// with "Other players' cosmetics" on Shown only; otherwise nothing is asked.
fn tick(&mut self, models: &mut dyn PlayerModels) {
let mut seen = std::mem::take(&mut self.models);
seen.clear();
models.for_each(&mut |m| {
if m.kind != Kind::Own {
seen.push(m);
}
});
self.play_others(models, &mut seen);
self.models = seen;
}
fn play_others(&self, models: &mut dyn PlayerModels, view: &mut Vec<PlayerModel>) {
let shown = self.said.others_shown;
// Items from a folder (the art, the bench): every mannequin plays its first emote, again
// and again.
if self.local && shown {
let Some(first) = &self.said.first else {
return;
};
for m in view.iter().filter(|m| m.kind == Kind::Mannequin) {
let age = m.age as f32;
let now = emotes::pose_playing(models.body_pose(m.id));
if now.is_none_or(|p| p.at(age).is_none()) {
let playing = horizon_cosmetics::Playing {
item: first.clone(),
// Out of step, so a crowd doesn't move as one.
start: age - (m.id % 40) as f32,
stop: None,
};
emotes::set_model_playing(models, m.id, Some(playing));
}
}
return;
}
let (Some(feed), Some(client)) = (&self.feed, &self.client) else {
return;
};
if !shown {
feed.ask([]);
for m in view.iter() {
emotes::set_model_playing(models, m.id, None);
}
return;
}
let mut frame = client.frame();
view.retain(|m| m.kind == Kind::Player && m.uuid.is_some_and(|u| frame.horizon(u)));
drop(frame);
feed.ask(view.iter().filter_map(|m| m.uuid));
let now = Instant::now();
for m in view.iter() {
let Some(uuid) = m.uuid else { continue };
let seen = feed.playing(uuid);
let age = m.age as f32;
let mut next = emotes::pose_playing(models.body_pose(m.id)).cloned();
match (seen, &mut next) {
(Some(seen), Some(p)) if Arc::ptr_eq(&p.item, &seen.item) => {
if m.moved && p.stop.is_none() {
p.stop = Some(age);
}
}
(Some(seen), slot) if !m.moved => {
let ticks = now.duration_since(seen.started).as_secs_f32() * 20.0;
*slot = Some(horizon_cosmetics::Playing {
item: seen.item,
start: age - ticks,
stop: None,
});
}
(_, Some(p)) if p.stop.is_none() => p.stop = Some(age),
_ => {}
}
emotes::set_model_playing(models, m.id, next.filter(|p| p.at(age).is_some()));
}
}
}
impl Game<'_> {
/// Hands the hooks what the game's settings and the player's emote say (allocating only when
/// the emote changes).
pub(super) fn cosmetics_say(&mut self) {
if !enabled() {
return;
}
let first = if self.cosmetics.local {
service(|s| s.said.first.is_none())
.then(|| self.owned_emotes().first().cloned())
.flatten()
} else {
None
};
let extras = &self.settings.extras;
let playing = self.cosmetics.emotes.sending();
let pull = self.cosmetics.emotes.pull();
service(|s| {
let said = &mut s.said;
said.mine = extras.cosmetics_mine;
said.listed = extras.horizon_listed;
said.others_shown = extras.effective_others() == Others::Shown;
said.others_hidden = extras.effective_others() == Others::Hidden;
said.marks = extras.horizon_marks;
said.solo = extras.cosmetics_solo;
said.pull = pull;
if said.playing.as_ref().map(|(id, l)| (id.as_str(), *l)) != playing {
said.playing = playing.map(|(id, l)| (id.to_owned(), l));
}
if first.is_some() {
said.first = first;
}
});
}
/// Once a frame, after the hooks: takes the service's client for drawing and puts the
/// Once a frame, before drawing: starts the service's client on first use and puts the
/// textures the last frame wanted into the atlas.
pub(super) fn cosmetics_frame(&mut self) {
if !enabled() {
return;
}
let c = &mut self.cosmetics;
let owns = service(|s| {
if c.client.is_none() {
c.client = s.client.clone();
c.local = s.local;
if c.client.is_none() {
let (client, local) = Cosmetics::client();
c.client = Some(client);
c.local = local;
// Others' emotes come from the service only (a local folder has no one playing).
if !local {
c.feed = Some(horizon_cosmetics::Feed::new(http()));
}
s.owner.owns
});
// Read every frame: the settings button shows it, and it is never saved.
self.settings.extras.cosmetics_owner = owns;
}
c.frame.set(c.frame.get().wrapping_add(1));
c.fx.get_mut().frame(c.frame.get());
// Item files from a folder (the art, the bench): shown as to an owner, nothing asked.
if c.local {
c.owner.owns = true;
} else {
// Whether the account owns a cosmetic: what the last run learnt, then the service.
let o = &mut c.owner;
if !o.started {
o.started = true;
o.owns = account()
.is_some_and(|(_, uuid, home)| prefs::owned(&home, &uuid).unwrap_or(false));
}
if let Some(done) = o.asking.as_ref().and_then(|rx| rx.try_recv().ok()) {
o.asking = None;
match done {
Ok(owns) => (o.owns, o.known, o.retry) = (owns, true, None),
Err(_) => o.retry = backoff(o.retry),
}
}
// One sign-in at a time: Mojang keeps one joined server id per account.
let due = !o.known && o.retry.is_none_or(|(at, _)| Instant::now() >= at);
if due && o.asking.is_none() && c.mine.sending.is_none() && c.listed.sending.is_none() {
let (tx, rx) = channel();
o.asking = Some(rx);
std::thread::Builder::new()
.name("Cosmetics account".into())
.spawn(move || {
let _ = tx.send(check_owner());
})
.ok();
}
}
// Read every frame: the settings button shows it, and it is never saved.
self.settings.extras.cosmetics_owner = c.owner.owns;
// Show my cosmetics: sent once per change of the wish, then retried until confirmed.
// Show me as a Horizon player: also said again every hour while on.
if !c.local {
let every = horizon_cosmetics::account::PRESENCE_EVERY;
c.mine.settle(None);
c.listed.settle(Some(every));
// One sign-in at a time, as for the ownership check above.
let busy =
c.owner.asking.is_some() || c.mine.sending.is_some() || c.listed.sending.is_some();
let extras = &self.settings.extras;
if let Some(done) = c
.emote_out
.sending
.as_ref()
.and_then(|rx| rx.try_recv().ok())
{
c.emote_out.sending = None;
match done {
Ok(()) => c.emote_out.retry = None,
Err(_) => {
c.emote_out.retry = backoff(c.emote_out.retry);
c.emote_out.sent = None;
}
}
}
// My emote: only while I show myself as a Horizon player (playing one says so).
let playing = c.emotes.sending();
let looping = playing.as_ref().is_some_and(|(_, looping)| *looping);
let wish = playing.map(|(id, _)| id).filter(|_| extras.horizon_listed);
let o = &mut c.emote_out;
let changed = o.sent.as_ref() != Some(&wish);
let again = looping && o.again.is_some_and(|at| Instant::now() >= at);
let waited = o.retry.is_none_or(|(at, _)| Instant::now() >= at);
// Nothing sent yet and nothing playing: nothing to say (a failed send forgets what
// was sent: the service ends a one-shot by itself, a loop within a minute).
let quiet = wish.is_none() && o.sent.is_none();
let emote_due = !quiet && waited && (changed || again);
// One sign-in at a time, the emote's included.
if busy || o.sending.is_some() {
} else if emote_due {
o.sent = Some(wish.clone());
o.again = looping.then(|| Instant::now() + LOOP_AGAIN);
let (tx, rx) = channel();
o.sending = Some(rx);
let kept = o.kept.clone();
std::thread::Builder::new()
.name("Cosmetics emote".into())
.spawn(move || {
let _ = tx.send(send_emote(&kept, wish.as_deref()));
})
.ok();
} else if c.mine.due(extras.cosmetics_mine) {
c.mine.start(extras.cosmetics_mine, send_mine);
} else if c.listed.due(extras.horizon_listed) {
c.listed.start(extras.horizon_listed, send_listed);
}
}
let uploads = std::mem::take(&mut *c.uploads.borrow_mut());
for item in uploads {
let location = format!("cosmetics/{:x}", Arc::as_ptr(&item) as usize);
@@ -872,6 +572,88 @@ impl Game<'_> {
.retain(|_, b| Arc::strong_count(&b.item) > 1);
}
/// Once a tick: names the Horizon players in view to the emote feed and plays what it says
/// they play on their models; one that moved ends theirs here at once. Others' emotes show
/// with "Other players' cosmetics" on Shown only; otherwise nothing is asked.
pub(super) fn tick_others_emotes(&mut self) {
let shown = self.settings.extras.effective_others() == Others::Shown;
// Items from a folder (the art, the bench): every mannequin plays its first emote, again
// and again.
if self.cosmetics.local && shown {
let Some(first) = self.owned_emotes().first().cloned() else {
return;
};
for m in self.mobs.iter_mut() {
let age = m.age as f32;
if &*m.kind.id == "minecraft:mannequin"
&& emotes::playing(m).is_none_or(|p| p.at(age).is_none())
{
let playing = horizon_cosmetics::Playing {
item: first.clone(),
// Out of step, so a crowd doesn't move as one.
start: age - (m.id % 40) as f32,
stop: None,
};
emotes::set_playing(m, Some(playing));
}
}
return;
}
let (Some(feed), Some(client)) = (&self.cosmetics.feed, &self.cosmetics.client) else {
return;
};
if !shown {
feed.ask([]);
for m in self.mobs.iter_mut() {
emotes::set_playing(m, None);
}
return;
}
let mut frame = client.frame();
let mut horizon = Vec::new();
for m in self.mobs.iter() {
if &*m.kind.id == "minecraft:player"
&& let Some(uuid) = self.remote_player_uuid(m.id)
&& frame.horizon(uuid)
{
horizon.push((m.id, uuid));
}
}
drop(frame);
feed.ask(horizon.iter().map(|(_, u)| *u));
let now = Instant::now();
for (id, uuid) in horizon {
let seen = feed.playing(uuid);
let Some(m) = self.mobs.iter_mut().find(|m| m.id == id) else {
continue;
};
let age = m.age as f32;
let moved = {
let (dx, dz) = (m.position.x - m.previous.x, m.position.z - m.previous.z);
dx * dx + dz * dz > 1e-4
};
let mut next = emotes::playing(m).cloned();
match (seen, &mut next) {
(Some(seen), Some(p)) if Arc::ptr_eq(&p.item, &seen.item) => {
if moved && p.stop.is_none() {
p.stop = Some(age);
}
}
(Some(seen), slot) if !moved => {
let ticks = now.duration_since(seen.started).as_secs_f32() * 20.0;
*slot = Some(horizon_cosmetics::Playing {
item: seen.item,
start: age - ticks,
stop: None,
});
}
(_, Some(p)) if p.stop.is_none() => p.stop = Some(age),
_ => {}
}
emotes::set_playing(m, next.filter(|p| p.at(age).is_some()));
}
}
/// Asks the service which emotes the account owns, for the wheel: when it opens, at most
/// every 10 minutes (a folder's emotes need no asking; an offline account has none).
pub(super) fn refresh_owned_emotes(&mut self) {
@@ -887,7 +669,7 @@ impl Game<'_> {
}
let (tx, rx) = channel();
e.loading = Some(rx);
let kept = service(|s| s.emote_out.kept.clone());
let kept = self.cosmetics.emote_out.kept.clone();
std::thread::Builder::new()
.name("Cosmetics emotes owned".into())
.spawn(move || {
@@ -896,6 +678,81 @@ impl Game<'_> {
.ok();
}
/// Items from a folder (`HORIZON_COSMETICS_LOCAL`): mannequins are dressed and marked too.
pub(super) fn cosmetics_local(&self) -> bool {
self.cosmetics.local
}
/// What goes by `m`'s name: Horizon's mark if they play Horizon (and the mark is on), the
/// name tag style they wear if cosmetics are shown for them (name tags only counts), and how
/// high what is drawn on their body gets (`body_top`). One look in the cache for all three.
pub(super) fn cosmetics_marks(&self, m: &Mob, own: bool) -> super::names::Marks {
let mut marks = super::names::Marks::default();
let extras = &self.settings.extras;
let styles = if own {
extras.cosmetics_mine
} else {
extras.effective_others() != Others::Hidden
};
if !(styles || extras.horizon_marks) {
return marks;
}
let (Some(client), Some(uuid)) = (&self.cosmetics.client, self.cosmetics_uuid(m, own))
else {
return marks;
};
let mut frame = client.frame();
marks.horizon = extras.horizon_marks && frame.horizon(uuid);
let worn = if styles { frame.worn(uuid) } else { None };
drop(frame);
if !horizon_client::mobs::trader::is_invisible(m) {
marks.top = body_top(extras, own, worn.as_ref(), |slot| covered(m, slot));
}
marks.style = worn.and_then(|w| self.cosmetics_tag(&w));
marks
}
/// Those of `uuids` who play Horizon, with the mark on (the player list): one look each.
pub(super) fn horizon_players(
&self,
uuids: impl Iterator<Item = u128>,
) -> std::collections::HashSet<u128> {
match &self.cosmetics.client {
Some(c) if self.settings.extras.horizon_marks => {
let mut frame = c.frame();
uuids.filter(|u| frame.horizon(*u)).collect()
}
_ => Default::default(),
}
}
/// The name tag style in `worn`: its badge's place in the atlas and its line's colour. None
/// until the badge is in the atlas.
fn cosmetics_tag(&self, worn: &horizon_cosmetics::Worn) -> Option<super::names::TagStyle> {
let item = worn.iter().find(|i| i.slot == Slot::NameTag)?;
let [r, g, b] = item.tag?.line;
let frame = self.cosmetics.frame.get();
let mut baked = self.cosmetics.baked.borrow_mut();
let entry = baked
.entry(Arc::as_ptr(item) as usize)
.or_insert_with(|| Baked::new(item, frame));
let Some(slot) = self.gui.atlas.sprites.get(&entry.location) else {
self.cosmetics.uploads.borrow_mut().push(item.clone());
return None;
};
if entry.marked.replace(frame) != frame {
self.skin_downloads.borrow_mut().drawn(&entry.location);
}
// The mark sits in the slot's top left corner.
let mut mark = *slot;
mark.u1 = mark.u0 + (slot.u1 - slot.u0) * item.texture.width as f32 / 64.0;
mark.v1 = mark.v0 + (slot.v1 - slot.v0) * item.texture.height as f32 / 64.0;
Some(super::names::TagStyle {
mark,
line: u32::from_be_bytes([0, r, g, b]),
})
}
/// What `m` wears, if cosmetics are shown for it: `own` for the player's own model.
fn worn(&self, m: &Mob, own: bool) -> Option<horizon_cosmetics::Worn> {
let client = self.cosmetics.client.as_ref()?;
@@ -987,7 +844,7 @@ impl Game<'_> {
}
continue;
}
if emoting.is_none() && covered(super::player_hooks::gear(m), item.slot) {
if emoting.is_none() && covered(m, item.slot) {
continue;
}
let Some((models, uv)) = self.cosmetics_baked(item) else {
@@ -1105,7 +962,7 @@ impl Game<'_> {
mod tests {
use super::*;
use horizon_client::extras::Extras;
use horizon_client::mobs::anim::BodyPose;
use std::sync::Mutex;
/// A name tag style with a 1×1 mark.
const TAG: &str = r##"{"format": 1, "id": "tag", "slot": "name_tag", "parts": [],
@@ -1293,18 +1150,13 @@ mod tests {
let (short, long) = (Duration::from_millis(300), Duration::from_secs(10));
let top_of = |extras: &Extras, patience| {
let (worn, _) = other_player_from(Service(Arc::default(), both), extras, patience);
body_top(extras.effective_others(), false, worn.as_ref(), |_| false)
body_top(extras, false, worn.as_ref(), |_| false)
};
// No cosmetics drawn on the body (a name tag style only): vanilla's height exactly.
let (worn, _) = other_player(&shown, long);
assert!(worn.is_some());
assert_eq!(
tag(body_top(
shown.effective_others(),
false,
worn.as_ref(),
|_| false
)),
tag(body_top(&shown, false, worn.as_ref(), |_| false)),
VANILLA
);
// A small hat under the tag: vanilla's height too.
@@ -1318,9 +1170,7 @@ mod tests {
);
// A helmet over it: vanilla's height.
let (worn, _) = other_player_from(Service(Arc::default(), both), &shown, long);
let helmet = body_top(shown.effective_others(), false, worn.as_ref(), |slot| {
slot == Slot::Head
});
let helmet = body_top(&shown, false, worn.as_ref(), |slot| slot == Slot::Head);
assert_eq!(tag(helmet), VANILLA);
// Others' cosmetics on name tags only, or hidden: nothing on the body, vanilla's height.
for others in [Others::NameTags, Others::Hidden] {
@@ -1336,66 +1186,4 @@ mod tests {
assert_eq!(tag(top_of(&extras, patience)), VANILLA, "{others:?}");
}
}
/// Player models as the hooks see them, with the pose laid over each.
struct View(Vec<(PlayerModel, Option<Arc<dyn BodyPose>>)>);
impl PlayerModels for View {
fn connection(&self) -> u64 {
1
}
fn for_each(&self, f: &mut dyn FnMut(PlayerModel)) {
self.0.iter().for_each(|(m, _)| f(*m));
}
fn body_pose(&self, id: u32) -> Option<&Arc<dyn BodyPose>> {
self.0.iter().find(|(m, _)| m.id == id)?.1.as_ref()
}
fn set_body_pose(&mut self, id: u32, pose: Option<Arc<dyn BodyPose>>) {
if let Some((_, p)) = self.0.iter_mut().find(|(m, _)| m.id == id) {
*p = pose;
}
}
}
#[test]
fn with_a_folder_mannequins_play_its_first_emote_out_of_step_and_players_do_not() {
let item = Arc::new(
Item::parse(
br#"{"format": 1, "id": "up", "slot": "emote", "parts": [],
"emote": {"length": 20, "blend": 0, "tracks": [
{"part": "right_arm", "keys": [{"t": 10, "rotation": [0, 0, 90]}]}]}}"#,
)
.unwrap(),
);
let mut service = Seat {
local: true,
said: Said {
others_shown: true,
first: Some(item.clone()),
..Said::default()
},
..Seat::default()
};
let model = |id, kind, uuid| (PlayerModel::new(id, kind, uuid, 50, false), None);
let mut view = View(vec![
model(1, Kind::Own, None),
model(41, Kind::Mannequin, None),
model(2, Kind::Player, Some(7)),
]);
service.tick(&mut view);
let playing = emotes::pose_playing(view.0[1].1.as_ref()).expect("the mannequin plays");
assert!(Arc::ptr_eq(&playing.item, &item));
// Out of step by its id: 50 - 41 % 40.
assert_eq!(playing.start, 49.0);
assert!(view.0[0].1.is_none() && view.0[2].1.is_none());
// Still playing: the same pose stays.
let first = view.0[1].1.clone().unwrap();
service.tick(&mut view);
assert!(Arc::ptr_eq(&first, view.0[1].1.as_ref().unwrap()));
// Others' cosmetics not shown: a new mannequin plays nothing.
service.said.others_shown = false;
view.0.push(model(3, Kind::Mannequin, None));
service.tick(&mut view);
assert!(view.0[3].1.is_none());
}
}
@@ -10,7 +10,6 @@ use horizon_client::mobs::Mob;
use horizon_client::mobs::anim::BodyPose;
use horizon_cosmetics::account::OwnedEmote;
use horizon_cosmetics::{Item, Playing, Slot};
use horizon_hooks::players::PlayerModels;
use std::sync::Arc;
/// How far the player may drift (blocks) before the emote stops: a nudge, not a step.
@@ -38,15 +37,10 @@ pub(in crate::game) struct Emotes {
impl Emotes {
/// The emote to tell the service about, while it plays: its id and whether it loops.
pub(in crate::game) fn sending(&self) -> Option<(&str, bool)> {
pub(in crate::game) fn sending(&self) -> Option<(String, bool)> {
let p = self.playing.as_ref().filter(|p| p.stop.is_none())?;
let looping = p.item.emote.as_ref().is_some_and(|e| e.looping);
Some((&p.item.id, looping))
}
/// When the emote that pulled the camera back started (the own model's age), while it plays.
pub(in crate::game) fn pull(&self) -> Option<f32> {
self.camera_from.and(self.playing.as_ref()).map(|p| p.start)
Some((p.item.id.clone(), looping))
}
}
@@ -65,48 +59,23 @@ impl BodyPose for EmotePose {
}
}
/// The emote a model's pose plays, if it is one.
pub(in crate::game) fn pose_playing(pose: Option<&Arc<dyn BodyPose>>) -> Option<&Playing> {
let any: &dyn std::any::Any = &**pose?;
/// The emote `m` plays, if any.
pub(in crate::game) fn playing(m: &Mob) -> Option<&Playing> {
let any: &dyn std::any::Any = &**m.emote.as_ref()?;
any.downcast_ref::<EmotePose>().map(|e| &e.0)
}
/// The emote `m` plays, if any.
pub(in crate::game) fn playing(m: &Mob) -> Option<&Playing> {
pose_playing(m.emote.as_ref())
}
/// The pose that plays `next` where `now` is laid, unless it plays that already (None: keep
/// it; a new one allocates).
fn replaced(
now: Option<&Arc<dyn BodyPose>>,
next: Option<Playing>,
) -> Option<Option<Arc<dyn BodyPose>>> {
let same = match (pose_playing(now), &next) {
/// Makes `m` play `next`, unless it plays that already (a new one allocates).
pub(in crate::game) fn set_playing(m: &mut Mob, next: Option<Playing>) {
let same = match (playing(m), &next) {
(None, None) => true,
(Some(a), Some(b)) => {
Arc::ptr_eq(&a.item, &b.item) && a.start == b.start && a.stop == b.stop
}
_ => false,
};
(!same).then(|| next.map(|p| Arc::new(EmotePose(p)) as Arc<dyn BodyPose>))
}
/// Makes `m` play `next`, unless it plays that already.
pub(in crate::game) fn set_playing(m: &mut Mob, next: Option<Playing>) {
if let Some(pose) = replaced(m.emote.as_ref(), next) {
m.emote = pose;
}
}
/// Makes model `id` play `next`, unless it plays that already.
pub(in crate::game) fn set_model_playing(
models: &mut dyn PlayerModels,
id: u32,
next: Option<Playing>,
) {
if let Some(pose) = replaced(models.body_pose(id), next) {
models.set_body_pose(id, pose);
if !same {
m.emote = next.map(|p| Arc::new(EmotePose(p)) as Arc<dyn BodyPose>);
}
}
@@ -260,7 +229,7 @@ impl Game<'_> {
}
}
/// Once a tick: my emote (the others' are `cosmetics::Seat::tick`'s, a hook's).
/// Once a tick: my emote, then the other players'.
pub(in crate::game) fn tick_emotes(&mut self) {
if let Some(done) = self
.cosmetics
@@ -281,6 +250,20 @@ impl Game<'_> {
}
}
self.tick_emote();
self.tick_others_emotes();
}
/// How far the camera stands back while an emote pulls it (1: the usual distance): it eases
/// out over the emote's first quarter second.
pub(in crate::game) fn emote_camera_pull(&self, partial: f32) -> f32 {
let (Some(p), Some(m)) = (&self.cosmetics.emotes.playing, &self.player_model) else {
return 1.0;
};
if self.cosmetics.emotes.camera_from.is_none() {
return 1.0;
}
let t = ((m.age as f32 + partial - p.start) / 5.0).clamp(0.0, 1.0);
0.35 + 0.65 * t * t * (3.0 - 2.0 * t)
}
}
@@ -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.input_holds(mapping)
|| self.pad.holds_action(&self.settings.controller, mapping)
}
/// `CreativeModeInventoryScreen.selectTab` on the HOTBAR tab: each saved
+2 -2
View File
@@ -2317,7 +2317,7 @@ impl Game<'_> {
a.spawn_velocity = Some(p.velocity);
a.spawn_rot = Some((p.pitch, p.yaw));
}
self.creatures.projectiles.put_back(p);
self.creatures.projectiles.push(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;
eat = Some(("entity.iron_golem.repair", 1.0, m.golem_repair_pitch()));
sound = Some("entity.iron_golem.repair");
}
Species::Creeper if creeper_igniter => {
m.extra.flag = true;
@@ -280,18 +280,25 @@ impl Game<'_> {
self.spin_attack_geometry(mob, camera, alpha as f32, packed, out);
self.stuck_in_body_geometry(mob, mob.id as i32, camera, alpha as f32, packed, out);
}
// What crates draw on players and mannequins (`game/player_hooks.rs`: cosmetics),
// posed on the GPU with the mob's own jobs: culled with it.
// Cosmetics (game/cosmetics.rs), posed on the GPU with the mob's own jobs: culled
// with it.
if !layers_only
&& horizon_hooks::engine::any()
&& super::super::cosmetics::enabled()
&& (is_player || &*mob.kind.id == "minecraft:mannequin")
{
let mark = self.skin_frame.borrow().mark();
let base = horizon_client::mobs::render::base_transform(mob, alpha as f32, camera);
let gpu = skinning;
if let Some((lo, hi)) =
self.player_layers_geometry(mob, base, camera, alpha as f32, packed, gpu, out)
{
if let Some((lo, hi)) = self.cosmetics_geometry(
mob,
false,
base,
Some(camera),
alpha as f32,
packed,
gpu,
out,
) {
skinned = Some(match skinned {
Some((first, Some((l, h)))) => (first, Some((l.min(lo), h.max(hi)))),
Some((first, None)) => (first, Some((lo, hi))),
@@ -523,7 +523,7 @@ fn debug_options_interrupts(screen: Screen, playing_overlay: bool) -> bool {
| Screen::EmoteWheel
| Screen::Stats
| Screen::Advancements
| Screen::Friends
| Screen::AddedPage
| Screen::Social
| Screen::ServerLinks
| Screen::ConfirmLink
@@ -584,7 +584,7 @@ mod tests {
Screen::GameModeSwitcher,
Screen::Stats,
Screen::Advancements,
Screen::Friends,
Screen::AddedPage,
Screen::Social,
Screen::ServerLinks,
Screen::ConfirmLink,
@@ -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, _pitch)) => format!(
Some((p, yaw)) => format!(
"respawn {} {} {} {}",
num(p.x),
num(p.y),
@@ -579,7 +579,6 @@ impl Game<'_> {
dim: self.dim,
pos: [p(0), p(1), p(2)],
yaw: 0.0,
pitch: 0.0,
forced: false,
});
}
+1 -2
View File
@@ -739,8 +739,7 @@ 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) {
// 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)? {
if let Some((at, yaw)) = self.find_respawn_and_use(c, false)? {
return Ok(Some((at, Some(yaw))));
}
target = fallback_spawn(self.spawn, self.level.world_spawn);
+1 -4
View File
@@ -1008,10 +1008,7 @@ impl Game<'_> {
p.position.z - center[2],
];
let distance = (d[0] * d[0] + d[1] * d[1] + d[2] * d[2]).sqrt();
self.input_feedback(horizon_client::input_source::Feedback::Explosion {
distance,
radius,
});
self.pad_rumble(horizon_client::gamepad::Rumble::Explosion { distance, radius });
}
// Explosion.explode: the blast is heard.
let ctx = horizon_client::sculk::Context::default();
@@ -70,10 +70,9 @@ 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, pitch)) = actual {
if let Some((at, yaw)) = 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());
}
}
}
+2 -2
View File
@@ -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.flying() {
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.flying(),
self.studio_freecam.active,
self.ui.hands_drawn(
self.spectator(),
self.sleeping.is_some(),
+678 -245
View File
@@ -1,5 +1,5 @@
//! Play with friends, host side: "Start Sharing" on the Play with Friends
//! screen (`screen`, the pause menu's Friends button) opens the world to LAN if
//! page (`PAGE`, what the pause menu's Friends icon opens) opens the world to LAN if
//! it isn't (with Open to LAN's defaults) and shares that port behind an
//! invite code (horizon-friends). The code holds this computer's addresses
//! and the key, so friends join with no server of ours; a relay the player
@@ -7,18 +7,23 @@
//! LAN one, unchanged: tunnelled guests are LAN guests that connect from the
//! loopback address. The joining side is horizon-friends' (`game`), registered by
//! horizon-official.
use super::{Game, lan::LanSettings};
use horizon_client::{Result, hud::HudBatch, lang, text::Text};
//!
//! It sees the game only through `horizon_hooks` (the `ShareView` its `Hooks::share` tick
//! gets, `Hooks::lan_closed`, its page, `tr`) and keeps what it shares in its own statics, so
//! it can leave the engine for horizon-friends (spec #14167). `horizon-ctl friends` reaches it
//! through `Hooks::control`.
use horizon_friends::{Host, HostConfig, HostEvent, PathMode, host::DEFAULT_PORT, reach, stun};
use horizon_hooks::{
Hooks,
pages::{Act, Page, Place, Row},
share::{LanOptions, ShareView},
};
use horizon_protocol::{GAME_VERSION, PROTOCOL_VERSION};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Mutex, MutexGuard, PoisonError};
use std::time::{Duration, Instant};
mod screen;
use screen::{self as friends_screen, Action, FriendsView};
pub use screen::{TITLE, title};
pub(super) struct Sharing {
struct Sharing {
host: Host,
/// Sharing opened the world to LAN, so stopping closes it again.
opened_lan: bool,
@@ -31,12 +36,126 @@ pub(super) struct Sharing {
paths: (u32, u32),
}
/// The world shared, if one is. The game has one window player who can share (the engine
/// calls the hooks for it only), so one is all there is.
static SHARING: Mutex<Option<Sharing>> = Mutex::new(None);
/// Whether [`SHARING`] holds one: a tick with nothing shared is this one load.
static ON: AtomicBool = AtomicBool::new(false);
/// What the page left for the next [`Hooks::share`] tick, which has the game's view.
#[derive(Default)]
struct PageState {
/// Chat lines (new codes, the relay stopping), said in order.
chat: Vec<String>,
/// A button pressed on the page.
pressed: Option<Action>,
/// The page was drawn since the last tick: its guests are read again.
open: bool,
/// The other players, as the last tick while the page was open read them.
guests: Vec<String>,
/// The action of each row the page last showed.
acts: Vec<Option<Action>>,
}
static PAGE_STATE: Mutex<PageState> = Mutex::new(PageState {
chat: Vec::new(),
pressed: None,
open: false,
guests: Vec::new(),
acts: Vec::new(),
});
/// Whether [`PAGE_STATE`] has something for the tick.
static WORK: AtomicBool = AtomicBool::new(false);
fn page() -> MutexGuard<'static, PageState> {
PAGE_STATE.lock().unwrap_or_else(PoisonError::into_inner)
}
/// A button of the page.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum Action {
Share,
Copy,
Stop,
/// Open the troubleshooting page.
Help,
}
fn sharing() -> MutexGuard<'static, Option<Sharing>> {
SHARING.lock().unwrap_or_else(PoisonError::into_inner)
}
/// Ends the sharing, if there is one. The lock is let go before the caller touches the
/// game: closing LAN calls [`Hooks::lan_closed`], which takes it again.
fn take() -> Option<Sharing> {
let taken = sharing().take();
ON.store(false, Ordering::Relaxed);
taken
}
/// Registered by the engine's entry while this module is part of it.
pub struct Friends;
pub static HOOKS: Friends = Friends;
impl Hooks for Friends {
fn control_commands(&self) -> Option<(&'static str, &'static [&'static str])> {
Some(("friends", HELP))
}
/// `horizon-ctl friends host|stop|status`.
fn control(
&self,
words: &[&str],
world: &mut dyn ShareView,
) -> Option<std::result::Result<String, String>> {
(words.first() == Some(&"friends"))
.then(|| control(words.get(1).copied(), world).map(|answer| answer.to_string()))
}
/// What the page left for the game: chat lines, a button's press, the guests while it is
/// open. With nothing left, one relaxed load.
fn share(&self, world: &mut dyn ShareView) {
if !WORK.swap(false, Ordering::Relaxed) {
return;
}
let (lines, pressed, open) = {
let mut page = page();
let open = std::mem::take(&mut page.open);
(std::mem::take(&mut page.chat), page.pressed.take(), open)
};
for line in lines {
world.chat(&line);
}
if let Some(action) = pressed {
press(action, world);
}
if open {
let mut guests = Vec::new();
world.players(&mut |name| guests.push(name.to_owned()));
page().guests = guests;
}
}
/// The LAN server is closing: the code goes with it.
fn lan_closed(&self) {
if ON.load(Ordering::Relaxed)
&& let Some(mut sharing) = take()
{
sharing.host.stop();
}
}
}
pub const HELP: &[&str] = &[
"friends host share the open world behind an invite code",
"friends stop stop sharing",
"friends status the code, how friends reach this host, the guests",
];
/// A string in the player's language, or the English one.
fn tr(key: &str, english: &str) -> String {
horizon_hooks::tr(key).unwrap_or_else(|| english.to_owned())
}
/// `HORIZON_STUN_SERVERS`, else `friendsStun` in horizon.txt, else the defaults.
fn stun_servers() -> Vec<String> {
if std::env::var_os("HORIZON_STUN_SERVERS").is_some() {
@@ -53,101 +172,98 @@ fn env_flag(name: &str, default: bool) -> bool {
std::env::var(name).map_or(default, |v| v != "0")
}
impl Game<'_> {
/// Start sharing the open world.
pub(super) fn friends_start(&mut self) -> Result<()> {
if self.friends.is_some() {
return Ok(());
}
// Single player on its server thread shares that server, as Open to
// LAN publishes it; another server can't be shared.
if self.title.is_some() || (self.remote.is_some() && self.integrated.is_none()) {
return Err("open a singleplayer world first".into());
}
let opened_lan = self.lan_port().is_none();
if opened_lan {
// ShareToLanScreen's defaults, as the world options screen offers them.
self.lan_open(LanSettings {
port: horizon_client::lan::available_port(),
guest_commands: false,
force_game_mode: true,
})?;
}
let port = self.lan_port().ok_or("the world isn't open to LAN")?;
let host = match Host::start(HostConfig {
lan: std::net::SocketAddr::from(([127, 0, 0, 1], port)),
protocol: PROTOCOL_VERSION,
game: GAME_VERSION.to_owned(),
relay: horizon_client::net::friends_relay_setting(),
path: PathMode::from_env(),
port: std::env::var("HORIZON_FRIENDS_PORT")
.ok()
.and_then(|p| p.parse().ok())
.unwrap_or(DEFAULT_PORT),
reach: reach::Options {
// HORIZON_FRIENDS_UPNP=0 leaves the router alone.
map_port: env_flag("HORIZON_FRIENDS_UPNP", true),
stun: stun_servers(),
// Both ends on one machine (tests).
loopback: env_flag("HORIZON_FRIENDS_LOOPBACK", false),
},
}) {
Ok(h) => h,
Err(e) => {
if opened_lan {
self.lan_close(true);
}
return Err(e.into());
/// Start sharing the open world.
fn start(world: &mut dyn ShareView) -> std::result::Result<(), String> {
if ON.load(Ordering::Relaxed) {
return Ok(());
}
if !world.can_share() {
return Err("open a singleplayer world first".into());
}
let (port, opened_lan) = match world.lan_port() {
Some(port) => (port, false),
// ShareToLanScreen's defaults, as the world options screen offers them.
None => (world.open_lan(LanOptions::default())?, true),
};
let host = Host::start(HostConfig {
lan: std::net::SocketAddr::from(([127, 0, 0, 1], port)),
protocol: PROTOCOL_VERSION,
game: GAME_VERSION.to_owned(),
relay: horizon_client::net::friends_relay_setting(),
path: PathMode::from_env(),
port: std::env::var("HORIZON_FRIENDS_PORT")
.ok()
.and_then(|p| p.parse().ok())
.unwrap_or(DEFAULT_PORT),
reach: reach::Options {
// HORIZON_FRIENDS_UPNP=0 leaves the router alone.
map_port: env_flag("HORIZON_FRIENDS_UPNP", true),
stun: stun_servers(),
// Both ends on one machine (tests).
loopback: env_flag("HORIZON_FRIENDS_LOOPBACK", false),
},
});
let host = match host {
Ok(h) => h,
Err(e) => {
if opened_lan {
world.close_lan();
}
};
self.friends = Some(Sharing {
host,
opened_lan,
code: None,
wrong: 0,
notice: None,
copied: None,
paths: (0, 0),
});
Ok(())
}
return Err(e.to_string());
}
};
*sharing() = Some(Sharing {
host,
opened_lan,
code: None,
wrong: 0,
notice: None,
copied: None,
paths: (0, 0),
});
ON.store(true, Ordering::Relaxed);
Ok(())
}
/// Stop sharing: the code ends (direct port closed, router mapping
/// deleted); the world closes to LAN again if sharing opened it.
pub(super) fn friends_stop(&mut self) {
let Some(mut sharing) = self.friends.take() else {
/// Stop sharing: the code ends (direct port closed, router mapping
/// deleted); the world closes to LAN again if sharing opened it.
fn stop(world: &mut dyn ShareView) {
if !world.can_share() {
return;
}
let Some(mut sharing) = take() else {
return;
};
sharing.host.stop();
if sharing.opened_lan {
world.close_lan();
} else {
let text = tr(
"horizon.friends.stopped",
"Stopped sharing the invite code.",
);
world.chat(&text);
}
}
/// New codes and sessions, while the page is drawn and when `friends status` asks. Its chat
/// lines wait for the next tick ([`Hooks::share`]), or for [`say`].
fn poll() {
if !ON.load(Ordering::Relaxed) {
return;
}
let mut announce = false;
let mut stopped = None;
{
let mut guard = sharing();
let Some(sharing) = guard.as_mut() else {
return;
};
sharing.host.stop();
if sharing.opened_lan {
self.lan_close(true);
} else {
self.system_message(Text::literal(lang::get_or(
"horizon.friends.stopped",
"Stopped sharing the invite code.",
)));
}
}
/// The LAN server is closing: the code goes with it.
pub(super) fn friends_lan_closed(&mut self) {
if let Some(mut sharing) = self.friends.take() {
sharing.host.stop();
}
}
/// New codes and sessions, once a tick and while the pause menu is drawn.
pub(super) fn tick_friends(&mut self) {
let Some(sharing) = self.friends.as_mut() else {
return;
};
let mut announce = false;
let mut stopped = None;
for event in sharing.host.poll() {
match event {
HostEvent::Code(code) => {
if sharing.code.is_some() && sharing.wrong >= horizon_friends::host::MAX_WRONG {
sharing.notice = Some(lang::get_or(
sharing.notice = Some(tr(
"horizon.friends.short_code_changed",
"Someone kept trying a wrong short code, so it changed.",
));
@@ -158,7 +274,7 @@ impl Game<'_> {
}
HostEvent::WrongCode { count } => {
sharing.wrong = count;
sharing.notice = Some(lang::get_or(
sharing.notice = Some(tr(
"horizon.friends.wrong_code",
"Someone tried a wrong code.",
));
@@ -167,7 +283,7 @@ impl Game<'_> {
HostEvent::Connected { direct: false, .. } => sharing.paths.1 += 1,
HostEvent::Disconnected { .. } => {}
HostEvent::RelayStopped => {
let text = lang::get_or(
let text = tr(
"horizon.friends.relay_stopped",
"Too many wrong short codes, so the relay is off until you share again. Friends can still join with the full code.",
);
@@ -176,164 +292,481 @@ impl Game<'_> {
}
}
}
if let Some(text) = stopped {
self.system_message(Text::literal(text));
}
if announce {
self.system_message(Text::literal(lang::get_or(
"horizon.friends.code_ready",
"Your invite code is ready: Game Menu, Play with Friends. Copy it and send it to your friends.",
)));
}
}
let mut page = page();
page.chat.extend(stopped);
if announce {
page.chat.push(tr(
"horizon.friends.code_ready",
"Your invite code is ready: Game Menu, Play with Friends. Copy it and send it to your friends.",
));
}
if !page.chat.is_empty() {
WORK.store(true, Ordering::Relaxed);
}
}
pub(super) fn friends_copy(&mut self) {
let Some(code) = self.friends.as_ref().and_then(|s| s.code.clone()) else {
return;
};
use horizon_client::clipboard::Clipboard;
self.clipboard.set(&code);
if let Some(s) = self.friends.as_mut() {
s.copied = Some(Instant::now());
}
/// The chat lines waiting, said now.
fn say(world: &mut dyn ShareView) {
let lines = std::mem::take(&mut page().chat);
for line in lines {
world.chat(&line);
}
}
/// Play with Friends. The world is paused under it in singleplayer, so
/// new codes and sessions are read while it is drawn.
pub(super) fn draw_friends_screen(&mut self, batch: &mut HudBatch) {
self.tick_friends();
let view = self.friends_view();
horizon_client::widgets::begin_screen(|| {
horizon_client::widgets::ScreenInfo::new("friends", friends_screen::TITLE)
});
friends_screen::draw(batch, &self.gui.atlas, self.cursor, &view, true);
}
pub(super) fn friends_screen_click(&mut self) {
let view = self.friends_view();
let Some(action) =
friends_screen::click(&self.gui.atlas, self.gui_size(), self.cursor, &view)
else {
return;
};
self.audio.play_button_click();
match action {
Action::Share => {
if let Err(e) = self.friends_start() {
self.system_message(Text::literal(
lang::get_or(
"horizon.friends.share_failed",
"Couldn't share the world: %s",
)
.replacen("%s", &e.to_string(), 1),
));
}
/// A button of the page, on the tick after its press.
fn press(action: Action, world: &mut dyn ShareView) {
match action {
Action::Share => {
if let Err(e) = start(world) {
let text = tr(
"horizon.friends.share_failed",
"Couldn't share the world: %s",
)
.replacen("%s", &e, 1);
world.chat(&text);
}
Action::Copy => self.friends_copy(),
Action::Stop => self.friends_stop(),
// The system browser, as the folder buttons open their folders.
Action::Help => {
if let Some(url) = &view.help {
super::packs::open_uri(url);
}
}
Action::Done => self.screen = super::Screen::Paused,
}
Action::Copy => copy(world),
Action::Stop => stop(world),
// The system browser, as the folder buttons open their folders.
Action::Help => world.open_url(reach::HELP_URL),
}
}
/// Whether the pause menu offers Play with Friends (a singleplayer world).
pub(super) fn friends_shown(&self) -> bool {
self.friends_view().shown
fn copy(world: &mut dyn ShareView) {
if !world.can_share() {
return;
}
/// What Play with Friends shows.
pub(super) fn friends_view(&self) -> FriendsView {
let guests = match &self.lan {
Some(h) => h.hub.guests.iter().map(|g| g.name.clone()).collect(),
// On the server thread: the other players the server lists.
None if self.integrated.is_some() => {
let me = self.options.player_name();
let mut names = self.remote_online_names().unwrap_or_default();
names.retain(|n| *n != me);
names
}
None => Vec::new(),
};
let Some(s) = &self.friends else {
return FriendsView {
shown: self.remote.is_none() || self.integrated.is_some(),
..FriendsView::default()
};
};
let state = s.host.state();
let mut status = if s.code.is_none() {
lang::get_or(
"horizon.friends.finding_route",
"Finding how friends can reach you…",
)
} else {
state.summary.clone()
};
if let Some(short) = &state.short {
status.push_str(&format!(" Short code for the relay: {short}."));
}
if let Some(problem) = &state.relay_problem {
status.push(' ');
status.push_str(problem);
}
let reachable = matches!(
state.verdict,
Some(reach::Verdict::Mapped(_) | reach::Verdict::Public)
) || state.short.is_some();
FriendsView {
shown: true,
sharing: true,
code: s.code.clone(),
status,
reachable,
help: (!reachable && s.code.is_some()).then(|| reach::HELP_URL.to_owned()),
guests,
notice: s.notice.clone(),
copied: s
.copied
.is_some_and(|t| t.elapsed() < Duration::from_secs(2)),
}
let Some(code) = sharing().as_ref().and_then(|s| s.code.clone()) else {
return;
};
world.copy(&code);
if let Some(s) = sharing().as_mut() {
s.copied = Some(Instant::now());
}
}
/// `horizon-ctl friends ...`.
pub(super) fn friends_control(&mut self, words: &[&str]) -> Result<Option<serde_json::Value>> {
use serde_json::json;
if words[0] != "friends" {
return Ok(None);
}
match words.get(1).copied() {
Some("host") => {
self.friends_start()?;
Ok(Some(json!({ "ok": true, "lan_port": self.lan_port() })))
}
Some("stop") => {
self.friends_stop();
Ok(Some(json!({ "ok": true })))
}
Some("status") => {
self.tick_friends();
let view = self.friends_view();
let state = self.friends.as_ref().map(|s| s.host.state());
Ok(Some(json!({
"sharing": view.sharing,
"code": view.code,
"short": state.as_ref().and_then(|s| s.short.as_ref().map(ToString::to_string)),
"summary": view.status,
"reachable": view.reachable,
"verdict": state.as_ref().and_then(|s| s.verdict.as_ref().map(|v| format!("{v:?}"))),
"direct_port": state.as_ref().map(|s| s.port),
"sessions": self.friends.as_ref().map_or(0, |s| s.host.sessions()),
"direct": self.friends.as_ref().map_or(0, |s| s.paths.0),
"relayed": self.friends.as_ref().map_or(0, |s| s.paths.1),
"guests": view.guests,
"lan_port": self.lan_port(),
})))
}
_ => Err("friends host|stop|status".into()),
/// What Play with Friends shows.
#[derive(Clone, Debug, Default)]
struct View {
sharing: bool,
code: Option<String>,
/// Who can join and what to do when friends on the internet can't (or,
/// before the code, what the game is doing).
status: String,
/// Friends on the internet can reach the host.
reachable: bool,
/// The players in the world besides the host.
guests: Vec<String>,
notice: Option<String>,
/// The code was just copied.
copied: bool,
}
/// What Play with Friends shows; `guests` is asked only while sharing.
fn view(guests: impl FnOnce() -> Vec<String>) -> View {
let guard = sharing();
let Some(s) = guard.as_ref() else {
return View::default();
};
let state = s.host.state();
let mut status = if s.code.is_none() {
tr(
"horizon.friends.finding_route",
"Finding how friends can reach you…",
)
} else {
state.summary.clone()
};
if let Some(short) = &state.short {
status.push_str(&format!(" Short code for the relay: {short}."));
}
if let Some(problem) = &state.relay_problem {
status.push(' ');
status.push_str(problem);
}
let reachable = matches!(
state.verdict,
Some(reach::Verdict::Mapped(_) | reach::Verdict::Public)
) || state.short.is_some();
View {
sharing: true,
code: s.code.clone(),
status,
reachable,
guests: guests(),
notice: s.notice.clone(),
copied: s
.copied
.is_some_and(|t| t.elapsed() < Duration::from_secs(2)),
}
}
/// The page's rows for `view`, each with the button's action: the layout today's Friends
/// screen had (the engine draws it, `game/page_screen.rs`).
fn rows(view: &View, rows: &mut Vec<Row>, acts: &mut Vec<Option<Action>>) {
let mut row = |row: Row, act: Option<Action>| {
rows.push(row);
acts.push(act);
};
let hint = || {
tr(
"horizon.friends.hint",
"Friends paste the code after the title screen's Friends button, in Direct Connection, or on the launcher's Play page.",
)
};
if !view.sharing {
row(
Row::text(tr(
"horizon.friends.share_intro",
"Share this world behind an invite code: it opens to LAN, and friends on the internet join with the code. No server of ours is involved.",
)),
None,
);
row(Row::Space(4), None);
row(Row::text(hint()).quiet(), None);
row(Row::Space(8), None);
row(
Row::button("horizon.friends.start_sharing"),
Some(Action::Share),
);
return;
}
row(
Row::text(tr("horizon.multiplayer.invite_code", "Invite Code")).quiet(),
None,
);
row(Row::Space(2), None);
match &view.code {
Some(code) => row(Row::text(code.clone()).groups(), None),
None => row(Row::text(view.status.clone()), None),
}
row(Row::Space(6), None);
let copy = if view.copied {
"horizon.friends.copied"
} else {
"horizon.friends.copy_code"
};
row(
Row::button(copy).half().active(view.code.is_some()),
Some(Action::Copy),
);
row(
Row::button("horizon.friends.stop_sharing").half(),
Some(Action::Stop),
);
if view.code.is_some() {
// Who can join: grey when friends anywhere can, white (it asks
// for something) when only the LAN and VPNs can.
let status = Row::text(view.status.clone());
row(
if view.reachable {
status.quiet()
} else {
status
},
None,
);
row(Row::Space(4), None);
}
let here = if view.guests.is_empty() {
tr("horizon.friends.none_yet", "No friends here yet.")
} else {
tr("horizon.friends.here_now", "Here now: %s").replacen("%s", &view.guests.join(", "), 1)
};
row(Row::text(here).quiet(), None);
if let Some(notice) = &view.notice {
row(Row::text(notice.clone()), None);
}
// Where friends paste the code, while it fits above the footer.
row(Row::text(hint()).quiet(), None);
// Troubleshooting beside Done when friends on the internet can't reach the host.
if !view.reachable && view.code.is_some() {
row(
Row::button("horizon.friends.troubleshooting").footer(),
Some(Action::Help),
);
}
}
/// Play with Friends' page: what the pause menu's Friends icon opens. Its rows are read when
/// it is drawn; a press waits for the next tick, which has the game's view.
pub struct FriendsPage;
pub static PAGE: FriendsPage = FriendsPage;
impl Page for FriendsPage {
fn id(&self) -> &'static str {
"horizon_friends.share"
}
fn title(&self) -> &'static str {
"horizon.friends.title"
}
fn place(&self) -> Place {
Place::PauseFriends
}
fn rows(&self, out: &mut Vec<Row>) {
// The world is paused under the page in singleplayer, so new codes and sessions are
// read while it is drawn.
poll();
let guests = std::mem::take(&mut page().guests);
let view = view(|| guests);
let mut acts = Vec::new();
rows(&view, out, &mut acts);
let mut page = page();
page.guests = view.guests;
page.acts = acts;
page.open = true;
WORK.store(true, Ordering::Relaxed);
}
fn act(&self, row: usize, act: Act) {
let mut page = page();
if act == Act::Press
&& let Some(Some(action)) = page.acts.get(row).copied()
{
page.pressed = Some(action);
WORK.store(true, Ordering::Relaxed);
}
}
}
/// `horizon-ctl friends host|stop|status`.
fn control(
word: Option<&str>,
world: &mut dyn ShareView,
) -> std::result::Result<serde_json::Value, String> {
use serde_json::json;
match word {
Some("host") => {
start(world)?;
Ok(json!({ "ok": true, "lan_port": world.lan_port() }))
}
Some("stop") => {
stop(world);
Ok(json!({ "ok": true }))
}
Some("status") => {
// A splitscreen guest asking hears nothing of P1's sharing, and its chat stays its own.
let view = if world.can_share() {
poll();
say(world);
view(|| {
let mut guests = Vec::new();
world.players(&mut |name| guests.push(name.to_owned()));
guests
})
} else {
View::default()
};
let guard = sharing();
let sharing = guard.as_ref().filter(|_| view.sharing);
let state = sharing.map(|s| s.host.state());
Ok(json!({
"sharing": view.sharing,
"code": view.code,
"short": state.as_ref().and_then(|s| s.short.as_ref().map(ToString::to_string)),
"summary": view.status,
"reachable": view.reachable,
"verdict": state.as_ref().and_then(|s| s.verdict.as_ref().map(|v| format!("{v:?}"))),
"direct_port": state.as_ref().map(|s| s.port),
"sessions": sharing.map_or(0, |s| s.host.sessions()),
"direct": sharing.map_or(0, |s| s.paths.0),
"relayed": sharing.map_or(0, |s| s.paths.1),
"guests": view.guests,
"lan_port": world.lan_port(),
}))
}
_ => Err("friends host|stop|status".into()),
}
}
#[cfg(test)]
mod tests {
use super::*;
/// A world as the engine shows it, recording what the crate did to it.
#[derive(Default)]
struct Fake {
can_share: bool,
lan: Option<u16>,
refuse_lan: bool,
did: Vec<String>,
}
impl ShareView for Fake {
fn can_share(&self) -> bool {
self.can_share
}
fn lan_port(&self) -> Option<u16> {
self.lan
}
fn open_lan(&mut self, options: LanOptions) -> std::result::Result<u16, String> {
self.did.push(format!("open_lan {options:?}"));
if self.refuse_lan {
return Err("no free port".into());
}
self.lan = Some(25565);
Ok(25565)
}
fn close_lan(&mut self) {
self.did.push("close_lan".into());
self.lan = None;
}
fn players(&self, each: &mut dyn FnMut(&str)) {
each("Alex");
}
fn chat(&mut self, text: &str) {
self.did.push(format!("chat {text}"));
}
fn copy(&mut self, text: &str) {
self.did.push(format!("copy {text}"));
}
fn open_url(&mut self, url: &str) {
self.did.push(format!("open_url {url}"));
}
fn tr(&self, _key: &str) -> Option<String> {
None
}
}
#[test]
fn a_world_that_cannot_be_shared_is_left_alone() {
// Another server, or a splitscreen guest in P1's world.
let mut guest = Fake {
lan: Some(25565),
..Fake::default()
};
assert_eq!(
control(Some("host"), &mut guest).unwrap_err(),
"open a singleplayer world first"
);
stop(&mut guest);
copy(&mut guest);
assert!(guest.did.is_empty(), "{:?}", guest.did);
// horizon-ctl reaches the same through the hook; other commands aren't its.
let status = HOOKS
.control(&["friends", "status"], &mut guest)
.unwrap()
.unwrap();
let status: serde_json::Value = serde_json::from_str(&status).unwrap();
assert_eq!(status["sharing"], false);
assert!(HOOKS.control(&["lan", "status"], &mut guest).is_none());
assert!(guest.did.is_empty(), "{:?}", guest.did);
assert!(!ON.load(Ordering::Relaxed));
}
#[test]
fn sharing_opens_lan_as_open_to_lan_does_and_a_refusal_shares_nothing() {
let mut world = Fake {
can_share: true,
refuse_lan: true,
..Fake::default()
};
assert_eq!(start(&mut world).unwrap_err(), "no free port");
assert_eq!(
world.did,
["open_lan LanOptions { guest_commands: false, force_game_mode: true }"]
);
// Offered, not shared: the guests aren't asked for.
let view = view(|| unreachable!("the guests of a world not shared"));
assert!(!view.sharing && view.guests.is_empty());
assert!(!ON.load(Ordering::Relaxed));
}
#[test]
fn the_page_shows_todays_screen_and_a_press_runs_on_the_next_tick() {
// Sharing, with a code friends on the internet can't reach yet.
let shared = View {
sharing: true,
code: Some("K7QM2-AAAAA".into()),
status: "Friends on your network can join.".into(),
..View::default()
};
let (mut out, mut acts) = (Vec::new(), Vec::new());
rows(&shared, &mut out, &mut acts);
let buttons: Vec<_> = out
.iter()
.zip(&acts)
.filter_map(|(row, act)| match row {
Row::Button {
label,
active,
half,
footer,
..
} => Some((*label, *active, *half, *footer, *act)),
_ => None,
})
.collect();
assert_eq!(
buttons,
[
(
"horizon.friends.copy_code",
true,
true,
false,
Some(Action::Copy)
),
(
"horizon.friends.stop_sharing",
true,
true,
false,
Some(Action::Stop)
),
(
"horizon.friends.troubleshooting",
true,
false,
true,
Some(Action::Help)
),
]
);
assert!(out.iter().any(
|row| matches!(row, Row::Text { text, groups: true, .. } if text == "K7QM2-AAAAA")
));
// Before the code there is nothing to copy.
let (mut out, mut acts) = (Vec::new(), Vec::new());
rows(
&View {
code: None,
..shared
},
&mut out,
&mut acts,
);
assert!(out.iter().any(|row| matches!(
row,
Row::Button {
label: "horizon.friends.copy_code",
active: false,
..
}
)));
// Not sharing: Start Sharing. Its press waits for the tick, which has the world.
let (mut out, mut acts) = (Vec::new(), Vec::new());
rows(&View::default(), &mut out, &mut acts);
let share = acts.iter().position(|a| *a == Some(Action::Share)).unwrap();
page().acts = acts;
PAGE.act(share, Act::Press);
let mut world = Fake {
can_share: true,
refuse_lan: true,
..Fake::default()
};
HOOKS.share(&mut world);
assert_eq!(
world.did,
[
"open_lan LanOptions { guest_commands: false, force_game_mode: true }",
"chat Couldn't share the world: no free port",
]
);
// Pressed once, run once.
HOOKS.share(&mut world);
assert_eq!(world.did.len(), 2);
}
}
@@ -1,340 +0,0 @@
//! Play with Friends: Horizon's own sharing of the open world behind an
//! invite code (`game/friends.rs`), with the rest of Play with Friends' game glue. The pause menu's Friends button, the icon
//! vanilla's PauseScreen already has, opens it. It is drawn the way a mod adds
//! a screen to vanilla: the menu background, a centred title and text,
//! vanilla's buttons and Done in the footer.
use horizon_client::{
hud::{HudAtlas, HudBatch, rgba},
multiplayer::wrap,
text::Text,
ui,
};
/// The English title: the fallback of [`title`], and what the screen's widgets are named by.
pub const TITLE: &str = "Play with Friends";
/// A string of this screen: the language table's, or the English text.
fn tr(key: &str, english: &str) -> String {
horizon_client::lang::get_or(key, english)
}
/// The title in the current language.
pub fn title() -> String {
tr("horizon.friends.title", TITLE)
}
/// What the screen shows (game/friends.rs fills it).
#[derive(Clone, Debug, Default)]
pub struct FriendsView {
/// A singleplayer world (not a server): sharing is offered.
pub shown: bool,
pub sharing: bool,
pub code: Option<String>,
/// Who can join and what to do when friends on the internet can't (or,
/// before the code, what the game is doing).
pub status: String,
/// Friends on the internet can reach the host.
pub reachable: bool,
/// Where the troubleshooting is, when they can't.
pub help: Option<String>,
/// The players in the world besides the host.
pub guests: Vec<String>,
pub notice: Option<String>,
/// The code was just copied.
pub copied: bool,
}
/// A button's action.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Action {
Share,
Copy,
Stop,
/// Open the troubleshooting page.
Help,
Done,
}
fn grey() -> u32 {
rgba(160, 160, 160, 255)
}
const WHITE: u32 = u32::MAX;
fn hint() -> String {
tr(
"horizon.friends.hint",
"Friends paste the code after the title screen's Friends button, in Direct Connection, or on the launcher's Play page.",
)
}
struct Button {
x: f32,
y: f32,
w: f32,
label: String,
action: Action,
active: bool,
}
/// The centred lines (text, colour, y) and the buttons.
fn layout(
atlas: &HudAtlas,
(w, h): (f32, f32),
view: &FriendsView,
) -> (Vec<(String, u32, f32)>, Vec<Button>) {
let width = (w - 40.0).min(310.0);
let cx = (w / 2.0).floor();
let mut lines = Vec::new();
let mut y = 40.0;
let add = |lines: &mut Vec<(String, u32, f32)>, text: &str, color: u32, y: &mut f32| {
for line in wrap(text, width, atlas) {
lines.push((line, color, *y));
*y += 10.0;
}
};
let mut buttons = Vec::new();
if view.sharing {
lines.push((
tr("horizon.multiplayer.invite_code", "Invite Code"),
grey(),
y,
));
y += 12.0;
match &view.code {
Some(code) => {
for line in code_lines(atlas, code, width) {
lines.push((line, WHITE, y));
y += 10.0;
}
}
None => add(&mut lines, &view.status, WHITE, &mut y),
}
y += 6.0;
buttons.push(Button {
x: cx - 154.0,
y,
w: 150.0,
label: if view.copied {
tr("horizon.friends.copied", "Copied")
} else {
tr("horizon.friends.copy_code", "Copy Code")
},
action: Action::Copy,
active: view.code.is_some(),
});
buttons.push(Button {
x: cx + 4.0,
y,
w: 150.0,
label: tr("horizon.friends.stop_sharing", "Stop Sharing"),
action: Action::Stop,
active: true,
});
y += 30.0;
if view.code.is_some() {
// Who can join: grey when friends anywhere can, white (it asks
// for something) when only the LAN and VPNs can.
let tint = if view.reachable { grey() } else { WHITE };
add(&mut lines, &view.status, tint, &mut y);
y += 4.0;
}
let here = if view.guests.is_empty() {
tr("horizon.friends.none_yet", "No friends here yet.")
} else {
tr("horizon.friends.here_now", "Here now: %s").replacen(
"%s",
&view.guests.join(", "),
1,
)
};
add(&mut lines, &here, grey(), &mut y);
if let Some(notice) = &view.notice {
add(&mut lines, notice, WHITE, &mut y);
}
// Where friends paste the code, while it fits above the footer.
let hint = hint();
if y + 10.0 * wrap(&hint, width, atlas).len() as f32 <= h - 33.0 {
add(&mut lines, &hint, grey(), &mut y);
}
} else {
add(
&mut lines,
&tr(
"horizon.friends.share_intro",
"Share this world behind an invite code: it opens to LAN, and friends on the internet join with the code. No server of ours is involved.",
),
WHITE,
&mut y,
);
y += 4.0;
add(&mut lines, &hint(), grey(), &mut y);
buttons.push(Button {
x: cx - 100.0,
y: y + 8.0,
w: 200.0,
label: tr("horizon.friends.start_sharing", "Start Sharing"),
action: Action::Share,
active: view.shown,
});
}
// HeaderAndFooterLayout's footer: Done, beside Troubleshooting when
// friends on the internet can't reach the host.
if view.sharing && view.help.is_some() {
buttons.push(Button {
x: cx - 154.0,
y: h - 27.0,
w: 150.0,
label: tr("horizon.friends.troubleshooting", "Troubleshooting..."),
action: Action::Help,
active: true,
});
buttons.push(Button {
x: cx + 4.0,
y: h - 27.0,
w: 150.0,
label: "gui.done".to_owned(),
action: Action::Done,
active: true,
});
} else {
buttons.push(Button {
x: cx - 100.0,
y: h - 27.0,
w: 200.0,
label: "gui.done".to_owned(),
action: Action::Done,
active: true,
});
}
(lines, buttons)
}
/// The code's groups packed into lines of at most `width`.
fn code_lines(atlas: &HudAtlas, code: &str, width: f32) -> Vec<String> {
let mut lines: Vec<String> = Vec::new();
for group in code.split('-') {
match lines.last_mut() {
Some(l) if HudBatch::text_width(atlas, &format!("{l}-{group}")) <= width => {
l.push('-');
l.push_str(group);
}
_ => lines.push(group.to_owned()),
}
}
lines
}
fn label(key: &str) -> Text {
if key.starts_with("gui.") {
Text::translate(key, &[])
} else {
Text::literal(key)
}
}
pub fn draw(
batch: &mut HudBatch,
atlas: &HudAtlas,
cursor: (f32, f32),
view: &FriendsView,
in_world: bool,
) {
let (w, h) = (batch.width, batch.height);
ui::menu_background(batch, atlas, 0.0, 0.0, w, h, in_world);
let title = Text::literal(title());
let tw = atlas.font.width_text(&title);
batch.text_styled(atlas, &title, ((w - tw) / 2.0).floor(), 15.0, WHITE, true);
let (lines, buttons) = layout(atlas, (w, h), view);
for (line, color, y) in lines {
let lw = HudBatch::text_width(atlas, &line);
batch.text(atlas, &line, ((w - lw) / 2.0).floor(), y, color, true);
}
for b in buttons {
let hover = b.active && ui::hovered(cursor, b.x, b.y, b.w, 20.0);
if matches!(b.action, Action::Done) {
horizon_client::widgets::declare_action(
horizon_client::widgets::Action::Done,
[b.x, b.y, b.w, 20.0],
b.active,
);
}
ui::button(
batch,
atlas,
b.x,
b.y,
b.w,
20.0,
&label(&b.label),
b.active,
hover,
);
}
}
/// The button under a click, if it is active.
pub fn click(
atlas: &HudAtlas,
size: (f32, f32),
cursor: (f32, f32),
view: &FriendsView,
) -> Option<Action> {
layout(atlas, size, view)
.1
.into_iter()
.find(|b| b.active && ui::hovered(cursor, b.x, b.y, b.w, 20.0))
.map(|b| b.action)
}
#[cfg(test)]
mod tests {
use super::*;
fn atlas() -> HudAtlas {
HudAtlas::build(std::path::Path::new("/nonexistent/horizon-test-assets")).unwrap()
}
#[test]
fn share_then_copy_and_stop() {
let atlas = atlas();
let size = (427.0, 240.0);
let idle = FriendsView {
shown: true,
..FriendsView::default()
};
let (_, buttons) = layout(&atlas, size, &idle);
let share = buttons.iter().find(|b| b.action == Action::Share).unwrap();
assert_eq!(
click(&atlas, size, (share.x + 1.0, share.y + 1.0), &idle),
Some(Action::Share)
);
let sharing = FriendsView {
shown: true,
sharing: true,
status: "Finding how friends can reach you…".into(),
..FriendsView::default()
};
let (_, buttons) = layout(&atlas, size, &sharing);
let copy = buttons.iter().find(|b| b.action == Action::Copy).unwrap();
assert!(!copy.active, "nothing to copy before the code");
let with_code = FriendsView {
code: Some("0B33K-Q405Q-1Z5CV-X6NHY-R12N0-VK8T1-60N00-RA134-952PE-1HA08-910P0-K5M01-ST1NB-JDTB6-K4W1P-0".into()),
status: "Friends on your network or VPN can join. For friends on the internet: turn on UPnP on your router, or forward TCP port 25580 to 10.0.0.2, or use a relay, or a VPN such as Tailscale or ZeroTier (the code already holds your VPN address).".into(),
help: Some("https://example.invalid/help".into()),
..sharing
};
let (lines, buttons) = layout(&atlas, size, &with_code);
assert!(
lines.iter().all(|(_, _, y)| y + 9.0 <= size.1 - 27.0),
"the text stays above the footer"
);
for action in [Action::Copy, Action::Stop, Action::Help, Action::Done] {
let b = buttons.iter().find(|b| b.action == action).unwrap();
assert_eq!(
click(&atlas, size, (b.x + 1.0, b.y + 1.0), &with_code),
Some(action)
);
}
}
#[test]
fn a_server_offers_no_sharing() {
let atlas = atlas();
let view = FriendsView::default();
let (_, buttons) = layout(&atlas, (427.0, 240.0), &view);
let share = buttons.iter().find(|b| b.action == Action::Share).unwrap();
assert!(!share.active);
}
}
@@ -1,38 +0,0 @@
//! Play with Friends left out of the build (the `friends` feature off, as in the server build):
//! no world is shared, the pause menu has no Friends button (`friends_shown` is false, as on
//! another server) and `horizon-ctl friends` says it isn't there. game/friends.rs is the real one.
use super::{Game, Screen};
use horizon_client::{Result, hud::HudBatch};
pub const HELP: &[&str] = &[];
/// The pause menu's Friends icon is vanilla's, drawn inactive: its name for the widget tree.
pub const TITLE: &str = "Play with Friends";
pub fn title() -> String {
horizon_client::lang::get_or("horizon.friends.title", TITLE)
}
impl Game<'_> {
pub(super) fn friends_lan_closed(&mut self) {}
pub(super) fn tick_friends(&mut self) {}
/// Only reached by naming the screen (`horizon-ctl`): back to the pause menu.
pub(super) fn draw_friends_screen(&mut self, _batch: &mut HudBatch) {
self.screen = Screen::Paused;
}
pub(super) fn friends_screen_click(&mut self) {}
pub(super) fn friends_shown(&self) -> bool {
false
}
/// `horizon-ctl friends ...`.
pub(super) fn friends_control(&mut self, words: &[&str]) -> Result<Option<serde_json::Value>> {
if words[0] != "friends" {
return Ok(None);
}
Err("this build has no Play with Friends (the `friends` feature)".into())
}
}
+2 -2
View File
@@ -1489,7 +1489,7 @@ impl Game<'_> {
None => self.unpublish(),
};
if closed {
self.friends_lan_closed();
self.lan_closed_hooks();
}
if closed && announce && self.integrated.is_some() {
self.system_message(Text::translate(
@@ -1499,7 +1499,7 @@ impl Game<'_> {
}
return;
}
self.friends_lan_closed();
self.lan_closed_hooks();
self.native_players_leaving();
let Some(mut host) = self.lan.take() else {
return;
@@ -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, pitch)| {
(host.hub.guests[i].yaw, host.hub.guests[i].pitch) = (yaw, pitch);
let at = found.map_or(world, |(at, yaw)| {
host.hub.guests[i].yaw = yaw;
[at.x, at.y, at.z]
});
if found.is_none() {
+72 -171
View File
@@ -118,78 +118,34 @@ impl View {
}
None
}
/// `WorldlyContainer.getSlotsForFace` or every slot, zero-allocation iterator.
pub(super) fn slots_for_face_iter(&self, face: Option<Direction>) -> Slots<'_> {
/// `WorldlyContainer.getSlotsForFace` or every slot.
fn slots_for_face(&self, face: Option<Direction>) -> Vec<usize> {
match (self.kind, face) {
(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::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::Mod, Some(face)) => self
.container
.as_ref()
.map_or(Slots::Slice(&[]), |c| Slots::Mod(&c.faces[face as usize])),
_ => Slots::Range(0..self.len()),
.map_or_else(Vec::new, |c| c.faces[face as usize].clone()),
_ => (0..self.len()).collect(),
}
}
/// 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> {
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)
}
if matches!(self.kind, Kind::Furnace | Kind::Brewing | Kind::Mod) && face.is_some() {
self.slots_for_face(face)
} else {
(0..self.len()).collect()
}
}
#[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 {
@@ -224,6 +180,8 @@ 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> {
@@ -388,7 +346,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(bone_meal_stack());
return (level == 8 && slot == 0).then_some(&self.bone_meal);
}
let (pos, i) = view.locate(slot)?;
self.world
@@ -494,7 +452,7 @@ impl Containers<'_> {
}
/// `HopperBlockEntity.isFullContainer`.
fn is_full(&self, view: &View, face: Direction) -> bool {
view.slots_for_face_iter(Some(face)).all(|i| {
view.slots_for_face(Some(face)).into_iter().all(|i| {
self.insertion_item(view, i)
.is_some_and(|s| s.count >= self.max_stack(&s))
})
@@ -540,9 +498,6 @@ 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
@@ -601,14 +556,13 @@ impl StampedItemColumns {
}
}
fn bone_meal_stack() -> &'static ItemStack {
static STACK: std::sync::LazyLock<ItemStack> = std::sync::LazyLock::new(|| ItemStack {
fn bone_meal() -> ItemStack {
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).
@@ -625,6 +579,7 @@ 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(_)))
@@ -680,6 +635,7 @@ 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 {
@@ -891,7 +847,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_iter(face) {
for slot in view.slots_to_add(face) {
let Some(stack) = rest.take() else { break };
rest = self.try_move_in_item(from_hopper, view, stack, slot, face);
}
@@ -924,31 +880,32 @@ impl Game<'_> {
/// is still a hopper (`BlockEntityType.isValid`).
pub(super) fn tick_hopper(&mut self, pos: Pos) {
let time = self.world.game_time;
// 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 {
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 {
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 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" {
let hopper = self
.res
.facts()
.get(self.world.block(pos.0, pos.1, pos.2))
.name
.as_ref()
== "minecraft:hopper";
if !hopper {
self.hoppers.remove(&pos);
return;
}
if info.properties.get("enabled").is_some_and(|v| v == "false") {
return;
if let Some(state) = self.hoppers.get_mut(&pos) {
state.cooldown = 0;
}
self.hopper_try_move_items_with_state(pos, state);
self.hopper_try_move_items(pos);
}
/// Drops the cooldowns of hoppers whose block entity is gone.
pub(super) fn forget_gone_hoppers(&mut self) {
@@ -960,69 +917,23 @@ 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);
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 {
let c = self.containers();
let enabled = c.prop(state, "enabled") != Some("false");
let Some(view) = c.view(pos, false) else {
return;
};
if info.properties.get("enabled").is_some_and(|v| v == "false") {
if self.hoppers.get(&pos).is_some_and(|h| h.cooldown > 0) || !enabled {
return;
}
if self.hoppers.get(&pos).is_some_and(|h| h.cooldown > 0) {
return;
}
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);
if !c.is_empty(&view) {
changed = self.hopper_eject(pos, &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 {
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 {
changed |= self.hopper_suck(pos, &view);
}
if changed {
@@ -1084,20 +995,19 @@ 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_with_state(pos, state, &view);
changed = self.hopper_eject(pos, &view);
}
let c = self.containers();
let full =
@@ -1133,7 +1043,8 @@ impl Game<'_> {
}
/// `HopperBlockEntity.ejectItems`: into the block container in front, else
/// a container entity there (`getContainerAt`).
fn hopper_eject_with_state(&mut self, pos: Pos, state: BlockStateId, hopper: &View) -> bool {
fn hopper_eject(&mut self, pos: Pos, hopper: &View) -> bool {
let state = self.world.block(pos.0, pos.1, pos.2);
let c = self.containers();
let Some(facing) = c.prop(state, "facing").and_then(dir_from_name) else {
return false;
@@ -1245,9 +1156,6 @@ 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) = (
[
@@ -1306,7 +1214,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 = bone_meal_stack().clone();
let bone_meal = c.bone_meal.clone();
if !horizon_client::mod_api::mods().is_empty() {
let mut cancel = false;
self.native_inventory_move_item(
@@ -1336,15 +1244,13 @@ impl Game<'_> {
source: &View,
hopper: &View,
) -> bool {
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;
}
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;
}
if self.hopper_move_canceled(source, slot, source, hopper) {
continue;
@@ -1363,9 +1269,7 @@ impl Game<'_> {
}
return true;
}
Some(rest) => {
self.put_back(source, slot, rest);
}
Some(rest) => self.put_back(source, slot, rest),
}
}
false
@@ -1375,9 +1279,6 @@ 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
+24 -27
View File
@@ -61,49 +61,46 @@ 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 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 {
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 {
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: is_bee
angry: m.species() == Species::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: species == Species::Sniffer
sniffer_digging: m.species() == Species::Sniffer
&& m.ai.target.is_none()
&& sniffer::can_play_digging_sound(m),
};
if is_bee {
bee_indices.push(mobs.len());
}
mobs.push(sound);
}
})
.collect();
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 &idx in &bee_indices {
let sound = &mobs[idx];
if self.bee_sounds.get(&sound.id) != Some(&sound.angry) {
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) {
self.bee_sounds.insert(sound.id, sound.angry);
let instance = SoundInstance::bee(sound, sound.angry, random_source::create());
self.audio.play_instance(instance, false);
+9 -6
View File
@@ -300,7 +300,11 @@ impl Game<'_> {
) {
continue;
}
let marks = self.hooks_name_marks(m, Some(m.id) == own);
let marks = if player || self.cosmetics_local() {
self.cosmetics_marks(m, Some(m.id) == own)
} else {
Marks::default()
};
let at = match marks.top {
Some(top) => p + glam::DVec3::Y * cosmetic_lift(top, m.model_scale()),
None => p,
@@ -766,10 +770,10 @@ impl Game<'_> {
if background { BACKGROUND } else { 0 },
),
};
if let Some(mark) = marks.mark.filter(|_| n == last) {
if n == last && marks.horizon {
let x = -self.gui.atlas.font.width_text(text) / 2.0 - 10.0;
// 8 text pixels square, a pixel left of the background.
for &(mx, my, w, h, argb) in mark {
for &(mx, my, w, h, argb) in super::cosmetics::MARK {
let a = text_alpha * (argb >> 24) / 255;
batch.fill(
&self.gui.atlas,
@@ -933,11 +937,10 @@ struct NameTag {
marks: Marks,
}
/// What a hook draws by a player's name (`horizon_hooks::players::NameDecoration`): a mark
/// before it (Horizon's: they play Horizon) and a name tag style.
/// What Horizon draws by a player's name: its mark (they play Horizon) and their name tag style.
#[derive(Clone, Copy, Default)]
pub(super) struct Marks {
pub mark: Option<horizon_hooks::players::Mark>,
pub horizon: bool,
pub style: Option<TagStyle>,
/// How high the cosmetics drawn on their body get above the neck, in model pixels: the tag
/// goes up over a tall one (`cosmetic_lift`).
@@ -471,7 +471,6 @@ 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,8 +46,6 @@ 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) {
@@ -130,20 +128,6 @@ 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];
@@ -666,10 +650,8 @@ fn spawn_cost_types(biomes: &horizon_client::biome::Biomes) -> Vec<&str> {
types
}
impl<'a> Game<'a> {
pub(super) fn natural_level_with_players(
&self,
players: Vec<LevelPlayer>,
) -> GameLevel<'_, 'a> {
pub(super) fn natural_level(&self) -> GameLevel<'_, 'a> {
let players = self.level_players();
let nearest = players
.iter()
.filter(|p| !p.spectator)
@@ -688,9 +670,6 @@ 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> {
@@ -794,8 +773,6 @@ 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)
})
@@ -842,28 +819,31 @@ impl<'a> Game<'a> {
{
let level = self.natural_level();
laps.lap("sn_level");
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 {
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 {
position: [p.position.x, p.position.y, p.position.z],
spectator: p.spectator,
}));
let spawn_players = std::mem::take(&mut scratch.spawn_players);
})
.collect();
laps.lap("sn_materialize");
let mut state = SpawnState::new(&level, spawnable, spawn_players, counted, |cx, cz| {
let mut state = SpawnState::new(&level, spawnable, players, &counted, |cx, cz| {
self.world.chunks.contains_key(&(cx, cz))
});
laps.lap("sn_state");
@@ -874,8 +854,7 @@ impl<'a> Game<'a> {
Vec::new()
};
laps.lap("sn_categories");
// Util.shuffle, every pass: it draws from the level's random even when no
// category may spawn.
// Util.shuffle.
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);
@@ -906,7 +885,6 @@ impl<'a> Game<'a> {
);
}
}
scratch.spawn_players = state.into_players();
}
spawn_selection::attempt_counters(true);
laps.lap("sn_attempts");
+1 -1
View File
@@ -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));
+20
View File
@@ -679,6 +679,26 @@ 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;
@@ -0,0 +1,330 @@
//! A page drawn as its own screen (`horizon_hooks::pages::Place::PauseFriends`, what the pause
//! menu's vanilla Friends icon opens), the way a mod adds a screen to vanilla: the menu
//! background over the paused world, the title centred at the top, the rows centred down the
//! middle, footer buttons beside Done at the bottom (`HeaderAndFooterLayout`).
use super::{Game, Screen};
use horizon_client::{
hud::{HudAtlas, HudBatch, rgba},
multiplayer::wrap,
text::Text,
ui,
};
use horizon_hooks::pages::{self, Act, Page, Place, Row};
/// What a click on the screen hit.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum Hit {
/// The page's row with this index.
Row(usize),
Done,
}
struct Button {
x: f32,
y: f32,
w: f32,
label: &'static str,
hit: Hit,
active: bool,
}
fn grey() -> u32 {
rgba(160, 160, 160, 255)
}
const WHITE: u32 = u32::MAX;
/// The centred lines (text, colour, y) and the buttons.
fn layout(
atlas: &HudAtlas,
(w, h): (f32, f32),
rows: &[Row],
) -> (Vec<(String, u32, f32)>, Vec<Button>) {
let width = (w - 40.0).min(310.0);
let cx = (w / 2.0).floor();
let mut lines = Vec::new();
let mut buttons = Vec::new();
let mut footer = Vec::new();
let mut y = 40.0;
// A half-width button waiting for the next to sit beside it.
let mut half_open = false;
for (r, row) in rows.iter().enumerate() {
let beside = std::mem::take(&mut half_open);
let (text, quiet, groups) = match row {
Row::Text {
text,
quiet,
groups,
..
} => (text.clone(), *quiet, *groups),
Row::Note(text) => (text.clone(), true, false),
Row::Heading(key) => (horizon_client::lang::get(key), true, false),
Row::Space(px) => {
y += f32::from(*px);
continue;
}
Row::Button {
label,
active,
footer: true,
..
} => {
footer.push((r, *label, *active));
continue;
}
Row::Button {
label,
active,
half,
..
} => {
let (x, w) = match (*half, beside) {
(true, true) => {
y -= 30.0;
(cx + 4.0, 150.0)
}
(true, false) => {
half_open = true;
(cx - 154.0, 150.0)
}
(false, _) => (cx - 100.0, 200.0),
};
buttons.push(Button {
x,
y,
w,
label,
hit: Hit::Row(r),
active: *active,
});
y += 30.0;
continue;
}
// Settings rows belong in the options shell.
_ => continue,
};
let wrapped = if groups {
groups_lines(atlas, &text, width)
} else {
wrap(&text, width, atlas)
};
// Text that would run into the footer is left out.
if y + 10.0 * wrapped.len() as f32 > h - 33.0 {
continue;
}
let color = if quiet { grey() } else { WHITE };
for line in wrapped {
lines.push((line, color, y));
y += 10.0;
}
}
// The footer: the page's buttons, then Done; Done alone is wider.
footer.push((usize::MAX, "gui.done", true));
let (each, gap) = if footer.len() == 1 {
(200.0, 0.0)
} else {
(150.0, 8.0)
};
let total = footer.len() as f32 * (each + gap) - gap;
let mut x = cx - total / 2.0;
for (r, label, active) in footer {
let hit = if r == usize::MAX {
Hit::Done
} else {
Hit::Row(r)
};
buttons.push(Button {
x,
y: h - 27.0,
w: each,
label,
hit,
active,
});
x += each + gap;
}
(lines, buttons)
}
/// `-`-separated groups packed into lines of at most `width`, never broken inside a group.
fn groups_lines(atlas: &HudAtlas, text: &str, width: f32) -> Vec<String> {
let mut lines: Vec<String> = Vec::new();
for group in text.split('-') {
match lines.last_mut() {
Some(l) if HudBatch::text_width(atlas, &format!("{l}-{group}")) <= width => {
l.push('-');
l.push_str(group);
}
_ => lines.push(group.to_owned()),
}
}
lines
}
fn draw(batch: &mut HudBatch, atlas: &HudAtlas, cursor: (f32, f32), title: &str, rows: &[Row]) {
let (w, h) = (batch.width, batch.height);
ui::menu_background(batch, atlas, 0.0, 0.0, w, h, true);
let title = Text::translate(title, &[]);
let tw = atlas.font.width_text(&title);
batch.text_styled(atlas, &title, ((w - tw) / 2.0).floor(), 15.0, WHITE, true);
let (lines, buttons) = layout(atlas, (w, h), rows);
for (line, color, y) in lines {
let lw = HudBatch::text_width(atlas, &line);
batch.text(atlas, &line, ((w - lw) / 2.0).floor(), y, color, true);
}
for b in buttons {
let hover = b.active && ui::hovered(cursor, b.x, b.y, b.w, 20.0);
if b.hit == Hit::Done {
horizon_client::widgets::declare_action(
horizon_client::widgets::Action::Done,
[b.x, b.y, b.w, 20.0],
b.active,
);
}
let label = Text::translate(b.label, &[]);
ui::button(batch, atlas, b.x, b.y, b.w, 20.0, &label, b.active, hover);
}
}
/// The active button under a click.
fn click(atlas: &HudAtlas, size: (f32, f32), cursor: (f32, f32), rows: &[Row]) -> Option<Hit> {
layout(atlas, size, rows)
.1
.into_iter()
.find(|b| b.active && ui::hovered(cursor, b.x, b.y, b.w, 20.0))
.map(|b| b.hit)
}
/// The page the Friends icon opens: the first registered there.
fn friends_page() -> Option<&'static dyn Page> {
pages::engine::all()
.iter()
.copied()
.find(|page| page.place() == Place::PauseFriends)
}
/// What the Friends icon and its screen are named, for its tooltip, the narrator, controller
/// focus and the control socket's widget tree: the page's title, else vanilla's tooltip.
pub(super) fn friends_name() -> String {
friends_page().map_or_else(
|| horizon_client::lang::get("gui.friends.open"),
|page| horizon_client::lang::get(page.title()),
)
}
impl Game<'_> {
/// Whether the pause menu's Friends icon is active: a page is registered for it and this
/// window plays a singleplayer world (another server's world can't be shared).
pub(super) fn friends_shown(&self) -> bool {
self.can_share() && friends_page().is_some()
}
/// The Friends icon's page. Its rows are asked each time it is drawn.
pub(super) fn draw_added_page(&mut self, batch: &mut HudBatch) {
let Some(page) = friends_page() else {
// Only reached by naming the screen (`horizon-ctl`): back to the pause menu.
self.screen = Screen::Paused;
return;
};
let mut rows = Vec::new();
page.rows(&mut rows);
horizon_client::widgets::begin_screen(|| {
horizon_client::widgets::ScreenInfo::new("friends", friends_name())
});
draw(batch, &self.gui.atlas, self.cursor, page.title(), &rows);
}
pub(super) fn added_page_click(&mut self) {
let Some(page) = friends_page() else {
return;
};
let mut rows = Vec::new();
page.rows(&mut rows);
let Some(hit) = click(&self.gui.atlas, self.gui_size(), self.cursor, &rows) else {
return;
};
self.audio.play_button_click();
match hit {
Hit::Row(r) => page.act(r, Act::Press),
Hit::Done => self.screen = Screen::Paused,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn atlas() -> HudAtlas {
HudAtlas::build(std::path::Path::new("/nonexistent/horizon-test-assets")).unwrap()
}
fn at(buttons: &[Button], hit: Hit) -> &Button {
buttons.iter().find(|b| b.hit == hit).unwrap()
}
#[test]
fn halves_sit_side_by_side_and_footer_buttons_beside_done() {
let atlas = atlas();
let size = (427.0, 240.0);
let rows = [
Row::text("Invite Code").quiet(),
Row::Space(2),
Row::text("0B33K-Q405Q-1Z5CV-X6NHY-R12N0-VK8T1-60N00-RA134-952PE-1HA08-910P0-K5M01-ST1NB-JDTB6-K4W1P-0").groups(),
Row::Space(6),
Row::button("copy").half().active(false),
Row::button("stop").half(),
Row::text("Friends on your network or VPN can join. For friends on the internet: turn on UPnP on your router, or forward TCP port 25580 to 10.0.0.2, or use a relay, or a VPN such as Tailscale or ZeroTier (the code already holds your VPN address)."),
Row::button("help").footer(),
];
let (lines, buttons) = layout(&atlas, size, &rows);
let (copy, stop) = (at(&buttons, Hit::Row(4)), at(&buttons, Hit::Row(5)));
// 213 is the middle: two 150-wide halves 8 apart around it, as vanilla's pairs.
assert_eq!(
(copy.x, copy.w, stop.x, stop.w),
(59.0, 150.0, 217.0, 150.0)
);
assert_eq!(copy.y, stop.y);
let (help, done) = (at(&buttons, Hit::Row(7)), at(&buttons, Hit::Done));
assert_eq!(
(help.x, done.x, help.y, done.y),
(59.0, 217.0, 213.0, 213.0)
);
// The code breaks between its groups only.
let code: Vec<_> = lines
.iter()
.filter(|(_, c, _)| *c == WHITE)
.map(|(l, ..)| l)
.collect();
assert!(code[0].starts_with("0B33K-") && code.iter().all(|l| !l.ends_with('-')));
assert!(
lines.iter().all(|(_, _, y)| y + 9.0 <= size.1 - 27.0),
"above the footer"
);
// An inactive button takes no click; Done alone is 200 wide.
assert_eq!(
click(&atlas, size, (copy.x + 1.0, copy.y + 1.0), &rows),
None
);
assert_eq!(
click(&atlas, size, (stop.x + 1.0, stop.y + 1.0), &rows),
Some(Hit::Row(5))
);
let (_, alone) = layout(&atlas, size, &[Row::button("share")]);
assert_eq!(at(&alone, Hit::Done).w, 200.0);
assert_eq!(at(&alone, Hit::Row(0)).x, 113.0);
}
#[test]
fn text_that_would_reach_the_footer_is_left_out() {
let atlas = atlas();
let rows = [
Row::Space(150),
Row::text("first"),
Row::text("too low").quiet(),
];
let (lines, _) = layout(&atlas, (427.0, 240.0), &rows);
// 40 + 150 = 190: one line fits above 207, the next (200..210) doesn't.
assert_eq!(lines.len(), 1);
assert_eq!(lines[0].0, "first");
}
}
+9 -10
View File
@@ -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.flying() {
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
@@ -311,8 +311,8 @@ impl Game<'_> {
}
};
let mut view = self.view;
// A hook may pull the camera back (an emote, from the player's face).
let pull = self.hooks_camera_distance(partial);
// An emote pulls the camera back from the player's face (`game/cosmetics/emotes.rs`).
let pull = self.emote_camera_pull(partial);
let kind = match self.camera_type {
CameraType::FirstPerson => horizon_client::camera::CameraType::FirstPerson,
CameraType::ThirdPersonBack => horizon_client::camera::CameraType::ThirdPersonBack,
@@ -643,9 +643,8 @@ 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.flying() {
self.studio
.freecam
if self.studio_freecam.active {
self.studio_freecam
.turn((xo * 0.15) as f32, (yo * 0.15) as f32);
return;
}
@@ -698,7 +697,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.flying()
!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).
+6 -77
View File
@@ -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.input_holds(mapping);
let pad = self.pad.holds_action(&self.settings.controller, 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.replay.playing() {
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.flying() {
if !studio_freecam_movement_key(key) || !self.studio_freecam.active {
return Ok(());
}
}
@@ -352,7 +352,9 @@ impl Game<'_> {
_ => return false,
};
match source {
Source::Keyboard | Source::Agent => self.input_holds(mapping),
Source::Keyboard | Source::Agent => {
self.pad.holds_action(&self.settings.controller, mapping)
}
Source::Pad => {
let keys = &self.settings.keys;
self.physical_buttons
@@ -448,79 +450,6 @@ 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};
+40 -6
View File
@@ -10,8 +10,41 @@ 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.replay.playing() {
self.studio_key(key);
if self.screen == Screen::Playing && self.studio_replay.is_some() {
match key {
KeyCode::Escape => {
self.close_studio_replay();
return Ok(());
}
KeyCode::KeyP => {
self.pause_focus = None;
self.screen = Screen::Paused;
self.pause_menu = true;
self.capture(false);
}
KeyCode::KeyF => self.handle_studio_timeline_action(
horizon_client::studio::TimelineAction::ToggleFreecam,
),
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;
}
KeyCode::KeyK => self.handle_studio_timeline_action(
horizon_client::studio::TimelineAction::AddCameraKeyframe,
),
KeyCode::Delete => {
if let Some(index) = self.studio_timeline.selected_keyframe {
self.handle_studio_timeline_action(
horizon_client::studio::TimelineAction::RemoveCameraKeyframe(index),
);
}
}
_ => {}
}
return Ok(());
}
// `CreativeModeInventoryScreen.keyPressed` clears `ignoreTextInput` on
@@ -45,7 +78,7 @@ impl Game<'_> {
KeyCode::Escape => match self.screen {
Screen::Options => self.options_key(KeyCode::Escape, None)?,
Screen::Playing => {
if self.replay.playing() && self.captured {
if self.studio_replay.is_some() && self.captured {
self.capture(false);
return Ok(());
}
@@ -75,7 +108,7 @@ impl Game<'_> {
self.death_confirm_answer(false)?;
}
}
Screen::Stats | Screen::Friends => self.screen = Screen::Paused,
Screen::Stats | Screen::AddedPage => self.screen = Screen::Paused,
Screen::Social => self.close_social(),
Screen::ServerLinks => self.screen = Screen::Paused,
// ConfirmScreen.keyPressed: Escape answers no; both return to
@@ -180,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.replay.playing() => {
KeyCode::F5 if self.screen == Screen::Playing && self.studio_replay.is_none() => {
self.cycle_camera()
}
KeyCode::KeyE
@@ -246,7 +279,8 @@ 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) || self.input_takes_stack_drop();
let all =
drop_whole_stack(self.modifiers) || std::mem::take(&mut self.pad.drop_stack);
self.drop_held(all);
}
KeyCode::Digit1
@@ -1,448 +0,0 @@
//! Where the game hands the player models to crates above the engine (`horizon_hooks::Hooks`'s
//! `frame`, `tick`, `name_decoration`, `player_mark`, `camera`; spec #14091): only a window's
//! game calls them, never the integrated server's thread or a dedicated server. With no hook
//! registered nothing is built.
use super::Game;
use horizon_client::fast_hash::FxHashMap;
use horizon_client::join_seq::JoinList;
use horizon_client::mesher::Vertex;
use horizon_client::mobs::anim::{self, BodyPose, PooledPoses, Poses};
use horizon_client::mobs::{Mob, Part, Polygon, render};
use horizon_hooks::engine;
use horizon_hooks::players::{
Bone, CameraView, Frame, Gear, Kind, Mesh, PartPoses, Pass, PlayerDraw, PlayerModel,
PlayerModels, SpriteStore, Sprites,
};
use std::sync::Arc;
/// Registers the hooks of the Horizon parts still in this crate (cosmetics: spec #14091 step 5
/// moves them out). Before the hooks are sealed.
pub(crate) fn register() {
if super::cosmetics::enabled()
&& let Err(e) = horizon_hooks::register(&super::cosmetics::HOOKS)
{
log::error!("Cosmetics: their hooks came too late: {e}");
}
}
/// The models of the world a window shows: its own first, then the other players and mannequins.
struct Models<'a> {
own: Option<&'a mut Mob>,
mobs: &'a mut JoinList<Mob>,
remote: &'a super::remote::Remote,
}
impl Models<'_> {
fn find(&mut self, id: u32) -> Option<&mut Mob> {
match self.own.as_deref_mut() {
Some(m) if m.id == id => Some(m),
_ => self
.mobs
.iter_mut()
.find(|m| m.id == id && kind(m).is_some()),
}
}
}
/// What kind of player model `m` is, if it is one (the window's own model aside).
fn kind(m: &Mob) -> Option<Kind> {
match &*m.kind.id {
"minecraft:player" => Some(Kind::Player),
"minecraft:mannequin" => Some(Kind::Mannequin),
_ => None,
}
}
/// What `m` wears where a crate may draw: a helmet or head item, a chestplate, a cape or elytra.
pub(super) fn gear(m: &Mob) -> Gear {
let elytra = m.extra.chest.as_deref() == Some("minecraft:elytra");
Gear::new(
m.extra.head.is_some(),
m.extra.chest.is_some() && !elytra,
m.cape.is_some() || elytra,
)
}
fn model(m: &Mob, kind: Kind, uuid: Option<u128>) -> PlayerModel {
let (dx, dz) = (m.position.x - m.previous.x, m.position.z - m.previous.z);
PlayerModel::new(m.id, kind, uuid, m.age, dx * dx + dz * dz > 1e-4)
.looks(gear(m), horizon_client::mobs::trader::is_invisible(m))
}
impl PlayerModels for Models<'_> {
fn connection(&self) -> u64 {
self.remote.session.serial()
}
fn for_each(&self, f: &mut dyn FnMut(PlayerModel)) {
if let Some(m) = self.own.as_deref() {
f(model(m, Kind::Own, Some(self.remote.profile())));
}
for m in self.mobs.iter() {
if let Some(kind) = kind(m) {
let uuid = (kind == Kind::Player)
.then(|| self.remote.player_uuid(m.id))
.flatten();
f(model(m, kind, uuid));
}
}
}
fn body_pose(&self, id: u32) -> Option<&Arc<dyn BodyPose>> {
match self.own.as_deref() {
Some(m) if m.id == id => m.emote.as_ref(),
_ => self
.mobs
.iter()
.find(|m| m.id == id && kind(m).is_some())?
.emote
.as_ref(),
}
}
fn set_body_pose(&mut self, id: u32, pose: Option<Arc<dyn BodyPose>>) {
if let Some(m) = self.find(id) {
m.emote = pose;
}
}
}
/// Sprite `id`'s place in the atlas's names (`hooks/<hex>`), written into `buf`: no allocation.
fn location(id: u64, buf: &mut [u8; 22]) -> &str {
use std::io::Write;
let mut out = std::io::Cursor::new(&mut buf[..]);
let _ = write!(out, "hooks/{id:x}");
let len = out.position() as usize;
std::str::from_utf8(&buf[..len]).unwrap_or_default()
}
/// The crates' sprites in the GUI atlas, in the skin slots: read while drawing.
struct HookSprites<'a, 'g>(&'a Game<'g>);
impl Sprites for HookSprites<'_, '_> {
fn get(&self, id: u64) -> Option<[f32; 4]> {
let mut buf = [0; 22];
let at = location(id, &mut buf);
let r = self.0.gui.atlas.sprites.get(at)?;
// Kept this frame (`skin_slots.next_frame`).
self.0.skin_downloads.borrow_mut().drawn(at);
Some([r.u0, r.v0, r.u1, r.v1])
}
}
/// Where a crate's frame hook puts its sprites.
struct HookStore<'a, 'g>(&'a mut Game<'g>);
impl SpriteStore for HookStore<'_, '_> {
fn has(&self, id: u64) -> bool {
self.0
.gui
.atlas
.sprites
.contains_key(location(id, &mut [0; 22]))
}
fn put(&mut self, id: u64, rgba: Vec<u8>) {
let at = location(id, &mut [0; 22]).to_owned();
self.0.store_skin(at, rgba);
}
}
thread_local! {
/// The crates' meshes as the renderer takes them, by the mesh's address (each entry holds its
/// mesh, so the address isn't reused); render thread only.
static MESHES: std::cell::RefCell<FxHashMap<usize, (Arc<Mesh>, Arc<Part>)>> =
Default::default();
}
/// The game's model of `mesh`, made once.
fn part_of(mesh: &Arc<Mesh>) -> Arc<Part> {
fn part(m: &Mesh) -> Part {
Part {
name: m.name.clone(),
pose: m.pose,
polygons: m
.quads
.iter()
.map(|q| Polygon {
normal: q.normal,
vertices: q.vertices,
cull_back: false,
})
.collect(),
children: m.children.iter().map(part).collect(),
}
}
MESHES.with_borrow_mut(|meshes| {
let (_, part_of) = meshes
.entry(Arc::as_ptr(mesh) as usize)
.or_insert_with(|| (mesh.clone(), Arc::new(part(mesh))));
part_of.clone()
})
}
/// Forgets the meshes no crate holds any more (once a frame).
fn forget_meshes() {
let _ = MESHES.try_with(|m| {
m.borrow_mut()
.retain(|_, (mesh, _)| Arc::strong_count(mesh) > 1)
});
}
/// `Poses` as a crate poses a mesh's parts.
struct PosesOf<'a>(&'a mut Poses);
impl PartPoses for PosesOf<'_> {
fn pose(&mut self, name: &str) -> &mut [f32; 9] {
self.0.get(name)
}
}
/// A player model drawn in the world pass, as a crate draws on it: posed on the GPU's skin path
/// when `gpu` (the mob pass's skin frame, begun; the bounds of what went there grow, for
/// culling), else its vertices go to `out`.
struct DrawPlayer<'a, 'g> {
game: &'a Game<'g>,
m: &'a Mob,
pass: Pass,
/// The model's matrix (`render::base_transform`).
base: glam::Mat4,
alpha: f32,
light: u32,
gpu: bool,
out: &'a mut Vec<Vertex>,
bounds: Option<(glam::Vec3, glam::Vec3)>,
/// The body as it is drawn, posed on the first `bone`.
body: Option<PooledPoses>,
poses: PooledPoses,
}
impl DrawPlayer<'_, '_> {
fn body(&self) -> &Part {
let (key, _) = render::body(self.m);
self.m.kind.layer(&key).unwrap_or(&self.m.kind.model)
}
}
impl PlayerDraw for DrawPlayer<'_, '_> {
fn pass(&self) -> Pass {
self.pass
}
fn ticks(&self) -> f32 {
self.m.age as f32 + self.alpha
}
fn bone(&mut self, bone: Bone) -> Option<[[f32; 4]; 4]> {
if self.body.is_none() {
let mut poses = Poses::pooled();
poses.reset(self.body());
let data = self.game.res.mob_data();
let state = anim::state(self.m, self.alpha);
anim::setup(self.m, &state, &mut poses, &data.animations);
self.body = Some(poses);
}
let poses = self.body.as_ref()?;
let at = render::part_matrix(self.body(), self.base, poses, bone.part(), 0.0)?;
Some(at.to_cols_array_2d())
}
fn body_pose(&self) -> Option<&Arc<dyn BodyPose>> {
self.m.emote.as_ref()
}
fn sprite(&self, id: u64) -> Option<[f32; 4]> {
HookSprites(self.game).get(id)
}
fn draw(
&mut self,
mesh: &Arc<Mesh>,
matrix: [[f32; 4]; 4],
uv: [f32; 4],
pose: &mut dyn FnMut(&mut dyn PartPoses),
) {
let model = part_of(mesh);
let matrix = glam::Mat4::from_cols_array_2d(&matrix);
self.poses.reset(&model);
pose(&mut PosesOf(&mut self.poses));
if self.gpu {
let mut models = self.game.skin_models.borrow_mut();
let mut frame = self.game.skin_frame.borrow_mut();
let mut skin = horizon_client::mobs::skin::Skin::new(&mut models, &mut frame);
skin.bounds = self.bounds;
let paint = horizon_client::mobs::skin::Paint {
uv,
scroll: 0.0,
color: 0xFFFF_FFFF,
light: self.light,
overlay: 0,
};
if skin.draw(&model, matrix, &self.poses, paint) {
self.bounds = skin.bounds;
return;
}
}
let poses = &self.poses;
horizon_client::mobs::render_model(
&model,
matrix,
&|p: &Part| poses.pose(p),
uv,
self.light,
0,
self.out,
);
}
}
impl Game<'_> {
/// What crates draw on player model `m` in the world pass (`creatures.rs`), then the
/// cosmetics still drawn here: see [`DrawPlayer`]. The bounds of what went to the GPU.
#[allow(clippy::too_many_arguments)]
pub(super) fn player_layers_geometry(
&self,
m: &Mob,
base: glam::Mat4,
camera: glam::DVec3,
alpha: f32,
light: u32,
gpu: bool,
out: &mut Vec<Vertex>,
) -> Option<(glam::Vec3, glam::Vec3)> {
debug_assert!(
self.serving.is_none(),
"a server thread calls no client hook"
);
let kind = kind(m)?;
let mut bounds = None;
if engine::any() {
let model = model(m, kind, self.remote_player_uuid(m.id));
let mut draw = DrawPlayer {
game: self,
m,
pass: Pass::World,
base,
alpha,
light,
gpu,
out: &mut *out,
bounds: None,
body: None,
poses: Poses::pooled(),
};
engine::player_layers(&model, &mut draw);
bounds = draw.bounds;
}
let worn = super::cosmetics::enabled()
.then(|| self.cosmetics_geometry(m, false, base, Some(camera), alpha, light, gpu, out))
.flatten();
match (bounds, worn) {
(Some((l, h)), Some((lo, hi))) => Some((l.min(lo), h.max(hi))),
(b, w) => b.or(w),
}
}
/// Once a frame, before drawing.
pub(super) fn client_hooks_frame(&mut self) {
debug_assert!(
self.serving.is_none(),
"a server thread calls no client hook"
);
if !engine::any() {
return;
}
self.cosmetics_say();
let connection = self.remote.as_ref().map_or(0, |r| r.session.serial());
let frame = Frame::new(connection, self.integrated.is_some());
engine::frame(&frame, &mut HookStore(self));
forget_meshes();
self.cosmetics_frame();
}
/// Once a client tick in a world: the player's own emote, then the hooks.
pub(super) fn client_hooks_tick(&mut self) {
debug_assert!(
self.serving.is_none(),
"a server thread calls no client hook"
);
self.tick_emotes();
if !engine::any() {
return;
}
self.cosmetics_say();
let Some(remote) = self.remote.as_ref() else {
return;
};
engine::tick(&mut Models {
own: self.player_model.as_mut(),
mobs: &mut self.mobs,
remote,
});
}
/// What goes by `m`'s name (`own`: the window's own player), from the hooks.
pub(super) fn hooks_name_marks(&self, m: &Mob, own: bool) -> super::names::Marks {
debug_assert!(
self.serving.is_none(),
"a server thread calls no client hook"
);
let mut marks = super::names::Marks::default();
if !engine::any() {
return marks;
}
let model = match (own, kind(m)) {
(true, _) => model(m, Kind::Own, self.remote.as_ref().map(|r| r.profile())),
(false, Some(kind)) => model(m, kind, self.remote_player_uuid(m.id)),
(false, None) => return marks,
};
if let Some(d) = engine::name_decoration(&model, &HookSprites(self)) {
marks.mark = d.mark;
marks.top = d.top;
marks.style = d.badge.map(|b| {
let [u0, v0, u1, v1] = b.uv;
super::names::TagStyle {
mark: horizon_client::hud::Rect {
u0,
v0,
u1,
v1,
w: 8,
h: 8,
},
line: b.line,
}
});
}
marks
}
/// How far the third-person camera stands back, as a share of the usual distance: a hook's
/// (an emote pulls it back), else 1.
pub(super) fn hooks_camera_distance(&self, partial: f32) -> f32 {
debug_assert!(
self.serving.is_none(),
"a server thread calls no client hook"
);
match &self.player_model {
Some(m) if engine::any() => {
engine::camera(&CameraView::new(m.age as f32 + partial)).map_or(1.0, |c| c.distance)
}
_ => 1.0,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn sprite_locations_fit_the_buffer_and_differ_by_id() {
let mut a = [0; 22];
let mut b = [0; 22];
assert_eq!(location(u64::MAX, &mut a), "hooks/ffffffffffffffff");
assert_eq!(location(10, &mut b), "hooks/a");
}
}
@@ -111,7 +111,7 @@ impl Game<'_> {
self.stop_sounds_for_new_level();
}
self.player = Some(player);
self.input_player_replaced();
self.pad.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.flying());
&& (self.player.is_some() || self.studio_freecam.active);
let active = self.focused || self.agent_pointer;
if let Some((xo, yo)) = self
.mouse
@@ -75,7 +75,10 @@ impl Game<'_> {
.is_some_and(|t| t.elapsed() > Duration::from_millis(500))
&& self.control.is_none()
&& self.screen == Screen::Playing
&& !studio_suppresses_focus_pause(self.replay.playing(), self.studio.flying())
&& !studio_suppresses_focus_pause(
self.studio_replay.is_some(),
self.studio_freecam.active,
)
&& self.player.is_some()
&& !self.options.no_pause
&& self.settings.file.bool("pauseOnLostFocus")
@@ -146,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;
@@ -350,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_up() {
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;
}
+48 -23
View File
@@ -1569,7 +1569,7 @@ impl Game<'_> {
self.left = false;
self.right = false;
// The shared world is closing: its invite code ends with it.
self.friends_lan_closed();
self.lan_closed_hooks();
// Single player's server thread saves the world before the title shows.
let single_player = match self.integrated.take() {
Some(server) => {
@@ -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.replay.playing() {
if self.studio_replay.is_some() {
return true;
}
self.remote
@@ -3110,6 +3110,33 @@ 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()?;
@@ -3680,7 +3707,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));
@@ -3689,8 +3716,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))
@@ -6149,7 +6176,7 @@ impl Game<'_> {
player.spectator = self.spectator();
player.velocity = delta;
self.player = Some(player);
self.input_player_replaced();
self.pad.player_replaced();
self.sway = Default::default();
self.eye = 1.62;
self.previous_eye = 1.62;
@@ -6158,7 +6185,7 @@ impl Game<'_> {
self.apply_remote_abilities();
}
}
if !self.replay.playing() {
if self.studio_replay.is_none() {
self.previous_position = position;
}
self.yaw = yaw;
@@ -6414,7 +6441,7 @@ impl Game<'_> {
}
return Ok(());
}
let stick = self.input_move();
let stick = self.pad_move();
if self.player.is_none() {
return Ok(());
}
@@ -6494,7 +6521,7 @@ impl Game<'_> {
return Ok(());
};
player.fluids.boat = boat;
if !riding && !self.replay.playing() {
if !riding && self.studio_replay.is_none() {
self.previous_position = player.position;
}
let before = (
@@ -6701,10 +6728,10 @@ impl Game<'_> {
self.snap_player_model_pitch();
}
// The player's own model (third person, the inventory's preview).
if !self.replay.playing() {
if self.studio_replay.is_none() {
self.tick_player_model();
}
self.client_hooks_tick();
self.tick_emotes();
if !dead {
self.remote_tick_using();
}
@@ -9498,7 +9525,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() || self.input_takes_stack_drop();
let all = self.has_control_down() || std::mem::take(&mut self.pad.drop_stack);
self.remote_drop(all);
}
KeyCode::KeyE
@@ -10501,7 +10528,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.replay.playing()
self.studio_replay.is_none()
&& self
.remote
.as_ref()
@@ -10585,7 +10612,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.replay.playing() {
if self.studio_replay.is_some() {
return false;
}
use horizon_client::level_loading::Reason;
@@ -10687,11 +10714,17 @@ impl Game<'_> {
/// The profile name of another player the server sent, by entity id.
/// The UUID of another player's entity in the level.
pub(super) fn remote_player_uuid(&self, id: u32) -> Option<u128> {
self.remote.as_ref()?.player_uuid(id)
let r = self.remote.as_ref()?;
let uuid = *r.level_players.get(&(id as i32))?;
(uuid != r.profile).then_some(uuid)
}
pub(super) fn remote_player_name(&self, id: u32) -> Option<&str> {
let r = self.remote.as_ref()?;
r.level_player_name(id as i32, &r.player_uuid(id)?)
let uuid = r.level_players.get(&(id as i32))?;
if *uuid == r.profile {
return None;
}
r.level_player_name(id as i32, uuid)
}
/// Status for `horizon-ctl status`.
/// The other players' entities in the level and their names (their
@@ -11472,11 +11505,6 @@ impl Remote {
pub(super) fn profile(&self) -> u128 {
self.profile
}
/// Another player's profile UUID, by their entity id (None for the local player).
pub(super) fn player_uuid(&self, id: u32) -> Option<u128> {
let uuid = *self.level_players.get(&(id as i32))?;
(uuid != self.profile).then_some(uuid)
}
/// The entity id of a player in the level, by profile UUID.
pub(super) fn player_entity(&self, uuid: u128) -> Option<u32> {
self.level_players
@@ -11614,9 +11642,6 @@ 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,17 +793,13 @@ 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);
@@ -1,142 +0,0 @@
//! 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,7 +168,6 @@ 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 {
-41
View File
@@ -1,41 +0,0 @@
//! Replay playback's state in a `Game`. Recording a session's packets as `.mcpr` and playing them
//! back is platform (docs/layers.md, "Replays"); Studio's editor (`studio.rs`) builds on it.
use super::Game;
use horizon_client::studio::ReplayDriver;
/// The packet replay a `Game` plays, if any, and its playback clock.
#[derive(Default)]
pub(super) struct Replay {
/// The packet replay playing, if any.
pub(super) driver: Option<ReplayDriver>,
/// Camera perspective to restore after leaving replay playback.
pub(super) previous_camera_type: Option<super::perspective::CameraType>,
/// Replay opened from the title screen owns the synthetic remote session.
pub(super) standalone: bool,
pub(super) last_tick: Option<std::time::Instant>,
pub(super) virtual_tick_ms: f32,
}
impl Replay {
/// A replay plays: the world shown is the recording's.
pub(super) fn playing(&self) -> bool {
self.driver.is_some()
}
/// How far the replay's player is between its last two ticks, while a replay plays: it
/// moves on the replay's clock, not the game's.
pub(super) fn player_alpha(&self) -> Option<f64> {
self.playing()
.then(|| (f64::from(self.virtual_tick_ms) / 50.0).clamp(0.0, 1.0))
}
}
impl Game<'_> {
/// How far the window's own player model is between two ticks: on the replay's clock while a
/// replay plays, else the game's.
pub(super) fn own_player_alpha(&self) -> f64 {
self.replay
.player_alpha()
.unwrap_or_else(|| self.clock.camera_fraction())
}
}
@@ -29,13 +29,12 @@ 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, pitch and whether it is forced.
pub(super) type SavedRespawn = (String, [i32; 3], f32, f32, bool);
/// block, yaw and whether it is forced.
pub(super) type SavedRespawn = (String, [i32; 3], f32, bool);
impl RespawnConfig {
/// `RespawnData.of`: the yaw wrapped to [-180, 180).
@@ -44,37 +43,19 @@ impl RespawnConfig {
dim,
pos,
yaw: wrap_degrees(f64::from(yaw)) as f32,
pitch: 0.0,
forced,
}
}
/// `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 {
pub(super) fn from_saved((dim, pos, yaw, 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.pitch,
self.forced,
)
(self.dim.id().to_owned(), self.pos, self.yaw, self.forced)
}
}
@@ -199,7 +180,6 @@ impl Game<'_> {
dim: self.dim,
pos,
yaw: 0.0,
pitch: 0.0,
forced: false,
};
// RespawnConfig.isSamePosition: dimension and block.
@@ -498,14 +478,14 @@ impl Game<'_> {
rule.can_set_spawn.test(self.dark_outside())
}
/// `ServerPlayer.findRespawnAndUseSpawnBlock` for a config in the loaded
/// dimension: position and angles, using an anchor's charge; a bed's
/// dimension: the position and yaw, 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, f32)>> {
) -> Result<Option<(Vec3, 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)
@@ -515,7 +495,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), 0.0)));
return Ok(stand.map(|p| (p, look_at_yaw(p, c.pos))));
}
let [x, y, z] = c.pos;
let free = c.forced
@@ -523,7 +503,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, c.pitch)));
return Ok(free.then_some((at, c.yaw)));
}
let stand = self.anchor_stand_up(c.pos);
if !c.forced
@@ -533,7 +513,7 @@ impl Game<'_> {
{
self.level_set(c.pos, next)?;
}
Ok(stand.map(|p| (p, look_at_yaw(p, c.pos), 0.0)))
Ok(stand.map(|p| (p, look_at_yaw(p, c.pos))))
}
}
@@ -580,354 +560,3 @@ 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]);
}
}
-1
View File
@@ -362,7 +362,6 @@ impl Game<'_> {
path,
size: Some([64, 64]),
scaled: false,
background: true,
});
}
}
+5 -7
View File
@@ -823,7 +823,7 @@ impl Game<'_> {
for (location, rgba) in ready {
self.store_skin(location, rgba);
}
self.client_hooks_frame();
self.cosmetics_frame();
}
/// `Hud.extractTabList`: the player list while the player-list key is
/// held. A local world shows it with a joined LAN guest or a `list`
@@ -900,12 +900,10 @@ impl Game<'_> {
let board = &self.score.board;
let objective = board.display_objective(sb::SLOT_LIST);
let up = |id: u128| upside_down.contains(&id);
// A hook's mark before a name (Horizon's: they play Horizon).
debug_assert!(
self.serving.is_none(),
"a server thread calls no client hook"
);
let marked = horizon_hooks::engine::player_mark;
// Horizon's mark before the names of Horizon players (the cosmetics service's cache).
let horizon = self.horizon_players(players.iter().map(|p| p.uuid));
let horizon_mark = super::cosmetics::MARK;
let marked = |id: u128| horizon.contains(&id).then_some(horizon_mark);
let inputs = TabInputs {
players: &players,
board,
+9 -8
View File
@@ -73,7 +73,7 @@ impl Screen {
| Self::Stats
| Self::Advancements
| Self::WorldOptions
| Self::Friends
| Self::AddedPage
| Self::ServerLinks
| Self::ConfirmLink
| Self::DialogWarning
@@ -92,7 +92,7 @@ impl Screen {
| Self::Stats
| Self::Advancements
| Self::WorldOptions
| Self::Friends
| Self::AddedPage
| Self::Social
| Self::ServerLinks
| Self::ConfirmLink
@@ -191,8 +191,8 @@ fn pause_buttons(
}
/// The icon row: report bugs, feedback, friends, player reporting.
/// `FRIENDS_ICON` (vanilla's `CommonButtons.friends`) opens Horizon's
/// Play with Friends; the first two open their link through
/// `FRIENDS_ICON` (vanilla's `CommonButtons.friends`) opens the page registered at
/// `Place::PauseFriends` (Play with Friends in the official build); the first two open their link through
/// `ConfirmLinkScreen`; player reporting opens `SocialInteractionsScreen`.
const FRIENDS_ICON: usize = 2;
/// `CommonLinks.SNAPSHOT_BUGS_FEEDBACK`, behind `PauseScreen.REPORT_BUGS`.
@@ -312,6 +312,7 @@ impl Game<'_> {
}
pub(super) fn draw_pause_menu(&mut self, batch: &mut HudBatch) {
let friends = self.friends_shown();
let friends_name = super::page_screen::friends_name();
let atlas = &self.gui.atlas;
let (w, h) = (batch.width, batch.height);
ui::menu_background(batch, atlas, 0.0, 0.0, w, h, true);
@@ -373,7 +374,7 @@ impl Game<'_> {
// The icon has no label: name it for controller focus and the
// control socket's widget tree.
if i == FRIENDS_ICON {
horizon_client::widgets::record(super::friends::TITLE, [x, y, 20.0, 20.0], active);
horizon_client::widgets::record(&friends_name, [x, y, 20.0, 20.0], active);
}
ui::button(
batch,
@@ -410,7 +411,7 @@ impl Game<'_> {
batch.sprite_scaled(atlas, sprite, x + 3.0, y + 3.0, 15.0, 15.0, u32::MAX);
if hover {
tooltip = Some(if i == FRIENDS_ICON {
Text::literal(super::friends::title())
Text::literal(friends_name.clone())
} else {
Text::translate(ICON_LABELS[i], &[])
});
@@ -438,7 +439,7 @@ impl Game<'_> {
3 => self.open_stats(),
4 => self.open_advancements(true),
9 => self.open_world_options(),
10 => self.screen = Screen::Friends,
10 => self.screen = Screen::AddedPage,
// DialogConnectionAccess.openDialog(Dialogs.SERVER_LINKS).
11 => {
self.links_ui.scroll = 0.0;
@@ -993,7 +994,7 @@ mod tests {
Screen::Stats,
Screen::Advancements,
Screen::WorldOptions,
Screen::Friends,
Screen::AddedPage,
Screen::ServerLinks,
Screen::ConfirmLink,
Screen::DialogWarning,
@@ -138,7 +138,10 @@ impl Game<'_> {
return Err("a screenshot sequence is already running".into());
}
let renderer = self.renderer.as_ref().ok_or("no renderer yet")?;
if self.pending_screenshot.is_some() || renderer.screenshot.is_some() {
if self.pending_screenshot.is_some()
|| self.ui.screenshot_message.is_some()
|| renderer.screenshot.is_some()
{
return Err("a screenshot is already pending".into());
}
if let Some(refusal) = self.sequence_refusal() {
@@ -230,7 +233,6 @@ impl Game<'_> {
path,
size: None,
scaled: false,
background: false,
});
renderer.screenshot_result = None;
}
@@ -644,14 +644,11 @@ 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, f32)> {
pub(super) fn resolve_server_respawn(&mut self, config: RespawnConfig) -> Option<(Vec3, 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, pitch))) => {
Ok(Some((at, yaw))) => {
if !config.forced && anchor {
let [x, y, z] = config.pos.map(f64::from);
self.audio.play(
@@ -661,7 +658,7 @@ impl Game<'_> {
1.0,
);
}
Some((at, yaw, pitch))
Some((at, yaw))
}
Ok(None) | Err(_) => {
let mut cancel = false;
@@ -799,10 +796,7 @@ 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((at, yaw, pitch))) => {
host.hub.guests[i].pitch = pitch;
(at, yaw)
}
Some(Some(found)) => found,
_ => {
host.hub.missing_respawn_block(trip.conn);
self.respawn_at_world_spawn(host, trip.conn);
@@ -0,0 +1,120 @@
//! The window's world as `horizon_hooks::share` shows it to the crates that share it past the
//! LAN (Play with Friends), and where the engine calls `Hooks::share` and `Hooks::lan_closed`.
use super::{Game, lan::LanSettings};
use horizon_client::text::Text;
use horizon_hooks::share::{LanOptions, ShareView};
/// The window's own player: not the integrated server's thread, not a splitscreen guest (it
/// plays P1's world and can't share it; its leaving doesn't close it).
fn shares(game: &Game<'_>) -> bool {
!game.serving() && !game.options.split_guest
}
/// Play with Friends' host side (game/friends.rs) hears the hooks, until it moves to
/// horizon-friends (spec #14167). Before the hooks are sealed.
#[cfg(feature = "friends")]
pub(crate) fn register_friends() {
if horizon_hooks::register(&super::friends::HOOKS).is_err()
|| horizon_hooks::pages::register(&super::friends::PAGE).is_err()
{
log::warn!("Play with Friends registered after the game started: sharing is off");
}
}
impl Game<'_> {
/// `Hooks::share`, once a client tick (`Minecraft.runTick`, so also while paused).
pub(super) fn share_tick(&mut self) {
if shares(self) {
horizon_hooks::engine::share(&mut World(self));
}
}
/// `Hooks::lan_closed`: the world stopped being open to LAN, or the player left it.
pub(super) fn lan_closed_hooks(&self) {
if shares(self) {
horizon_hooks::engine::lan_closed();
}
}
/// `Hooks::control`: a `horizon-ctl` command a crate answers, with this window's world.
pub(super) fn hooks_control(
&mut self,
words: &[&str],
) -> horizon_client::Result<Option<serde_json::Value>> {
match horizon_hooks::engine::control(words, &mut World(self)) {
None => Ok(None),
Some(Ok(json)) => Ok(Some(serde_json::from_str(&json)?)),
Some(Err(why)) => Err(why.into()),
}
}
/// A singleplayer world runs on this window's server thread, so it can be shared, as Open
/// to LAN publishes it; another server can't be.
pub(super) fn can_share(&self) -> bool {
self.title.is_none() && (self.remote.is_none() || self.integrated.is_some())
}
}
pub(super) struct World<'a, 'g>(&'a mut Game<'g>);
impl ShareView for World<'_, '_> {
fn can_share(&self) -> bool {
self.0.can_share()
}
fn lan_port(&self) -> Option<u16> {
self.0.lan_port()
}
fn open_lan(&mut self, options: LanOptions) -> Result<u16, String> {
self.0
.lan_open(LanSettings {
port: horizon_client::lan::available_port(),
guest_commands: options.guest_commands,
force_game_mode: options.force_game_mode,
})
.map_err(|e| e.to_string())?;
self.0
.lan_port()
.ok_or_else(|| "the world isn't open to LAN".to_owned())
}
fn close_lan(&mut self) {
self.0.lan_close(true);
}
fn players(&self, each: &mut dyn FnMut(&str)) {
let g = &self.0;
if let Some(host) = &g.lan {
for guest in &host.hub.guests {
each(&guest.name);
}
} else if g.integrated.is_some() {
// On the server thread: the other players the server lists.
let me = g.options.player_name();
for name in g.remote_online_names().unwrap_or_default() {
if name != me {
each(&name);
}
}
}
}
fn chat(&mut self, text: &str) {
self.0.system_message(Text::literal(text));
}
fn copy(&mut self, text: &str) {
use horizon_client::clipboard::Clipboard;
self.0.clipboard.set(text);
}
fn open_url(&mut self, url: &str) {
// The system browser, as the folder buttons open their folders.
super::packs::open_uri(url);
}
fn tr(&self, key: &str) -> Option<String> {
horizon_client::lang::has(key).then(|| horizon_client::lang::get(key))
}
}
-2
View File
@@ -261,8 +261,6 @@ impl<'a> Split<'a> {
.get(i.wrapping_sub(1))
.map(|g| g.options.clone()),
);
let seat = self.guests.get(i.wrapping_sub(1)).map_or(1, |g| g.seat);
horizon_hooks::engine::set_seat(seat - 1);
self.turn = i;
}
@@ -126,7 +126,6 @@ impl Split<'_> {
// Until it has a profile, a scratch options file: never P1's.
let options = Self::folder()?.join(".joining/options.txt");
OPTIONS.set(Some(options.clone()));
horizon_hooks::engine::set_seat(seat - 1);
let mut settings = self.host.settings.clone();
if pad.is_some() {
// The Controller setting is P1's own: a player who pressed a pad to join plays
@@ -136,7 +135,6 @@ impl Split<'_> {
}
let game = self.start_guest(seat, &options, settings);
OPTIONS.set(None);
horizon_hooks::engine::set_seat(0);
let mut game = game?;
if let Some(pad) = pad {
// The press that joined it doesn't act in its game.
@@ -301,31 +299,16 @@ impl Split<'_> {
}
let profile = profiles.join(&name);
let options = profile.join("options.txt");
let made = if options.exists() {
None
let mut settings = if options.exists() {
horizon_client::settings::Settings::load(&options)
} else {
std::fs::create_dir_all(&profile)?;
let mut new = self.host.settings.clone();
// The Controller setting is P1's, as on the profile page (`join`).
new.controller.enabled |= self.guests[i].pad.is_some();
// On P1's seat, so the crates' settings stores write P1's values too.
let turn = horizon_hooks::seat();
horizon_hooks::engine::set_seat(0);
new.save(&options);
horizon_hooks::engine::set_seat(turn);
Some(new)
new
};
// The crates' stores load into the guest's seat, before its turn starts below.
let turn = horizon_hooks::seat();
horizon_hooks::engine::set_seat(self.guests[i].seat - 1);
let mut settings = match made {
Some(new) => {
horizon_client::settings::Settings::load_stores(&options);
new
}
None => horizon_client::settings::Settings::load(&options),
};
horizon_hooks::engine::set_seat(turn);
// A profile an earlier join saved with P1's off, or the player's pad would be dead.
settings.controller.enabled |= self.guests[i].pad.is_some();
// P1's own world through memory (`Ok`), else P1's server over the network (`Err`).
@@ -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,8 +15,6 @@ 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
@@ -42,7 +40,6 @@ 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]);
+128 -362
View File
@@ -6,7 +6,7 @@
use super::{Game, PendingWorldEntry, Screen, packs};
use horizon_client::{
Result,
studio::{Metadata, ReplayDriver, TimelineAction},
studio::{Metadata, ReplayDriver},
text::Text,
};
use serde_json::{Value, json};
@@ -15,25 +15,6 @@ use std::{
time::{Duration, SystemTime, UNIX_EPOCH},
};
/// Studio's state in a `Game`: the replay editor's free camera, timeline and top bar. Studio is a
/// Horizon extra (docs/layers.md: the replay editor) on top of the platform's replay
/// (`replay.rs`); the engine only asks it whether the free camera flies.
#[derive(Default)]
pub(super) struct Studio {
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,
}
impl Studio {
/// The free camera flies, apart from the player.
pub(super) fn flying(&self) -> bool {
self.freecam.active
}
}
pub const HELP: &[&str] = &[
"studio record start [NAME] start recording clientbound packets into .mcpr archive",
"studio record stop stop recording and finalize .mcpr archive",
@@ -67,11 +48,6 @@ pub(super) fn studio_screen_opens(screen: Screen, blocked: bool) -> Option<bool>
}
impl Game<'_> {
/// Studio is up (its bars, its camera path, its mouse): a replay plays or the free camera flies.
pub(super) fn studio_up(&self) -> bool {
self.replay.playing() || self.studio.flying()
}
/// Opens the Studio script screen (`Screen::Studio`) over the world.
///
/// Horizon Studio script windows (`ui.open`) use this slot instead of the
@@ -133,13 +109,13 @@ impl Game<'_> {
match words[0] {
"freecam" => match words.get(1).copied() {
Some("on") => {
if self.replay.driver.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,
@@ -155,24 +131,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.replay.driver.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,
@@ -180,16 +156,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",
&[],
@@ -205,15 +181,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()],
@@ -223,14 +199,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", &[])),
@@ -323,7 +299,7 @@ impl Game<'_> {
}
}
Some("pause") => {
if let Some(r) = self.replay.driver.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 {
@@ -331,7 +307,7 @@ impl Game<'_> {
}
}
Some("resume") => {
if let Some(r) = self.replay.driver.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 {
@@ -347,7 +323,7 @@ impl Game<'_> {
return true;
};
if let Ok(spd) = spd_str.parse::<f64>() {
let new_speed = if let Some(r) = self.replay.driver.as_mut() {
let new_speed = if let Some(r) = self.studio_replay.as_mut() {
r.set_speed(spd);
Some(r.speed())
} else {
@@ -381,12 +357,11 @@ impl Game<'_> {
return true;
};
if let Ok(ms) = ms_str.parse::<u32>() {
if self.replay.driver.is_some() {
if self.studio_replay.is_some() {
match self.seek_studio_replay(ms) {
Ok(()) => {
let actual_ms = self
.replay
.driver
.studio_replay
.as_ref()
.map_or(ms, |replay| ms.min(replay.duration_ms()));
let ms_text = actual_ms.to_string();
@@ -425,7 +400,7 @@ impl Game<'_> {
}
}
Some("status") => {
if let Some(r) = self.replay.driver.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);
@@ -460,7 +435,7 @@ impl Game<'_> {
/// Reset playback tick accumulator and align positions on seek.
pub(super) fn reset_studio_playback_ticks(&mut self) {
self.replay.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() {
@@ -477,14 +452,12 @@ 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
.replay
.driver
.studio_replay
.as_ref()
.map_or(0, ReplayDriver::position_ms);
let packets = if self.replay.standalone {
let packets = if self.studio_replay_standalone {
let packets = self
.replay
.driver
.studio_replay
.as_mut()
.expect("replay checked by caller")
.seek_and_collect(target_ms)?;
@@ -498,8 +471,7 @@ impl Game<'_> {
"backward seek requires a standalone replay",
));
}
self.replay
.driver
self.studio_replay
.as_mut()
.expect("replay checked by caller")
.seek_forward(target_ms)?
@@ -507,7 +479,7 @@ impl Game<'_> {
self.dispatch_studio_replay_packets(packets);
self.reset_studio_playback_ticks();
self.replay.last_tick = Some(std::time::Instant::now());
self.last_replay_tick = Some(std::time::Instant::now());
Ok(())
}
@@ -538,19 +510,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.replay.driver.as_mut() else {
self.replay.last_tick = None;
let Some(driver) = self.studio_replay.as_mut() else {
self.last_replay_tick = None;
return Ok(());
};
let now = std::time::Instant::now();
let delta = match self.replay.last_tick {
let delta = match self.last_replay_tick {
Some(last) => now
.saturating_duration_since(last)
.min(Duration::from_millis(250)),
None => Duration::from_millis(16),
};
self.replay.last_tick = Some(now);
self.last_replay_tick = Some(now);
let ready_packets = match driver.advance(delta) {
Ok(pkts) => pkts,
@@ -561,18 +533,16 @@ 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
.replay
.driver
.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
.replay
.driver
.studio_replay
.as_ref()
.is_some_and(|replay| replay.is_paused());
if self.player_model.is_none() {
@@ -580,14 +550,13 @@ impl Game<'_> {
}
if !paused {
let speed = self
.replay
.driver
.studio_replay
.as_ref()
.map_or(1.0, |r| r.speed() as f32)
.max(0.1);
self.replay.virtual_tick_ms += delta.as_secs_f32() * 1000.0 * speed;
while self.replay.virtual_tick_ms >= 50.0 {
self.replay.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) = (
@@ -638,7 +607,7 @@ impl Game<'_> {
m.previous_limb_speed = m.limb_speed;
}
if let Some((position, duration, paused, markers)) =
self.replay.driver.as_ref().map(|replay| {
self.studio_replay.as_ref().map(|replay| {
(
replay.position_ms(),
replay.duration_ms(),
@@ -647,10 +616,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(())
}
@@ -670,10 +639,10 @@ impl Game<'_> {
self.capture(false);
}
TopBarAction::ExitStudio => {
if self.replay.driver.is_some() {
if self.studio_replay.is_some() {
self.close_studio_replay();
} else {
self.studio.freecam.deactivate();
self.studio_freecam.deactivate();
self.capture(false);
}
}
@@ -699,46 +668,42 @@ 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);
}
}
}
@@ -751,97 +716,93 @@ impl Game<'_> {
use horizon_client::studio::TimelineAction;
match action {
TimelineAction::PauseForSeek => {
self.studio.timeline.playing = false;
if let Some(replay) = self.replay.driver.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.replay.standalone {
let time_ms = if self.studio_replay_standalone {
time_ms
} else {
let current =
self.replay
.driver
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.replay.driver.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.replay.driver.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.replay.driver.as_mut() else {
let Some(replay) = self.studio_replay.as_mut() else {
return;
};
let label = Text::translate(
@@ -850,7 +811,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}"),
}
}
@@ -884,7 +845,7 @@ impl Game<'_> {
Ok(Some(res))
}
Some("pause") => {
if let Some(r) = self.replay.driver.as_mut() {
if let Some(r) = self.studio_replay.as_mut() {
r.set_paused(true);
Ok(Some(json!({ "ok": true, "paused": true })))
} else {
@@ -892,7 +853,7 @@ impl Game<'_> {
}
}
Some("resume") => {
if let Some(r) = self.replay.driver.as_mut() {
if let Some(r) = self.studio_replay.as_mut() {
r.set_paused(false);
Ok(Some(json!({ "ok": true, "paused": false })))
} else {
@@ -902,7 +863,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.replay.driver.as_mut() {
if let Some(r) = self.studio_replay.as_mut() {
r.set_speed(spd);
Ok(Some(json!({ "ok": true, "speed": r.speed() })))
} else {
@@ -912,7 +873,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.replay.driver.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 {
@@ -926,7 +887,7 @@ impl Game<'_> {
}
}
Some("status") => {
if let Some(r) = self.replay.driver.as_ref() {
if let Some(r) = self.studio_replay.as_ref() {
Ok(Some(json!({
"ok": true,
"active": true,
@@ -1038,13 +999,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.replay.driver.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
@@ -1067,7 +1028,7 @@ impl Game<'_> {
}
};
let standalone = self.replay.driver.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()
{
@@ -1077,17 +1038,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.replay.driver.is_none() {
self.replay.previous_camera_type = Some(self.camera_type);
self.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.replay.driver = Some(driver);
self.studio_replay = Some(driver);
self.camera_type = super::perspective::CameraType::FirstPerson;
self.title = None;
self.screen = Screen::Playing;
@@ -1105,14 +1066,14 @@ impl Game<'_> {
}))
}
pub(super) fn close_studio_replay(&mut self) -> bool {
if self.studio.camera_track_dirty
&& let Some(driver) = self.replay.driver.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.replay.driver.take().is_some();
self.studio_camera_track_dirty = false;
let closed = self.studio_replay.take().is_some();
if !closed {
return false;
}
@@ -1120,23 +1081,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.replay.standalone
let standalone = self.studio_replay_standalone
|| self
.remote
.as_ref()
.is_some_and(|remote| remote.brand == "horizon-studio");
self.replay.standalone = false;
if let Some(camera_type) = self.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.replay.last_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.replay.virtual_tick_ms = 0.0;
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.capture(false);
if standalone {
if let Some(mut remote) = self.remote.take() {
@@ -1282,198 +1243,3 @@ impl Game<'_> {
);
}
}
/// What the engine's frame, render and window code hand to Studio.
impl Game<'_> {
/// The top bar and the timeline over the HUD, while Studio is up in play.
pub(super) fn draw_studio_bars(&mut self, batch: &mut super::HudBatch) {
if self.ui.hide_gui || self.screen != Screen::Playing || !self.studio_up() {
return;
}
self.studio.topbar.render(
batch,
&self.gui.atlas,
self.cursor,
self.studio.freecam.locked_to_path,
);
self.studio
.timeline
.render(batch, &self.gui.atlas, self.cursor, &self.studio.freecam);
}
/// The camera path among the debug lines, while Studio is up.
pub(super) fn studio_camera_path(&self, gizmos: &mut horizon_client::gizmos::Gizmos) {
if self.studio_up() {
horizon_client::studio::render_camera_path(
&self.studio.freecam.track,
gizmos,
None,
100,
);
}
}
/// The pointer moved while the timeline's playhead or a keyframe is dragged: Studio took it.
pub(super) fn studio_mouse_dragged(&mut self, width: f32) -> bool {
if self.screen != Screen::Playing
|| !(self.studio.timeline.dragging_playhead
|| self.studio.timeline.dragging_keyframe.is_some())
{
return false;
}
let action =
self.studio
.timeline
.handle_mouse_drag(self.cursor, width, &mut self.studio.freecam);
if matches!(action, TimelineAction::KeyframeMoved) {
self.studio.camera_track_dirty = true;
}
true
}
/// A mouse button while Studio is up: true when Studio took it (its bars, the right button's
/// mouse look, every click while a replay plays).
pub(super) fn studio_mouse_input(
&mut self,
pressed: bool,
button: winit::event::MouseButton,
) -> bool {
if !pressed && button == winit::event::MouseButton::Left {
let action = self.studio.timeline.handle_mouse_up();
if !matches!(action, TimelineAction::None) {
self.handle_studio_timeline_action(action);
}
}
if (self.studio.freecam.active || self.replay.driver.is_some())
&& button == winit::event::MouseButton::Right
{
if let Some(renderer) = &self.renderer {
let batch = super::HudBatch::new([renderer.config.width, renderer.config.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(
self.cursor,
batch.width,
batch.height,
&self.studio.freecam,
)
{
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;
}
return true;
}
}
if pressed {
self.studio.topbar.close_dropdown();
self.studio.rmb_down = true;
self.capture(true);
return true;
} else if self.studio.rmb_down {
self.studio.rmb_down = false;
self.capture(false);
return true;
}
}
if pressed
&& button == winit::event::MouseButton::Left
&& self.screen == Screen::Playing
&& (self.replay.driver.is_some() || self.studio.freecam.active)
{
let size = self
.renderer
.as_ref()
.map(|renderer| [renderer.config.width, renderer.config.height])
.unwrap_or([1, 1]);
let batch = super::HudBatch::new(size);
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);
if !matches!(topbar_action, horizon_client::studio::TopBarAction::None) {
self.handle_studio_topbar_action(topbar_action);
return true;
}
if had_open_menu || self.cursor.1 <= self.studio.topbar.height {
return true;
}
if self.studio.timeline.visible {
let action = self.studio.timeline.handle_mouse_down(
self.cursor,
batch.width,
batch.height,
&self.studio.freecam,
);
if !matches!(action, TimelineAction::None) {
self.handle_studio_timeline_action(action);
return true;
}
if self.cursor.1 >= batch.height - self.studio.timeline.height() {
return true;
}
}
}
if self.screen == Screen::Playing && self.replay.driver.is_some() {
return true;
}
false
}
/// The free camera flies by the movement keys, once per frame.
pub(super) fn studio_fly(&mut self, elapsed: Duration) {
if !self.studio.flying() || self.screen != Screen::Playing || self.dialog_open() {
return;
}
use winit::keyboard::KeyCode;
let held = |key| f64::from(u8::from(self.keys.raw(&key)));
let forward = held(KeyCode::KeyW) - held(KeyCode::KeyS);
let strafe = held(KeyCode::KeyD) - held(KeyCode::KeyA);
let vertical = held(KeyCode::KeyE) - held(KeyCode::KeyQ) + held(KeyCode::Space);
let fast = self.keys.raw(&KeyCode::ControlRight);
self.studio.freecam.update_flight_motion(
forward,
strafe,
vertical,
fast,
elapsed.as_secs_f64().min(0.1),
);
}
/// A key pressed while a replay plays: Studio's shortcuts, and nothing else reaches play.
pub(super) fn studio_key(&mut self, key: winit::keyboard::KeyCode) {
use winit::keyboard::KeyCode;
match key {
KeyCode::Escape => {
self.close_studio_replay();
}
KeyCode::KeyP => {
self.pause_focus = None;
self.screen = Screen::Paused;
self.pause_menu = true;
self.capture(false);
}
KeyCode::KeyF => self.handle_studio_timeline_action(TimelineAction::ToggleFreecam),
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;
}
KeyCode::KeyK => self.handle_studio_timeline_action(TimelineAction::AddCameraKeyframe),
KeyCode::Delete => {
if let Some(index) = self.studio.timeline.selected_keyframe {
self.handle_studio_timeline_action(TimelineAction::RemoveCameraKeyframe(index));
}
}
_ => {}
}
}
}
+1 -1
View File
@@ -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.input_feedback(horizon_client::input_source::Feedback::Totem);
self.pad_rumble(horizon_client::gamepad::Rumble::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
+1 -1
View File
@@ -298,7 +298,7 @@ impl super::Game<'_> {
.into_iter()
.any(|action| self.keys.held(action))
|| self
.input_move()
.pad_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.input_made_pointer_event()
) && !self.pad.menu.synthetic
{
self.input_keyboard_used();
self.pad.keyboard_took_over = true;
}
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.replay.playing() {
if self.studio_replay.is_some() {
self.close_studio_replay();
return Ok(());
}
-43
View File
@@ -165,22 +165,6 @@ 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> {
@@ -209,33 +193,6 @@ 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() {
+4 -4
View File
@@ -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 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)>,
/// 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)>,
/// Pending ClientboundPlayerPositionPacket id; movement is ignored until
/// the client accepts it (ServerGamePacketListenerImpl.awaitingPositionFromClient).
pub awaiting_teleport: Option<(i32, [f64; 3])>,
+1 -1
View File
@@ -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, f32, bool)>)>,
spawn: Option<(Option<[f64; 3]>, Option<(String, [i32; 3], 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.
+20 -3
View File
@@ -771,9 +771,26 @@ fn swim(p: &mut Poses, pos: f32, h: &Hum) {
lerp(amount, x, 0.3 * (pos * 0.333_333_34).mcos()),
);
}
/// A pose laid over the player model's body after its vanilla animation, and the parts it poses:
/// the hook's own types (`horizon_hooks::players`), kept here under their old path.
pub use horizon_hooks::players::{BODY_PARTS, BodyPose};
/// The player model's parts a [`BodyPose`] poses, in its order.
pub const BODY_PARTS: [&str; 6] = [
"head",
"body",
"right_arm",
"left_arm",
"right_leg",
"left_leg",
];
/// A pose a higher layer lays over the player model's body after its vanilla animation (Horizon's
/// emotes, docs/cosmetics.md), handed to the model through [`Mob::emote`]. The engine names no
/// type of that layer (docs/layers.md); it downcasts its own back through [`std::any::Any`].
pub trait BodyPose: std::any::Any + std::fmt::Debug + Send + Sync {
/// Poses the [`BODY_PARTS`] at `age` (the mob's ticks): `at_rest` holds each part before the
/// animation, `poses` as the animation left it; a part the model lacks holds the identity in
/// both and is not written back. False when it poses nothing at `age` (`poses` is then
/// ignored).
fn pose(&self, age: f32, at_rest: &[[f32; 9]; 6], poses: &mut [[f32; 9]; 6]) -> bool;
}
/// Apply the mob's model animation to `p` (at rest), then the pose laid over it, if any.
pub fn setup(
+9 -111
View File
@@ -246,9 +246,7 @@ 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. The villagers come
// through the same `attackTargeting` (`forCombat().range(64)`), so the
// golem must see them too.
// villager's gossips about that player's own UUID.
let bad = cx.w.others_near(near).any(|s| {
s.species == Species::Villager
&& s.alive
@@ -257,7 +255,6 @@ 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)
}
@@ -325,39 +322,6 @@ 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 {
@@ -370,10 +334,16 @@ impl Goal for OfferFlower {
MOVE | LOOK
}
fn can_use(&mut self, m: &mut Mob, cx: &mut Cx) -> bool {
if !bright_outside(cx.w) || m.random.next_int_bound(8000).unwrap_or(1) != 0 {
if !cx.w.daylight || m.random.next_int_bound(8000).unwrap_or(1) != 0 {
return false;
}
self.villager = gift_candidate(m, cx.w);
// 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.is_some()
}
fn can_continue(&mut self, _: &mut Mob, _: &mut Cx) -> bool {
@@ -642,78 +612,6 @@ 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(
@@ -21,12 +21,7 @@ use horizon_core::{
random::RandomSource,
};
use serde_json::Value;
use std::{
borrow::{Borrow, Cow},
collections::HashMap,
fs,
path::Path,
};
use std::{borrow::Cow, collections::HashMap, fs, path::Path};
pub use crate::biome::{SpawnCost, parse_costs, parse_entries};
@@ -884,17 +879,13 @@ 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<'a, L: NaturalLevel, I>(
pub fn new<L: NaturalLevel>(
l: &L,
spawnable_chunks: i32,
players: Vec<SpawnPlayer>,
entities: I,
entities: &[Counted],
chunk_loaded: impl Fn(i32, i32) -> bool,
) -> Self
where
I: IntoIterator,
I::Item: std::borrow::Borrow<Counted<'a>>,
{
) -> Self {
let mut state = Self {
spawnable_chunks,
counts: [0; 8],
@@ -907,7 +898,6 @@ impl SpawnState {
last_checked: None,
};
for e in entities {
let e = e.borrow();
if e.is_mob && e.persistent || e.category == Category::Misc {
continue;
}
@@ -925,10 +915,6 @@ 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));
-17
View File
@@ -149,20 +149,8 @@ pub struct Session {
far: Arc<crate::lod_net::Inbox>,
/// The recording running, if any.
recorder: RecorderSlot,
/// This connection's number, new for each one ([`Session::serial`]).
serial: u64,
}
/// The next [`Session::serial`].
static NEXT_SERIAL: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
fn next_serial() -> u64 {
NEXT_SERIAL.fetch_add(1, Ordering::Relaxed)
}
impl Session {
/// A number of its own for each connection, never 0: entity ids are per connection, so what
/// is kept by id goes when it changes (`horizon_hooks::players::PlayerModels::connection`).
pub fn serial(&self) -> u64 {
self.serial
}
/// The negotiated wire release, once the connection has been established.
pub fn server_version(&self) -> horizon_protocol::adapter::ServerVersion {
self.writer.lock().unwrap().as_ref().map_or(
@@ -268,7 +256,6 @@ impl Session {
debug: false,
far: Arc::new(crate::lod_net::Inbox::default()),
recorder: Arc::new(Mutex::new(None)),
serial: next_serial(),
}
}
fn start(
@@ -346,7 +333,6 @@ impl Session {
debug,
far,
recorder,
serial: next_serial(),
}
}
/// Everything received since the last poll.
@@ -1843,7 +1829,6 @@ mod tests {
debug: false,
far: Arc::default(),
recorder: Arc::new(Mutex::new(None)),
serial: next_serial(),
};
let started = std::time::Instant::now();
if explicit_disconnect {
@@ -1947,7 +1932,6 @@ mod tests {
debug: false,
far: Arc::default(),
recorder: Arc::new(Mutex::new(None)),
serial: next_serial(),
};
let (thread_writer, thread_configuring, thread_recorder) = (
session.writer.clone(),
@@ -2231,7 +2215,6 @@ mod tests {
debug: false,
far: Arc::default(),
recorder: Arc::new(Mutex::new(None)),
serial: next_serial(),
};
let recorder = TestRecorder::default();
session.start_recording(recorder.clone(), |_| {});
@@ -1,29 +1,12 @@
//! Horizon's own entries in the multiplayer list: the official server, added once at the top,
//! and the withdrawn partner server, taken back out once.
//!
//! A Horizon crate (docs/layers.md): it plugs into the game through the platform's hooks
//! ([`HOOKS`], when the game starts), which the official executable (`horizon-official`)
//! registers, and reads servers.dat through `horizon_platform::servers`. horizon-client's plain
//! executable adds no server. Its addresses are in `horizon-addresses`.
#![forbid(unsafe_code)]
use horizon_platform::hooks::{Hooks, Started};
use horizon_platform::servers::{self, ServerEntry};
//! Horizon's own entries in the multiplayer list (docs/layers.md: Horizon layer): the official
//! server, added once at the top, and the withdrawn partner server, taken back out once. Behind
//! `horizon-extras` (docs/forking.md), so a plain build adds no server and holds no Horizon
//! address. It reads and writes servers.dat only through `horizon_protocol::servers`, ready to
//! leave the engine crates for a Horizon crate.
use horizon_protocol::servers::{self, ServerEntry};
use std::path::Path;
/// The server list's hooks, for `horizon_platform::hooks::register`.
pub static HOOKS: OfficialServers = OfficialServers;
pub struct OfficialServers;
impl Hooks for OfficialServers {
/// Someone is playing (not a server, a benchmark or an automated run): their list, once.
fn started(&self, game: &Started<'_>) {
update(game.game_dir);
}
}
/// At the start of a player's game: the official server into the list in `game_dir` and the
/// At startup of a player's game: the official server into the list in `game_dir` and the
/// withdrawn partner server out of it, a failure only logged.
pub fn update(game_dir: &Path) {
if let Err(e) = add_official_server(game_dir) {
@@ -34,8 +17,12 @@ pub fn update(game_dir: &Path) {
}
}
/// Horizon's official survival server: a name and an address like any entry.
pub const OFFICIAL_SERVER: (&str, &str) = ("Horizon Official", horizon_addresses::OFFICIAL_SERVER);
/// Horizon's official survival server: a name and an address like any entry. The address is
/// `horizon_telemetry::server_status::OFFICIAL_ADDRESS`, the one place it is kept.
pub const OFFICIAL_SERVER: (&str, &str) = (
"Horizon Official",
horizon_telemetry::server_status::OFFICIAL_ADDRESS,
);
/// Beside servers.dat once the official server was offered, so a player who
/// deletes it never gets it back: the address offered, so a new one moves the
/// list once. The launcher writes the same file.
@@ -68,7 +55,7 @@ pub fn add_official_server(game_dir: &Path) -> std::io::Result<()> {
let file = game_dir.join("servers.dat");
let _guard = servers::lock();
let mut list = servers::load(&file);
let previous = horizon_addresses::OFFICIAL_SERVER_PREVIOUS;
let previous = horizon_telemetry::server_status::OFFICIAL_PREVIOUS;
if move_official(&mut list, address, previous) {
servers::save(&file, &list)?;
}
@@ -84,10 +71,7 @@ pub fn add_official_server(game_dir: &Path) -> std::io::Result<()> {
/// Afri-Network, which the game added once below the official server (#9299) until the
/// partnership ended: the name and address the game gave its entry.
const WITHDRAWN_PARTNER: (&str, &str) = (
"Afri-Network (South Africa)",
horizon_addresses::WITHDRAWN_PARTNER_SERVER,
);
const WITHDRAWN_PARTNER: (&str, &str) = ("Afri-Network (South Africa)", "afri-network.co.za");
/// Beside servers.dat: the partner addresses the game offered, one per line (#9299).
const PARTNER_MARKER: &str = "horizon_partner_servers.txt";
+29 -10
View File
@@ -13,8 +13,6 @@
//! 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,
@@ -3947,8 +3945,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, pitch and `forced`.
pub respawn_elsewhere: Option<(String, [i32; 3], f32, f32, bool)>,
/// dimension, block, yaw and `forced`.
pub respawn_elsewhere: Option<(String, [i32; 3], f32, bool)>,
pub position: Option<[f64; 3]>,
pub rotation: [f32; 2],
pub health: f32,
@@ -4294,8 +4292,20 @@ impl Level {
if let Some(d) = text(p.get("Dimension")) {
self.dimension = d;
}
if let Some(respawn) = respawn::read(p) {
self.respawn_elsewhere = Some(respawn);
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));
}
let mut inventory = Vec::new();
let mut inventory_components = HashMap::new();
@@ -4526,8 +4536,17 @@ impl Level {
));
}
// A respawn point other than the world spawn: an anchor elsewhere, or a bed.
if let Some(saved) = &self.respawn_elsewhere {
entries.push(("respawn", respawn::write(saved)));
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)));
} else if let Some(s) = self.spawn {
let block = [
s[0].floor() as i32,
@@ -7679,7 +7698,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, 0.0, true))
Some(("minecraft:overworld".into(), [20, 70, 20], 0.0, true))
);
assert!(back.difficulty_locked);
assert!(back.weather.raining);
@@ -7950,7 +7969,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, 0.0, true);
let point = ("minecraft:overworld".to_owned(), pos, 37.0, true);
let level = Level {
respawn_elsewhere: Some(point.clone()),
..Level::default()
Loaded 100 of 255 files, more files were not shown because too many files have changed in this diff. Show more