mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-07 11:06:11 -10:00
Compare commits
136
Commits
No files matched your search
Generated
+1
@@ -1395,6 +1395,7 @@ dependencies = [
|
||||
name = "horizon-bench"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"horizon-telemetry",
|
||||
"serde",
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
@@ -8,3 +8,5 @@ description = "The benchmark report format (docs/benchmark-report.md), shared by
|
||||
[dependencies]
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
# The hardware probes shared with telemetry (cores, clocks, drive, VRAM, power, form).
|
||||
horizon-telemetry = { path = "../horizon-telemetry", default-features = false }
|
||||
@@ -16,8 +16,22 @@ pub struct Hardware {
|
||||
pub cpu: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub cpu_threads: Option<usize>,
|
||||
/// Physical cores.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub cpu_cores: Option<u16>,
|
||||
/// Base and top boost clock, where the OS gives them.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub cpu_base_mhz: Option<u32>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub cpu_boost_mhz: Option<u32>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub ram_mb: Option<u64>,
|
||||
/// The drive under the game folder: `nvme`, `ssd` or `hdd`.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub storage: Option<String>,
|
||||
/// `laptop` or `desktop`.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub form: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub gpu: Option<Gpu>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
@@ -33,6 +47,9 @@ pub struct Gpu {
|
||||
pub backend: String,
|
||||
/// `integrated`, `discrete`, `virtual`, `cpu`, `other`.
|
||||
pub kind: String,
|
||||
/// Its own memory in MiB (rounded to 256), for a discrete GPU whose driver says.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub vram_mb: Option<u32>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
@@ -47,17 +64,50 @@ pub struct Display {
|
||||
|
||||
impl Hardware {
|
||||
pub fn detect() -> Hardware {
|
||||
let probes = probes();
|
||||
Hardware {
|
||||
os: std::env::consts::OS.to_owned(),
|
||||
os_version: os_version(),
|
||||
arch: std::env::consts::ARCH.to_owned(),
|
||||
cpu: cpu(),
|
||||
cpu_threads: std::thread::available_parallelism().ok().map(|n| n.get()),
|
||||
cpu_cores: probes.cores,
|
||||
cpu_base_mhz: probes.base,
|
||||
cpu_boost_mhz: probes.boost,
|
||||
ram_mb: ram_bytes().map(|b| b >> 20),
|
||||
storage: None,
|
||||
form: probes.form,
|
||||
gpu: None,
|
||||
display: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// [`Hardware::detect`] with the kind of drive `game_dir` is on.
|
||||
pub fn detect_in(game_dir: &std::path::Path) -> Hardware {
|
||||
Hardware {
|
||||
storage: horizon_telemetry::system::storage(game_dir).map(str::to_owned),
|
||||
..Hardware::detect()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What the telemetry probes read (`horizon_telemetry::system`).
|
||||
struct Probes {
|
||||
cores: Option<u16>,
|
||||
base: Option<u32>,
|
||||
boost: Option<u32>,
|
||||
form: Option<String>,
|
||||
}
|
||||
|
||||
fn probes() -> Probes {
|
||||
let s = horizon_telemetry::payload::System::detect();
|
||||
let some = |n: u32| (n > 0).then_some(n);
|
||||
Probes {
|
||||
cores: (s.cores > 0).then_some(s.cores),
|
||||
base: some(s.cpu_base_mhz),
|
||||
boost: some(s.cpu_boost_mhz),
|
||||
form: (!s.form.is_empty()).then_some(s.form),
|
||||
}
|
||||
}
|
||||
|
||||
/// The one-minute load average: above about the number of threads the machine was busy.
|
||||
@@ -82,25 +132,7 @@ pub fn load_average() -> Option<f64> {
|
||||
|
||||
/// `ac` or `battery`; `None` where it can't be read (and on machines without a battery on Linux).
|
||||
pub fn power_source() -> Option<&'static str> {
|
||||
if cfg!(target_os = "macos") {
|
||||
let batt = output("pmset", &["-g", "batt"])?;
|
||||
return Some(if batt.contains("'Battery Power'") {
|
||||
"battery"
|
||||
} else {
|
||||
"ac"
|
||||
});
|
||||
}
|
||||
let mut on_battery = None;
|
||||
for entry in std::fs::read_dir("/sys/class/power_supply").ok()?.flatten() {
|
||||
let read =
|
||||
|name: &str| std::fs::read_to_string(entry.path().join(name)).unwrap_or_default();
|
||||
match read("type").trim() {
|
||||
"Mains" if read("online").trim() == "1" => return Some("ac"),
|
||||
"Battery" => on_battery = Some("battery"),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
on_battery
|
||||
horizon_telemetry::system::power()
|
||||
}
|
||||
|
||||
/// Resident memory of a process, in bytes (macOS, Linux).
|
||||
|
||||
@@ -111,6 +111,8 @@ struct World<'a> {
|
||||
slime: f32,
|
||||
starts: Vec<Start>,
|
||||
players: Vec<([f64; 3], String)>,
|
||||
/// Where the non-spectators among them are.
|
||||
nearest: Vec<[f64; 3]>,
|
||||
respawn: Option<[i32; 3]>,
|
||||
unloaded: HashSet<(i32, i32)>,
|
||||
spawn_mobs: bool,
|
||||
@@ -302,19 +304,8 @@ impl NaturalLevel for World<'_> {
|
||||
fn structure_spawns(&self) -> &StructureSpawns {
|
||||
self.s.structures
|
||||
}
|
||||
fn nearest_player_distance_sqr(&self, x: f64, y: f64, z: f64) -> Option<f64> {
|
||||
let mut best: Option<f64> = None;
|
||||
for (p, mode) in &self.players {
|
||||
if mode == "spectator" {
|
||||
continue;
|
||||
}
|
||||
let d = [p[0] - x, p[1] - y, p[2] - z];
|
||||
let d = d[0] * d[0] + d[1] * d[1] + d[2] * d[2];
|
||||
if best.is_none_or(|b| d < b) {
|
||||
best = Some(d);
|
||||
}
|
||||
}
|
||||
best
|
||||
fn nearest_player_positions(&self) -> &[[f64; 3]] {
|
||||
&self.nearest
|
||||
}
|
||||
fn respawn(&self) -> Option<[i32; 3]> {
|
||||
self.respawn
|
||||
@@ -730,6 +721,11 @@ fn main() -> horizon_client::Result<()> {
|
||||
dimension_id: dimension_id.clone(),
|
||||
slime: get("slime", "0.0").parse()?,
|
||||
starts,
|
||||
nearest: players
|
||||
.iter()
|
||||
.filter(|(_, mode)| mode != "spectator")
|
||||
.map(|(p, _)| *p)
|
||||
.collect(),
|
||||
players,
|
||||
respawn,
|
||||
unloaded,
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
//! Random-tick sections companion for tools/test_random_tick_sections_parity.py:
|
||||
//! the game's `RandomTickSections` choosing the sections of a chunk that
|
||||
//! `Behaviours::tick_chunk_random` ticks, after the case's edits (see
|
||||
//! VanillaRandomTickSectionsOracle for the format; the `#resources` line names
|
||||
//! the prepared resources). The level the ticks run against reads as air
|
||||
//! everywhere, so nothing ticks for real: its reads are the drawn positions.
|
||||
use horizon_client::{
|
||||
blocks::BlockFacts,
|
||||
chunk::{Chunk, MIN_Y, Section},
|
||||
random_tick_sections::RandomTickSections,
|
||||
};
|
||||
use horizon_core::{
|
||||
position::{BlockPos, ChunkPos},
|
||||
random::LegacyRandom,
|
||||
};
|
||||
use horizon_world::{
|
||||
fluid::FluidKind,
|
||||
level::{GameRules, LevelAccess, LevelEvent, Ticks, data},
|
||||
registry::BlockStateId,
|
||||
};
|
||||
use std::{cell::RefCell, error::Error, fmt::Write, path::PathBuf, sync::Arc};
|
||||
|
||||
struct Air {
|
||||
reads: RefCell<Vec<BlockPos>>,
|
||||
random: LegacyRandom,
|
||||
speed: i32,
|
||||
}
|
||||
impl LevelAccess for Air {
|
||||
fn block_state(&self, pos: BlockPos) -> BlockStateId {
|
||||
self.reads.borrow_mut().push(pos);
|
||||
BlockStateId(0)
|
||||
}
|
||||
fn set_raw(&mut self, _: BlockPos, _: BlockStateId) -> Option<BlockStateId> {
|
||||
None
|
||||
}
|
||||
fn is_loaded(&self, _: BlockPos) -> bool {
|
||||
true
|
||||
}
|
||||
fn random(&mut self) -> &mut LegacyRandom {
|
||||
&mut self.random
|
||||
}
|
||||
fn game_time(&self) -> i64 {
|
||||
0
|
||||
}
|
||||
fn game_rules(&self) -> GameRules {
|
||||
GameRules {
|
||||
random_tick_speed: self.speed,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
fn emit(&mut self, _: LevelEvent) {}
|
||||
}
|
||||
|
||||
fn main() -> Result<(), Box<dyn Error>> {
|
||||
let args: Vec<String> = std::env::args().collect();
|
||||
let input = std::fs::read_to_string(&args[1])?;
|
||||
let root = input
|
||||
.lines()
|
||||
.find_map(|l| l.strip_prefix("#resources "))
|
||||
.map(PathBuf::from)
|
||||
.ok_or("no #resources line")?;
|
||||
let registry = data::load_registry(&root)?;
|
||||
let (facts, behaviours) = BlockFacts::load_pair(&root, ®istry)?;
|
||||
let mut output = String::new();
|
||||
for line in input.lines().filter(|l| !l.starts_with('#')) {
|
||||
let mut words = line.split(' ');
|
||||
let mut ticks = Ticks::new(0);
|
||||
ticks.random_tick_value = words.next().ok_or("no randValue")?.parse()?;
|
||||
let mut level = Air {
|
||||
reads: RefCell::default(),
|
||||
random: LegacyRandom::new(0),
|
||||
speed: words.next().ok_or("no speed")?.parse()?,
|
||||
};
|
||||
let mut chunk = Arc::new(Chunk::empty(0, 0));
|
||||
let mut cache = RandomTickSections::default();
|
||||
for word in words {
|
||||
let op: Vec<&str> = word.split(':').collect();
|
||||
let int = |i: usize| -> Result<i32, Box<dyn Error>> { Ok(op[i].parse()?) };
|
||||
match op[0] {
|
||||
"s" => {
|
||||
let (section, index) = (int(1)?, int(2)?);
|
||||
Arc::make_mut(&mut chunk).set(
|
||||
index & 15,
|
||||
MIN_Y + section * 16 + (index >> 8),
|
||||
index >> 4 & 15,
|
||||
int(3)? as u32,
|
||||
);
|
||||
}
|
||||
"f" => {
|
||||
let (a, b, m) = (int(2)? as u32, int(3)? as u32, int(4)? as usize);
|
||||
let values = std::array::from_fn(|i| if i % m == m - 1 { b } else { a });
|
||||
Arc::make_mut(&mut Arc::make_mut(&mut chunk).sections[int(1)? as usize])
|
||||
.blocks = Section::from_blocks(&values);
|
||||
}
|
||||
"t" => {
|
||||
output.push('|');
|
||||
level.reads.borrow_mut().clear();
|
||||
behaviours.tick_chunk_random(
|
||||
&mut level,
|
||||
&mut ticks,
|
||||
ChunkPos::new(0, 0),
|
||||
|_, from| cache.get((0, 0), &chunk, &facts).from(from),
|
||||
);
|
||||
for pos in level.reads.borrow().iter() {
|
||||
let state = chunk.block(pos.x, pos.y, pos.z);
|
||||
let fluid = matches!(
|
||||
behaviours.fluid(state).kind(),
|
||||
FluidKind::Lava | FluidKind::FlowingLava
|
||||
);
|
||||
write!(
|
||||
output,
|
||||
" {},{},{},{}{}",
|
||||
pos.x,
|
||||
pos.y,
|
||||
pos.z,
|
||||
if behaviours.is_randomly_ticking(state) {
|
||||
'B'
|
||||
} else {
|
||||
'-'
|
||||
},
|
||||
if fluid { 'F' } else { '-' },
|
||||
)?;
|
||||
}
|
||||
}
|
||||
other => return Err(format!("unknown op {other}").into()),
|
||||
}
|
||||
}
|
||||
writeln!(output, " | {}", ticks.random_tick_value)?;
|
||||
}
|
||||
std::fs::write(&args[2], output)?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -22,6 +22,11 @@ impl PlayerMotion {
|
||||
pub fn refresh_attributes(&mut self) -> Result<()> {
|
||||
live_updates::refresh(self)
|
||||
}
|
||||
/// `Player.internalSetAbsorptionAmount`: the amount as given, unclamped
|
||||
/// (the saved `AbsorptionAmount`, or what a client's server sent).
|
||||
pub fn internal_set_absorption(&mut self, value: f32) -> Result<()> {
|
||||
self.store_absorption(value)
|
||||
}
|
||||
}
|
||||
impl HealthWorld for PlayerMotion {
|
||||
type Error = Box<dyn std::error::Error>;
|
||||
|
||||
@@ -140,7 +140,22 @@ fn run(control: &Control, setup: Setup) -> Result<PathBuf, String> {
|
||||
driver: text(&renderer["driver"]),
|
||||
backend: text(&renderer["backend"]).to_lowercase(),
|
||||
kind: text(&renderer["device_type"]),
|
||||
vram_mb: None,
|
||||
});
|
||||
if let Some(gpu) = report.hardware.gpu.as_mut() {
|
||||
let vendor = horizon_telemetry::system::gpu_vendor(&gpu.name, 0);
|
||||
let vendor = if gpu.vendor.is_empty() {
|
||||
vendor
|
||||
} else {
|
||||
gpu.vendor.as_str()
|
||||
}
|
||||
.to_owned();
|
||||
if gpu.kind.contains("iscrete") {
|
||||
gpu.vram_mb = horizon_telemetry::system::vram_mb(&vendor);
|
||||
}
|
||||
}
|
||||
report.hardware.storage =
|
||||
horizon_telemetry::system::storage(&setup.game_dir).map(str::to_owned);
|
||||
report.hardware.display = serde_json::from_value(title["display"].clone()).ok();
|
||||
|
||||
replace_world(&setup.game_dir)?;
|
||||
@@ -224,6 +239,10 @@ fn run(control: &Control, setup: Setup) -> Result<PathBuf, String> {
|
||||
|
||||
let status = control.status();
|
||||
let renderer = &status["renderer"];
|
||||
// On Wayland the window is on a screen only once it has been shown: ask again.
|
||||
if report.hardware.display.is_none() {
|
||||
report.hardware.display = serde_json::from_value(status["display"].clone()).ok();
|
||||
}
|
||||
let c = &mut report.conditions;
|
||||
c.scenario = Some(setup.scenario.clone());
|
||||
c.seed = Some(SEED.to_string());
|
||||
|
||||
@@ -32,16 +32,18 @@ pub enum Group {
|
||||
Move,
|
||||
Act,
|
||||
Screens,
|
||||
Multiplayer,
|
||||
World,
|
||||
Scripting,
|
||||
}
|
||||
impl Group {
|
||||
pub const ALL: [Group; 7] = [
|
||||
pub const ALL: [Group; 8] = [
|
||||
Group::Session,
|
||||
Group::Observe,
|
||||
Group::Move,
|
||||
Group::Act,
|
||||
Group::Screens,
|
||||
Group::Multiplayer,
|
||||
Group::World,
|
||||
Group::Scripting,
|
||||
];
|
||||
@@ -52,6 +54,7 @@ impl Group {
|
||||
Group::Move => "Move and look",
|
||||
Group::Act => "Act on the world",
|
||||
Group::Screens => "Screens, menus and chat",
|
||||
Group::Multiplayer => "Multiplayer, LAN and friends",
|
||||
Group::World => "World and cheats",
|
||||
Group::Scripting => "Scripting and benchmarks",
|
||||
}
|
||||
@@ -117,6 +120,50 @@ const OVER: Param = flag(
|
||||
"turn with mouse motion spread over this many seconds (eased), as a player would",
|
||||
);
|
||||
const FACE: Ty = Ty::Enum(&["down", "up", "north", "south", "west", "east"]);
|
||||
const BOX: [Param; 6] = [
|
||||
p("x1", Ty::Int, "corner X"),
|
||||
p("y1", Ty::Int, "corner Y"),
|
||||
p("z1", Ty::Int, "corner Z"),
|
||||
p("x2", Ty::Int, "opposite X"),
|
||||
p("y2", Ty::Int, "opposite Y"),
|
||||
p("z2", Ty::Int, "opposite Z"),
|
||||
];
|
||||
const PAGES: Ty = Ty::Enum(&[
|
||||
"main",
|
||||
"video",
|
||||
"sound",
|
||||
"controls",
|
||||
"keybinds",
|
||||
"controller",
|
||||
"controller-binds",
|
||||
"mouse",
|
||||
"chat",
|
||||
"accessibility",
|
||||
"language",
|
||||
"font",
|
||||
"packs",
|
||||
"mods",
|
||||
"graphics",
|
||||
"graphics-advanced",
|
||||
"extras",
|
||||
"horizon",
|
||||
"shader-packs",
|
||||
"agent",
|
||||
]);
|
||||
|
||||
/// `--confirm`, required by destructive commands: the player agreed.
|
||||
const CONFIRM: Param = Param {
|
||||
name: "confirm",
|
||||
ty: Ty::Bool,
|
||||
required: true,
|
||||
flag: true,
|
||||
help: "true once the player agreed: it cannot be undone",
|
||||
};
|
||||
|
||||
/// MCP tools that delete or overwrite the player's things (`destructiveHint`).
|
||||
pub const DESTRUCTIVE: &[&str] = &["world-delete", "fill", "setblock"];
|
||||
/// MCP tools that reach other players or the network (`openWorldHint`): only when the player asks.
|
||||
pub const OPEN_WORLD: &[&str] = &["connect", "lan", "friends", "chat", "command"];
|
||||
|
||||
macro_rules! cmd {
|
||||
($name:expr, $group:ident, $usage:expr, $summary:expr, $details:expr, [$($ex:expr),* $(,)?], $params:expr, mcp: $mcp:expr, local: $local:expr $(, wire: $wire:expr)?) => {
|
||||
@@ -164,7 +211,19 @@ pub const COMMANDS: &[Cmd] = &[
|
||||
&[flag("lines", Ty::Int, "how many lines of latest.log (default 100)"), flag("crash", Ty::Bool, "the newest crash report instead")],
|
||||
mcp: true, local: true),
|
||||
cmd!("status", Session, "status", "game, renderer and streaming state (the scripting socket's original summary)",
|
||||
"", ["horizon-ctl status"], &[], mcp: false, local: false),
|
||||
"Also what F3 shows: the overlay flags, the renderer, chunk streaming and memory.", ["horizon-ctl status"], &[], mcp: true, local: false),
|
||||
cmd!("worlds", Session, "worlds", "the saved worlds, newest first: folder, name, mode, last played, version",
|
||||
"folder is what open, world-rename, world-backup and world-delete take.", ["horizon-ctl worlds"], &[], mcp: true, local: false),
|
||||
cmd!("world-rename", Session, "world-rename FOLDER NAME", "rename a world, as Edit World does (its folder stays)",
|
||||
"Needs the title screen (no world open).", ["horizon-ctl world-rename Test \"My Base\""],
|
||||
&[p("folder", Ty::Str, "world folder from worlds"), p("name", Ty::Rest, "the new name")], mcp: true, local: false),
|
||||
cmd!("world-backup", Session, "world-backup FOLDER", "zip a world into backups/, as Edit World's Make Backup does",
|
||||
"Needs the title screen. Replies once the zip is written, with its size.", ["horizon-ctl world-backup Test"],
|
||||
&[p("folder", Ty::Str, "world folder from worlds")], mcp: true, local: false),
|
||||
cmd!("world-delete", Session, "world-delete FOLDER --confirm", "delete a world for good, as Select World's Delete does",
|
||||
"Needs the title screen and --confirm: ask the player first, it cannot be undone (world-backup keeps a copy).",
|
||||
["horizon-ctl world-delete Test --confirm"],
|
||||
&[p("folder", Ty::Str, "world folder from worlds"), CONFIRM], mcp: true, local: false),
|
||||
cmd!("open", Session, "open WORLD [SEED] [creative]", "open or create a world from the title screen",
|
||||
"Opens saves/WORLD, creating it with SEED (and creative mode) when it doesn't exist.",
|
||||
["horizon-ctl open Test 12345", "horizon-ctl open Build 0 creative"],
|
||||
@@ -190,10 +249,10 @@ pub const COMMANDS: &[Cmd] = &[
|
||||
cmd!("light", Observe, "light X1 Y1 Z1 X2 Y2 Z2", "the stored sky and block light of a box",
|
||||
"At most 32768 blocks per request. One hex digit per block in `sky` and `block`, x fastest, then z, then y; `.` where no chunk is loaded.",
|
||||
["horizon-ctl light 0 64 0 15 70 15"],
|
||||
&[p("x1", Ty::Int, "corner X"), p("y1", Ty::Int, "corner Y"), p("z1", Ty::Int, "corner Z"), p("x2", Ty::Int, "opposite X"), p("y2", Ty::Int, "opposite Y"), p("z2", Ty::Int, "opposite Z")], mcp: true, local: false),
|
||||
&BOX, mcp: true, local: false),
|
||||
cmd!("blocks", Observe, "blocks X1 Y1 Z1 X2 Y2 Z2", "the non-air blocks of a box, with counts per block",
|
||||
"At most 32768 blocks per request.", ["horizon-ctl blocks 0 60 0 15 70 15"],
|
||||
&[p("x1", Ty::Int, "corner X"), p("y1", Ty::Int, "corner Y"), p("z1", Ty::Int, "corner Z"), p("x2", Ty::Int, "opposite X"), p("y2", Ty::Int, "opposite Y"), p("z2", Ty::Int, "opposite Z")], mcp: true, local: false),
|
||||
&BOX, mcp: true, local: false),
|
||||
cmd!("find-block", Observe, "find-block BLOCK [RADIUS]", "the nearest loaded blocks of a kind",
|
||||
"Searches a cube of RADIUS (default 32, at most 64) around the player; returns the count and the 16 nearest.",
|
||||
["horizon-ctl find-block oak_log", "horizon-ctl find-block diamond_ore 48"],
|
||||
@@ -264,12 +323,15 @@ pub const COMMANDS: &[Cmd] = &[
|
||||
"down holds the key until key ... up, release or stop; wait does not release it. move, goto, jump, break and the other actions release the movement keys when they end.",
|
||||
["horizon-ctl key space tap"], &[p("key", Ty::Str, "key name"), p("action", Ty::Enum(&["down", "up", "tap", "repeat"]), "what to do")], mcp: true, local: false),
|
||||
cmd!("mouse", Act, "mouse left|right|middle down|up|click", "press or release a mouse button in the world", "",
|
||||
["horizon-ctl mouse right click"], &[p("button", Ty::Enum(&["left", "right", "middle"]), "button"), p("action", Ty::Enum(&["down", "up", "click"]), "what to do")], mcp: false, local: false),
|
||||
["horizon-ctl mouse right click"], &[p("button", Ty::Enum(&["left", "right", "middle"]), "button"), p("action", Ty::Enum(&["down", "up", "click"]), "what to do")], mcp: true, local: false),
|
||||
cmd!("interact-entity", Act, "interact-entity ID", "right-click an entity: trade with a villager, mount a horse, ride a boat, shear a sheep",
|
||||
"Faces the entity (from entities), right-clicks it with the held item, waits two ticks and replies with the screen.",
|
||||
["horizon-ctl interact-entity 42"], &[p("id", Ty::Int, "entity id from entities")], mcp: true, local: true),
|
||||
cmd!("pad", Act, "pad BUTTON down|up|tap | pad stick left|right X Y | pad trigger left|right V | pad release | pad status", "a virtual controller: buttons, sticks and triggers as a real pad would send them",
|
||||
"Buttons by position: south east west north lb rb lt rt select start ls rs up down left right (south is Xbox A / PlayStation cross). Sticks run -1..1 with up positive and stay tilted until centred; triggers 0..1. Merged with any real controller; status lists the connected ones and what is held.",
|
||||
["horizon-ctl pad south tap", "horizon-ctl pad stick left 0 1", "horizon-ctl pad trigger right 1", "horizon-ctl pad status"],
|
||||
&[p("what", Ty::Str, "a button name, stick, trigger, release or status"), opt("a", Ty::Str, "down|up|tap, or left|right"), opt("b", Ty::Num, "stick X or trigger value"), opt("c", Ty::Num, "stick Y")], mcp: false, local: false),
|
||||
cmd!("release", Act, "release", "release every key and mouse button", "", ["horizon-ctl release"], &[], mcp: false, local: false),
|
||||
&[p("what", Ty::Str, "a button name, stick, trigger, release or status"), opt("a", Ty::Str, "down|up|tap, or left|right"), opt("b", Ty::Num, "stick X or trigger value"), opt("c", Ty::Num, "stick Y")], mcp: true, local: false),
|
||||
cmd!("release", Act, "release", "release every key and mouse button", "", ["horizon-ctl release"], &[], mcp: true, local: false),
|
||||
cmd!("stop", Act, "stop", "cancel the running action (goto, break...) and release every key", "", ["horizon-ctl stop"], &[], mcp: true, local: false),
|
||||
// Screens
|
||||
cmd!("open-inventory", Screens, "open-inventory", "open the inventory screen (crafting grid 100-103, result 200)", "",
|
||||
@@ -317,7 +379,31 @@ pub const COMMANDS: &[Cmd] = &[
|
||||
cmd!("respawn", Screens, "respawn", "respawn from the death screen", "", ["horizon-ctl respawn"], &[], mcp: true, local: false),
|
||||
cmd!("pause", Screens, "pause", "open the pause menu", "", ["horizon-ctl pause"], &[], mcp: true, local: false),
|
||||
cmd!("resume", Screens, "resume", "leave the pause menu", "", ["horizon-ctl resume"], &[], mcp: true, local: false),
|
||||
cmd!("options", Screens, "options [PAGE]", "open the options screen, or one of its pages (Horizon's too)",
|
||||
"Then ui lists its widgets and click, set and type change them, as with the mouse. options-set changes an options.txt value without the screen.",
|
||||
["horizon-ctl options video", "horizon-ctl options horizon"], &[opt("page", PAGES, "the page (default: the main options)")], mcp: true, local: false),
|
||||
cmd!("book", Screens, "book [next|back|take|done]", "the open book or lectern: its page text, or press one of its buttons",
|
||||
"", ["horizon-ctl book", "horizon-ctl book next"], &[opt("action", Ty::Enum(&["next", "back", "take", "done"]), "a button to press")], mcp: true, local: false),
|
||||
cmd!("signedit", Screens, "signedit X Y Z [front|back]", "open the sign editor of the sign at X Y Z (then type and close)",
|
||||
"", ["horizon-ctl signedit 3 64 1"], &[BLOCK_XYZ[0], BLOCK_XYZ[1], BLOCK_XYZ[2], opt("side", Ty::Enum(&["front", "back"]), "which side")], mcp: true, local: false),
|
||||
cmd!("mount-menu", Screens, "mount-menu", "open the inventory of the horse, donkey, mule or llama being ridden",
|
||||
"As the inventory key does while riding; also opens the nearest one's.", ["horizon-ctl mount-menu"], &[], mcp: true, local: false),
|
||||
cmd!("chat-log", Screens, "chat-log [N]", "the newest N chat messages", "", ["horizon-ctl chat-log 5"], &[], mcp: false, local: false),
|
||||
// Multiplayer
|
||||
cmd!("connect", Multiplayer, "connect ADDRESS [NAME]", "join a multiplayer server, or a friend's world by its invite code, from the title screen",
|
||||
"ADDRESS is HOST[:PORT], or friend:CODE for an invite code. Only when the player asks: it reaches the network.",
|
||||
["horizon-ctl connect play.example.net", "horizon-ctl connect localhost:25565", "horizon-ctl connect friend:0B33K-Q405Q-..."],
|
||||
&[p("address", Ty::Str, "HOST[:PORT] or friend:CODE"), opt("name", Ty::Str, "player name when offline")], mcp: true, local: false),
|
||||
cmd!("disconnect", Multiplayer, "disconnect", "leave the server, back to the server list", "", ["horizon-ctl disconnect"], &[], mcp: true, local: false),
|
||||
cmd!("servers", Multiplayer, "servers", "the Multiplayer screen's server list: name and address",
|
||||
"Add, edit and remove them on the Multiplayer screen (click-button Multiplayer, then ui).", ["horizon-ctl servers"], &[], mcp: true, local: false),
|
||||
cmd!("lan", Multiplayer, "lan open [PORT] [commands] [force] | lan close | lan status", "open the world to LAN (Open to LAN), stop, or see the guests",
|
||||
"PORT 0 or none picks one; commands lets guests use cheats; force forces the game mode. Only when the player asks.",
|
||||
["horizon-ctl lan open", "horizon-ctl lan status", "horizon-ctl lan close"],
|
||||
&[p("action", Ty::Enum(&["open", "close", "status"]), "what to do"), opt("port", Ty::Int, "port for open"), opt("commands", Ty::Bool, "allow cheats"), opt("force", Ty::Bool, "force the game mode")], mcp: true, local: false),
|
||||
cmd!("friends", Multiplayer, "friends host|stop|status", "Play with Friends: share the open world behind an invite code, stop, or see who joined",
|
||||
"status gives the code: share it only when the player asks. Friends join with connect friend:CODE.",
|
||||
["horizon-ctl friends host", "horizon-ctl friends status"], &[p("action", Ty::Enum(&["host", "stop", "status"]), "what to do")], mcp: true, local: false),
|
||||
// World
|
||||
cmd!("give", World, "give ITEM [COUNT]", "add items to the inventory", "", ["horizon-ctl give oak_log 16"],
|
||||
&[p("item", Ty::Str, "item id"), opt("count", Ty::Int, "how many (default 1)")], mcp: true, local: false),
|
||||
@@ -325,8 +411,20 @@ pub const COMMANDS: &[Cmd] = &[
|
||||
&[p("ticks", Ty::Int, "day time")], mcp: true, local: false),
|
||||
cmd!("setblock", World, "setblock X Y Z BLOCK[props]", "set a block (with neighbour updates)", "", ["horizon-ctl setblock 0 64 0 stone"],
|
||||
&[BLOCK_XYZ[0], BLOCK_XYZ[1], BLOCK_XYZ[2], p("block", Ty::Str, "block state such as oak_stairs[facing=east]")], mcp: true, local: false),
|
||||
cmd!("summon", World, "summon KIND [X Y Z] [baby]", "spawn a mob", "", ["horizon-ctl summon cow"], &[p("kind", Ty::Str, "mob type")], mcp: true, local: false),
|
||||
cmd!("effect", World, "effect ID SECONDS [AMPLIFIER] | effect clear", "give a status effect", "", ["horizon-ctl effect speed 30 1"], &[], mcp: false, local: false),
|
||||
cmd!("summon", World, "summon KIND [X Y Z] [baby]", "spawn a mob (in front of the player without a position)", "Replies with its id.", ["horizon-ctl summon cow", "horizon-ctl summon pig 10 64 3 baby"],
|
||||
&[p("kind", Ty::Str, "mob type"), opt("x", Ty::Num, "X"), opt("y", Ty::Num, "Y"), opt("z", Ty::Num, "Z"), opt("baby", Ty::Bool, "a baby")], mcp: true, local: false),
|
||||
cmd!("effect", World, "effect ID SECONDS [AMPLIFIER] | effect clear", "give a status effect, or clear them all", "", ["horizon-ctl effect speed 30 1", "horizon-ctl effect clear"],
|
||||
&[p("id", Ty::Str, "effect id such as speed, or clear"), opt("seconds", Ty::Num, "duration"), opt("amplifier", Ty::Int, "level - 1 (default 0)")], mcp: true, local: false),
|
||||
cmd!("effects", World, "effects", "the player's active status effects", "", ["horizon-ctl effects"], &[], mcp: true, local: false),
|
||||
cmd!("xp", World, "xp LEVELS", "add experience levels (negative removes)", "", ["horizon-ctl xp 30"], &[p("levels", Ty::Int, "levels to add")], mcp: true, local: false),
|
||||
cmd!("enchant", World, "enchant ID LEVEL", "enchant the held item (a book stores it)", "", ["horizon-ctl enchant sharpness 5"],
|
||||
&[p("id", Ty::Str, "enchantment id"), p("level", Ty::Int, "level")], mcp: true, local: false),
|
||||
cmd!("potion", World, "potion ITEM POTION", "give a potion item", "", ["horizon-ctl potion splash_potion healing"],
|
||||
&[p("item", Ty::Enum(&["potion", "splash_potion", "lingering_potion", "tipped_arrow"]), "which item"), p("potion", Ty::Str, "potion id")], mcp: true, local: false),
|
||||
cmd!("fill", World, "fill X1 Y1 Z1 X2 Y2 Z2 BLOCK", "set a box of loaded blocks", "", ["horizon-ctl fill 0 64 0 4 64 4 glass"],
|
||||
&[BOX[0], BOX[1], BOX[2], BOX[3], BOX[4], BOX[5], p("block", Ty::Str, "block state")], mcp: true, local: false),
|
||||
cmd!("container", World, "container X Y Z [ITEM COUNT]", "list a container's items, or add items to it", "", ["horizon-ctl container 3 64 1", "horizon-ctl container 3 64 1 diamond 3"],
|
||||
&[BLOCK_XYZ[0], BLOCK_XYZ[1], BLOCK_XYZ[2], opt("item", Ty::Str, "item to add"), opt("count", Ty::Int, "how many")], mcp: true, local: false),
|
||||
cmd!("render-distance", World, "render-distance N", "chunks, 2..64", "", ["horizon-ctl render-distance 8"], &[p("chunks", Ty::Int, "render distance")], mcp: false, local: false),
|
||||
cmd!("wait", World, "wait TICKS", "wait until TICKS game ticks have passed (20 per second)",
|
||||
"Keys and mouse buttons held with key ... down or mouse ... down stay held while it waits. Release them with key ... up, release or stop.",
|
||||
@@ -340,8 +438,8 @@ pub const COMMANDS: &[Cmd] = &[
|
||||
cmd!("mcp", Scripting, "mcp", "serve the Model Context Protocol on stdin/stdout, one tool per command",
|
||||
"Add it to an MCP client as a stdio server: command horizon-ctl, args [\"mcp\"] (with --socket PATH first if needed). The screenshot tool returns the image.",
|
||||
["horizon-ctl mcp"], &[], mcp: false, local: true),
|
||||
cmd!("bench", Scripting, "bench SECONDS", "reset frame stats, wait, reply with the summary", "", ["horizon-ctl bench 10"], &[], mcp: false, local: false),
|
||||
cmd!("stats", Scripting, "stats [reset]", "frame-time summary since the last reset", "", ["horizon-ctl stats"], &[], mcp: false, local: false),
|
||||
cmd!("bench", Scripting, "bench SECONDS", "reset frame stats, wait, reply with the summary", "", ["horizon-ctl bench 10"], &[p("seconds", Ty::Num, "how long to measure")], mcp: true, local: false),
|
||||
cmd!("stats", Scripting, "stats [reset]", "frame-time summary since the last reset", "", ["horizon-ctl stats"], &[opt("reset", Ty::Bool, "start counting again")], mcp: true, local: false),
|
||||
cmd!("fly", Scripting, "fly X1 Y1 Z1 X2 Y2 Z2 SECONDS [PITCH [raw]]", "fly the camera along a line, then reply with the frame summary",
|
||||
"The reply's tick_ms and tick_ms_max are the world's smoothed tick time over the flight (the server thread's when single player runs on one). With raw, the reply also lists every frame time in milliseconds (frame_ms).",
|
||||
["horizon-ctl fly 0 100 0 500 100 0 20"], &[], mcp: false, local: false),
|
||||
@@ -366,8 +464,9 @@ pub fn overview() -> String {
|
||||
}
|
||||
out.push_str(
|
||||
"\nEvery reply is one JSON line. Errors are {\"error\": \"...\"}; the exit status is 0 on\n\
|
||||
success, 1 when the game replied with an error, 2 for a usage error and 3 when the\n\
|
||||
game cannot be reached or started. Actions (goto, break, place...) block until done.\n\
|
||||
success, 1 when the game replied with an error, 2 for a usage error, 3 when the\n\
|
||||
game cannot be reached or started and 4 while No AI is on (the launcher's settings).\n\
|
||||
Actions (goto, break, place...) block until done.\n\
|
||||
horizon-ctl help --game lists the running game's own commands, test hooks included.\n\
|
||||
The socket is $HORIZON_CONTROL, else $XDG_RUNTIME_DIR/horizon-control.sock (on Windows\n\
|
||||
%TEMP%\\horizon-control.json, a localhost TCP port with a token; any path ending in .json\n\
|
||||
@@ -402,6 +501,73 @@ pub fn detail(c: &Cmd) -> String {
|
||||
out
|
||||
}
|
||||
|
||||
/// The generated reference, docs/horizon-ctl-commands.md (`horizon-ctl help --markdown`).
|
||||
pub fn markdown() -> String {
|
||||
let cell = |s: &str| s.replace('|', "\\|");
|
||||
let mut out = String::from(
|
||||
"<!-- Generated by `horizon-ctl help --markdown` from the command table in \
|
||||
crates/horizon-client/src/bin/horizon-ctl/commands.rs: edit the table, then regenerate. -->\n\
|
||||
# horizon-ctl commands\n\n\
|
||||
Every command `horizon-ctl` takes, its MCP tool in `horizon-ctl mcp` (the launcher's \
|
||||
`horizon-launcher mcp` offers the same tools as `game_<tool>`, all but `launch`) and its \
|
||||
arguments. Positional arguments come first, in order; `--flags` after them. \
|
||||
`horizon-ctl help --game` lists the game's own commands, test hooks included. \
|
||||
Design rules and the full inventory: [agent-control.md](agent-control.md).\n",
|
||||
);
|
||||
for group in Group::ALL {
|
||||
out.push_str(&format!(
|
||||
"\n## {}\n\n| Command | MCP tool | What it does | Arguments |\n| --- | --- | --- | --- |\n",
|
||||
group.title()
|
||||
));
|
||||
for c in COMMANDS.iter().filter(|c| c.group == group) {
|
||||
let tool = if c.mcp {
|
||||
let mut t = format!("`{}`", tool_name(c));
|
||||
if DESTRUCTIVE.contains(&c.name) {
|
||||
t.push_str(" (destructive)");
|
||||
} else if OPEN_WORLD.contains(&c.name) {
|
||||
t.push_str(" (network)");
|
||||
}
|
||||
t
|
||||
} else {
|
||||
"CLI only".to_owned()
|
||||
};
|
||||
let args: Vec<String> = c
|
||||
.params
|
||||
.iter()
|
||||
.map(|p| {
|
||||
let ty = match p.ty {
|
||||
Ty::Int => "integer".to_owned(),
|
||||
Ty::Num => "number".to_owned(),
|
||||
Ty::Str => "string".to_owned(),
|
||||
Ty::Rest => "text".to_owned(),
|
||||
Ty::Bool => "boolean".to_owned(),
|
||||
Ty::Enum(v) => v.join(", "),
|
||||
};
|
||||
let name = if p.flag {
|
||||
format!("--{}", p.name)
|
||||
} else {
|
||||
p.name.to_owned()
|
||||
};
|
||||
let need = if p.required { ", required" } else { "" };
|
||||
format!("`{name}` ({ty}{need}): {}", cell(p.help))
|
||||
})
|
||||
.collect();
|
||||
out.push_str(&format!(
|
||||
"| `{}` | {} | {} | {} |\n",
|
||||
cell(c.usage),
|
||||
tool,
|
||||
cell(&capitalize(c.summary)),
|
||||
if args.is_empty() {
|
||||
"none".to_owned()
|
||||
} else {
|
||||
args.join("<br>")
|
||||
}
|
||||
));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn capitalize(s: &str) -> String {
|
||||
let mut chars = s.chars();
|
||||
chars
|
||||
@@ -640,6 +806,43 @@ mod tests {
|
||||
assert_eq!(tool_name(find("look-at-entity").unwrap()), "look_at_entity");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_generated_reference_is_up_to_date() {
|
||||
let path = concat!(
|
||||
env!("CARGO_MANIFEST_DIR"),
|
||||
"/../../docs/horizon-ctl-commands.md"
|
||||
);
|
||||
let on_disk = std::fs::read_to_string(path).unwrap_or_default();
|
||||
assert!(
|
||||
on_disk == markdown(),
|
||||
"docs/horizon-ctl-commands.md is out of date: run `horizon-ctl help --markdown > docs/horizon-ctl-commands.md`"
|
||||
);
|
||||
for c in COMMANDS {
|
||||
assert!(
|
||||
on_disk.contains(&format!("`{}", c.usage.replace('|', "\\|"))),
|
||||
"{} undocumented",
|
||||
c.name
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn destructive_and_network_tools_exist() {
|
||||
for name in DESTRUCTIVE.iter().chain(OPEN_WORLD) {
|
||||
assert!(find(name).is_some_and(|c| c.mcp), "{name}");
|
||||
}
|
||||
let delete = find("world-delete").unwrap();
|
||||
assert_eq!(
|
||||
line_for(delete, &json!({ "folder": "A", "confirm": true })).unwrap(),
|
||||
["world-delete", "A", "--confirm"]
|
||||
);
|
||||
let lan = find("lan").unwrap();
|
||||
assert_eq!(
|
||||
line_for(lan, &json!({ "action": "open", "commands": true })).unwrap(),
|
||||
["lan", "open", "commands"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn help_mentions_every_command() {
|
||||
let text = overview();
|
||||
|
||||
@@ -20,6 +20,8 @@ use serde_json::{Value, json};
|
||||
const GAME_ERROR: u8 = 1;
|
||||
const USAGE: u8 = 2;
|
||||
const UNREACHABLE: u8 = 3;
|
||||
/// No AI is on (`horizon_core::no_ai`): agent control is off.
|
||||
const DENIED: u8 = 4;
|
||||
|
||||
/// A failure, with the exit status it maps to.
|
||||
#[derive(Debug)]
|
||||
@@ -34,6 +36,13 @@ impl Failure {
|
||||
status: USAGE,
|
||||
}
|
||||
}
|
||||
/// No AI is on in the game's home.
|
||||
fn denied() -> Self {
|
||||
Self {
|
||||
reply: json!({ "error": horizon_core::no_ai::REFUSAL, "kind": "denied" }),
|
||||
status: DENIED,
|
||||
}
|
||||
}
|
||||
fn unreachable(message: impl Into<String>) -> Self {
|
||||
Self {
|
||||
reply: json!({ "error": message.into(), "kind": "connection" }),
|
||||
@@ -98,6 +107,8 @@ impl Client {
|
||||
pub struct Session {
|
||||
pub socket: PathBuf,
|
||||
pub timeout: Option<Duration>,
|
||||
/// The game's home, where No AI is read (`horizon_core::no_ai`).
|
||||
pub home: PathBuf,
|
||||
client: Option<Client>,
|
||||
}
|
||||
|
||||
@@ -106,9 +117,14 @@ impl Session {
|
||||
Self {
|
||||
socket,
|
||||
timeout,
|
||||
home: horizon_client::logging::game_home(),
|
||||
client: None,
|
||||
}
|
||||
}
|
||||
/// A failure while No AI is on: every command refuses (help aside).
|
||||
pub fn allowed(&self) -> Result<(), Failure> {
|
||||
horizon_core::no_ai::check(&self.home).map_err(|_| Failure::denied())
|
||||
}
|
||||
fn client(&mut self) -> Result<&mut Client, Failure> {
|
||||
if self.client.is_none() {
|
||||
self.client = Some(Client::connect(&self.socket, self.timeout)?);
|
||||
@@ -137,6 +153,7 @@ impl Session {
|
||||
let Some(name) = words.first().map(String::as_str) else {
|
||||
return Err(Failure::usage("no command; try horizon-ctl help"));
|
||||
};
|
||||
self.allowed()?;
|
||||
if words.iter().skip(1).any(|w| w == "--help" || w == "-h") {
|
||||
return Err(Failure::usage(
|
||||
"use horizon-ctl COMMAND --help outside scripts",
|
||||
@@ -174,6 +191,18 @@ impl Session {
|
||||
ready["launch"] = reply;
|
||||
Ok(ready)
|
||||
}
|
||||
"interact-entity" => {
|
||||
let id = match words {
|
||||
[_, id] if id.parse::<i64>().is_ok() => id,
|
||||
_ => return Err(Failure::usage(format!("usage: {}", usage(name)))),
|
||||
};
|
||||
self.game(&format!("look-at entity {id}"))?;
|
||||
self.game("mouse right click")?;
|
||||
self.game("wait 2")?;
|
||||
let mut screen = self.game("screen")?;
|
||||
screen["ok"] = json!(true);
|
||||
Ok(screen)
|
||||
}
|
||||
"hold" => {
|
||||
let (key, seconds) = match words {
|
||||
[_, key, _] => (key.clone(), number(2, "SECONDS")?),
|
||||
@@ -183,7 +212,7 @@ impl Session {
|
||||
thread::sleep(Duration::from_secs_f64(seconds.max(0.0)));
|
||||
self.game(&format!("key {key} up"))
|
||||
}
|
||||
"logs" => logs(&horizon_client::logging::game_home(), &words[1..]),
|
||||
"logs" => logs(&self.home, &words[1..]),
|
||||
"wait-ready" => {
|
||||
let limit = if words.len() > 1 {
|
||||
number(1, "SECONDS")?
|
||||
@@ -417,6 +446,10 @@ fn main() -> ExitCode {
|
||||
print!("{}", commands::overview());
|
||||
0
|
||||
}
|
||||
Some("help") if args.get(1).is_some_and(|a| a == "--markdown") => {
|
||||
print!("{}", commands::markdown());
|
||||
0
|
||||
}
|
||||
Some("help") if args.get(1).is_none_or(|a| a != "--game") => match args.get(1) {
|
||||
None => {
|
||||
print!("{}", commands::overview());
|
||||
@@ -451,6 +484,10 @@ fn main() -> ExitCode {
|
||||
}
|
||||
}
|
||||
}
|
||||
Some("mcp") if session.allowed().is_err() => {
|
||||
eprintln!("horizon-ctl mcp: {}", horizon_core::no_ai::REFUSAL);
|
||||
DENIED
|
||||
}
|
||||
Some("mcp") => match mcp::serve(&mut session) {
|
||||
Ok(()) => 0,
|
||||
Err(e) => {
|
||||
@@ -581,6 +618,27 @@ mod tests {
|
||||
assert_eq!(f.status, USAGE);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_ai_refuses_every_command() {
|
||||
let home = std::env::temp_dir().join(format!("horizon-ctl-no-ai-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&home);
|
||||
let mut s = Session::new(std::env::temp_dir().join("horizon-ctl-nobody.sock"), None);
|
||||
s.home = home.clone();
|
||||
assert_eq!(
|
||||
s.run(&["state".to_owned()]).unwrap_err().status,
|
||||
UNREACHABLE
|
||||
);
|
||||
horizon_core::no_ai::set(&home, true).unwrap();
|
||||
for words in [vec!["state"], vec!["logs"], vec!["launch", "--world", "A"]] {
|
||||
let words: Vec<String> = words.into_iter().map(str::to_owned).collect();
|
||||
let f = s.run(&words).unwrap_err();
|
||||
assert_eq!(f.status, DENIED, "{words:?}");
|
||||
assert_eq!(f.reply["kind"], "denied");
|
||||
assert_eq!(f.reply["error"], horizon_core::no_ai::REFUSAL);
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(&home);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn screenshot_paths_become_absolute() {
|
||||
let mut words = vec!["screenshot".to_owned(), "a.png".to_owned()];
|
||||
|
||||
@@ -23,7 +23,13 @@ fn tools() -> Vec<Value> {
|
||||
schema = json!({ "type": "object", "properties": {} });
|
||||
description = "Capture the game window and return it as a PNG image.".into();
|
||||
}
|
||||
json!({ "name": commands::tool_name(c), "description": description, "inputSchema": schema })
|
||||
let mut tool = json!({ "name": commands::tool_name(c), "description": description, "inputSchema": schema });
|
||||
if commands::DESTRUCTIVE.contains(&c.name) {
|
||||
tool["annotations"] = json!({ "destructiveHint": true });
|
||||
} else if commands::OPEN_WORLD.contains(&c.name) {
|
||||
tool["annotations"] = json!({ "openWorldHint": true });
|
||||
}
|
||||
tool
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
@@ -167,6 +167,7 @@ fn object_style(o: &serde_json::Map<String, Value>) -> Style {
|
||||
style.underlined = flag("underlined");
|
||||
style.strikethrough = flag("strikethrough");
|
||||
style.obfuscated = flag("obfuscated");
|
||||
style.font = o.get("font").and_then(crate::text::font_id);
|
||||
style
|
||||
}
|
||||
/// `ComponentSerialization.CODEC` decoding into text. `selector` components
|
||||
@@ -266,4 +267,26 @@ mod tests {
|
||||
let (v, _) = parse_snbt("[\"a\",{\"text\":\"b\"}]").unwrap();
|
||||
assert_eq!(to_text(&v, &mut |_| Text::empty()).plain(), "ab");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_font_is_inherited_and_kept() {
|
||||
let (v, _) =
|
||||
parse_snbt(r#"{text:"a",font:"alt",extra:["b",{text:"c",font:"minecraft:uniform"}]}"#)
|
||||
.unwrap();
|
||||
let t = to_text(&v, &mut |_| Text::empty());
|
||||
let fonts: Vec<_> = t.chars().map(|(_, s)| s.font.clone()).collect();
|
||||
let (alt, uniform) = (
|
||||
Some("minecraft:alt".into()),
|
||||
Some("minecraft:uniform".into()),
|
||||
);
|
||||
assert_eq!(fonts, [alt.clone(), alt, uniform]);
|
||||
// Back to JSON with the field, which a re-read keeps.
|
||||
let again = to_text(&t.component(), &mut |_| Text::empty());
|
||||
let kept: Vec<_> = again.chars().map(|(_, s)| s.font.clone()).collect();
|
||||
assert_eq!(kept, fonts);
|
||||
// Not an identifier: no font.
|
||||
let (v, _) = parse_snbt(r#"{text:"a",font:"Bad Font"}"#).unwrap();
|
||||
let t = to_text(&v, &mut |_| Text::empty());
|
||||
assert_eq!(t.spans[0].style.font, None);
|
||||
}
|
||||
}
|
||||
@@ -13,7 +13,11 @@ use std::{
|
||||
io::{self, BufRead, BufReader, Read, Write},
|
||||
net::{Ipv4Addr, TcpListener, TcpStream},
|
||||
path::{Path, PathBuf},
|
||||
sync::mpsc,
|
||||
sync::{
|
||||
Arc,
|
||||
atomic::{AtomicBool, AtomicUsize, Ordering},
|
||||
mpsc,
|
||||
},
|
||||
thread,
|
||||
};
|
||||
|
||||
@@ -151,6 +155,12 @@ pub struct Server {
|
||||
path: PathBuf,
|
||||
requests: mpsc::Receiver<Request>,
|
||||
sender: mpsc::Sender<Request>,
|
||||
/// Clients connected now (an MCP server keeps one open; horizon-ctl one per command).
|
||||
clients: Arc<AtomicUsize>,
|
||||
/// Set when the server is dropped: the listener stops accepting and exits.
|
||||
closed: Arc<AtomicBool>,
|
||||
/// The TCP port, to wake the listener when closing.
|
||||
port: Option<u16>,
|
||||
}
|
||||
|
||||
/// `$HORIZON_CONTROL`, else `horizon-control.sock` in the user's runtime
|
||||
@@ -174,10 +184,15 @@ impl Server {
|
||||
}
|
||||
let (sender, requests) = mpsc::channel();
|
||||
let local = sender.clone();
|
||||
let clients = Arc::new(AtomicUsize::new(0));
|
||||
let counted = clients.clone();
|
||||
let closed = Arc::new(AtomicBool::new(false));
|
||||
let mut tcp_port = None;
|
||||
if uses_tcp(path) {
|
||||
let listener = TcpListener::bind((Ipv4Addr::LOCALHOST, 0))?;
|
||||
let token = random_token();
|
||||
let port = listener.local_addr()?.port();
|
||||
tcp_port = Some(port);
|
||||
write_private(
|
||||
path,
|
||||
&serde_json::json!({ "transport": "tcp", "port": port, "token": token })
|
||||
@@ -186,6 +201,8 @@ impl Server {
|
||||
spawn_listener(
|
||||
move || listener.accept().map(|(s, _)| s),
|
||||
sender,
|
||||
counted,
|
||||
closed.clone(),
|
||||
move |stream: TcpStream| {
|
||||
let _ = stream.set_nodelay(true);
|
||||
let mut stream = Stream::Tcp(stream);
|
||||
@@ -199,6 +216,8 @@ impl Server {
|
||||
spawn_listener(
|
||||
move || listener.accept().map(|(s, _)| s),
|
||||
sender,
|
||||
counted,
|
||||
closed.clone(),
|
||||
|s| Some(Stream::Unix(s)),
|
||||
)?;
|
||||
}
|
||||
@@ -207,6 +226,9 @@ impl Server {
|
||||
path: path.to_owned(),
|
||||
requests,
|
||||
sender: local,
|
||||
clients,
|
||||
closed,
|
||||
port: tcp_port,
|
||||
})
|
||||
}
|
||||
/// Commands from inside the process only (`--benchmark` without `--control`).
|
||||
@@ -216,8 +238,15 @@ impl Server {
|
||||
path: PathBuf::new(),
|
||||
requests,
|
||||
sender,
|
||||
clients: Arc::default(),
|
||||
closed: Arc::default(),
|
||||
port: None,
|
||||
}
|
||||
}
|
||||
/// How many clients are connected.
|
||||
pub fn clients(&self) -> usize {
|
||||
self.clients.load(Ordering::Relaxed)
|
||||
}
|
||||
/// Sends commands as a socket client would, from another thread.
|
||||
pub fn sender(&self) -> mpsc::Sender<Request> {
|
||||
self.sender.clone()
|
||||
@@ -234,6 +263,8 @@ impl Server {
|
||||
fn spawn_listener<S: Send + 'static>(
|
||||
mut accept_next: impl FnMut() -> io::Result<S> + Send + 'static,
|
||||
sender: mpsc::Sender<Request>,
|
||||
clients: Arc<AtomicUsize>,
|
||||
closed: Arc<AtomicBool>,
|
||||
accept: impl Fn(S) -> Option<Stream> + Send + Sync + 'static,
|
||||
) -> io::Result<()> {
|
||||
let accept = std::sync::Arc::new(accept);
|
||||
@@ -241,17 +272,24 @@ fn spawn_listener<S: Send + 'static>(
|
||||
.name("horizon-control".into())
|
||||
.spawn(move || {
|
||||
loop {
|
||||
let Ok(stream) = accept_next() else {
|
||||
let next = accept_next();
|
||||
if closed.load(Ordering::Relaxed) {
|
||||
return;
|
||||
}
|
||||
let Ok(stream) = next else {
|
||||
thread::sleep(std::time::Duration::from_millis(10));
|
||||
continue;
|
||||
};
|
||||
let sender = sender.clone();
|
||||
let accept = accept.clone();
|
||||
let clients = clients.clone();
|
||||
let _ = thread::Builder::new()
|
||||
.name("horizon-control-client".into())
|
||||
.spawn(move || {
|
||||
if let Some(stream) = accept(stream) {
|
||||
clients.fetch_add(1, Ordering::Relaxed);
|
||||
serve(stream, &sender);
|
||||
clients.fetch_sub(1, Ordering::Relaxed);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -287,6 +325,18 @@ fn authenticate(stream: &mut Stream, token: &str) -> bool {
|
||||
|
||||
impl Drop for Server {
|
||||
fn drop(&mut self) {
|
||||
if self.path.as_os_str().is_empty() {
|
||||
return;
|
||||
}
|
||||
// Wake the listener so it sees `closed` and exits (agent control turned off in-game).
|
||||
self.closed.store(true, Ordering::Relaxed);
|
||||
match self.port {
|
||||
Some(port) => drop(TcpStream::connect((Ipv4Addr::LOCALHOST, port))),
|
||||
#[cfg(unix)]
|
||||
None => drop(UnixStream::connect(&self.path)),
|
||||
#[cfg(not(unix))]
|
||||
None => {}
|
||||
}
|
||||
let _ = std::fs::remove_file(&self.path);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -98,12 +98,73 @@ impl ItemEntity {
|
||||
lava_hurt,
|
||||
}
|
||||
}
|
||||
/// Returns false when the item should be removed.
|
||||
/// `ItemEntity.tick` on a server: an item whose box is inside a block
|
||||
/// (one placed where it lay) has no physics this tick
|
||||
/// (`level.noCollision(box.deflate(1.0E-7))`).
|
||||
pub fn inside_block(&self, world: &World, collision: &BlockCollisionRegistry<'_>) -> bool {
|
||||
let mut scene = SceneCollisions {
|
||||
world,
|
||||
binding: collision,
|
||||
subject: None,
|
||||
};
|
||||
let inner = self.bounds().deflate(Vec3::new(1.0e-7, 1.0e-7, 1.0e-7));
|
||||
!crate::collisions::no_collision_empty(&mut scene, inner)
|
||||
}
|
||||
/// `Entity.moveTowardsClosestSpace` from the middle of the item's box:
|
||||
/// towards the nearest side of its block (north, south, west, east or
|
||||
/// up) without a full collision cube, at `speed` (`nextFloat() * 0.2 +
|
||||
/// 0.1`).
|
||||
pub fn move_towards_closest_space(&mut self, world: &World, facts: &BlockFacts, speed: f32) {
|
||||
let bounds = self.bounds();
|
||||
let at = Vec3::new(
|
||||
self.position.x,
|
||||
(bounds.min_position().y + bounds.max_position().y) / 2.0,
|
||||
self.position.z,
|
||||
);
|
||||
let pos = [
|
||||
at.x.floor() as i32,
|
||||
at.y.floor() as i32,
|
||||
at.z.floor() as i32,
|
||||
];
|
||||
let delta = [
|
||||
at.x - f64::from(pos[0]),
|
||||
at.y - f64::from(pos[1]),
|
||||
at.z - f64::from(pos[2]),
|
||||
];
|
||||
// (axis, step): NORTH, SOUTH, WEST, EAST, UP.
|
||||
let mut closest = (1, 1);
|
||||
let mut nearest = f64::MAX;
|
||||
for (axis, step) in [(2, -1), (2, 1), (0, -1), (0, 1), (1, 1)] {
|
||||
let mut n = pos;
|
||||
n[axis] += step;
|
||||
if !facts.get(world.block(n[0], n[1], n[2])).full_collision {
|
||||
let d = if step > 0 {
|
||||
1.0 - delta[axis]
|
||||
} else {
|
||||
delta[axis]
|
||||
};
|
||||
if d < nearest {
|
||||
nearest = d;
|
||||
closest = (axis, step);
|
||||
}
|
||||
}
|
||||
}
|
||||
let push = f64::from(closest.1 as f32 * speed);
|
||||
let v = self.velocity.scale(0.75);
|
||||
self.velocity = match closest.0 {
|
||||
0 => Vec3::new(push, v.y, v.z),
|
||||
1 => Vec3::new(v.x, push, v.z),
|
||||
_ => Vec3::new(v.x, v.y, push),
|
||||
};
|
||||
}
|
||||
/// Returns false when the item should be removed. `no_physics`: the item
|
||||
/// moves through blocks this tick ([`ItemEntity::inside_block`]).
|
||||
pub fn tick(
|
||||
&mut self,
|
||||
world: &World,
|
||||
facts: &BlockFacts,
|
||||
collision: &BlockCollisionRegistry<'_>,
|
||||
no_physics: bool,
|
||||
) -> bool {
|
||||
self.ticks += 1;
|
||||
self.previous = self.position;
|
||||
@@ -140,15 +201,22 @@ impl ItemEntity {
|
||||
subject: None,
|
||||
};
|
||||
let wanted = self.velocity;
|
||||
let moved = scene
|
||||
.resolve(self.bounds(), wanted, 0.0, self.on_ground)
|
||||
.unwrap_or(Vec3::ZERO);
|
||||
// Entity.move with noPhysics: straight through, the ground as it was.
|
||||
let moved = if no_physics {
|
||||
wanted
|
||||
} else {
|
||||
scene
|
||||
.resolve(self.bounds(), wanted, 0.0, self.on_ground)
|
||||
.unwrap_or(Vec3::ZERO)
|
||||
};
|
||||
self.position = Vec3::new(
|
||||
self.position.x + moved.x,
|
||||
self.position.y + moved.y,
|
||||
self.position.z + moved.z,
|
||||
);
|
||||
self.on_ground = wanted.y < 0.0 && moved.y != wanted.y;
|
||||
if !no_physics {
|
||||
self.on_ground = wanted.y < 0.0 && moved.y != wanted.y;
|
||||
}
|
||||
if moved.x != wanted.x {
|
||||
self.velocity.x = 0.0;
|
||||
}
|
||||
|
||||
@@ -445,10 +445,10 @@ pub fn mob_to_nbt(m: &Mob, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
|
||||
map.entry(key.into()).or_insert(value);
|
||||
}
|
||||
}
|
||||
// Sheep.addAdditionalSaveData: its wool as it is now.
|
||||
"minecraft:sheep" => {
|
||||
for (key, value) in [("Color", Tag::Byte(0)), ("Sheared", Tag::Byte(0))] {
|
||||
map.entry(key.into()).or_insert(value);
|
||||
}
|
||||
map.insert("Color".into(), Tag::Byte((m.extra.color & 15) as i8));
|
||||
map.insert("Sheared".into(), Tag::Byte(i8::from(m.extra.sheared)));
|
||||
}
|
||||
// CopperGolem.addAdditionalSaveData.
|
||||
"minecraft:copper_golem" => {
|
||||
@@ -832,6 +832,11 @@ pub fn mob_from_nbt(tag: &Tag, types: &HashMap<String, Arc<MobType>>, id: u32) -
|
||||
}
|
||||
m.extra.egg_time = number(tag.get("life_ticks")).map_or(-1, |v| v as i32);
|
||||
}
|
||||
// Sheep.readAdditionalSaveData: white and unsheared when absent.
|
||||
if m.species() == crate::mobs::Species::Sheep {
|
||||
m.extra.color = number(tag.get("Color")).map_or(0, |c| (c as u8) & 15);
|
||||
m.extra.sheared = flag(tag, "Sheared");
|
||||
}
|
||||
if m.species() == crate::mobs::Species::Shulker {
|
||||
m.extra.variant3 = number(tag.get("AttachFace")).unwrap_or(0).clamp(0, 5) as u8;
|
||||
m.extra.counter = number(tag.get("Peek")).unwrap_or(0).clamp(0, 100) as i32;
|
||||
@@ -2051,3 +2056,39 @@ mod axolotl_nbt_tests {
|
||||
assert_eq!(mm.temptation_cooldown_ticks.value(), Some(42));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod sheep_nbt_tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn a_sheep_keeps_its_dyed_and_sheared_wool() {
|
||||
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../../.local/horizon-client")
|
||||
.join(horizon_core::VANILLA_VERSION);
|
||||
if !root.join("items.json").exists() {
|
||||
return;
|
||||
}
|
||||
let data = crate::mobs::MobTypes::load(&root).unwrap();
|
||||
let mut m = Mob::new(
|
||||
1,
|
||||
Arc::new(data.types["minecraft:sheep"].clone()),
|
||||
Vec3::new(0.0, 4.0, 0.0),
|
||||
0.0,
|
||||
);
|
||||
// Summoned white, then dyed red and sheared: the save has it now.
|
||||
let raw = Tag::compound([("Color", Tag::Byte(0)), ("Sheared", Tag::Byte(0))]);
|
||||
(m.extra.color, m.extra.sheared) = (14, true);
|
||||
let tag = mob_to_nbt(&m, [1, 2, 3, 4], Some(&raw));
|
||||
assert_eq!(
|
||||
(number(tag.get("Color")), number(tag.get("Sheared"))),
|
||||
(Some(14), Some(1))
|
||||
);
|
||||
let types: HashMap<String, Arc<MobType>> = data
|
||||
.types
|
||||
.iter()
|
||||
.map(|(k, v)| (k.clone(), Arc::new(v.clone())))
|
||||
.collect();
|
||||
let back = mob_from_nbt(&tag, &types, 2).unwrap();
|
||||
assert_eq!((back.extra.color, back.extra.sheared), (14, true));
|
||||
}
|
||||
}
|
||||
@@ -107,6 +107,11 @@ pub struct Extras {
|
||||
pub sort: bool,
|
||||
/// A vanilla key name: sorts the part under the cursor.
|
||||
pub sort_key: String,
|
||||
/// No AI: every AI feature hidden and off (`horizon_core::no_ai`; the launcher writes it too).
|
||||
pub no_ai: bool,
|
||||
/// Agents may control the game the player starts: it opens its control socket for
|
||||
/// horizon-ctl and MCP clients (off by default; an agent's own launch always opens it).
|
||||
pub agent_control: bool,
|
||||
}
|
||||
impl Default for Extras {
|
||||
fn default() -> Self {
|
||||
@@ -135,6 +140,8 @@ impl Default for Extras {
|
||||
mouse_tweaks_wheel: true,
|
||||
sort: false,
|
||||
sort_key: "key.keyboard.unknown".into(),
|
||||
no_ai: false,
|
||||
agent_control: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -198,6 +205,8 @@ impl Extras {
|
||||
"mouseTweaksWheel" => e.mouse_tweaks_wheel = flag(e.mouse_tweaks_wheel),
|
||||
"sort" => e.sort = flag(e.sort),
|
||||
"sortKey" if value.starts_with("key.") => e.sort_key = value.to_owned(),
|
||||
horizon_core::no_ai::KEY => e.no_ai = flag(e.no_ai),
|
||||
"agentControl" => e.agent_control = flag(e.agent_control),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
@@ -205,7 +214,7 @@ impl Extras {
|
||||
}
|
||||
pub fn serialize(&self) -> String {
|
||||
format!(
|
||||
"fullbright:{}\ndynamicLights:{}\nzoom:{}\nzoomKey:{}\nzoomLevel:{}\nzoomSmooth:{}\nzoomSensitivity:{}\nfps:{}\nfpsCorner:{}\nfpsLows:{}\nkeystrokes:{}\nkeystrokesCorner:{}\nkeystrokesCps:{}\nminimap:{}\nminimapCorner:{}\nminimapSize:{}\nminimapShape:{}\nminimapZoom:{}\nminimapRotate:{}\nautoReconnect:{}\nmouseTweaks:{}\nmouseTweaksWheel:{}\nsort:{}\nsortKey:{}\n",
|
||||
"fullbright:{}\ndynamicLights:{}\nzoom:{}\nzoomKey:{}\nzoomLevel:{}\nzoomSmooth:{}\nzoomSensitivity:{}\nfps:{}\nfpsCorner:{}\nfpsLows:{}\nkeystrokes:{}\nkeystrokesCorner:{}\nkeystrokesCps:{}\nminimap:{}\nminimapCorner:{}\nminimapSize:{}\nminimapShape:{}\nminimapZoom:{}\nminimapRotate:{}\nautoReconnect:{}\nmouseTweaks:{}\nmouseTweaksWheel:{}\nsort:{}\nsortKey:{}\n{}:{}\nagentControl:{}\n",
|
||||
bool_text(self.fullbright),
|
||||
bool_text(self.dynamic_lights),
|
||||
bool_text(self.zoom),
|
||||
@@ -234,6 +243,9 @@ impl Extras {
|
||||
bool_text(self.mouse_tweaks_wheel),
|
||||
bool_text(self.sort),
|
||||
self.sort_key,
|
||||
horizon_core::no_ai::KEY,
|
||||
bool_text(self.no_ai),
|
||||
bool_text(self.agent_control),
|
||||
)
|
||||
}
|
||||
/// Binds the extra key `name` (`ZOOM_KEY`, `SORT_KEY`); false for
|
||||
@@ -451,6 +463,9 @@ pub enum Item {
|
||||
MouseTweaksWheel,
|
||||
Sort,
|
||||
SortKey,
|
||||
/// Not on the Extras page: Horizon Settings shows No AI, its AI Agent page Agent Control.
|
||||
NoAi,
|
||||
AgentControl,
|
||||
}
|
||||
/// The Extras page: headers and items.
|
||||
pub fn rows() -> Vec<Result<Item, &'static str>> {
|
||||
@@ -529,6 +544,8 @@ impl Item {
|
||||
Item::FpsLows => "Minimum and 1% low",
|
||||
Item::Keystrokes => "Keystrokes",
|
||||
Item::KeystrokesCps => "Clicks per second",
|
||||
Item::NoAi => "No AI",
|
||||
Item::AgentControl => "Agent Control",
|
||||
}
|
||||
}
|
||||
/// The switch this setting belongs to: it does nothing while that is off.
|
||||
@@ -542,7 +559,7 @@ impl Item {
|
||||
MouseTweaksWheel => MouseTweaks,
|
||||
SortKey => Sort,
|
||||
Fullbright | DynamicLights | Zoom | Minimap | Fps | Keystrokes | MouseTweaks | Sort
|
||||
| AutoReconnect => {
|
||||
| AutoReconnect | NoAi | AgentControl => {
|
||||
return None;
|
||||
}
|
||||
})
|
||||
@@ -586,6 +603,12 @@ impl Item {
|
||||
}
|
||||
Item::Keystrokes => "Movement keys, jump and mouse buttons as you press them.",
|
||||
Item::KeystrokesCps => "Clicks in the last second under each mouse button.",
|
||||
Item::NoAi => {
|
||||
"Hides and turns off every AI feature: agent control, horizon-ctl and the MCP servers. The launcher's switch too."
|
||||
}
|
||||
Item::AgentControl => {
|
||||
"Your own AI agent can play this game through horizon-ctl or an MCP client, on this computer only."
|
||||
}
|
||||
}
|
||||
}
|
||||
/// The frame-time cost the main switch of an extra shows.
|
||||
@@ -616,6 +639,8 @@ impl Item {
|
||||
Item::MouseTweaks => e.mouse_tweaks,
|
||||
Item::MouseTweaksWheel => e.mouse_tweaks_wheel,
|
||||
Item::Sort => e.sort,
|
||||
Item::NoAi => e.no_ai,
|
||||
Item::AgentControl => e.agent_control,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
@@ -671,6 +696,8 @@ impl Item {
|
||||
}
|
||||
Item::MinimapShape => e.minimap_round = !e.minimap_round,
|
||||
Item::MinimapZoom => e.minimap_zoom = next(&MINIMAP_ZOOMS, e.minimap_zoom, back),
|
||||
Item::NoAi => e.no_ai = !e.no_ai,
|
||||
Item::AgentControl => e.agent_control = !e.agent_control,
|
||||
Item::ZoomKey | Item::SortKey => {}
|
||||
}
|
||||
}
|
||||
|
||||
+821
-225
File diff suppressed because it is too large.
Load diff
@@ -3,6 +3,7 @@
|
||||
mod advancements;
|
||||
mod advancements_screen;
|
||||
mod agent;
|
||||
mod agent_settings;
|
||||
mod allays;
|
||||
mod armor_stands;
|
||||
mod banners;
|
||||
@@ -83,6 +84,7 @@ mod level_particles;
|
||||
mod levels;
|
||||
mod lightning;
|
||||
mod llamas;
|
||||
mod locator_bar;
|
||||
mod loom;
|
||||
mod mace;
|
||||
mod machines;
|
||||
@@ -119,6 +121,7 @@ mod pumpkin;
|
||||
mod raids;
|
||||
mod recipe_book;
|
||||
mod remote;
|
||||
mod remote_mob_data;
|
||||
mod remote_objects;
|
||||
mod remote_projectiles;
|
||||
mod remote_stations;
|
||||
@@ -126,6 +129,7 @@ mod remote_vehicles;
|
||||
mod render_control;
|
||||
mod render_tick;
|
||||
mod respawn_anchor;
|
||||
mod saves_control;
|
||||
mod saving;
|
||||
mod scoreboard;
|
||||
mod screens;
|
||||
@@ -159,6 +163,7 @@ mod ui_control;
|
||||
mod vault;
|
||||
mod villagers;
|
||||
mod wandering_trader;
|
||||
mod win;
|
||||
mod wind;
|
||||
mod wither;
|
||||
mod world_loading;
|
||||
@@ -263,6 +268,9 @@ struct Options {
|
||||
/// Render into a texture instead of the window (`--offscreen`), so a
|
||||
/// GPU the display cannot present from can still be benchmarked.
|
||||
offscreen: bool,
|
||||
/// `--width` and `--height` (vanilla's own arguments): the window's
|
||||
/// starting size, 1280x720 by default.
|
||||
window_size: [u32; 2],
|
||||
/// Multiplayer: `--server HOST[:PORT]` joins at start; `--name` is the offline player name.
|
||||
server: Option<String>,
|
||||
/// `--name`, `HORIZON_PLAYER_NAME` or the signed-in account's name; else
|
||||
@@ -277,8 +285,9 @@ struct Options {
|
||||
dev_mods: Vec<PathBuf>,
|
||||
/// `--server-thread` or `HORIZON_SERVER_THREAD=1`: single player runs on an
|
||||
/// integrated server thread (game/integrated.rs), as vanilla's does. Off by
|
||||
/// default again until a server's players can board, ride and hit
|
||||
/// vehicles (#1222); `--no-server-thread` keeps it off.
|
||||
/// default again until a server's players reach parity with the in-frame
|
||||
/// world (signs, books, totems, keep_inventory...); `--no-server-thread`
|
||||
/// keeps it off.
|
||||
server_thread: bool,
|
||||
/// Set on the integrated server's own options: it draws nothing, so it skips meshing.
|
||||
integrated: bool,
|
||||
@@ -323,6 +332,7 @@ impl Options {
|
||||
no_pause: false,
|
||||
control: None,
|
||||
offscreen: false,
|
||||
window_size: [1280, 720],
|
||||
spikes: std::env::var("HORIZON_SPIKES")
|
||||
.ok()
|
||||
.and_then(|v| v.parse().ok()),
|
||||
@@ -345,7 +355,7 @@ impl Options {
|
||||
match arg.as_str() {
|
||||
"--help" | "-h" => {
|
||||
println!(
|
||||
"Horizon — Minecraft Java Edition {} in Rust\n\n --resources PATH Prepared resources (horizon-prepare)\n --render-distance N Chunks, 2..64 (default 12)\n --world NAME World folder under saves/ (default \"New World\")\n --seed N World seed for new worlds (default 0)\n --creative Creative mode (default survival)\n --test-terrain Simple test terrain instead of vanilla generation\n --frames N Exit after N frames (testing)\n --world-frames N Exit after N frames drawn in the world (testing)\n --smoke-report PATH Write a JSON report on exit\n --benchmark SCENARIO Run a benchmark (standard, village, forest, ocean, mountain), write its report to benchmarks/ and quit\n --control Accept horizon-ctl commands on a local socket\n --control-socket PATH Control socket path (default $XDG_RUNTIME_DIR/horizon-control.sock)\n --server HOST[:PORT] Join a multiplayer server (offline mode) at start\n --name NAME Player name for multiplayer (default: generated once, kept in horizon-player.txt)\n --session-file PATH Signed-in account from the launcher (read once, then deleted)\n --dev-mod PATH A Horizon mod (.hmod or folder) to develop: always on, reloaded when rebuilt\n --server-thread Run single player's world on its server thread (no vehicles yet)\n\nControls: mouse look, WASD move, Space jump, Left Shift sneak, Left Ctrl sprint,\nleft click break, right click place/use, 1-9/scroll hotbar, E inventory,\nQ drop, F3 debug, Esc pause.",
|
||||
"Horizon — Minecraft Java Edition {} in Rust\n\n --resources PATH Prepared resources (horizon-prepare)\n --render-distance N Chunks, 2..64 (default 12)\n --world NAME World folder under saves/ (default \"New World\")\n --seed N World seed for new worlds (default 0)\n --width N, --height N Window size (default 1280 x 720)\n --creative Creative mode (default survival)\n --test-terrain Simple test terrain instead of vanilla generation\n --frames N Exit after N frames (testing)\n --world-frames N Exit after N frames drawn in the world (testing)\n --smoke-report PATH Write a JSON report on exit\n --benchmark SCENARIO Run a benchmark (standard, village, forest, ocean, mountain), write its report to benchmarks/ and quit\n --control Accept horizon-ctl commands on a local socket\n --control-socket PATH Control socket path (default $XDG_RUNTIME_DIR/horizon-control.sock)\n --server HOST[:PORT] Join a multiplayer server (offline mode) at start\n --name NAME Player name for multiplayer (default: generated once, kept in horizon-player.txt)\n --session-file PATH Signed-in account from the launcher (read once, then deleted)\n --dev-mod PATH A Horizon mod (.hmod or folder) to develop: always on, reloaded when rebuilt\n --server-thread Run single player's world on its server thread (not at parity yet)\n\nControls: mouse look, WASD move, Space jump, Left Shift sneak, Left Ctrl sprint,\nleft click break, right click place/use, 1-9/scroll hotbar, E inventory,\nQ drop, F3 debug, Esc pause.",
|
||||
horizon_core::VANILLA_VERSION
|
||||
);
|
||||
return Ok(None);
|
||||
@@ -394,6 +404,8 @@ impl Options {
|
||||
"--timeout-seconds" => o.timeout = Duration::from_secs(value.parse()?),
|
||||
"--smoke-report" => o.report = Some(value.into()),
|
||||
"--screenshot-after" => o.screenshot_after = Some(value.parse()?),
|
||||
"--width" => o.window_size[0] = value.parse::<u32>()?.max(1),
|
||||
"--height" => o.window_size[1] = value.parse::<u32>()?.max(1),
|
||||
"--world" => o.world = Some(value),
|
||||
"--dev-mod" => o.dev_mods.push(std::path::absolute(&value)?),
|
||||
"--benchmark" => {
|
||||
@@ -462,6 +474,8 @@ enum Screen {
|
||||
Friends,
|
||||
/// CommandBlockEditScreen (game/command_blocks.rs).
|
||||
CommandBlock,
|
||||
/// WinScreen: the end poem and the credits (game/win.rs).
|
||||
Win,
|
||||
}
|
||||
struct Mining {
|
||||
pos: [i32; 3],
|
||||
@@ -524,6 +538,7 @@ struct GameLevel<'w> {
|
||||
spill: &'w mut spill::Spill,
|
||||
/// Where the chunk tickets let blocks tick.
|
||||
tickets: horizon_client::ticket_levels::Levels,
|
||||
random_tick_sections: &'w mut horizon_client::random_tick_sections::RandomTickSections,
|
||||
}
|
||||
struct PlayerCollision {
|
||||
y: f64,
|
||||
@@ -1354,6 +1369,8 @@ struct Game<'a> {
|
||||
respawn_travel: Option<respawn_anchor::RespawnConfig>,
|
||||
/// The next enter_dimension call comes from an End portal.
|
||||
end_portal_travel: bool,
|
||||
/// The open WinScreen (game/win.rs).
|
||||
win: Option<win::Win>,
|
||||
end: end::EndState,
|
||||
block_ticks: Ticks,
|
||||
tick_chunks: horizon_client::tick_chunk_membership::TickChunkMembership,
|
||||
@@ -1373,6 +1390,14 @@ struct Game<'a> {
|
||||
/// Entities of types Horizon does not simulate.
|
||||
others: Vec<commands::OtherEntity>,
|
||||
armor_stand_assets: armor_stands::ArmorStandAssets,
|
||||
/// `WaypointStyleManager`.
|
||||
waypoint_styles: horizon_client::locator_bar::WaypointStyles,
|
||||
/// `ClientWaypointManager`: the locator bar's waypoints.
|
||||
waypoints: horizon_client::locator_bar::ClientWaypoints,
|
||||
/// The camera the last frame drew from (position, yaw, pitch, fov), for the locator bar.
|
||||
locator_camera: Option<([f64; 3], f32, f32, f32)>,
|
||||
/// `LocalPlayer.experienceDisplayStartTick`: (last progress, seen a change, tick).
|
||||
experience_display: (f32, bool, u64),
|
||||
boats: Vec<boats::Boat>,
|
||||
boat_models: boats::BoatModels,
|
||||
/// EndCrystalModel's baked layer.
|
||||
@@ -1395,6 +1420,8 @@ struct Game<'a> {
|
||||
/// (least recently shown first).
|
||||
skin_downloads: RefCell<horizon_client::skins::Downloader>,
|
||||
skin_slots: Vec<(String, usize)>,
|
||||
/// Atlas trim slots in use: (sprite name, slot), least recently drawn first.
|
||||
trim_slots: Vec<(String, usize)>,
|
||||
cart_models: minecarts::CartModels,
|
||||
/// The boat the player rides.
|
||||
riding: Option<u32>,
|
||||
@@ -1427,6 +1454,10 @@ struct Game<'a> {
|
||||
item_columns: Option<machines::ItemColumns>,
|
||||
creative_screen: Creative,
|
||||
control: Option<horizon_client::control::Server>,
|
||||
/// The Agent Control setting the socket was last opened or closed for (`agent_settings`).
|
||||
agent_control_on: bool,
|
||||
/// When an agent's last command came.
|
||||
agent_last: Option<Instant>,
|
||||
frame_stats: horizon_client::control::FrameStats,
|
||||
/// This play session's frame and tick times, for opt-in performance statistics.
|
||||
perf: horizon_client::telemetry::Session,
|
||||
@@ -2084,7 +2115,14 @@ impl Game<'_> {
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
let alive = item.tick(&self.world, self.res.facts(), self.collision);
|
||||
// ItemEntity.tick's noPhysics (servers only): out of a block
|
||||
// placed on it, towards the closest open side (#1252).
|
||||
let no_physics = merging && item.inside_block(&self.world, self.collision);
|
||||
if no_physics {
|
||||
let speed = self.world.random.next_float() * 0.2 + 0.1;
|
||||
item.move_towards_closest_space(&self.world, self.res.facts(), speed);
|
||||
}
|
||||
let alive = item.tick(&self.world, self.res.facts(), self.collision, no_physics);
|
||||
cells.moved(i, item.previous, item.position);
|
||||
// ItemEntity.tick: merge every 2 ticks while moving between
|
||||
// blocks, every 40 otherwise, before the lifetime check.
|
||||
@@ -2291,6 +2329,7 @@ impl Game<'_> {
|
||||
pending: &mut self.pending,
|
||||
spill: &mut self.spill,
|
||||
tickets: self.tickets.levels(),
|
||||
random_tick_sections: &mut self.random_tick_sections,
|
||||
};
|
||||
let result = f(self.res.behaviours(), &mut level, &mut self.block_ticks);
|
||||
self.flush_spill();
|
||||
@@ -2385,18 +2424,15 @@ impl Game<'_> {
|
||||
self.sync_tick_chunks();
|
||||
self.start_ticking_chunks();
|
||||
let levels = self.tickets.levels();
|
||||
let mut ticking = Vec::new();
|
||||
let facts = self.res.facts();
|
||||
for (&(x, z), chunk) in &self.world.chunks {
|
||||
if !levels.random_ticking(x, z) || !ticking_chunk(&self.world, x, z) {
|
||||
continue;
|
||||
}
|
||||
// Capture eligibility before inside-block and scheduled updates, as
|
||||
// before. Keep the world map's chunk order, including empty chunks
|
||||
// (tick_chunk_random still draws for precipitation in those).
|
||||
let sections = self.random_tick_sections.get((x, z), chunk, facts);
|
||||
ticking.push((x, z, sections));
|
||||
}
|
||||
// Keep the world map's chunk order, including empty chunks
|
||||
// (tick_chunk_random still draws for precipitation in those).
|
||||
let ticking: Vec<_> = self
|
||||
.world
|
||||
.chunks
|
||||
.keys()
|
||||
.copied()
|
||||
.filter(|&(x, z)| levels.random_ticking(x, z) && ticking_chunk(&self.world, x, z))
|
||||
.collect();
|
||||
// Entity.checkInsideBlocks for the plates, tripwires and detector rails
|
||||
// under the players (the host and the LAN guests) and the minecarts.
|
||||
let mut inside = Vec::new();
|
||||
@@ -2438,13 +2474,24 @@ impl Game<'_> {
|
||||
self.process_level_events()?;
|
||||
self.tick_scheduled()?;
|
||||
self.with_level(|b, level, ticks| {
|
||||
for (x, z, sections) in ticking {
|
||||
b.tick_chunk_random(
|
||||
level,
|
||||
ticks,
|
||||
horizon_core::position::ChunkPos::new(x, z),
|
||||
sections,
|
||||
);
|
||||
for (x, z) in ticking {
|
||||
// The sections are read again only after a block changed.
|
||||
let mut read = None;
|
||||
let mut sections = Default::default();
|
||||
let pos = horizon_core::position::ChunkPos::new(x, z);
|
||||
b.tick_chunk_random(level, ticks, pos, |level, from| {
|
||||
if read != Some(level.world.revision) {
|
||||
read = Some(level.world.revision);
|
||||
sections = level
|
||||
.world
|
||||
.chunks
|
||||
.get(&(x, z))
|
||||
.map_or_else(Default::default, |chunk| {
|
||||
level.random_tick_sections.get((x, z), chunk, level.facts)
|
||||
});
|
||||
}
|
||||
sections.from(from)
|
||||
});
|
||||
}
|
||||
b.run_block_events(level, ticks);
|
||||
b.tick_block_entities(level, ticks);
|
||||
@@ -3555,8 +3602,6 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
fn tick_using(&mut self) {
|
||||
// ItemCooldowns.tick for the shield.
|
||||
self.shield_cooldown = self.shield_cooldown.saturating_sub(1);
|
||||
let Some(hand) = self.used_hand() else {
|
||||
return;
|
||||
};
|
||||
@@ -3772,6 +3817,9 @@ impl Game<'_> {
|
||||
return Ok(());
|
||||
}
|
||||
if let Some((_, target)) = &self.travelling {
|
||||
if self.win_screen_holds_arrival() {
|
||||
return Ok(());
|
||||
}
|
||||
let (cx, cz) = (
|
||||
(target.x.floor() as i32) >> 4,
|
||||
(target.z.floor() as i32) >> 4,
|
||||
@@ -3899,6 +3947,7 @@ impl Game<'_> {
|
||||
None => 0.0,
|
||||
};
|
||||
self.ticks += 1;
|
||||
self.track_experience_display();
|
||||
// `/tick freeze` stops the world but not the player.
|
||||
let running = self.clock.world_runs();
|
||||
if running {
|
||||
@@ -4258,6 +4307,9 @@ impl Game<'_> {
|
||||
fn tick_world(&mut self, running: bool) -> Result<()> {
|
||||
self.update_chunk_tickets();
|
||||
horizon_client::spans::time("tick_effects", || self.tick_effects());
|
||||
// Player.tick's ItemCooldowns.tick for the window's player (a
|
||||
// server's players count theirs down in `tick_guests`).
|
||||
self.shield_cooldown = self.shield_cooldown.saturating_sub(1);
|
||||
horizon_client::spans::time("tick_using", || self.tick_using());
|
||||
horizon_client::spans::time("tick_hud", || self.tick_hud());
|
||||
// The player's digging comes in with its packets, before the level
|
||||
@@ -4458,7 +4510,8 @@ impl Game<'_> {
|
||||
| Screen::WorldOptions
|
||||
| Screen::Friends
|
||||
| Screen::CommandBlock
|
||||
) || self.book.is_some())
|
||||
) || self.book.is_some()
|
||||
|| self.win_screen_pauses())
|
||||
}
|
||||
/// `MouseHandler.grabMouse` / `releaseMouse`. Both put the pointer at
|
||||
/// the window's centre, so a screen opened from gameplay (inventory,
|
||||
@@ -4673,6 +4726,10 @@ impl Game<'_> {
|
||||
fn hud_layers(&mut self, size: [u32; 2]) -> HudBatch {
|
||||
let mut batch = HudBatch::new(size);
|
||||
let (w, h) = (batch.width, batch.height);
|
||||
if self.screen == Screen::Win {
|
||||
self.draw_win_screen(&mut batch);
|
||||
return batch;
|
||||
}
|
||||
if self.screen == Screen::Options {
|
||||
self.draw_options(&mut batch);
|
||||
return batch;
|
||||
@@ -4753,12 +4810,41 @@ impl Game<'_> {
|
||||
self.draw_extras(&mut batch);
|
||||
}
|
||||
if show_hud {
|
||||
// The mount's hearts (and the jump bar that replaces the experience bar).
|
||||
let (vehicle_hearts, jump_bar) = if spectator {
|
||||
(0, false)
|
||||
// Hud.nextContextualInfoState: the jump bar, the experience bar or
|
||||
// the locator bar above the hotbar.
|
||||
let has_experience = !self.creative && !spectator;
|
||||
let contextual = horizon_client::locator_bar::ContextualBar::next(
|
||||
self.waypoints.has_waypoints(),
|
||||
!spectator && self.jumpable_mount(),
|
||||
self.mount_jumping(),
|
||||
has_experience,
|
||||
self.experience_recent(),
|
||||
);
|
||||
use horizon_client::locator_bar::ContextualBar;
|
||||
// The mount's hearts (and the jump bar).
|
||||
let vehicle_hearts = if spectator {
|
||||
0
|
||||
} else {
|
||||
self.draw_mount_hud(&mut batch, wi, hi)
|
||||
self.draw_mount_hud(
|
||||
&mut batch,
|
||||
wi,
|
||||
hi,
|
||||
contextual == ContextualBar::JumpableVehicle,
|
||||
)
|
||||
};
|
||||
if contextual == ContextualBar::Locator {
|
||||
let (sprite, [x, y, w, h]) = horizon_client::locator_bar::background(wi, hi);
|
||||
horizon_client::ui::nine_slice(
|
||||
&mut batch,
|
||||
&self.gui.atlas,
|
||||
sprite,
|
||||
x as f32,
|
||||
y as f32,
|
||||
w as f32,
|
||||
h as f32,
|
||||
u32::MAX,
|
||||
);
|
||||
}
|
||||
if !self.creative
|
||||
&& !spectator
|
||||
&& let Some(p) = &mut self.player
|
||||
@@ -4770,7 +4856,7 @@ impl Game<'_> {
|
||||
hi,
|
||||
self.experience.progress,
|
||||
self.experience.level,
|
||||
!jump_bar,
|
||||
contextual == ContextualBar::Experience,
|
||||
&width,
|
||||
);
|
||||
hud_layout::paint(&mut batch, atlas, &draws);
|
||||
@@ -4820,6 +4906,10 @@ impl Game<'_> {
|
||||
let draws = self.health_hud.layout(&inputs, wi, hi);
|
||||
hud_layout::paint(&mut batch, atlas, &draws);
|
||||
}
|
||||
if contextual == ContextualBar::Locator {
|
||||
let draws = self.locator_bar_draws(wi, hi);
|
||||
hud_layout::paint(&mut batch, &self.gui.atlas, &draws);
|
||||
}
|
||||
if !spectator {
|
||||
self.draw_selected_name(&mut batch, !self.creative);
|
||||
}
|
||||
@@ -4874,7 +4964,7 @@ impl Game<'_> {
|
||||
self.draw_creative(&mut batch);
|
||||
}
|
||||
Screen::Chat => self.draw_chat_screen(&mut batch),
|
||||
Screen::Playing | Screen::Options => {}
|
||||
Screen::Playing | Screen::Options | Screen::Win => {}
|
||||
}
|
||||
self.draw_toasts(&mut batch);
|
||||
self.remote_hud(&mut batch);
|
||||
@@ -6461,6 +6551,7 @@ impl Game<'_> {
|
||||
mut out: Vec<Vertex>,
|
||||
) -> Vec<Vertex> {
|
||||
self.prepare_entity_culling(camera);
|
||||
self.prepare_trims();
|
||||
let fire_yaw = if self.camera_type == perspective::CameraType::ThirdPersonFront {
|
||||
self.yaw + 180.0
|
||||
} else {
|
||||
@@ -7458,16 +7549,11 @@ impl Game<'_> {
|
||||
self.sleep_timer = 0;
|
||||
self.spawn = None;
|
||||
self.respawn_travel = None;
|
||||
self.respawn_config =
|
||||
self.level
|
||||
.respawn_elsewhere
|
||||
.as_ref()
|
||||
.map(|(dim, pos, yaw, forced)| respawn_anchor::RespawnConfig {
|
||||
dim: Dim::from_id(dim),
|
||||
pos: *pos,
|
||||
yaw: *yaw,
|
||||
forced: *forced,
|
||||
});
|
||||
self.respawn_config = self
|
||||
.level
|
||||
.respawn_elsewhere
|
||||
.as_ref()
|
||||
.map(respawn_anchor::RespawnConfig::from_saved);
|
||||
self.spawn_origin = None;
|
||||
self.spawn_spiral = Default::default();
|
||||
self.player_spawn = None;
|
||||
@@ -7717,6 +7803,10 @@ impl Game<'_> {
|
||||
fullscreen: s.fullscreen,
|
||||
graphics: s.graphics_preset.clone(),
|
||||
};
|
||||
// Again now: on Wayland the window is on a screen only once it has been shown.
|
||||
if let Some(window) = &self.window {
|
||||
horizon_client::telemetry::note_display(window);
|
||||
}
|
||||
self.perf
|
||||
.finish(&horizon_client::logging::game_home(), settings);
|
||||
}
|
||||
@@ -7749,6 +7839,7 @@ impl Game<'_> {
|
||||
Ok(())
|
||||
}
|
||||
fn close_world(&mut self) -> Result<()> {
|
||||
self.clear_waypoints();
|
||||
self.begin_closing_save(false)
|
||||
}
|
||||
fn finish_close_world(&mut self) -> Result<()> {
|
||||
@@ -9064,31 +9155,41 @@ impl Game<'_> {
|
||||
} else {
|
||||
pos
|
||||
};
|
||||
self.spawn = Some(Vec3::new(
|
||||
f64::from(head[0]) + 0.5,
|
||||
f64::from(head[1]) + 0.6,
|
||||
f64::from(head[2]) + 0.5,
|
||||
));
|
||||
// The bed replaces a respawn anchor.
|
||||
self.respawn_config = None;
|
||||
self.level.spawn = Some([
|
||||
f64::from(head[0]),
|
||||
f64::from(head[1]) + 1.0,
|
||||
f64::from(head[2]),
|
||||
]);
|
||||
let dist = ((p.position.x - f64::from(head[0]) - 0.5).powi(2)
|
||||
+ (p.position.z - f64::from(head[2]) - 0.5).powi(2))
|
||||
.sqrt();
|
||||
if !night {
|
||||
if dist > 3.0 {
|
||||
self.message = Some((
|
||||
"You can sleep only at night or during thunderstorms".into(),
|
||||
"You may not rest now; the bed is too far away".into(),
|
||||
Instant::now(),
|
||||
));
|
||||
return;
|
||||
}
|
||||
if dist > 3.0 {
|
||||
// In reach (bedInRange), day or night: setRespawnPosition(
|
||||
// RespawnConfig(the head, the player's yaw), not forced). It replaces
|
||||
// an anchor or a spawnpoint; respawning checks the bed is still there
|
||||
// and finds where to stand up (findRespawnAndUseSpawnBlock).
|
||||
// Straw beds never set it (`straw_bed_rule`).
|
||||
if self.bed_sets_spawn(state) {
|
||||
self.respawn_config = Some(respawn_anchor::RespawnConfig::wrapped(
|
||||
self.dim, head, p.yaw, false,
|
||||
));
|
||||
// The fallback becomes the world spawn: a guest's is none of its
|
||||
// own, so it follows `setworldspawn`.
|
||||
self.spawn = if self.lan_acting.is_some() {
|
||||
None
|
||||
} else {
|
||||
self.level
|
||||
.world_spawn
|
||||
.map(|[x, y, z]| {
|
||||
Vec3::new(f64::from(x) + 0.5, f64::from(y), f64::from(z) + 0.5)
|
||||
})
|
||||
.or(self.spawn)
|
||||
};
|
||||
}
|
||||
if !night {
|
||||
self.message = Some((
|
||||
"You may not rest now; the bed is too far away".into(),
|
||||
"You can sleep only at night or during thunderstorms".into(),
|
||||
Instant::now(),
|
||||
));
|
||||
return;
|
||||
@@ -9440,9 +9541,7 @@ impl Game<'_> {
|
||||
level.weather = self.world.weather;
|
||||
level.day_time = self.world.day_time;
|
||||
level.spawn = self.spawn.map(|s| [s.x, s.y, s.z]);
|
||||
level.respawn_elsewhere = self
|
||||
.respawn_config
|
||||
.map(|c| (c.dim.id().to_owned(), c.pos, c.yaw, c.forced));
|
||||
level.respawn_elsewhere = self.respawn_config.map(|c| c.saved());
|
||||
level.rotation = [self.yaw, self.pitch];
|
||||
level.xp_level = self.experience.level;
|
||||
level.xp_progress = self.experience.progress;
|
||||
@@ -9450,6 +9549,7 @@ impl Game<'_> {
|
||||
if let Some(p) = &self.player {
|
||||
level.position = Some([p.position.x, p.position.y, p.position.z]);
|
||||
level.health = p.health.health();
|
||||
level.absorption = p.health.absorption();
|
||||
let extra = level
|
||||
.player_extra
|
||||
.get_or_insert_with(|| horizon_world::nbt::Tag::Compound(Default::default()));
|
||||
@@ -9767,6 +9867,8 @@ impl Game<'_> {
|
||||
} else {
|
||||
self.fov_degrees(camera_alpha as f32, camera.to_array())
|
||||
};
|
||||
self.locator_camera =
|
||||
(!title).then_some((camera.to_array(), view_yaw, view_pitch, fov_degrees));
|
||||
let bob = if title {
|
||||
glam::Mat4::IDENTITY
|
||||
} else {
|
||||
@@ -10030,12 +10132,13 @@ impl Game<'_> {
|
||||
let loading = self.loading_reason() == Some(horizon_client::level_loading::Reason::Other);
|
||||
let blurred = loading
|
||||
|| self.title.as_ref().is_some_and(|t| {
|
||||
self.screen == Screen::Options
|
||||
matches!(self.screen, Screen::Options | Screen::Win)
|
||||
|| !matches!(t.mode, horizon_client::title::Mode::Main)
|
||||
})
|
||||
|| (self.title.is_none()
|
||||
&& self.player.is_some()
|
||||
&& self.screen.blurs_background(self.pause_menu));
|
||||
&& (self.screen.blurs_background(self.pause_menu)
|
||||
|| (self.screen == Screen::Win && self.win_screen_pauses())));
|
||||
let radius = self.settings.menu_blur as f32;
|
||||
(blurred && radius >= 1.0).then_some(radius)
|
||||
}
|
||||
@@ -10119,6 +10222,7 @@ impl Game<'_> {
|
||||
Screen::WorldOptions => self.world_options_escape(),
|
||||
Screen::CommandBlock => self.close_command_block(),
|
||||
Screen::Chat => self.close_chat(),
|
||||
Screen::Win => self.close_win_screen()?,
|
||||
},
|
||||
KeyCode::Digit1
|
||||
| KeyCode::Digit2
|
||||
@@ -10450,6 +10554,10 @@ impl Game<'_> {
|
||||
if let (Some(lod), Some(player)) = (&mut self.lod, &self.player) {
|
||||
let lod_distance = self.settings.horizon.lod_distance_on(&renderer.info);
|
||||
lod.set_near_loading(self.streaming.busy());
|
||||
lod.set_reduced_detail(
|
||||
self.settings.horizon.far_detail
|
||||
== horizon_client::graphics::FarDetail::Reduced,
|
||||
);
|
||||
// The covered columns change only with the complete ones and
|
||||
// the render area: rebuilt then, not every frame.
|
||||
let at = (
|
||||
@@ -10494,6 +10602,7 @@ impl ApplicationHandler for Game<'_> {
|
||||
}
|
||||
// The Window constructor: created fullscreen when options.txt says so.
|
||||
let fullscreen = self.settings.fullscreen;
|
||||
let size = self.options.window_size.map(f64::from);
|
||||
self.fullscreen = horizon_client::window::FullscreenState::new(fullscreen);
|
||||
let attributes = Window::default_attributes()
|
||||
.with_title("Horizon")
|
||||
@@ -10505,7 +10614,7 @@ impl ApplicationHandler for Game<'_> {
|
||||
);
|
||||
let window = match event_loop.create_window(
|
||||
attributes
|
||||
.with_inner_size(winit::dpi::LogicalSize::new(1280.0, 720.0))
|
||||
.with_inner_size(winit::dpi::LogicalSize::new(size[0], size[1]))
|
||||
.with_fullscreen(fullscreen.then_some(winit::window::Fullscreen::Borderless(None))),
|
||||
) {
|
||||
Ok(w) => Arc::new(w),
|
||||
@@ -10515,6 +10624,7 @@ impl ApplicationHandler for Game<'_> {
|
||||
return;
|
||||
}
|
||||
};
|
||||
horizon_client::telemetry::note_display(&window);
|
||||
match self.new_renderer(window.clone()) {
|
||||
Ok(r) => {
|
||||
self.renderer = Some(r);
|
||||
@@ -10713,7 +10823,9 @@ impl ApplicationHandler for Game<'_> {
|
||||
}) * wheel_sensitivity
|
||||
};
|
||||
let d = scaled(raw_y) as f32;
|
||||
if let Some(title) = &mut self.title {
|
||||
if let Some(title) = &mut self.title
|
||||
&& self.screen != Screen::Win
|
||||
{
|
||||
let size = self
|
||||
.window
|
||||
.as_ref()
|
||||
@@ -10771,6 +10883,7 @@ impl ApplicationHandler for Game<'_> {
|
||||
self.advancements_scroll(scaled(raw_x) as f32, d);
|
||||
}
|
||||
}
|
||||
WindowEvent::MouseInput { .. } if self.screen == Screen::Win => {}
|
||||
WindowEvent::MouseInput { state, button, .. } if self.screen == Screen::Options => {
|
||||
if self.options_waiting_for_key() && state == ElementState::Pressed {
|
||||
self.options_bind_mouse(button)?;
|
||||
@@ -11027,6 +11140,9 @@ impl ApplicationHandler for Game<'_> {
|
||||
{
|
||||
self.world_options_key(&event)
|
||||
}
|
||||
WindowEvent::KeyboardInput { event, .. } if self.screen == Screen::Win => {
|
||||
self.win_key(&event)?
|
||||
}
|
||||
WindowEvent::KeyboardInput { event, .. } if self.screen == Screen::CommandBlock => {
|
||||
self.command_block_key(&event)
|
||||
}
|
||||
@@ -11160,6 +11276,7 @@ impl ApplicationHandler for Game<'_> {
|
||||
self.tick_ambience();
|
||||
self.tick_fov();
|
||||
}
|
||||
self.tick_win_screen()?;
|
||||
self.tick_music();
|
||||
self.tick_sound_engine();
|
||||
self.clock.record(started.elapsed());
|
||||
@@ -11587,6 +11704,7 @@ impl<'a> Game<'a> {
|
||||
respawn_config: None,
|
||||
respawn_travel: None,
|
||||
end_portal_travel: false,
|
||||
win: None,
|
||||
end: Default::default(),
|
||||
block_ticks: Ticks::new(0),
|
||||
tick_chunks: Default::default(),
|
||||
@@ -11604,6 +11722,10 @@ impl<'a> Game<'a> {
|
||||
armor_stands: Vec::new(),
|
||||
others: Vec::new(),
|
||||
armor_stand_assets: prepared.armor_stand_assets,
|
||||
waypoint_styles: prepared.waypoint_styles,
|
||||
waypoints: Default::default(),
|
||||
locator_camera: None,
|
||||
experience_display: Default::default(),
|
||||
boats: Vec::new(),
|
||||
boat_models: prepared.boat_models,
|
||||
crystal_model: prepared.crystal_model,
|
||||
@@ -11624,6 +11746,7 @@ impl<'a> Game<'a> {
|
||||
.join("assets/skins"),
|
||||
)),
|
||||
skin_slots: Vec::new(),
|
||||
trim_slots: Vec::new(),
|
||||
cart_models: prepared.cart_models,
|
||||
riding: None,
|
||||
ender_items: vec![None; 27],
|
||||
@@ -11645,6 +11768,8 @@ impl<'a> Game<'a> {
|
||||
item_columns: None,
|
||||
creative_screen: prepared.creative_screen,
|
||||
control: None,
|
||||
agent_control_on: false,
|
||||
agent_last: None,
|
||||
frame_stats: Default::default(),
|
||||
perf: Default::default(),
|
||||
pending_screenshot: None,
|
||||
@@ -11728,6 +11853,7 @@ struct GameCpuResources {
|
||||
map_frame_model: frames::FrameModel,
|
||||
glow_map_frame_model: frames::FrameModel,
|
||||
armor_stand_assets: armor_stands::ArmorStandAssets,
|
||||
waypoint_styles: horizon_client::locator_bar::WaypointStyles,
|
||||
boat_models: boats::BoatModels,
|
||||
cart_models: minecarts::CartModels,
|
||||
crystal_model: Option<horizon_client::mobs::Part>,
|
||||
@@ -11747,6 +11873,7 @@ impl GameCpuResources {
|
||||
map_frame_model: frames::bake_frame(root, "item_frame_map"),
|
||||
glow_map_frame_model: frames::bake_frame(root, "glow_item_frame_map"),
|
||||
armor_stand_assets: armor_stands::ArmorStandAssets::load(root, document.as_ref()),
|
||||
waypoint_styles: horizon_client::locator_bar::WaypointStyles::load(root),
|
||||
boat_models: boats::BoatModels::load(document.as_ref()),
|
||||
cart_models: minecarts::CartModels::load(document.as_ref()),
|
||||
crystal_model: model("minecraft:end_crystal#main"),
|
||||
@@ -12439,12 +12566,7 @@ fn start_game<'a>(
|
||||
game.mods.dev = game.options.dev_mods.clone();
|
||||
game.start_mods();
|
||||
}
|
||||
if let Some(path) = &game.options.control {
|
||||
let server = horizon_client::control::Server::start(path)
|
||||
.map_err(|e| format!("control socket {}: {e}", path.display()))?;
|
||||
log::info!("Horizon: control socket {}", server.path().display());
|
||||
game.control = Some(server);
|
||||
}
|
||||
game.start_agent_control()?;
|
||||
if let Some(scenario) = game.options.benchmark.clone() {
|
||||
let control = game
|
||||
.control
|
||||
@@ -12585,6 +12707,11 @@ impl Loading {
|
||||
}
|
||||
// The Window constructor: created fullscreen when options.txt says so.
|
||||
let fullscreen = self.settings.fullscreen;
|
||||
let size = self
|
||||
.options
|
||||
.as_ref()
|
||||
.map_or([1280, 720], |o| o.window_size)
|
||||
.map(f64::from);
|
||||
self.fullscreen = horizon_client::window::FullscreenState::new(fullscreen);
|
||||
let attributes = Window::default_attributes()
|
||||
.with_title("Horizon")
|
||||
@@ -12596,7 +12723,7 @@ impl Loading {
|
||||
);
|
||||
let window = match event_loop.create_window(
|
||||
attributes
|
||||
.with_inner_size(winit::dpi::LogicalSize::new(1280.0, 720.0))
|
||||
.with_inner_size(winit::dpi::LogicalSize::new(size[0], size[1]))
|
||||
.with_fullscreen(fullscreen.then_some(winit::window::Fullscreen::Borderless(None))),
|
||||
) {
|
||||
Ok(w) => Arc::new(w),
|
||||
@@ -12606,6 +12733,7 @@ impl Loading {
|
||||
return;
|
||||
}
|
||||
};
|
||||
horizon_client::telemetry::note_display(&window);
|
||||
let offscreen = self.options.as_ref().is_some_and(|o| o.offscreen);
|
||||
match loading_renderer(
|
||||
window.clone(),
|
||||
|
||||
@@ -405,6 +405,7 @@ impl Game<'_> {
|
||||
Screen::WorldOptions => "world-options",
|
||||
Screen::Friends => "friends",
|
||||
Screen::CommandBlock => "command-block",
|
||||
Screen::Win => "win",
|
||||
}
|
||||
}
|
||||
pub(super) fn inventory_counts(&self) -> BTreeMap<String, i64> {
|
||||
@@ -459,8 +460,11 @@ impl Game<'_> {
|
||||
"sneaking": self.keys.contains(&KeyCode::ShiftLeft),
|
||||
"sprinting": self.keys.contains(&KeyCode::ControlLeft),
|
||||
"health": p.health.health(),
|
||||
"absorption": p.health.absorption(),
|
||||
"air": p.air,
|
||||
"food": p.health.food.food,
|
||||
"saturation": p.health.food.saturation,
|
||||
"shield_cooldown": self.shield_cooldown,
|
||||
"xp": { "level": self.experience.level, "progress": self.experience.progress, "total": self.experience.total },
|
||||
"effects": self.potions.effects.list.iter().map(|e| json!({ "id": e.id, "duration_ticks": e.duration, "amplifier": e.amplifier })).collect::<Vec<_>>(),
|
||||
})
|
||||
@@ -478,6 +482,8 @@ impl Game<'_> {
|
||||
"sleeping": self.sleeping,
|
||||
// The item in use: its slot and for how many ticks.
|
||||
"using": self.using.map(|(slot, ticks)| json!({ "slot": slot, "ticks": ticks })),
|
||||
// The block being dug: how far along, after how many ticks.
|
||||
"mining": self.mining.as_ref().map(|m| json!({ "pos": m.pos, "progress": m.progress, "ticks": m.ticks })),
|
||||
// The arm swings started (LivingEntity.swing).
|
||||
"swings": self.swings_started,
|
||||
"time": { "day_time": self.world.day_time, "day": self.world.day_time.div_euclid(24000), "time_of_day": self.world.day_time.rem_euclid(24000) },
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
//! Agent control as a setting (Horizon Settings > AI Agent): when the control socket is open, who
|
||||
//! is connected, and the details an agent needs. The socket opens when the game was started for
|
||||
//! an agent (`--control`, `--control-socket`: horizon-ctl launch, the launcher's MCP `play`) or
|
||||
//! when the player turned Agent Control on, and never under No AI (`horizon_core::no_ai`).
|
||||
//! Turning either switch in the game opens or closes it at once.
|
||||
use super::Game;
|
||||
use horizon_client::{Result, control};
|
||||
use std::path::PathBuf;
|
||||
|
||||
impl Game<'_> {
|
||||
/// The endpoint: the one the command line gave, else the default horizon-ctl looks for.
|
||||
fn agent_socket_path(&self) -> PathBuf {
|
||||
self.options
|
||||
.control
|
||||
.clone()
|
||||
.unwrap_or_else(control::default_path)
|
||||
}
|
||||
|
||||
/// At startup: open the socket if the game was started for an agent or Agent Control is on.
|
||||
pub(super) fn start_agent_control(&mut self) -> Result<()> {
|
||||
self.agent_control_on = self.settings.extras.agent_control;
|
||||
if self.options.benchmark.is_some() && self.options.control.is_none() {
|
||||
return Ok(());
|
||||
}
|
||||
if self.settings.extras.no_ai {
|
||||
if self.options.control.is_some() {
|
||||
log::warn!(
|
||||
"Horizon: control socket not opened: {}",
|
||||
horizon_core::no_ai::REFUSAL
|
||||
);
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
if let Some(path) = &self.options.control {
|
||||
// Asked for on the command line: failing to listen is an error.
|
||||
let server = control::Server::start(path)
|
||||
.map_err(|e| format!("control socket {}: {e}", path.display()))?;
|
||||
log::info!("Horizon: control socket {}", server.path().display());
|
||||
self.control = Some(server);
|
||||
} else if self.agent_control_on {
|
||||
self.open_agent_socket();
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The setting's socket: a failure (another game holds the default endpoint) is logged and
|
||||
/// shown on the AI Agent page, not fatal.
|
||||
fn open_agent_socket(&mut self) {
|
||||
let path = self.agent_socket_path();
|
||||
match control::Server::start(&path) {
|
||||
Ok(server) => {
|
||||
log::info!("Horizon: control socket {}", server.path().display());
|
||||
self.control = Some(server);
|
||||
}
|
||||
Err(e) => log::warn!("Horizon: control socket {}: {e}", path.display()),
|
||||
}
|
||||
}
|
||||
|
||||
/// After a settings change: No AI closes the socket; Agent Control turned on or off opens or
|
||||
/// closes it.
|
||||
pub(super) fn sync_agent_control(&mut self) {
|
||||
let wanted = self.settings.extras.agent_control;
|
||||
let changed = wanted != self.agent_control_on;
|
||||
self.agent_control_on = wanted;
|
||||
let open = self
|
||||
.control
|
||||
.as_ref()
|
||||
.is_some_and(|c| !c.path().as_os_str().is_empty());
|
||||
if self.settings.extras.no_ai || (changed && !wanted) {
|
||||
if open {
|
||||
log::info!("Horizon: control socket closed");
|
||||
self.control = None;
|
||||
}
|
||||
} else if changed && wanted && self.control.is_none() {
|
||||
self.open_agent_socket();
|
||||
}
|
||||
}
|
||||
|
||||
/// An agent's command arrived.
|
||||
pub(super) fn agent_command_seen(&mut self) {
|
||||
self.agent_last = Some(std::time::Instant::now());
|
||||
}
|
||||
|
||||
/// The AI Agent page's lines: the state, then where agents connect.
|
||||
pub(super) fn agent_status_lines(&self) -> Vec<String> {
|
||||
let server = self
|
||||
.control
|
||||
.as_ref()
|
||||
.filter(|c| !c.path().as_os_str().is_empty());
|
||||
let mut lines = vec![match server {
|
||||
Some(s) if s.clients() > 0 => {
|
||||
let ago = self
|
||||
.agent_last
|
||||
.map(|t| format!(", last command {} s ago", t.elapsed().as_secs()))
|
||||
.unwrap_or_default();
|
||||
format!(
|
||||
"Connected: {} agent connection{}{ago}",
|
||||
s.clients(),
|
||||
if s.clients() == 1 { "" } else { "s" }
|
||||
)
|
||||
}
|
||||
Some(_) => "Waiting for an agent.".into(),
|
||||
None => "Off: agents can't reach this game.".into(),
|
||||
}];
|
||||
if let Some(s) = server {
|
||||
if self.options.control.is_some() && !self.agent_control_on {
|
||||
lines.push("Opened for this session by the program that started the game.".into());
|
||||
}
|
||||
// The full path is too wide for the page: Copy Connection Details has it.
|
||||
let name = s.path().file_name().unwrap_or_default().to_string_lossy();
|
||||
lines.push(format!(
|
||||
"Socket: {name} (copy the details for its full path)"
|
||||
));
|
||||
}
|
||||
lines.push("Local only: agents on this computer, never the network.".into());
|
||||
lines
|
||||
}
|
||||
|
||||
/// What Copy Connection Details puts on the clipboard: the socket, a horizon-ctl line to try,
|
||||
/// and an MCP client's config for this game.
|
||||
pub(super) fn agent_details(&self) -> String {
|
||||
let socket = self.agent_socket_path();
|
||||
let ctl = std::env::current_exe()
|
||||
.unwrap_or_default()
|
||||
.with_file_name(if cfg!(windows) {
|
||||
"horizon-ctl.exe"
|
||||
} else {
|
||||
"horizon-ctl"
|
||||
});
|
||||
let config = serde_json::json!({ "mcpServers": { "horizon-game": {
|
||||
"command": ctl, "args": ["--socket", socket, "mcp"] } } });
|
||||
format!(
|
||||
"Horizon agent control (this computer only)\nSocket: {}\nTry it: \"{}\" --socket \"{}\" state\nMCP config:\n{}\n",
|
||||
socket.display(),
|
||||
ctl.display(),
|
||||
socket.display(),
|
||||
serde_json::to_string_pretty(&config).unwrap_or_default()
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -8,12 +8,13 @@ use horizon_client::{
|
||||
items::ItemStack,
|
||||
mesher::Vertex,
|
||||
mobs::{Mob, Part, anim::Poses},
|
||||
trims::{TrimOverride, TrimRegistry, TrimTexture},
|
||||
};
|
||||
use horizon_core::{
|
||||
geometry::{Aabb, Vec3},
|
||||
random::RandomSource,
|
||||
};
|
||||
use std::{collections::HashMap, sync::Arc};
|
||||
use std::{collections::HashMap, path::PathBuf, sync::Arc};
|
||||
|
||||
/// Armor stand models and the humanoid layers of every equipment asset.
|
||||
pub(super) struct ArmorStandAssets {
|
||||
@@ -25,6 +26,11 @@ pub(super) struct ArmorStandAssets {
|
||||
pub player: HashMap<&'static str, Part>,
|
||||
/// asset id -> layer kind (humanoid / humanoid_leggings) -> (texture, undyed colour).
|
||||
pub equipment: HashMap<String, HashMap<String, (String, Option<u32>)>>,
|
||||
/// asset id -> `trim_overrides`.
|
||||
pub trim_overrides: HashMap<String, Vec<TrimOverride>>,
|
||||
pub trims: TrimRegistry,
|
||||
/// The resources the trim textures are made from.
|
||||
pub root: PathBuf,
|
||||
}
|
||||
impl ArmorStandAssets {
|
||||
pub fn load(
|
||||
@@ -48,6 +54,7 @@ impl ArmorStandAssets {
|
||||
player.insert("elytra", part);
|
||||
}
|
||||
let mut equipment = HashMap::new();
|
||||
let mut trim_overrides = HashMap::new();
|
||||
if let Ok(entries) = std::fs::read_dir(root.join("assets/minecraft/equipment")) {
|
||||
for e in entries.flatten() {
|
||||
let path = e.path();
|
||||
@@ -76,6 +83,7 @@ impl ArmorStandAssets {
|
||||
);
|
||||
}
|
||||
}
|
||||
trim_overrides.insert(format!("minecraft:{name}"), TrimOverride::list(&v));
|
||||
equipment.insert(format!("minecraft:{name}"), kinds);
|
||||
}
|
||||
}
|
||||
@@ -85,30 +93,67 @@ impl ArmorStandAssets {
|
||||
layers,
|
||||
player,
|
||||
equipment,
|
||||
trim_overrides,
|
||||
trims: TrimRegistry::load(root),
|
||||
root: root.to_owned(),
|
||||
}
|
||||
}
|
||||
/// Trim data of reloaded resources (the models stay).
|
||||
pub fn reload_trims(&mut self, root: &std::path::Path) {
|
||||
let fresh = Self::load(root, None);
|
||||
self.trim_overrides = fresh.trim_overrides;
|
||||
self.trims = fresh.trims;
|
||||
self.root = fresh.root;
|
||||
}
|
||||
/// `TrimTextureKey`: the trim texture of `stack` on its humanoid
|
||||
/// layer (`humanoid` or `humanoid_leggings`), if it has a trim.
|
||||
fn trim_texture(&self, asset: &str, kind: &str, stack: &ItemStack) -> Option<TrimTexture> {
|
||||
let (material, pattern) = stack.extra.as_ref()?.trim.as_ref()?;
|
||||
let overrides = self
|
||||
.trim_overrides
|
||||
.get(asset)
|
||||
.map_or(&[][..], Vec::as_slice);
|
||||
TrimTexture::resolve(&self.trims, overrides, material, pattern, kind)
|
||||
}
|
||||
}
|
||||
impl Game<'_> {
|
||||
/// `HumanoidArmorLayer` for the player: (model, texture rect, colour) of
|
||||
/// each worn piece, head to feet.
|
||||
pub(super) fn player_armor_layers(&self) -> Vec<(&Part, [f32; 4], u32)> {
|
||||
self.armor_layers(self.armor.each_ref().map(|s| s.as_ref().map(|s| &*s.item)))
|
||||
self.stack_armor_layers(&self.armor)
|
||||
}
|
||||
/// `HumanoidArmorLayer` for worn stacks (head to feet), trims included.
|
||||
pub(super) fn stack_armor_layers(
|
||||
&self,
|
||||
armor: &[Option<ItemStack>; 4],
|
||||
) -> Vec<(&Part, [f32; 4], u32)> {
|
||||
self.armor_layers(
|
||||
armor
|
||||
.each_ref()
|
||||
.map(|s| s.as_ref().map(|s| (&*s.item, Some(s)))),
|
||||
)
|
||||
}
|
||||
/// `HumanoidArmorLayer` for another player's model: the armour it wears
|
||||
/// (`extra.head` .. `extra.feet`).
|
||||
pub(super) fn worn_armor_layers(&self, m: &Mob) -> Vec<(&Part, [f32; 4], u32)> {
|
||||
let e = &m.extra;
|
||||
self.armor_layers([&e.head, &e.chest, &e.legs, &e.feet].map(|s| s.as_deref()))
|
||||
self.armor_layers(
|
||||
[&e.head, &e.chest, &e.legs, &e.feet].map(|s| s.as_deref().map(|i| (i, None))),
|
||||
)
|
||||
}
|
||||
/// The layers of the armour items `worn`, head to feet.
|
||||
fn armor_layers(&self, worn: [Option<&str>; 4]) -> Vec<(&Part, [f32; 4], u32)> {
|
||||
/// The layers of the armour items `worn` (with their stacks when known,
|
||||
/// for trims), head to feet.
|
||||
fn armor_layers(
|
||||
&self,
|
||||
worn: [Option<(&str, Option<&ItemStack>)>; 4],
|
||||
) -> Vec<(&Part, [f32; 4], u32)> {
|
||||
let assets = &self.armor_stand_assets;
|
||||
let mut out = Vec::new();
|
||||
for (item, layer) in worn
|
||||
.into_iter()
|
||||
.zip(["helmet", "chestplate", "leggings", "boots"])
|
||||
{
|
||||
let Some(item) = item else {
|
||||
let Some((item, stack)) = item else {
|
||||
continue;
|
||||
};
|
||||
let Some(asset) = self
|
||||
@@ -138,9 +183,117 @@ impl Game<'_> {
|
||||
0xFF00_0000 | (c & 0xFF) << 16 | (c & 0xFF00) | (c >> 16) & 0xFF
|
||||
});
|
||||
out.push((part, [r.u0, r.v0, r.u1, r.v1], color));
|
||||
// EquipmentLayerRenderer: the trim layer over the armour.
|
||||
if let Some(r) = stack
|
||||
.and_then(|s| assets.trim_texture(&asset, kind, s))
|
||||
.and_then(|t| self.gui.atlas.sprites.get(&t.sprite()))
|
||||
{
|
||||
out.push((part, [r.u0, r.v0, r.u1, r.v1], 0xFFFF_FFFF));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
/// `PalettedTextureManager.getOrPrepare` for the trims drawn this frame
|
||||
/// (the player's armour and the armour stands'): each generated texture
|
||||
/// goes into an atlas trim slot, a free one, else the least recently
|
||||
/// drawn.
|
||||
pub(super) fn prepare_trims(&mut self) {
|
||||
use horizon_client::hud::TRIM_SLOTS;
|
||||
let assets = &self.armor_stand_assets;
|
||||
let items = self.res.items();
|
||||
let mut wanted: Vec<TrimTexture> = Vec::new();
|
||||
let guests = self.lan.iter().flat_map(|host| host.hub.guests.iter());
|
||||
let stacks = self
|
||||
.armor
|
||||
.iter()
|
||||
.chain(guests.flat_map(|g| g.armor.iter()))
|
||||
.zip(
|
||||
["helmet", "chestplate", "leggings", "boots"]
|
||||
.into_iter()
|
||||
.cycle(),
|
||||
)
|
||||
.chain(self.armor_stands.iter().flat_map(|a| {
|
||||
[5usize, 4, 3, 2]
|
||||
.map(|slot| &a.equipment[slot])
|
||||
.into_iter()
|
||||
.zip(["helmet", "chestplate", "leggings", "boots"])
|
||||
}));
|
||||
for (stack, layer) in stacks {
|
||||
let Some(stack) = stack.as_ref().filter(|s| s.extra.is_some()) else {
|
||||
continue;
|
||||
};
|
||||
let Some(asset) = items
|
||||
.get(&stack.item)
|
||||
.and_then(|i| i.equip_asset.as_deref())
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let kind = if layer == "leggings" {
|
||||
"humanoid_leggings"
|
||||
} else {
|
||||
"humanoid"
|
||||
};
|
||||
if let Some(t) = assets.trim_texture(asset, kind, stack)
|
||||
&& !wanted.contains(&t)
|
||||
{
|
||||
wanted.push(t);
|
||||
}
|
||||
}
|
||||
for t in wanted {
|
||||
let name = t.sprite();
|
||||
// Entries the atlas no longer has (rebuilt by a resource reload) are free.
|
||||
let sprites = &self.gui.atlas.sprites;
|
||||
self.trim_slots.retain(|(n, _)| sprites.contains_key(n));
|
||||
if let Some(i) = self.trim_slots.iter().position(|e| e.0 == name) {
|
||||
let e = self.trim_slots.remove(i);
|
||||
self.trim_slots.push(e);
|
||||
continue;
|
||||
}
|
||||
let Some((w, h, rgba)) = t.generate(&self.armor_stand_assets.root) else {
|
||||
continue;
|
||||
};
|
||||
let used: Vec<usize> = self.trim_slots.iter().map(|e| e.1).collect();
|
||||
let slot = match (0..TRIM_SLOTS).find(|i| !used.contains(i)) {
|
||||
Some(free) => free,
|
||||
None => {
|
||||
let (old, slot) = self.trim_slots.remove(0);
|
||||
self.gui.atlas.sprites.remove(&old);
|
||||
slot
|
||||
}
|
||||
};
|
||||
let Some(area) = self
|
||||
.gui
|
||||
.atlas
|
||||
.sprites
|
||||
.get(&format!("horizon/trim{slot}"))
|
||||
.copied()
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
if w > area.w || h > area.h {
|
||||
continue;
|
||||
}
|
||||
let (aw, ah) = (self.gui.atlas.width as f32, self.gui.atlas.height as f32);
|
||||
let (x, y) = ((area.u0 * aw).round(), (area.v0 * ah).round());
|
||||
self.map_patches.push(horizon_client::font::Patch {
|
||||
x: x as u32,
|
||||
y: y as u32,
|
||||
width: w,
|
||||
height: h,
|
||||
rgba,
|
||||
});
|
||||
let rect = horizon_client::hud::Rect {
|
||||
u0: area.u0,
|
||||
v0: area.v0,
|
||||
u1: (x + w as f32) / aw,
|
||||
v1: (y + h as f32) / ah,
|
||||
w,
|
||||
h,
|
||||
};
|
||||
self.gui.atlas.sprites.insert(name.clone(), rect);
|
||||
self.trim_slots.push((name, slot));
|
||||
}
|
||||
}
|
||||
/// `ArmorStandItem.useOn`.
|
||||
pub(super) fn place_armor_stand(&mut self, hit: &super::Hit, stack: &ItemStack) -> bool {
|
||||
if &*stack.item != "minecraft:armor_stand" || hit.face == [0, -1, 0] {
|
||||
@@ -564,6 +717,22 @@ impl Game<'_> {
|
||||
0,
|
||||
out,
|
||||
);
|
||||
// EquipmentLayerRenderer: the trim layer over the armour.
|
||||
if let Some(trim) = assets
|
||||
.trim_texture(&asset, kind, stack)
|
||||
.and_then(|t| uv_of(&t.sprite()))
|
||||
{
|
||||
horizon_client::mobs::render::draw(
|
||||
part,
|
||||
base,
|
||||
&layer_poses,
|
||||
trim,
|
||||
0xFFFF_FFFF,
|
||||
packed,
|
||||
0,
|
||||
out,
|
||||
);
|
||||
}
|
||||
}
|
||||
self.cull_entity_vertices(out, entity_start);
|
||||
}
|
||||
|
||||
@@ -294,6 +294,18 @@ impl Game<'_> {
|
||||
}
|
||||
// ---- entities -------------------------------------------------------
|
||||
|
||||
/// The level `t` is in (`Entity.level`): a server's players have their
|
||||
/// own, which need not be the one in place; everything else is in the
|
||||
/// level in place.
|
||||
pub(super) fn target_level(&self, t: Target) -> Dim {
|
||||
let conn = match t {
|
||||
Target::Player => self.lan_acting,
|
||||
Target::Guest(conn) => Some(conn),
|
||||
_ => None,
|
||||
};
|
||||
conn.and_then(|c| self.command_guest(c))
|
||||
.map_or(self.dim, |g| Dim::from_id(&g.dimension))
|
||||
}
|
||||
pub(super) fn target_position(&self, t: Target) -> Option<[f64; 3]> {
|
||||
match t {
|
||||
Target::Player => self
|
||||
@@ -1191,15 +1203,12 @@ impl Game<'_> {
|
||||
pos: [f64; 3],
|
||||
rotation: Option<[f32; 2]>,
|
||||
) -> bool {
|
||||
let (conn, at) = match t {
|
||||
Target::Player => (self.lan_acting, self.dim),
|
||||
Target::Guest(conn) => (
|
||||
Some(conn),
|
||||
self.command_guest(conn)
|
||||
.map_or(self.dim, |g| super::Dim::from_id(&g.dimension)),
|
||||
),
|
||||
_ => (None, self.dim),
|
||||
let conn = match t {
|
||||
Target::Player => self.lan_acting,
|
||||
Target::Guest(conn) => Some(conn),
|
||||
_ => None,
|
||||
};
|
||||
let at = self.target_level(t);
|
||||
let Some(conn) = conn.filter(|_| at != level && self.options.dedicated.is_some()) else {
|
||||
return false;
|
||||
};
|
||||
@@ -1222,12 +1231,7 @@ impl Game<'_> {
|
||||
let dest = self.entities(ctx, "destination", false)?[0];
|
||||
let pos = self.target_position(dest).unwrap_or(self.source_position());
|
||||
// Into the destination's level.
|
||||
let level = match dest {
|
||||
Target::Guest(conn) => self
|
||||
.command_guest(conn)
|
||||
.map_or(self.dim, |g| super::Dim::from_id(&g.dimension)),
|
||||
_ => self.dim,
|
||||
};
|
||||
let level = self.target_level(dest);
|
||||
// TeleportCommand.teleportToEntity: the destination's rotation too.
|
||||
let rotation = Some(self.target_rotation(dest));
|
||||
for &t in &targets {
|
||||
@@ -2281,12 +2285,23 @@ impl Game<'_> {
|
||||
};
|
||||
let yaw = ((rot[0] + 180.0).rem_euclid(360.0)) - 180.0;
|
||||
let pitch = rot[1].clamp(-90.0, 90.0);
|
||||
self.spawn = Some(Vec3::new(
|
||||
f64::from(pos[0]) + 0.5,
|
||||
f64::from(pos[1]),
|
||||
f64::from(pos[2]) + 0.5,
|
||||
));
|
||||
self.respawn_config = None;
|
||||
// SetSpawnCommand: a forced point in the source's level.
|
||||
if self.dim == Dim::Overworld {
|
||||
self.spawn = Some(Vec3::new(
|
||||
f64::from(pos[0]) + 0.5,
|
||||
f64::from(pos[1]),
|
||||
f64::from(pos[2]) + 0.5,
|
||||
));
|
||||
self.respawn_config = None;
|
||||
} else {
|
||||
self.spawn = None;
|
||||
self.respawn_config = Some(super::respawn_anchor::RespawnConfig {
|
||||
dim: self.dim,
|
||||
pos,
|
||||
yaw,
|
||||
forced: true,
|
||||
});
|
||||
}
|
||||
let dim = Text::literal(self.dim.id());
|
||||
let p = pos.map(|v| Text::literal(v.to_string()));
|
||||
let mut args = vec![
|
||||
@@ -2322,7 +2337,20 @@ impl Game<'_> {
|
||||
Some(Arg::Coords(c)) => c.rotation(self.source_rotation()),
|
||||
_ => [0.0, 0.0],
|
||||
};
|
||||
self.level.spawn = Some([f64::from(pos[0]), f64::from(pos[1]), f64::from(pos[2])]);
|
||||
// LevelData.setRespawnData: the world's spawn (level.dat `spawn`).
|
||||
let old = self.level.world_spawn.replace(pos);
|
||||
self.level.world_spawn_angle = rot;
|
||||
// A player whose fallback is the world spawn (no spawnpoint of its
|
||||
// own; a bed or an anchor that is gone falls back to it) respawns at
|
||||
// the new one.
|
||||
let floor = |s: Vec3| [s.x.floor() as i32, s.y.floor() as i32, s.z.floor() as i32];
|
||||
if self.spawn.map(floor) == old {
|
||||
self.spawn = Some(Vec3::new(
|
||||
f64::from(pos[0]) + 0.5,
|
||||
f64::from(pos[1]),
|
||||
f64::from(pos[2]) + 0.5,
|
||||
));
|
||||
}
|
||||
let args = [
|
||||
Text::literal(pos[0].to_string()),
|
||||
Text::literal(pos[1].to_string()),
|
||||
|
||||
@@ -155,14 +155,20 @@ impl Game<'_> {
|
||||
_ => s.pos,
|
||||
}
|
||||
}
|
||||
/// Evaluate `f` with `source` as the command source.
|
||||
/// Evaluate `f` with `source` as the command source, in the source's
|
||||
/// level (`CommandSourceStack.getLevel`): a server puts that level in
|
||||
/// place for it.
|
||||
pub(in crate::game) fn with_source<T>(
|
||||
&mut self,
|
||||
source: Source,
|
||||
f: impl FnOnce(&mut Self) -> T,
|
||||
) -> T {
|
||||
let previous = self.command_source.replace(source);
|
||||
let back = self.enter_command_level(source.dimension);
|
||||
let out = f(self);
|
||||
if let Some(back) = back {
|
||||
self.enter_command_level(back);
|
||||
}
|
||||
self.command_source = previous;
|
||||
out
|
||||
}
|
||||
@@ -447,11 +453,13 @@ impl Game<'_> {
|
||||
"at" => targets(self)?
|
||||
.into_iter()
|
||||
.filter_map(|t| {
|
||||
// CommandSourceStack.withLevel(entity.level()).
|
||||
let level = self.target_level(t);
|
||||
Some(Source {
|
||||
pos: self.target_position(t)?,
|
||||
rot: self.target_rotation(t),
|
||||
dimension: self.dim,
|
||||
level: None,
|
||||
dimension: level,
|
||||
level: (level != self.dim).then_some(level),
|
||||
..s
|
||||
})
|
||||
})
|
||||
|
||||
@@ -42,6 +42,7 @@ const HELP: &[&str] = &[
|
||||
"chat MESSAGE send chat or a /command to the server",
|
||||
"disconnect leave the server for the server list",
|
||||
"respawn respawn after death on a server",
|
||||
"credits [poem] [SCROLL] roll the credits (with the end poem), or hold them SCROLL pixels in",
|
||||
"quit save and exit",
|
||||
];
|
||||
|
||||
@@ -117,6 +118,7 @@ impl Game<'_> {
|
||||
self.answer(request, event_loop);
|
||||
}
|
||||
while let Some(request) = self.control.as_ref().and_then(|c| c.try_recv()) {
|
||||
self.agent_command_seen();
|
||||
self.answer(request, event_loop);
|
||||
if self.pending_save.is_some() {
|
||||
break;
|
||||
@@ -125,6 +127,11 @@ impl Game<'_> {
|
||||
if self.control.is_some() {
|
||||
self.watch_events();
|
||||
}
|
||||
if self.screen == Screen::Options
|
||||
&& self.ui.options.page() == horizon_client::options_ui::Page::Agent
|
||||
{
|
||||
self.ui.options.agent_status = self.agent_status_lines();
|
||||
}
|
||||
}
|
||||
|
||||
fn answer(&mut self, request: horizon_client::control::Request, event_loop: &ActiveEventLoop) {
|
||||
@@ -165,6 +172,9 @@ impl Game<'_> {
|
||||
if let Some(value) = self.events_command(&words, reply)? {
|
||||
return Ok(value);
|
||||
}
|
||||
if let Some(value) = self.saves_control(&words, line, reply)? {
|
||||
return Ok(value);
|
||||
}
|
||||
if let Some(value) = self.options_control(&words, line)? {
|
||||
return Ok(Some(value));
|
||||
}
|
||||
@@ -192,6 +202,7 @@ impl Game<'_> {
|
||||
.chain(super::ui_control::HELP)
|
||||
.chain(super::options::CONTROL_HELP)
|
||||
.chain(super::events::HELP)
|
||||
.chain(super::saves_control::HELP)
|
||||
.chain(super::pad::HELP)
|
||||
.collect::<Vec<_>>() }))),
|
||||
"setblock" | "fill" | "particle" | "signedit" => self.render_command(&words),
|
||||
@@ -564,7 +575,9 @@ impl Game<'_> {
|
||||
"graphics" => Page::Graphics,
|
||||
"graphics-advanced" => Page::GraphicsAdvanced,
|
||||
"extras" => Page::Extras,
|
||||
"credits" => Page::Credits,
|
||||
"horizon" => Page::Horizon,
|
||||
"agent" => Page::Agent,
|
||||
"shader-packs" => Page::ShaderPacks,
|
||||
other => return Err(format!("unknown options page {other}").into()),
|
||||
};
|
||||
@@ -641,6 +654,25 @@ impl Game<'_> {
|
||||
self.connect_server(arg(1)?, &name)?;
|
||||
ok()
|
||||
}
|
||||
"credits" => {
|
||||
let poem = words.get(1) == Some(&"poem");
|
||||
let hold = words
|
||||
.get(1 + usize::from(poem))
|
||||
.map(|s| s.parse::<f32>())
|
||||
.transpose()?;
|
||||
if self.screen != Screen::Win {
|
||||
let finish = if self.screen == Screen::Options {
|
||||
super::win::Finish::Options
|
||||
} else {
|
||||
super::win::Finish::Closed
|
||||
};
|
||||
self.open_win_screen(poem, finish);
|
||||
}
|
||||
if let Some(scroll) = hold {
|
||||
self.hold_win_screen(scroll);
|
||||
}
|
||||
ok()
|
||||
}
|
||||
"respawn" => {
|
||||
if self.remote.is_none() || self.screen != Screen::Dead {
|
||||
return Err("respawn needs the death screen on a server".into());
|
||||
@@ -723,6 +755,9 @@ impl Game<'_> {
|
||||
}
|
||||
|
||||
fn screen_name(&self) -> &'static str {
|
||||
if self.screen == Screen::Win {
|
||||
return "win";
|
||||
}
|
||||
if self.title.is_some() {
|
||||
return "title";
|
||||
}
|
||||
@@ -740,6 +775,7 @@ impl Game<'_> {
|
||||
Screen::WorldOptions => "world-options",
|
||||
Screen::Friends => "friends",
|
||||
Screen::CommandBlock => "command-block",
|
||||
Screen::Win => "win",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -850,6 +886,8 @@ impl Game<'_> {
|
||||
"source_attempted": self.lod_source_attempted,
|
||||
"active_tiles": self.lod.as_ref().map_or(0, |lod| lod.stats().0),
|
||||
"cached_tiles": self.lod.as_ref().map_or(0, |lod| lod.stats().1),
|
||||
"far_detail": self.settings.horizon.far_detail.name(),
|
||||
"resident_quads": self.renderer.as_ref().map_or(0, |r| r.lod_quads()),
|
||||
"pending_observations": self.lod_observe.len(),
|
||||
"refined_samples": self.lod.as_ref().map_or(0, |lod| lod.refined_samples()),
|
||||
});
|
||||
@@ -906,7 +944,7 @@ impl Game<'_> {
|
||||
"streaming": self.streaming.stages().into_iter().map(|(k, v)| (k.to_string(), v.into())).collect::<serde_json::Map<_, _>>(),
|
||||
"dirty_light": self.world.dirty_light.len(),
|
||||
"renderer": renderer,
|
||||
"display": self.window.as_ref().and_then(|w| w.current_monitor()).map(|m| json!({
|
||||
"display": self.window.as_deref().and_then(horizon_client::telemetry::monitor).map(|m| json!({
|
||||
"width": m.size().width,
|
||||
"height": m.size().height,
|
||||
"scale": m.scale_factor(),
|
||||
|
||||
@@ -343,8 +343,12 @@ impl Game<'_> {
|
||||
"on_ground": m.on_ground,
|
||||
"variant": m.extra.variant,
|
||||
"color": m.extra.color,
|
||||
"sheared": m.extra.sheared,
|
||||
"burning": m.fire_ticks > 0,
|
||||
"size": m.extra.size,
|
||||
"leash": m.extra.leash.map(|h| format!("{h:?}")),
|
||||
"tame": m.extra.tame,
|
||||
"sitting": m.extra.sitting,
|
||||
"temper": m.extra.variant3,
|
||||
"chested": m.extra.chested,
|
||||
"head": m.extra.head.as_deref(),
|
||||
|
||||
@@ -291,12 +291,12 @@ impl Game<'_> {
|
||||
used = self.fixture_use_item(origin, op)?;
|
||||
} else if let Some(op) = case.get("respawn_at") {
|
||||
// ServerPlayer.findRespawnAndUseSpawnBlock for a respawn point at the origin.
|
||||
let config = super::respawn_anchor::RespawnConfig {
|
||||
dim: self.dim,
|
||||
pos: [origin.x, origin.y, origin.z],
|
||||
yaw: 0.0,
|
||||
forced: op["forced"].as_bool().unwrap_or(false),
|
||||
};
|
||||
let config = super::respawn_anchor::RespawnConfig::wrapped(
|
||||
self.dim,
|
||||
[origin.x, origin.y, origin.z],
|
||||
op["yaw"].as_f64().map_or(0.0, |v| v as f32),
|
||||
op["forced"].as_bool().unwrap_or(false),
|
||||
);
|
||||
let found =
|
||||
self.find_respawn_and_use(config, op["consume"].as_bool().unwrap_or(true))?;
|
||||
used.push(match found {
|
||||
|
||||
@@ -2697,9 +2697,12 @@ impl Game<'_> {
|
||||
velocity: [d.velocity.x, d.velocity.y, d.velocity.z],
|
||||
data: 0,
|
||||
meta,
|
||||
defaults: Vec::new(),
|
||||
equipment: Vec::new(),
|
||||
attributes: Vec::new(),
|
||||
precise: false,
|
||||
needs_sync: false,
|
||||
leash: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@ const GATEWAY_FILE: &str = "horizon-end-gateways.json";
|
||||
|
||||
/// A gateway teleport waiting for its destination chunks.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
enum GatewayJob {
|
||||
pub(super) enum GatewayJob {
|
||||
/// TheEndGatewayBlockEntity.findOrCreateValidTeleportPos from this gateway.
|
||||
Find { gateway: [i32; 3] },
|
||||
/// A known exit (exact, or findExitPosition around it).
|
||||
@@ -342,7 +342,10 @@ impl Game<'_> {
|
||||
f64::from(p.dimensions.width),
|
||||
f64::from(p.dimensions.height),
|
||||
);
|
||||
if portal {
|
||||
if portal && self.dim == Dim::End && !self.level.seen_credits {
|
||||
// EndPortalBlock.entityInside: the first exit shows the credits.
|
||||
self.show_end_credits()?;
|
||||
} else if portal {
|
||||
if self.portals.player.cooldown == 0 {
|
||||
let target = if self.dim == Dim::End {
|
||||
Dim::Overworld
|
||||
@@ -413,7 +416,15 @@ impl Game<'_> {
|
||||
f64::from(END_SPAWN[2]) + 0.5,
|
||||
));
|
||||
}
|
||||
// Leaving the End: the respawn position (the world spawn here).
|
||||
// Leaving the End: the respawn position, a bed's found once it has
|
||||
// loaded (findRespawnPositionAndUseSpawnBlock, not using it up).
|
||||
if let Some(c) = self.respawn_config.filter(|c| c.dim == target) {
|
||||
return Some(Vec3::new(
|
||||
f64::from(c.pos[0]) + 0.5,
|
||||
f64::from(c.pos[1]),
|
||||
f64::from(c.pos[2]) + 0.5,
|
||||
));
|
||||
}
|
||||
let s = self.level.spawn.unwrap_or([0.0, 64.0, 0.0]);
|
||||
Some(Vec3::new(s[0].floor() + 0.5, s[1], s[2].floor() + 0.5))
|
||||
}
|
||||
@@ -484,7 +495,16 @@ impl Game<'_> {
|
||||
// Direction.WEST.toYRot().
|
||||
return Ok((target, Some(90.0)));
|
||||
}
|
||||
// ServerPlayer.findRespawnPositionAndUseSpawnBlock: onto the spawn column.
|
||||
// ServerPlayer.findRespawnPositionAndUseSpawnBlock: where the bed or
|
||||
// anchor puts the player, facing it; else onto the spawn column.
|
||||
let mut target = target;
|
||||
if let Some(c) = self.respawn_config.filter(|c| c.dim == self.dim) {
|
||||
if let Some((at, yaw)) = self.find_respawn_and_use(c, false)? {
|
||||
return Ok((at, Some(yaw)));
|
||||
}
|
||||
let s = self.level.spawn.unwrap_or([0.0, 64.0, 0.0]);
|
||||
target = Vec3::new(s[0].floor() + 0.5, s[1], s[2].floor() + 0.5);
|
||||
}
|
||||
let y = self
|
||||
.land_column(target.x.floor() as i32, target.z.floor() as i32)
|
||||
.map_or(target.y, f64::from);
|
||||
@@ -700,6 +720,20 @@ impl Game<'_> {
|
||||
fn enter_gateway(&mut self, gateway: [i32; 3]) -> Result<()> {
|
||||
// TheEndGatewayBlockEntity.triggerCooldown: 40 ticks.
|
||||
self.end.gateway_cooldown = 40;
|
||||
let (job, destination) = self.gateway_job(gateway);
|
||||
let Some(player) = self.player.take() else {
|
||||
return Ok(());
|
||||
};
|
||||
self.end.job = Some(job);
|
||||
self.end_travel = true;
|
||||
self.travelling = Some((player, destination));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Where `gateway` leads (TheEndGatewayBlockEntity.getPortalPosition):
|
||||
/// its exit, or the search for one, and the spot to load before either
|
||||
/// is resolved ([`Game::resolve_gateway`]).
|
||||
pub(super) fn gateway_job(&mut self, gateway: [i32; 3]) -> (GatewayJob, Vec3) {
|
||||
let known = self.gateway_exits().get(&gateway).copied();
|
||||
let job = match known {
|
||||
Some((exit, exact)) => GatewayJob::Known { exit, exact },
|
||||
@@ -725,13 +759,7 @@ impl Game<'_> {
|
||||
Vec3::new(d.x, 75.0, d.y)
|
||||
}
|
||||
};
|
||||
let Some(player) = self.player.take() else {
|
||||
return Ok(());
|
||||
};
|
||||
self.end.job = Some(job);
|
||||
self.end_travel = true;
|
||||
self.travelling = Some((player, destination));
|
||||
Ok(())
|
||||
(job, destination)
|
||||
}
|
||||
|
||||
fn chunk_empty(&self, x: f64, z: f64) -> Option<bool> {
|
||||
@@ -768,7 +796,7 @@ impl Game<'_> {
|
||||
|
||||
/// The teleport position of a gateway job, creating the exit gateway (and
|
||||
/// an island when the chunk has no landing spot) for new destinations.
|
||||
fn resolve_gateway(&mut self, job: GatewayJob, target: Vec3) -> Result<Vec3> {
|
||||
pub(super) fn resolve_gateway(&mut self, job: GatewayJob, target: Vec3) -> Result<Vec3> {
|
||||
let bottom_center = |p: [i32; 3]| {
|
||||
Vec3::new(
|
||||
f64::from(p[0]) + 0.5,
|
||||
|
||||
@@ -224,7 +224,7 @@ impl Game<'_> {
|
||||
}
|
||||
set.entries().map(|(k, _)| k.0).collect()
|
||||
}
|
||||
/// `ServerExplosion.hurtEntities` (player, mobs, items, primed TNT);
|
||||
/// `ServerExplosion.hurtEntities` (player, mobs, items, primed TNT, minecarts);
|
||||
/// the player takes `damage_type`.
|
||||
/// `ServerExplosion.hurtEntities` for the player: damage scaled by
|
||||
/// distance and exposure, through a raised shield (`hurtServer` blocks
|
||||
@@ -460,6 +460,34 @@ impl Game<'_> {
|
||||
);
|
||||
}
|
||||
self.tnts = tnts;
|
||||
// Minecarts: VehicleEntity.hurtServer (explosions ignite TNT carts),
|
||||
// then the push from their eyes (0.85 of their height).
|
||||
let ids: Vec<u32> = self.carts.iter().map(|c| c.id).collect();
|
||||
for id in ids {
|
||||
let Some(i) = self.carts.iter().position(|c| c.id == id) else {
|
||||
continue;
|
||||
};
|
||||
let bounds = self.carts[i].bounds();
|
||||
let feet = self.carts[i].position;
|
||||
let dist = distance([feet.x, feet.y, feet.z]) / diameter;
|
||||
if dist > 1.0 {
|
||||
continue;
|
||||
}
|
||||
let (lo, hi) = (bounds.min_position(), bounds.max_position());
|
||||
let exposure = self.seen_percent(center, [lo.x, lo.y, lo.z], [hi.x, hi.y, hi.z]);
|
||||
self.hurt_cart(i, damage(dist, exposure), false, true);
|
||||
let Some(c) = self.carts.iter_mut().find(|c| c.id == id) else {
|
||||
continue;
|
||||
};
|
||||
let eye_height = f64::from((hi.y - lo.y) as f32 * 0.85f32);
|
||||
let dir = direction([feet.x, feet.y + eye_height, feet.z]);
|
||||
let power = (1.0 - dist) * f64::from(exposure);
|
||||
c.velocity = Vec3::new(
|
||||
c.velocity.x + dir[0] * power,
|
||||
c.velocity.y + dir[1] * power,
|
||||
c.velocity.z + dir[2] * power,
|
||||
);
|
||||
}
|
||||
}
|
||||
/// `Level.explode` on the server: positions, entities, blocks, fire; then
|
||||
/// the client's explosion sound.
|
||||
|
||||
@@ -6,10 +6,12 @@
|
||||
//! the world's options) with [`Integrated::call`], as vanilla's client calls
|
||||
//! into its `IntegratedServer`.
|
||||
//!
|
||||
//! Opt-in again (`--server-thread` or `HORIZON_SERVER_THREAD=1`): it was the
|
||||
//! default for one day, but a server's players can't board, ride or hit
|
||||
//! boats and minecarts yet (#1222), and single player's window is one of
|
||||
//! them here.
|
||||
//! Opt-in again (`--server-thread` or `HORIZON_SERVER_THREAD=1`) until a
|
||||
//! server's players reach parity with the in-frame world: on the server
|
||||
//! thread, signs open no editor, books don't open, a totem doesn't save its
|
||||
//! holder and death keeps the inventory (#1380, #1366, #1292, #1284 and the
|
||||
//! other `[server-thread]` issues). LAN guests and dedicated servers use the
|
||||
//! same code either way.
|
||||
use super::{
|
||||
Game, Options, PackResources, Resources, UiResources,
|
||||
lan::{LanSettings, Owner},
|
||||
|
||||
@@ -14,7 +14,7 @@ use horizon_client::{
|
||||
hub::{HostEvent, HostState, Hub, HubConfig, TickCtx},
|
||||
playerdata,
|
||||
},
|
||||
mobs::Mob,
|
||||
mobs::{Mob, leash::Holder},
|
||||
text::Text,
|
||||
};
|
||||
use horizon_core::geometry::Vec3;
|
||||
@@ -91,7 +91,7 @@ pub(super) fn entity_uuid(id: u32) -> u128 {
|
||||
/// The SynchedEntityData index of the baby flag: AgeableMob.DATA_BABY_ID and
|
||||
/// Zombie.DATA_BABY_ID are both the first entry after Mob's (16); Piglin's
|
||||
/// comes after AbstractPiglin's zombification flag (17).
|
||||
fn baby_index(kind: &str) -> Option<u8> {
|
||||
pub(super) fn baby_index(kind: &str) -> Option<u8> {
|
||||
match kind {
|
||||
"minecraft:piglin" => Some(17),
|
||||
"minecraft:cow"
|
||||
@@ -139,7 +139,13 @@ fn baby_index(kind: &str) -> Option<u8> {
|
||||
/// A mob's synched data as ServerEntity sends it (only the values that differ
|
||||
/// from the defaults the client constructs), with vanilla's entry indices:
|
||||
/// Entity 0..=7, LivingEntity 8..=14, Mob 15, then the subclasses.
|
||||
fn mob_meta(m: &Mob, hub: &Hub, meta: &mut Vec<(u8, i32, Value)>) {
|
||||
/// `defaults` gets the defaults of the entries that can go back to theirs.
|
||||
fn mob_meta(
|
||||
m: &Mob,
|
||||
hub: &Hub,
|
||||
meta: &mut Vec<(u8, i32, Value)>,
|
||||
defaults: &mut Vec<(u8, i32, Value)>,
|
||||
) {
|
||||
let kind = m.kind.id.as_str();
|
||||
if m.fire_ticks > 0 {
|
||||
meta.push((0, serializer::BYTE, Value::Byte(1)));
|
||||
@@ -159,15 +165,18 @@ fn mob_meta(m: &Mob, hub: &Hub, meta: &mut Vec<(u8, i32, Value)>) {
|
||||
if health != 1.0 {
|
||||
meta.push((9, serializer::FLOAT, Value::Float(health)));
|
||||
}
|
||||
defaults.push((9, serializer::FLOAT, Value::Float(1.0)));
|
||||
// Mob.DATA_MOB_FLAGS_ID: no AI 1, left-handed 2.
|
||||
let flags = i8::from(m.no_ai) | i8::from(m.extra.left_handed) << 1;
|
||||
if flags != 0 {
|
||||
meta.push((15, serializer::BYTE, Value::Byte(flags)));
|
||||
}
|
||||
if m.is_baby()
|
||||
&& let Some(index) = baby_index(kind)
|
||||
{
|
||||
meta.push((index, serializer::BOOLEAN, Value::Bool(true)));
|
||||
// AgeableMob.DATA_BABY_ID (and Zombie's, Piglin's).
|
||||
if let Some(index) = baby_index(kind) {
|
||||
if m.is_baby() {
|
||||
meta.push((index, serializer::BOOLEAN, Value::Bool(true)));
|
||||
}
|
||||
defaults.push((index, serializer::BOOLEAN, Value::Bool(false)));
|
||||
}
|
||||
let e = &m.extra;
|
||||
let id = |registry: &str, name: &str| hub.registry_id(registry, &format!("minecraft:{name}"));
|
||||
@@ -178,6 +187,7 @@ fn mob_meta(m: &Mob, hub: &Hub, meta: &mut Vec<(u8, i32, Value)>) {
|
||||
if value != 0 {
|
||||
meta.push((18, serializer::BYTE, Value::Byte(value as i8)));
|
||||
}
|
||||
defaults.push((18, serializer::BYTE, Value::Byte(0)));
|
||||
}
|
||||
"minecraft:cat" | "minecraft:wolf" => {
|
||||
// TamableAnimal.DATA_FLAGS_ID: sitting 1, tame 4.
|
||||
@@ -200,6 +210,13 @@ fn mob_meta(m: &Mob, hub: &Hub, meta: &mut Vec<(u8, i32, Value)>) {
|
||||
if let Some(v) = id(registry, name) {
|
||||
meta.push((index, serializer, Value::Id(v)));
|
||||
}
|
||||
// Wolf.DATA_COLLAR_COLOR and Cat's, red by default.
|
||||
let collar = if kind == "minecraft:cat" { 23 } else { 21 };
|
||||
let red = i32::from(horizon_client::mobs::tame::RED);
|
||||
if i32::from(e.color & 15) != red {
|
||||
meta.push((collar, serializer::INT, Value::Int(i32::from(e.color & 15))));
|
||||
}
|
||||
defaults.push((collar, serializer::INT, Value::Int(red)));
|
||||
}
|
||||
"minecraft:horse"
|
||||
| "minecraft:donkey"
|
||||
@@ -390,9 +407,14 @@ impl Game<'_> {
|
||||
"placed": g.placed,
|
||||
"loaded": g.loaded,
|
||||
"game_mode": g.game_mode,
|
||||
// ItemCooldowns: the ticks the shield an axe disabled stays down.
|
||||
"shield_cooldown": h.play.get(&g.conn).map_or(0, |p| p.shield_cooldown),
|
||||
"sneaking": g.sneaking,
|
||||
"sleeping": g.bed,
|
||||
"health": g.health,
|
||||
// The server's body: its air supply and golden hearts.
|
||||
"air": g.motion.as_ref().map(|m| m.air),
|
||||
"absorption": g.motion.as_ref().map(|m| m.health.absorption()),
|
||||
// FoodData: food level, saturation and exhaustion.
|
||||
"food": [
|
||||
f64::from(g.food),
|
||||
@@ -752,17 +774,37 @@ impl Game<'_> {
|
||||
}
|
||||
|
||||
/// The entities guests track: mobs, items and the rest (game/lan_entities.rs).
|
||||
fn lan_entities(&self, host_entity: Option<i32>, hub: &mut Hub, out: &mut States) {
|
||||
/// `mob_players` is the guest each mob sees as its player (game/lan_mobs.rs).
|
||||
fn lan_entities(
|
||||
&self,
|
||||
host_entity: Option<i32>,
|
||||
mob_players: &HashMap<u32, u32>,
|
||||
hub: &mut Hub,
|
||||
out: &mut States,
|
||||
) {
|
||||
out.clear();
|
||||
for m in self.mobs.iter().filter(|m| !m.removed) {
|
||||
// The lead's holder: the player the mob sees (a guest, else the
|
||||
// host) or another mob. A fence knot isn't an entity here.
|
||||
let leash = m.extra.leash.and_then(|h| match h {
|
||||
Holder::Player => mob_players
|
||||
.get(&m.id)
|
||||
.and_then(|conn| hub.guest(*conn))
|
||||
.map(|g| g.entity_id)
|
||||
.or(host_entity),
|
||||
Holder::Mob(id) => Some(id as i32),
|
||||
Holder::Knot(_) => None,
|
||||
});
|
||||
let e = out.add(m.id as i32, entity_uuid(m.id), m.kind.id.as_str());
|
||||
e.position = [m.position.x, m.position.y, m.position.z];
|
||||
(e.yaw, e.pitch, e.head_yaw) = (m.yaw, m.head_pitch, m.head_yaw);
|
||||
e.on_ground = m.on_ground;
|
||||
e.velocity = [m.velocity.x, m.velocity.y, m.velocity.z];
|
||||
mob_meta(m, hub, &mut e.meta);
|
||||
e.needs_sync = m.needs_sync;
|
||||
mob_meta(m, hub, &mut e.meta, &mut e.defaults);
|
||||
mob_equipment(m, hub, &mut e.equipment);
|
||||
self.mob_attributes(m, &mut e.attributes);
|
||||
e.leash = leash;
|
||||
}
|
||||
for d in &self.dropped {
|
||||
let stack = hub.stack(&Some(d.stack.clone()));
|
||||
@@ -824,10 +866,14 @@ impl Game<'_> {
|
||||
.filter_map(|g| Some((g.vehicle?, g.entity_id)))
|
||||
.collect();
|
||||
self.lan_riding(host_entity, &guests, &mut host.hub.riding);
|
||||
self.lan_entities(host_entity, &mut host.hub, &mut states);
|
||||
self.lan_entities(host_entity, &host.mob_players, &mut host.hub, &mut states);
|
||||
} else {
|
||||
states.clear();
|
||||
}
|
||||
// ServerEntity.sendChanges clears needsSync once it has looked.
|
||||
for m in &mut self.mobs {
|
||||
m.needs_sync = false;
|
||||
}
|
||||
let entities = states.as_slice();
|
||||
let spawn = self.spawn.map_or([0.5, 80.0, 0.5], |s| [s.x, s.y, s.z]);
|
||||
let events = {
|
||||
@@ -1008,7 +1054,7 @@ impl Game<'_> {
|
||||
return;
|
||||
}
|
||||
// A dedicated server has no host player.
|
||||
self.lan_entities(None, &mut host.hub, &mut theirs);
|
||||
self.lan_entities(None, &host.mob_players, &mut host.hub, &mut theirs);
|
||||
theirs.as_slice()
|
||||
} else {
|
||||
entities
|
||||
@@ -1243,7 +1289,7 @@ impl Game<'_> {
|
||||
};
|
||||
let atlas = &self.gui.atlas;
|
||||
let lookup = |name: &str| atlas.sprites.get(name).map(|r| [r.u0, r.v0, r.u1, r.v1]);
|
||||
for m in host.models.values() {
|
||||
for (conn, m) in &host.models {
|
||||
let entity_start = out.len();
|
||||
let light = self.world.light(
|
||||
m.position.x.floor() as i32,
|
||||
@@ -1258,7 +1304,10 @@ impl Game<'_> {
|
||||
&lookup,
|
||||
packed,
|
||||
self.res.mob_data(),
|
||||
&self.worn_armor_layers(m),
|
||||
&host.hub.guest(*conn).map_or_else(
|
||||
|| self.worn_armor_layers(m),
|
||||
|g| self.stack_armor_layers(&g.armor),
|
||||
),
|
||||
out,
|
||||
);
|
||||
self.held_item_geometry(m, camera, alpha, packed, true, out);
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
//! blocks, attacking and the commands all behave exactly as they do for the
|
||||
//! host, and every world change they cause reaches all players through the
|
||||
//! block log, the level events and the entity tracker.
|
||||
use super::{Game, GuestUse, Hit, Mining, Screen, lan::LanHost};
|
||||
use super::{Game, GuestUse, Hit, Mining, Screen, lan::LanHost, respawn_anchor::RespawnConfig};
|
||||
use horizon_client::{
|
||||
command::vanilla,
|
||||
containers::BlockEntity,
|
||||
@@ -34,6 +34,8 @@ pub(super) struct GuestPlay {
|
||||
destroy_delay: u32,
|
||||
using: Option<(usize, u32)>,
|
||||
use_delay: u32,
|
||||
/// The shield's ItemCooldowns entry: an axe disabled it for this many ticks.
|
||||
pub(super) shield_cooldown: u32,
|
||||
attack_ticks: u32,
|
||||
/// The main hand's item at the guest's last tick (`Game::attack_item`).
|
||||
attack_item: Option<std::sync::Arc<str>>,
|
||||
@@ -117,6 +119,7 @@ pub(super) struct GuestSwap {
|
||||
destroy_delay: u32,
|
||||
using: Option<(usize, u32)>,
|
||||
use_delay: u32,
|
||||
pub(super) shield_cooldown: u32,
|
||||
attack_ticks: u32,
|
||||
attack_item: Option<std::sync::Arc<str>>,
|
||||
swing: horizon_client::first_person::SwingState,
|
||||
@@ -217,6 +220,12 @@ impl Game<'_> {
|
||||
host.hub.guests[i].position[2],
|
||||
);
|
||||
host.hub.guests[i].motion = PlayerMotion::new(self.motion, p).ok().map(Box::new);
|
||||
// The saved golden hearts go on the first body of a player who
|
||||
// has been here before.
|
||||
let saved = host.hub.guests[i].saved_absorption.take();
|
||||
if let (Some(m), Some(amount)) = (host.hub.guests[i].motion.as_mut(), saved) {
|
||||
let _ = m.internal_set_absorption(amount);
|
||||
}
|
||||
let kept = host.hub.guests[i].logs_kept.take();
|
||||
if let (Some(m), Some((hurts, attacks))) = (host.hub.guests[i].motion.as_mut(), kept) {
|
||||
m.hurt_log = hurts;
|
||||
@@ -290,6 +299,7 @@ impl Game<'_> {
|
||||
let destroy_delay = std::mem::replace(&mut self.destroy_delay, play.destroy_delay);
|
||||
let using = std::mem::replace(&mut self.using, play.using.take());
|
||||
let use_delay = std::mem::replace(&mut self.use_delay, play.use_delay);
|
||||
let shield_cooldown = std::mem::replace(&mut self.shield_cooldown, play.shield_cooldown);
|
||||
let attack_ticks = std::mem::replace(&mut self.attack_ticks, play.attack_ticks);
|
||||
let attack_item = std::mem::replace(&mut self.attack_item, play.attack_item.take());
|
||||
let swing = std::mem::replace(&mut self.swing, play.swing);
|
||||
@@ -309,6 +319,7 @@ impl Game<'_> {
|
||||
g.spawn.map(|s| Vec3::new(s[0], s[1], s[2])),
|
||||
);
|
||||
let respawn = (self.respawn_config.take(), self.level.spawn);
|
||||
self.respawn_config = g.respawn_elsewhere.as_ref().map(RespawnConfig::from_saved);
|
||||
let message = self.message.take();
|
||||
// The guest's own ender inventory; one the host has open goes back
|
||||
// into the host's while the guest acts.
|
||||
@@ -369,6 +380,7 @@ impl Game<'_> {
|
||||
destroy_delay,
|
||||
using,
|
||||
use_delay,
|
||||
shield_cooldown,
|
||||
attack_ticks,
|
||||
attack_item,
|
||||
swing,
|
||||
@@ -421,6 +433,7 @@ impl Game<'_> {
|
||||
destroy_delay,
|
||||
using,
|
||||
use_delay,
|
||||
shield_cooldown,
|
||||
attack_ticks,
|
||||
attack_item,
|
||||
swing,
|
||||
@@ -470,7 +483,8 @@ impl Game<'_> {
|
||||
let bed = std::mem::replace(&mut self.sleeping, sleeping);
|
||||
let guest_sleep_timer = std::mem::replace(&mut self.sleep_timer, sleep_timer);
|
||||
let guest_spawn = std::mem::replace(&mut self.spawn, spawn).map(|s| [s.x, s.y, s.z]);
|
||||
(self.respawn_config, self.level.spawn) = respawn;
|
||||
let guest_respawn = std::mem::replace(&mut self.respawn_config, respawn.0);
|
||||
self.level.spawn = respawn.1;
|
||||
// ServerPlayer.displayClientMessage(_, true): the guest's action bar.
|
||||
let guest_message = std::mem::replace(&mut self.message, message);
|
||||
let guest_vehicle = std::mem::replace(&mut self.riding, riding);
|
||||
@@ -480,6 +494,7 @@ impl Game<'_> {
|
||||
play.destroy_delay = std::mem::replace(&mut self.destroy_delay, destroy_delay);
|
||||
play.using = std::mem::replace(&mut self.using, using);
|
||||
play.use_delay = std::mem::replace(&mut self.use_delay, use_delay);
|
||||
play.shield_cooldown = std::mem::replace(&mut self.shield_cooldown, shield_cooldown);
|
||||
play.attack_ticks = std::mem::replace(&mut self.attack_ticks, attack_ticks);
|
||||
play.attack_item = std::mem::replace(&mut self.attack_item, attack_item);
|
||||
play.swing = std::mem::replace(&mut self.swing, swing);
|
||||
@@ -507,6 +522,7 @@ impl Game<'_> {
|
||||
g.vehicle = guest_vehicle;
|
||||
let fell_asleep = bed.is_some() && g.bed.is_none();
|
||||
(g.bed, g.sleep_timer, g.spawn) = (bed, guest_sleep_timer, guest_spawn);
|
||||
g.respawn_elsewhere = guest_respawn.map(|c| c.saved());
|
||||
g.sleeping = g.bed.is_some();
|
||||
if let Some((text, _)) = guest_message {
|
||||
g.send(&C::SystemChat(play::SystemChat {
|
||||
@@ -1486,17 +1502,43 @@ impl Game<'_> {
|
||||
if !host.hub.guests[i].dead {
|
||||
return;
|
||||
}
|
||||
// PlayerList.respawn: a server's player comes back in the Overworld,
|
||||
// where the world spawn is (respawn anchors and beds aside).
|
||||
// PlayerList.respawn: a server's player comes back in its respawn
|
||||
// point's level: the Overworld, where the world spawn and beds are,
|
||||
// or another one's anchor or forced point, which its block resolves
|
||||
// once the level has loaded around it.
|
||||
let server = self.options.dedicated.is_some();
|
||||
let own = host.hub.guests[i]
|
||||
.respawn_elsewhere
|
||||
.as_ref()
|
||||
.map(RespawnConfig::from_saved);
|
||||
let elsewhere = own.filter(|_| server);
|
||||
// In the window's world a guest has no level of its own to wait in:
|
||||
// its bed is resolved at once when its chunk is there, else it
|
||||
// comes back on the bed.
|
||||
let in_frame_bed = own.filter(|c| !server && c.dim == super::Dim::Overworld);
|
||||
let spawn = in_frame_bed.map_or(spawn, |c| {
|
||||
[
|
||||
f64::from(c.pos[0]) + 0.5,
|
||||
f64::from(c.pos[1]) + 0.6,
|
||||
f64::from(c.pos[2]) + 0.5,
|
||||
]
|
||||
});
|
||||
let level = elsewhere.map_or(super::Dim::Overworld, |c| c.dim);
|
||||
let spawn = elsewhere.map_or(spawn, |c| {
|
||||
[
|
||||
f64::from(c.pos[0]) + 0.5,
|
||||
f64::from(c.pos[1]),
|
||||
f64::from(c.pos[2]) + 0.5,
|
||||
]
|
||||
});
|
||||
let back = self.dim;
|
||||
let away = self.options.dedicated.is_some()
|
||||
&& host.hub.guests[i].dimension != super::Dim::Overworld.id();
|
||||
if away {
|
||||
let away = server && host.hub.guests[i].dimension != level.id();
|
||||
if server {
|
||||
self.server_trips.retain(|t| !t.is(conn));
|
||||
if let Err(e) = self.enter_level(Some(host), super::Dim::Overworld) {
|
||||
log::error!("could not open the Overworld: {e}");
|
||||
return;
|
||||
}
|
||||
}
|
||||
if away && let Err(e) = self.enter_level(Some(host), level) {
|
||||
log::error!("could not open {}: {e}", level.id());
|
||||
return;
|
||||
}
|
||||
let g = &mut host.hub.guests[i];
|
||||
g.dead = false;
|
||||
@@ -1535,6 +1577,28 @@ impl Game<'_> {
|
||||
collision: Some(self.collision),
|
||||
};
|
||||
host.hub.respawn(i, spawn, &ctx);
|
||||
if let Some(config) = elsewhere {
|
||||
self.respawn_trip(conn, config);
|
||||
}
|
||||
if let Some(c) = in_frame_bed
|
||||
&& self.dim == super::Dim::Overworld
|
||||
&& self.world.loaded(c.pos[0], c.pos[2])
|
||||
{
|
||||
let found = self
|
||||
.as_guest(host, conn, |g| g.resolve_server_respawn(c))
|
||||
.flatten();
|
||||
let world = self
|
||||
.level
|
||||
.world_spawn
|
||||
.map_or([0.5, 80.0, 0.5], |[x, y, z]| {
|
||||
[f64::from(x) + 0.5, f64::from(y), f64::from(z) + 0.5]
|
||||
});
|
||||
let at = found.map_or(world, |(at, yaw)| {
|
||||
host.hub.guests[i].yaw = yaw;
|
||||
[at.x, at.y, at.z]
|
||||
});
|
||||
host.hub.teleport(i, Some(at));
|
||||
}
|
||||
if away && let Err(e) = self.enter_level(Some(host), back) {
|
||||
log::error!("could not reopen {}: {e}", back.id());
|
||||
}
|
||||
@@ -1562,6 +1626,9 @@ impl Game<'_> {
|
||||
continue;
|
||||
}
|
||||
let play = host.play.entry(conn).or_default();
|
||||
// Player.tick's ItemCooldowns.tick, once a tick for each player
|
||||
// (the shield an axe disabled).
|
||||
play.shield_cooldown = play.shield_cooldown.saturating_sub(1);
|
||||
let digging = play.digging;
|
||||
let using = play.using.is_some();
|
||||
let delayed = play.destroy_delay > 0 || play.use_delay > 0 || play.attack_ticks < 100;
|
||||
@@ -1616,6 +1683,9 @@ impl Game<'_> {
|
||||
self.guest_dig_feedback(host, conn);
|
||||
self.guest_pickups(host, gi);
|
||||
self.guest_orbs(host, gi);
|
||||
if host.hub.guests[gi].health <= 0.0 && !host.hub.guests[gi].dead {
|
||||
self.guest_forget_digging(host, conn);
|
||||
}
|
||||
let g = &mut host.hub.guests[gi];
|
||||
if g.health <= 0.0 && !g.dead {
|
||||
g.dead = true;
|
||||
@@ -1765,6 +1835,7 @@ impl Game<'_> {
|
||||
}
|
||||
let in_wall = !g.sleeping && self.in_wall(g.position, g.eye_height());
|
||||
let at = g.position;
|
||||
let sneaking = g.sneaking;
|
||||
let sounds = self.as_guest(host, conn, |game| {
|
||||
let (rules, motion) = (game.world.health_rules, game.motion);
|
||||
let health = &motion.health;
|
||||
@@ -1783,6 +1854,26 @@ impl Game<'_> {
|
||||
let _ = w.fluid_base_tick();
|
||||
w.player.ground.on_ground = from_ground;
|
||||
let travelled: Vec<[f64; 3]> = moves.iter().map(|m| m.0).collect();
|
||||
// Entity.move's movementThisTick: each move, from where the
|
||||
// tick's first one started.
|
||||
let end = w.player.position;
|
||||
let start = travelled
|
||||
.iter()
|
||||
.fold(end, |p, d| p.subtract(Vec3::new(d[0], d[1], d[2])));
|
||||
w.player.history.clear();
|
||||
let mut from = start;
|
||||
for d in &travelled {
|
||||
let delta = Vec3::new(d[0], d[1], d[2]);
|
||||
let to = from.add_vector(delta);
|
||||
w.player
|
||||
.history
|
||||
.add(horizon_world::entity_move::MovementRecord {
|
||||
from,
|
||||
to,
|
||||
original: Some(delta),
|
||||
});
|
||||
from = to;
|
||||
}
|
||||
for (moved, on_ground) in moves {
|
||||
let movement = Vec3::new(moved[0], moved[1], moved[2]);
|
||||
if w.player.ground.on_ground && !on_ground && moved[1] > 0.0 {
|
||||
@@ -1808,6 +1899,10 @@ impl Game<'_> {
|
||||
if w.climbable().unwrap_or(false) {
|
||||
w.player.fall_distance = 0.0;
|
||||
}
|
||||
// Entity.applyEffectsFromBlocks along those moves: magma, cactus,
|
||||
// lit campfires and grown sweet berry bushes hurt (#1305).
|
||||
w.player.shift_key_down = sneaking;
|
||||
let _ = w.server_block_damage(start);
|
||||
let _ = w.tick_environment_damage();
|
||||
let _ = w.apply_fire_contact();
|
||||
let _ = w.server_hazards_tick(in_wall);
|
||||
@@ -1968,6 +2063,23 @@ impl Game<'_> {
|
||||
|
||||
/// `level.destroyBlockProgress(id, pos, -1)` for a guest: its crack on `pos` is gone, for the
|
||||
/// host and the other guests.
|
||||
/// A player who dies digs and uses nothing any more: the block it was
|
||||
/// breaking, or had left to break (`hasDelayedDestroy`), stays, and its
|
||||
/// crack goes. Respawning gives it a new ServerPlayerGameMode (#1315).
|
||||
fn guest_forget_digging(&mut self, host: &mut LanHost, conn: u32) {
|
||||
let Some(play) = host.play.get_mut(&conn) else {
|
||||
return;
|
||||
};
|
||||
let cracked = play.digging.take().filter(|_| play.last_progress >= 0);
|
||||
play.mining = None;
|
||||
play.delayed = None;
|
||||
play.destroy_delay = 0;
|
||||
play.using = None;
|
||||
play.last_progress = -1;
|
||||
if let Some((pos, _)) = cracked {
|
||||
self.guest_crack_gone(host, conn, pos);
|
||||
}
|
||||
}
|
||||
fn guest_crack_gone(&mut self, host: &mut LanHost, conn: u32, pos: [i32; 3]) {
|
||||
let Some(id) = host.hub.guest(conn).map(|g| g.entity_id) else {
|
||||
return;
|
||||
|
||||
@@ -36,9 +36,12 @@ fn state(id: u32, kind: &str, position: [f64; 3], yaw: f32, on_ground: bool) ->
|
||||
velocity: [0.0; 3],
|
||||
data: 0,
|
||||
meta: Vec::new(),
|
||||
defaults: Vec::new(),
|
||||
equipment: Vec::new(),
|
||||
attributes: Vec::new(),
|
||||
precise: false,
|
||||
needs_sync: false,
|
||||
leash: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,10 @@ use horizon_client::{
|
||||
},
|
||||
};
|
||||
|
||||
/// The host player's connection id in the waypoint managers: its packets
|
||||
/// go to this client's locator bar rather than to a guest.
|
||||
const HOST_CONN: u32 = u32::MAX;
|
||||
|
||||
/// `waypoint_transmit_range`: hidden by crouching, invisibility or a head worn.
|
||||
fn transmit_range(crouching: bool, invisible: bool, head: Option<&ItemStack>) -> f64 {
|
||||
let hiding_head = head.is_some_and(|s| HIDING_HEADS.contains(&&*s.item));
|
||||
@@ -47,7 +51,7 @@ impl Game<'_> {
|
||||
self.dim.id(),
|
||||
waypoints::Player {
|
||||
uuid: h.uuid,
|
||||
conn: None,
|
||||
conn: Some(HOST_CONN),
|
||||
position: h.position,
|
||||
spectator: self.spectator(),
|
||||
transmit_range: transmit_range(
|
||||
@@ -123,7 +127,12 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
for (conn, packet) in out {
|
||||
if let Some(g) = host.hub.guest(conn) {
|
||||
if conn == HOST_CONN {
|
||||
// The host's own connection: its locator bar.
|
||||
if let horizon_protocol::packets::play::Clientbound::TrackedWaypoint(w) = &packet {
|
||||
self.handle_waypoint(w);
|
||||
}
|
||||
} else if let Some(g) = host.hub.guest(conn) {
|
||||
g.send(&packet);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -253,6 +253,24 @@ impl Game<'_> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Put `dim`'s level in place for a command whose source is there
|
||||
/// (`execute in`, `execute at` a player in another level), opening it if
|
||||
/// nobody is in it; returns the level to put back afterwards. Only a
|
||||
/// server holds other levels.
|
||||
pub(super) fn enter_command_level(&mut self, dim: Dim) -> Option<Dim> {
|
||||
if self.options.dedicated.is_none() || dim == self.dim {
|
||||
return None;
|
||||
}
|
||||
let back = self.dim;
|
||||
match self.enter_level(None, dim) {
|
||||
Ok(()) => Some(back),
|
||||
Err(e) => {
|
||||
log::error!("could not open {}: {e}", dim.id());
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One server tick (`MinecraftServer.tickServer`): each level with
|
||||
/// players in turn (`tickChildren`), its chunks streamed and loaded and
|
||||
/// its world ticked. The first pass reads the network and runs the
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
//! The locator bar on this client (horizon_client::locator_bar): the
|
||||
//! waypoints a server tracks for the player (`handleWaypoint`; on a served
|
||||
//! world the host's own connection in lan/waypoints.rs), the experience
|
||||
//! bar's priority after a change, and the dots drawn from the last frame's
|
||||
//! camera.
|
||||
use super::Game;
|
||||
use horizon_client::{
|
||||
hud_layout::GuiDraw,
|
||||
locator_bar::{ClientWaypoints, View},
|
||||
};
|
||||
use horizon_protocol::packets::play::TrackedWaypoint;
|
||||
|
||||
/// `Player.getEyeHeight` while standing.
|
||||
const PLAYER_EYES: f64 = 1.62;
|
||||
|
||||
fn uuid(ints: [i32; 4]) -> u128 {
|
||||
ints.iter()
|
||||
.fold(0u128, |acc, &i| acc << 32 | u128::from(i as u32))
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
/// `ClientPacketListener.handleWaypoint`.
|
||||
pub(super) fn handle_waypoint(&mut self, packet: &TrackedWaypoint) {
|
||||
self.waypoints.apply(packet);
|
||||
}
|
||||
/// A new connection or world starts with no waypoints.
|
||||
pub(super) fn clear_waypoints(&mut self) {
|
||||
self.waypoints = ClientWaypoints::default();
|
||||
}
|
||||
/// `LocalPlayer.setExperienceValues`: a change of progress shows the
|
||||
/// experience bar for 100 ticks, except the first one
|
||||
/// (`experienceDisplayStartTick` starts at `Integer.MIN_VALUE`).
|
||||
pub(super) fn track_experience_display(&mut self) {
|
||||
let progress = self.experience.progress;
|
||||
let (last, seen, _) = &mut self.experience_display;
|
||||
if progress != *last {
|
||||
*last = progress;
|
||||
if *seen {
|
||||
self.experience_display.2 = self.ticks;
|
||||
} else {
|
||||
*seen = true;
|
||||
}
|
||||
}
|
||||
// EnchantmentScreen and AnvilScreen.containerTick keep it up.
|
||||
if self.station.as_ref().is_some_and(|s| {
|
||||
matches!(
|
||||
s.kind,
|
||||
super::stations::Kind::Enchanting | super::stations::Kind::Anvil
|
||||
)
|
||||
}) {
|
||||
self.experience_display.2 = self.ticks;
|
||||
}
|
||||
}
|
||||
/// `Hud.willPrioritizeExperienceInfo`.
|
||||
pub(super) fn experience_recent(&self) -> bool {
|
||||
let (_, _, start) = self.experience_display;
|
||||
start != 0 && start + 100 > self.ticks
|
||||
}
|
||||
/// The local player's UUID, as servers know it.
|
||||
fn own_uuid(&self) -> u128 {
|
||||
match &self.remote {
|
||||
Some(r) => r.profile(),
|
||||
None => self.level.player_uuid.map_or(0, uuid),
|
||||
}
|
||||
}
|
||||
/// An entity's block position and (interpolated) eyes, by UUID.
|
||||
fn waypoint_entity(&self, id: u128, alpha: f64) -> Option<([i32; 3], [f64; 3])> {
|
||||
let at = |p: [f64; 3], q: [f64; 3], eyes: f64| {
|
||||
let pos: [f64; 3] = std::array::from_fn(|i| q[i] + (p[i] - q[i]) * alpha);
|
||||
(p.map(|c| c.floor() as i32), [pos[0], pos[1] + eyes, pos[2]])
|
||||
};
|
||||
if let Some(r) = &self.remote {
|
||||
let entity = r.player_entity(id)?;
|
||||
let m = self.mobs.iter().find(|m| m.id == entity)?;
|
||||
let v = |v: horizon_core::geometry::Vec3| [v.x, v.y, v.z];
|
||||
return Some(at(v(m.position), v(m.previous), m.eye_height()));
|
||||
}
|
||||
let host = self.lan.as_ref()?;
|
||||
let g = host.hub.guests.iter().find(|g| g.uuid == id && g.placed)?;
|
||||
Some(at(g.position, g.previous_position, PLAYER_EYES))
|
||||
}
|
||||
/// `LocatorBar.extractRenderState` from the last frame's camera.
|
||||
pub(super) fn locator_bar_draws(&self, gui_width: i32, gui_height: i32) -> Vec<GuiDraw> {
|
||||
let (Some((camera_pos, yaw, pitch, fov)), Some(p)) = (self.locator_camera, &self.player)
|
||||
else {
|
||||
return Vec::new();
|
||||
};
|
||||
let alpha = self.clock.camera_fraction();
|
||||
// Camera.getViewRotationProjectionMatrix, through view space: the
|
||||
// depth along the look direction and the height on screen.
|
||||
let (sy, cy) = (
|
||||
f64::from(yaw.to_radians()).sin(),
|
||||
f64::from(yaw.to_radians()).cos(),
|
||||
);
|
||||
let (sp, cp) = (
|
||||
f64::from(pitch.to_radians()).sin(),
|
||||
f64::from(pitch.to_radians()).cos(),
|
||||
);
|
||||
let forward = [-sy * cp, -sp, cy * cp];
|
||||
let up = [-sy * sp, cp, cy * sp];
|
||||
let focal = 1.0 / f64::from((fov / 2.0).to_radians()).tan();
|
||||
let project = |o: [f64; 3]| {
|
||||
let dot = |a: [f64; 3]| a[0] * o[0] + a[1] * o[1] + a[2] * o[2];
|
||||
let depth = dot(forward);
|
||||
let y = dot(up) * focal / depth;
|
||||
// Points behind the camera land past the far plane (z > 1).
|
||||
[0.0, y, if depth < 0.0 { 2.0 } else { 0.5 }]
|
||||
};
|
||||
let entity = |id: u128| self.waypoint_entity(id, alpha);
|
||||
let view = View {
|
||||
gui_width,
|
||||
gui_height,
|
||||
camera_uuid: self.own_uuid(),
|
||||
camera_entity_pos: [p.position.x, p.position.y, p.position.z],
|
||||
camera_pos,
|
||||
yaw,
|
||||
pitch,
|
||||
fov,
|
||||
project: &project,
|
||||
entity: &entity,
|
||||
styles: &self.waypoint_styles,
|
||||
tick: self.ticks,
|
||||
};
|
||||
self.waypoints.draw(&view)
|
||||
}
|
||||
}
|
||||
@@ -16,7 +16,7 @@ use horizon_client::{
|
||||
sound_engine::{CartSound, SoundEngine, SoundInstance, Sounds},
|
||||
};
|
||||
use horizon_core::{
|
||||
geometry::{Aabb, Vec3},
|
||||
geometry::{Aabb, Vec3, trig},
|
||||
random::RandomSource,
|
||||
};
|
||||
use horizon_world::registry::BlockStateId;
|
||||
@@ -637,6 +637,9 @@ impl Game<'_> {
|
||||
let r = self.world.random.next_double();
|
||||
let power = (4.0 + r * 1.5 * speed) as f32;
|
||||
let at = [c.position.x, c.position.y, c.position.z];
|
||||
// The cart is the explosion's source: it is gone before the blast
|
||||
// looks for entities, so it can't hit itself.
|
||||
self.carts.retain(|x| x.id != c.id);
|
||||
let _ = self.server_explode(at, power, BlockInteraction::Destroy, false);
|
||||
}
|
||||
/// `MinecartHopper.suckInItems`: items above, into the five slots.
|
||||
@@ -683,8 +686,7 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
}
|
||||
/// `AbstractMinecart.push(player)` when the player walks into a cart,
|
||||
/// and carts pushing each other (`pushOtherMinecart`).
|
||||
/// `AbstractMinecart.push(player)` when the player walks into a cart.
|
||||
fn push_carts(&mut self) {
|
||||
let player = self.player.as_ref().map(|p| p.position);
|
||||
let riding = self.riding;
|
||||
@@ -719,55 +721,71 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
}
|
||||
for j in i + 1..self.carts.len() {
|
||||
let (a, b) = (&self.carts[i], &self.carts[j]);
|
||||
if !a
|
||||
.bounds()
|
||||
.inflate(Vec3::new(0.2, 0.0, 0.2))
|
||||
.intersects(b.bounds())
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let (mut xa, mut za) = (b.position.x - a.position.x, b.position.z - a.position.z);
|
||||
let dd = xa * xa + za * za;
|
||||
if dd < 1.0e-4 {
|
||||
continue;
|
||||
}
|
||||
let d = dd.sqrt();
|
||||
let pow = (1.0 / d).min(1.0);
|
||||
xa = xa / d * pow * f64::from(0.1f32) * 0.5;
|
||||
za = za / d * pow * f64::from(0.1f32) * 0.5;
|
||||
let dir =
|
||||
glam::DVec2::new(b.position.x - a.position.x, b.position.z - a.position.z)
|
||||
.normalize_or_zero();
|
||||
let r = a.yaw.to_radians();
|
||||
let facing =
|
||||
glam::DVec2::new(f64::from(r.cos()), f64::from(r.sin())).normalize_or_zero();
|
||||
if dir.dot(facing).abs() < f64::from(0.8f32) {
|
||||
continue;
|
||||
}
|
||||
let (ma, mb) = (a.velocity, b.velocity);
|
||||
let (fa, fb) = (a.kind == CartKind::Furnace, b.kind == CartKind::Furnace);
|
||||
let (na, nb) = if fb && !fa {
|
||||
(
|
||||
Vec3::new(ma.x * 0.2 + mb.x - xa, ma.y, ma.z * 0.2 + mb.z - za),
|
||||
Vec3::new(mb.x * 0.95, mb.y, mb.z * 0.95),
|
||||
)
|
||||
} else if fa && !fb {
|
||||
(
|
||||
Vec3::new(ma.x * 0.95, ma.y, ma.z * 0.95),
|
||||
Vec3::new(mb.x * 0.2 + ma.x + xa, mb.y, mb.z * 0.2 + ma.z + za),
|
||||
)
|
||||
} else {
|
||||
let (xdd, zdd) = ((mb.x + ma.x) / 2.0, (mb.z + ma.z) / 2.0);
|
||||
(
|
||||
Vec3::new(ma.x * 0.2 + xdd - xa, ma.y, ma.z * 0.2 + zdd - za),
|
||||
Vec3::new(mb.x * 0.2 + xdd + xa, mb.y, mb.z * 0.2 + zdd + za),
|
||||
)
|
||||
};
|
||||
self.carts[i].velocity = na;
|
||||
self.carts[j].velocity = nb;
|
||||
}
|
||||
}
|
||||
/// `OldMinecartBehavior.pushAndPickupEntities` for carts, at the end of
|
||||
/// cart `i`'s own tick: each other cart in its box (0.2 wider on each
|
||||
/// side) gets `push(cart i)`. Running it per cart, after that cart moved
|
||||
/// and before the next one does, is what splits stacked carts apart.
|
||||
fn push_from_cart(&mut self, i: usize) {
|
||||
let reach = f64::from(0.2f32);
|
||||
let hitbox = self.carts[i].bounds().inflate(Vec3::new(reach, 0.0, reach));
|
||||
for j in 0..self.carts.len() {
|
||||
if j == i || !hitbox.intersects(self.carts[j].bounds()) {
|
||||
continue;
|
||||
}
|
||||
// AbstractMinecart.push(entity): `this` is cart j, `entity` cart i.
|
||||
let (this, other) = (&self.carts[j], &self.carts[i]);
|
||||
let (mut xa, mut za) = (
|
||||
other.position.x - this.position.x,
|
||||
other.position.z - this.position.z,
|
||||
);
|
||||
let dd = xa * xa + za * za;
|
||||
if dd < f64::from(1.0e-4f32) {
|
||||
continue;
|
||||
}
|
||||
let d = dd.sqrt();
|
||||
xa /= d;
|
||||
za /= d;
|
||||
let pow = (1.0 / d).min(1.0);
|
||||
xa = xa * pow * f64::from(0.1f32) * 0.5;
|
||||
za = za * pow * f64::from(0.1f32) * 0.5;
|
||||
// pushOtherMinecart (old movement): only along this cart's facing.
|
||||
let dir = glam::DVec2::new(
|
||||
other.position.x - this.position.x,
|
||||
other.position.z - this.position.z,
|
||||
)
|
||||
.normalize_or_zero();
|
||||
let r = f64::from(this.yaw * 0.017453292f32);
|
||||
let facing = glam::DVec2::new(f64::from(trig::cos(r)), f64::from(trig::sin(r)))
|
||||
.normalize_or_zero();
|
||||
if dir.dot(facing).abs() < f64::from(0.8f32) {
|
||||
continue;
|
||||
}
|
||||
let (mt, mo) = (this.velocity, other.velocity);
|
||||
let (ft, fo) = (
|
||||
this.kind == CartKind::Furnace,
|
||||
other.kind == CartKind::Furnace,
|
||||
);
|
||||
let (nt, no) = if fo && !ft {
|
||||
(
|
||||
Vec3::new(mt.x * 0.2 + (mo.x - xa), mt.y, mt.z * 0.2 + (mo.z - za)),
|
||||
Vec3::new(mo.x * 0.95, mo.y, mo.z * 0.95),
|
||||
)
|
||||
} else if !fo && ft {
|
||||
(
|
||||
Vec3::new(mt.x * 0.95, mt.y, mt.z * 0.95),
|
||||
Vec3::new(mo.x * 0.2 + (mt.x + xa), mo.y, mo.z * 0.2 + (mt.z + za)),
|
||||
)
|
||||
} else {
|
||||
let (xdd, zdd) = ((mo.x + mt.x) / 2.0, (mo.z + mt.z) / 2.0);
|
||||
(
|
||||
Vec3::new(mt.x * 0.2 + (xdd - xa), mt.y, mt.z * 0.2 + (zdd - za)),
|
||||
Vec3::new(mo.x * 0.2 + (xdd + xa), mo.y, mo.z * 0.2 + (zdd + za)),
|
||||
)
|
||||
};
|
||||
self.carts[j].velocity = nt;
|
||||
self.carts[i].velocity = no;
|
||||
}
|
||||
}
|
||||
/// Every cart but the ridden one (which ticks with its rider).
|
||||
@@ -777,14 +795,17 @@ impl Game<'_> {
|
||||
let ticking = self.entity_ticking_area();
|
||||
// A server player's minecart moves with its rider (lan_vehicles.rs).
|
||||
let ridden = self.guest_vehicles();
|
||||
let mut i = 0;
|
||||
while i < self.carts.len() {
|
||||
// By id: an exploding cart can destroy or prime others mid-loop.
|
||||
let ids: Vec<u32> = self.carts.iter().map(|c| c.id).collect();
|
||||
for id in ids {
|
||||
let Some(i) = self.carts.iter().position(|c| c.id == id) else {
|
||||
continue;
|
||||
};
|
||||
let p = self.carts[i].position;
|
||||
if Some(self.carts[i].id) == self.riding
|
||||
|| ridden.contains(&self.carts[i].id)
|
||||
if Some(id) == self.riding
|
||||
|| ridden.contains(&id)
|
||||
|| !ticking(p.x.floor() as i32, p.z.floor() as i32)
|
||||
{
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
let mut c = self.carts[i].clone();
|
||||
@@ -792,9 +813,12 @@ impl Game<'_> {
|
||||
if c.kind == CartKind::Hopper {
|
||||
self.hopper_cart_pickup(&mut c);
|
||||
}
|
||||
let Some(i) = self.carts.iter().position(|c| c.id == id) else {
|
||||
continue;
|
||||
};
|
||||
if alive {
|
||||
self.carts[i] = c;
|
||||
i += 1;
|
||||
self.push_from_cart(i);
|
||||
} else {
|
||||
self.carts.remove(i);
|
||||
}
|
||||
@@ -865,8 +889,13 @@ impl Game<'_> {
|
||||
p.velocity = Vec3::ZERO;
|
||||
p.fall_distance = 0.0;
|
||||
}
|
||||
let Some(i) = self.carts.iter().position(|x| x.id == c.id) else {
|
||||
self.riding = None;
|
||||
return true;
|
||||
};
|
||||
if alive {
|
||||
self.carts[i] = c;
|
||||
self.push_from_cart(i);
|
||||
} else {
|
||||
self.carts.remove(i);
|
||||
self.riding = None;
|
||||
@@ -1121,8 +1150,8 @@ impl Game<'_> {
|
||||
}
|
||||
/// `VehicleEntity.hurtServer`: shake, add the damage, break past 40 (at
|
||||
/// once for creative players); TNT minecarts prime instead when moving
|
||||
/// or hurt by fire.
|
||||
fn hurt_cart(&mut self, i: usize, damage: f32, creative: bool, fire: bool) {
|
||||
/// or hurt by fire or an explosion (`damageSourceIgnitesTnt`).
|
||||
pub(super) fn hurt_cart(&mut self, i: usize, damage: f32, creative: bool, fire: bool) {
|
||||
let c = &mut self.carts[i];
|
||||
c.hurt_dir = -c.hurt_dir;
|
||||
c.hurt_time = 10;
|
||||
@@ -1131,7 +1160,7 @@ impl Game<'_> {
|
||||
if destroyed {
|
||||
let mut c = self.carts.remove(i);
|
||||
let speed = c.velocity.x * c.velocity.x + c.velocity.z * c.velocity.z;
|
||||
if c.kind == CartKind::Tnt && (fire || speed >= 0.01) {
|
||||
if c.kind == CartKind::Tnt && (fire || speed >= f64::from(0.01f32)) {
|
||||
// MinecartTNT.destroy: prime instead of breaking.
|
||||
if c.fuse < 0 {
|
||||
self.prime_cart(&mut c);
|
||||
|
||||
@@ -463,25 +463,40 @@ impl Game<'_> {
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
/// `Hud.extractVehicleHealth` and the jump bar that replaces the
|
||||
/// experience bar on a saddled jumper. Returns the mount's heart count
|
||||
/// (`getVehicleMaxHearts`) and whether the jump bar shows.
|
||||
pub(super) fn draw_mount_hud(&self, batch: &mut HudBatch, w: i32, h: i32) -> (i32, bool) {
|
||||
/// `Player.jumpableVehicle`: a saddled mount that jumps.
|
||||
pub(super) fn jumpable_mount(&self) -> bool {
|
||||
self.mount_index()
|
||||
.is_some_and(|i| self.mobs[i].extra.saddled)
|
||||
}
|
||||
/// `willPrioritizeJumpInfo`: charging a jump or recharging.
|
||||
pub(super) fn mount_jumping(&self) -> bool {
|
||||
self.mount.jump_scale > 0.0 || self.mount.jump_ticks < 0
|
||||
}
|
||||
/// `Hud.extractVehicleHealth`, and the jump bar when it is the
|
||||
/// contextual bar (`jump_bar`). Returns the mount's heart count
|
||||
/// (`getVehicleMaxHearts`).
|
||||
pub(super) fn draw_mount_hud(
|
||||
&self,
|
||||
batch: &mut HudBatch,
|
||||
w: i32,
|
||||
h: i32,
|
||||
jump_bar: bool,
|
||||
) -> i32 {
|
||||
let Some(i) = self.mount_index() else {
|
||||
return (0, false);
|
||||
return 0;
|
||||
};
|
||||
let m = &self.mobs[i];
|
||||
let atlas = &self.gui.atlas;
|
||||
let hearts = horizon_client::hud_layout::vehicle_max_hearts(m.attrs.max_health);
|
||||
let draws = horizon_client::hud_layout::vehicle_health(w, h, hearts, m.health);
|
||||
horizon_client::hud_layout::paint(batch, atlas, &draws);
|
||||
if m.extra.saddled {
|
||||
if jump_bar {
|
||||
// JumpableVehicleBar: the cooldown shows while the jump recharges.
|
||||
let cooldown = i32::from(self.mount.jump_ticks < 0);
|
||||
let draws =
|
||||
horizon_client::hud_overlays::jump_bar(w, h, cooldown, self.mount.jump_scale);
|
||||
horizon_client::hud_layout::paint(batch, atlas, &draws);
|
||||
}
|
||||
(hearts, m.extra.saddled)
|
||||
hearts
|
||||
}
|
||||
}
|
||||
@@ -12,7 +12,7 @@ impl Game<'_> {
|
||||
pub(super) fn tick_music(&mut self) {
|
||||
let (music, volume) = self.situational_music();
|
||||
// LevelLoadingScreen: no new track before the player is in the world.
|
||||
let loading = self.title.is_none() && self.player.is_none();
|
||||
let loading = self.title.is_none() && self.player.is_none() && self.win.is_none();
|
||||
self.music
|
||||
.tick(music.as_ref(), volume, loading, &mut self.audio);
|
||||
}
|
||||
@@ -59,7 +59,11 @@ impl Game<'_> {
|
||||
None => (empty, 1.0),
|
||||
};
|
||||
let situation = Situation {
|
||||
screen_music: None,
|
||||
// WinScreen.getBackgroundMusic.
|
||||
screen_music: self
|
||||
.win
|
||||
.is_some()
|
||||
.then(horizon_client::music::Music::credits),
|
||||
in_world: eye.is_some(),
|
||||
in_end: self.dim == super::Dim::End,
|
||||
boss_music: self.boss_music(),
|
||||
|
||||
@@ -72,6 +72,8 @@ pub(super) struct GameLevel<'g, 'a> {
|
||||
pub(super) w: MobWorld<'g>,
|
||||
/// Every player in the level ([`Game::level_players`]).
|
||||
pub(super) players: Vec<LevelPlayer>,
|
||||
/// Where the non-spectators among them are (`getNearestPlayer`).
|
||||
pub(super) nearest: Vec<[f64; 3]>,
|
||||
/// Boxes of the mobs this pass created, not yet in the game's list.
|
||||
pub(super) pending: std::cell::RefCell<Vec<Aabb>>,
|
||||
/// The types and boxes of what a spawner created this tick.
|
||||
@@ -255,14 +257,8 @@ impl NaturalLevel for GameLevel<'_, '_> {
|
||||
fn structure_spawns(&self) -> &StructureSpawns {
|
||||
StructureSpawns::shared(&self.game.options.resources)
|
||||
}
|
||||
fn nearest_player_distance_sqr(&self, x: f64, y: f64, z: f64) -> Option<f64> {
|
||||
// The nearest player's distance (`nearest_player`), without finding
|
||||
// which player it is: every spawn attempt asks.
|
||||
self.players
|
||||
.iter()
|
||||
.filter(|p| !p.spectator)
|
||||
.map(|p| p.distance_sqr(x, y, z))
|
||||
.reduce(f64::min)
|
||||
fn nearest_player_positions(&self) -> &[[f64; 3]] {
|
||||
&self.nearest
|
||||
}
|
||||
fn respawn(&self) -> Option<[i32; 3]> {
|
||||
// The world spawn point lives in the overworld.
|
||||
@@ -363,10 +359,17 @@ impl horizon_client::mobs::spawner::SpawnerLevel for GameLevel<'_, '_> {
|
||||
}
|
||||
impl<'a> Game<'a> {
|
||||
pub(super) fn natural_level(&self) -> GameLevel<'_, 'a> {
|
||||
let players = self.level_players();
|
||||
let nearest = players
|
||||
.iter()
|
||||
.filter(|p| !p.spectator)
|
||||
.map(|p| [p.position.x, p.position.y, p.position.z])
|
||||
.collect();
|
||||
GameLevel {
|
||||
game: self,
|
||||
w: self.mob_world_base(&[]),
|
||||
players: self.level_players(),
|
||||
players,
|
||||
nearest,
|
||||
pending: Default::default(),
|
||||
pending_ids: Default::default(),
|
||||
boxes: Default::default(),
|
||||
|
||||
@@ -200,6 +200,13 @@ impl Game<'_> {
|
||||
self.settings.save(&Self::options_path());
|
||||
self.apply_settings();
|
||||
self.sync_mods();
|
||||
self.sync_agent_control();
|
||||
self.ui.options.agent_status = self.agent_status_lines();
|
||||
}
|
||||
Event::CopyAgentDetails => {
|
||||
use horizon_client::clipboard::Clipboard;
|
||||
let text = self.agent_details();
|
||||
self.clipboard.set(&text);
|
||||
}
|
||||
Event::Close if self.ui.options.resource_error.is_some() => {}
|
||||
Event::Close => {
|
||||
@@ -235,6 +242,7 @@ impl Game<'_> {
|
||||
let _ = std::fs::create_dir_all(&dir);
|
||||
super::packs::open_folder(&dir);
|
||||
}
|
||||
Event::Credits => self.open_win_screen(false, super::win::Finish::Options),
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
|
||||
@@ -171,6 +171,8 @@ impl Game<'_> {
|
||||
}
|
||||
};
|
||||
// Everything loaded: switch over.
|
||||
self.waypoint_styles = horizon_client::locator_bar::WaypointStyles::load(root);
|
||||
self.armor_stand_assets.reload_trims(root);
|
||||
self.random_tick_sections.clear();
|
||||
self.portals.sections.clear();
|
||||
self.options.resources = root.to_path_buf();
|
||||
|
||||
@@ -40,7 +40,11 @@ use horizon_protocol::{
|
||||
registry::{Builtin, DimensionType, Dynamic, ceil_log2},
|
||||
text::{Language, format_java, plain},
|
||||
};
|
||||
use horizon_world::{nbt::Tag, registry::BlockStateId};
|
||||
use horizon_world::{
|
||||
nbt::Tag,
|
||||
registry::BlockStateId,
|
||||
stepped_interpolation::{Path, PosRot, Start, SteppedInterpolation},
|
||||
};
|
||||
use std::{
|
||||
collections::{HashMap, VecDeque},
|
||||
sync::Arc,
|
||||
@@ -85,6 +89,15 @@ struct Lerp {
|
||||
steps: u32,
|
||||
}
|
||||
|
||||
/// `SteppedInterpolationHandler.doInterpolate`'s `setPos` and `setRot` on a
|
||||
/// mob: its yaw kept continuous (the same turn as vanilla's, without the
|
||||
/// wrap that would spin it in the frame between ticks), its pitch clamped.
|
||||
fn place_interpolated(m: &mut Mob, at: PosRot) {
|
||||
m.position = Vec3::new(at.position[0], at.position[1], at.position[2]);
|
||||
m.yaw += wrap_degrees(at.yaw - m.yaw);
|
||||
m.head_pitch = (at.pitch % 360.0).clamp(-90.0, 90.0);
|
||||
}
|
||||
|
||||
pub(super) struct Remote {
|
||||
pub session: Session,
|
||||
pub brand: String,
|
||||
@@ -123,6 +136,8 @@ pub(super) struct Remote {
|
||||
use_target: Option<[i32; 3]>,
|
||||
entities: HashMap<i32, Tracked>,
|
||||
lerps: HashMap<i32, Lerp>,
|
||||
/// Living entities' `SteppedInterpolationHandler`s.
|
||||
stepped: HashMap<i32, SteppedInterpolation>,
|
||||
/// `LivingEntity.lerpHeadTo`: the head yaw and the ticks left to reach it.
|
||||
head_lerps: HashMap<i32, (f32, u32)>,
|
||||
/// Item entities waiting for their item data.
|
||||
@@ -351,6 +366,7 @@ impl Game<'_> {
|
||||
r.clear_sections();
|
||||
}
|
||||
self.apply_settings();
|
||||
self.clear_waypoints();
|
||||
self.remote = Some(Remote {
|
||||
session,
|
||||
brand: "unknown".into(),
|
||||
@@ -379,6 +395,7 @@ impl Game<'_> {
|
||||
use_target: None,
|
||||
entities: HashMap::new(),
|
||||
lerps: HashMap::new(),
|
||||
stepped: HashMap::new(),
|
||||
head_lerps: HashMap::new(),
|
||||
pending_items: HashMap::new(),
|
||||
health: (20.0, 20, 5.0),
|
||||
@@ -429,6 +446,7 @@ impl Game<'_> {
|
||||
// Leaving on purpose: nothing for auto-reconnect to retry.
|
||||
self.extras_left();
|
||||
}
|
||||
self.clear_waypoints();
|
||||
if let Some(mut r) = self.remote.take() {
|
||||
r.session.disconnect();
|
||||
}
|
||||
@@ -929,6 +947,7 @@ impl Game<'_> {
|
||||
if let Some(r) = self.remote.as_mut() {
|
||||
r.entities.clear();
|
||||
r.lerps.clear();
|
||||
r.stepped.clear();
|
||||
r.head_lerps.clear();
|
||||
r.pending_items.clear();
|
||||
// A new ClientLevel: a new chunk storage, centred on 0, 0.
|
||||
@@ -1315,11 +1334,15 @@ impl Game<'_> {
|
||||
}
|
||||
if let Some(t) = mob_type {
|
||||
let mut mob = Mob::new(id as u32, t, at, head);
|
||||
super::remote_mob_data::client_defaults(&mut mob);
|
||||
(mob.yaw, mob.previous_yaw) = (yaw, yaw);
|
||||
(mob.head_pitch, mob.previous_head_pitch) = (pitch, pitch);
|
||||
mob.persistent = true;
|
||||
self.mobs.retain(|m| m.id != id as u32);
|
||||
self.mobs.push(mob);
|
||||
if let Some(r) = self.remote.as_mut() {
|
||||
r.stepped.remove(&id);
|
||||
}
|
||||
}
|
||||
if let Some(r) = self.remote.as_mut() {
|
||||
if kind == "minecraft:item" {
|
||||
@@ -1334,23 +1357,77 @@ impl Game<'_> {
|
||||
);
|
||||
}
|
||||
}
|
||||
/// A move packet for entity `id`: to the end of `path` (None: a
|
||||
/// rotation alone), turned to `rotation` if it has one. `far_snaps`:
|
||||
/// an `EntityPositionSync`, which puts an entity more than 64 blocks
|
||||
/// away there at once.
|
||||
fn move_entity(
|
||||
&mut self,
|
||||
id: i32,
|
||||
position: [f64; 3],
|
||||
path: Option<Path>,
|
||||
rotation: Option<(f32, f32)>,
|
||||
on_ground: bool,
|
||||
far_snaps: bool,
|
||||
) {
|
||||
let mob = self.mobs.iter_mut().find(|m| m.id == id as u32);
|
||||
let tracked = self
|
||||
.remote
|
||||
.as_ref()
|
||||
.and_then(|r| r.entities.get(&id))
|
||||
.map(|t| t.position);
|
||||
let position = path
|
||||
.as_ref()
|
||||
.map(Path::end)
|
||||
.or(tracked)
|
||||
.or_else(|| {
|
||||
mob.as_ref()
|
||||
.map(|m| [m.position.x, m.position.y, m.position.z])
|
||||
})
|
||||
.unwrap_or([0.0; 3]);
|
||||
if let Some(r) = self.remote.as_mut() {
|
||||
if let Some(t) = r.entities.get_mut(&id) {
|
||||
t.position = position;
|
||||
}
|
||||
if let Some(m) = mob {
|
||||
// A living entity: LivingEntity.createInterpolationHandler's
|
||||
// SteppedInterpolationHandler, over its type's update interval.
|
||||
let entity = PosRot {
|
||||
position: [m.position.x, m.position.y, m.position.z],
|
||||
yaw: m.yaw,
|
||||
pitch: m.head_pitch,
|
||||
};
|
||||
let far = far_snaps && {
|
||||
let d = [0, 1, 2].map(|i| position[i] - entity.position[i]);
|
||||
d[0] * d[0] + d[1] * d[1] + d[2] * d[2] > 4096.0
|
||||
};
|
||||
let handler = r.stepped.entry(id).or_insert_with(|| {
|
||||
let interval = horizon_client::lan::tracking::settings(&m.kind.id).1;
|
||||
SteppedInterpolation::new(interval as i32)
|
||||
});
|
||||
let snap = if far {
|
||||
handler.cancel();
|
||||
let (yaw, pitch) = rotation.unwrap_or((m.yaw, m.head_pitch));
|
||||
Some(PosRot {
|
||||
position,
|
||||
yaw,
|
||||
pitch,
|
||||
})
|
||||
} else {
|
||||
match handler.interpolate_to(entity, path, rotation) {
|
||||
Start::Snap(at) => Some(at),
|
||||
Start::Interpolating | Start::Unchanged => None,
|
||||
}
|
||||
};
|
||||
if let Some(at) = snap {
|
||||
place_interpolated(m, at);
|
||||
m.previous = m.position;
|
||||
}
|
||||
m.on_ground = on_ground;
|
||||
return;
|
||||
}
|
||||
// AbstractInterpolationHandler.interpolateTo: without a rotation,
|
||||
// the one it was heading for (or has).
|
||||
let rotation = rotation
|
||||
.or_else(|| r.lerps.get(&id).and_then(|l| l.rotation))
|
||||
.or_else(|| mob.as_ref().map(|m| (m.yaw, m.head_pitch)));
|
||||
// the one it was heading for.
|
||||
let rotation = rotation.or_else(|| r.lerps.get(&id).and_then(|l| l.rotation));
|
||||
r.lerps.insert(
|
||||
id,
|
||||
Lerp {
|
||||
@@ -1363,9 +1440,6 @@ impl Game<'_> {
|
||||
*p = position;
|
||||
}
|
||||
}
|
||||
if let Some(m) = mob {
|
||||
m.on_ground = on_ground;
|
||||
}
|
||||
self.turn_remote_object(id, rotation.map(|(yaw, _)| yaw), on_ground);
|
||||
self.turn_remote_projectile(id, rotation);
|
||||
}
|
||||
@@ -1382,6 +1456,7 @@ impl Game<'_> {
|
||||
if let Some(r) = self.remote.as_mut() {
|
||||
r.entities.remove(&id);
|
||||
r.lerps.remove(&id);
|
||||
r.stepped.remove(&id);
|
||||
r.head_lerps.remove(&id);
|
||||
r.pending_items.remove(&id);
|
||||
}
|
||||
@@ -1741,17 +1816,36 @@ impl Game<'_> {
|
||||
.and_then(|r| r.entities.get(&m.id))
|
||||
.map(|t| t.position);
|
||||
let Some(base) = base else { return Ok(()) };
|
||||
let position = m.delta.as_ref().map_or(base, |d| d.end(base));
|
||||
// VecDeltaCodec.decode: each step's position from the last.
|
||||
let path = m.delta.as_ref().map(|d| match d {
|
||||
play::VecDelta::Linear(_) => Path::Linear(d.end(base)),
|
||||
play::VecDelta::Stepped(steps) => {
|
||||
let mut at = base;
|
||||
Path::Stepped(
|
||||
steps
|
||||
.iter()
|
||||
.map(|(delta, ticks)| {
|
||||
at = play::VecDelta::apply(at, *delta);
|
||||
(at, *ticks)
|
||||
})
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
});
|
||||
let rotation = m.rotation.map(|(y, x)| {
|
||||
(
|
||||
horizon_protocol::buf::unpack_degrees(y),
|
||||
horizon_protocol::buf::unpack_degrees(x),
|
||||
)
|
||||
});
|
||||
self.move_entity(m.id, position, rotation, m.on_ground);
|
||||
self.move_entity(m.id, path, rotation, m.on_ground, false);
|
||||
}
|
||||
C::EntityPositionSync(p) => {
|
||||
self.move_entity(p.id, p.path.end(), Some((p.yaw, p.pitch)), p.on_ground)
|
||||
let path = match p.path {
|
||||
play::PositionPath::Linear(at) => Path::Linear(at),
|
||||
play::PositionPath::Stepped(steps) => Path::Stepped(steps),
|
||||
};
|
||||
self.move_entity(p.id, Some(path), Some((p.yaw, p.pitch)), p.on_ground, true)
|
||||
}
|
||||
C::TeleportEntity(p) => {
|
||||
let base = self
|
||||
@@ -1771,9 +1865,28 @@ impl Game<'_> {
|
||||
rel(play::relative::Y, 1),
|
||||
rel(play::relative::Z, 2),
|
||||
];
|
||||
self.move_entity(p.id, position, Some((p.yaw, p.pitch)), p.on_ground);
|
||||
self.move_entity(
|
||||
p.id,
|
||||
Some(Path::Linear(position)),
|
||||
Some((p.yaw, p.pitch)),
|
||||
p.on_ground,
|
||||
false,
|
||||
);
|
||||
}
|
||||
C::SetPassengers(p) => self.remote_set_passengers(p.vehicle, &p.passengers),
|
||||
// ClientPacketListener.handleEntityLinkPacket: who holds a mob's
|
||||
// lead (this player, another player or a mob), for the rope.
|
||||
C::SetEntityLink(p) => {
|
||||
let me = self.remote.as_ref().map(|r| r.entity_id);
|
||||
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == p.source as u32) {
|
||||
use horizon_client::mobs::leash::Holder;
|
||||
m.extra.leash = match p.dest {
|
||||
0 => None,
|
||||
d if Some(d) == me => Some(Holder::Player),
|
||||
d => Some(Holder::Mob(d as u32)),
|
||||
};
|
||||
}
|
||||
}
|
||||
C::MoveVehicle(p) => self.remote_move_vehicle(p.position, p.yaw),
|
||||
// ClientPacketListener.handleRotateMob: lerpHeadTo(yHeadRot, 3).
|
||||
C::RotateHead(p) => {
|
||||
@@ -1810,13 +1923,12 @@ impl Game<'_> {
|
||||
&& m.species() == horizon_client::mobs::Species::Rabbit
|
||||
{
|
||||
for (index, _, value) in &p.values {
|
||||
match (index, value) {
|
||||
(15, Value::Byte(flags)) => m.no_ai = flags & 1 != 0,
|
||||
(16, Value::Bool(baby)) => m.extra.age = if *baby { -1 } else { 0 },
|
||||
_ => {}
|
||||
if let (15, Value::Byte(flags)) = (index, value) {
|
||||
m.no_ai = flags & 1 != 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
self.remote_mob_data(p.id, &p.values);
|
||||
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == p.id as u32)
|
||||
&& m.species() == horizon_client::mobs::Species::Creeper
|
||||
{
|
||||
@@ -1865,11 +1977,27 @@ impl Game<'_> {
|
||||
// player lies down in the bed, or gets up.
|
||||
if self.remote.as_ref().is_some_and(|r| r.entity_id == p.id) {
|
||||
for (index, _, value) in &p.values {
|
||||
if let (14, Value::OptPos(bed)) = (index, value) {
|
||||
if bed.is_some() && self.sleeping.is_none() {
|
||||
self.sleep_timer = 0;
|
||||
match (index, value) {
|
||||
(14, Value::OptPos(bed)) => {
|
||||
if bed.is_some() && self.sleeping.is_none() {
|
||||
self.sleep_timer = 0;
|
||||
}
|
||||
self.sleeping = *bed;
|
||||
}
|
||||
self.sleeping = *bed;
|
||||
// Entity.DATA_AIR_SUPPLY_ID and
|
||||
// Player.DATA_PLAYER_ABSORPTION_ID: the bubbles
|
||||
// and golden hearts the server's player has.
|
||||
(1, Value::Int(air)) => {
|
||||
if let Some(pl) = self.player.as_mut() {
|
||||
pl.air = *air;
|
||||
}
|
||||
}
|
||||
(17, Value::Float(amount)) => {
|
||||
if let Some(pl) = self.player.as_mut() {
|
||||
let _ = pl.internal_set_absorption(*amount);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2061,6 +2189,7 @@ impl Game<'_> {
|
||||
self.capture(false);
|
||||
}
|
||||
}
|
||||
C::TrackedWaypoint(p) => self.handle_waypoint(&p),
|
||||
C::SetExperience(p) => {
|
||||
self.experience = Experience {
|
||||
level: p.level,
|
||||
@@ -2114,6 +2243,10 @@ impl Game<'_> {
|
||||
);
|
||||
}
|
||||
}
|
||||
// WIN_GAME: the poem and credits, then PERFORM_RESPAWN.
|
||||
4 if self.screen != Screen::Win => {
|
||||
self.open_win_screen(true, super::win::Finish::RemoteRespawn);
|
||||
}
|
||||
1 => {
|
||||
self.world.weather.raining = true;
|
||||
self.world.weather.rain_level = (0.0, 0.0);
|
||||
@@ -2884,6 +3017,7 @@ impl Game<'_> {
|
||||
|
||||
fn tick_remote_level(&mut self) -> Result<()> {
|
||||
self.ticks += 1;
|
||||
self.track_experience_display();
|
||||
// Options.broadcastOptions: a changed render distance is sent again.
|
||||
let view = client_view_distance(self.settings.render_distance);
|
||||
if let Some(r) = self.remote.as_mut()
|
||||
@@ -2993,6 +3127,13 @@ impl Game<'_> {
|
||||
);
|
||||
let was_flying = player.flying;
|
||||
player.yaw = self.yaw;
|
||||
// As the window's own tick has them: the helmet's Respiration
|
||||
// (decreaseAirSupply) and the dimension's FAST_LAVA, the Nether's
|
||||
// stronger lava current.
|
||||
player.respiration = self.armor[0]
|
||||
.as_ref()
|
||||
.map_or(0, |s| s.enchantment_level("minecraft:respiration") as i32);
|
||||
player.fluids.fast_lava = self.dim == Dim::Nether;
|
||||
let dead = self.screen == Screen::Dead;
|
||||
// LivingEntity.isImmobile: a sleeper lies still in the bed.
|
||||
if let Some(head) = self.sleeping {
|
||||
@@ -3145,6 +3286,7 @@ impl Game<'_> {
|
||||
return;
|
||||
};
|
||||
let mut lerps = std::mem::take(&mut r.lerps);
|
||||
let stepped = &mut r.stepped;
|
||||
let head_lerps = &mut r.head_lerps;
|
||||
let players = &r.level_players;
|
||||
let mut using = Vec::new();
|
||||
@@ -3175,22 +3317,12 @@ impl Game<'_> {
|
||||
// LivingEntity.updateSwingTime.
|
||||
m.anim.attack_anim_o = m.anim.attack_anim;
|
||||
horizon_client::mobs::ai::tick_swing(m);
|
||||
// Entity.commonTick: LinearInterpolationHandler.interpolate.
|
||||
if let Some(l) = lerps.get_mut(&(m.id as i32))
|
||||
&& l.steps > 0
|
||||
// Entity.tick: the SteppedInterpolationHandler's step.
|
||||
if let Some(at) = stepped
|
||||
.get_mut(&(m.id as i32))
|
||||
.and_then(|h| h.interpolate())
|
||||
{
|
||||
let n = f64::from(l.steps);
|
||||
m.position = Vec3::new(
|
||||
m.position.x + (l.target[0] - m.position.x) / n,
|
||||
m.position.y + (l.target[1] - m.position.y) / n,
|
||||
m.position.z + (l.target[2] - m.position.z) / n,
|
||||
);
|
||||
if let Some((yaw, pitch)) = l.rotation {
|
||||
let alpha = 1.0 / l.steps as f32;
|
||||
m.yaw += wrap_degrees(yaw - m.yaw) * alpha;
|
||||
m.head_pitch += (pitch - m.head_pitch) * alpha;
|
||||
}
|
||||
l.steps -= 1;
|
||||
place_interpolated(m, at);
|
||||
}
|
||||
// LivingEntity.aiStep: the head's own interpolation.
|
||||
if let Some((yaw, steps)) = head_lerps.get_mut(&(m.id as i32))
|
||||
@@ -3465,22 +3597,13 @@ impl Game<'_> {
|
||||
return;
|
||||
}
|
||||
let facts = self.res.facts().get(state).clone();
|
||||
let held = self.held().cloned();
|
||||
let mut speed = self.res.items().mining_speed(held.as_ref(), &facts.name);
|
||||
if self.player.as_ref().is_some_and(|p| !p.ground.on_ground) {
|
||||
speed /= 5.0;
|
||||
}
|
||||
let harvest = !facts.requires_tool
|
||||
|| self
|
||||
.res
|
||||
.items()
|
||||
.correct_for_drops(held.as_ref(), &facts.name);
|
||||
let progress = if facts.hardness < 0.0 {
|
||||
0.0
|
||||
} else if facts.hardness == 0.0 {
|
||||
// The server's own getDestroyProgress: under water, off the ground,
|
||||
// with haste, fatigue and efficiency, so the client stops digging
|
||||
// when the server is done too (#1254).
|
||||
let progress = if facts.hardness == 0.0 {
|
||||
1.0
|
||||
} else {
|
||||
speed / facts.hardness / if harvest { 30.0 } else { 100.0 }
|
||||
self.destroy_progress(hit.pos)
|
||||
};
|
||||
let same = self
|
||||
.remote
|
||||
@@ -5023,6 +5146,20 @@ impl horizon_client::interaction_input::InteractionWorld for RemoteInputWorld<'_
|
||||
}
|
||||
}
|
||||
|
||||
impl Remote {
|
||||
/// The local player's profile UUID.
|
||||
pub(super) fn profile(&self) -> u128 {
|
||||
self.profile
|
||||
}
|
||||
/// 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
|
||||
.iter()
|
||||
.find(|(_, u)| **u == uuid)
|
||||
.map(|(id, _)| *id as u32)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::Game;
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
//! A server's mobs as their synched data has them
|
||||
//! (ClientPacketListener.handleSetEntityData, then each entity's
|
||||
//! `onSyncedDataUpdated`): what a client draws from it rather than from its
|
||||
//! own simulation, which a client of a server doesn't run.
|
||||
use super::Game;
|
||||
use horizon_client::mobs::{Mob, Species, tame};
|
||||
use horizon_protocol::entity_data::Value;
|
||||
|
||||
/// The values `apply` takes from one SetEntityData packet, by vanilla's entry
|
||||
/// indices (Entity 0..=7, LivingEntity 8..=14, Mob 15, then the subclasses).
|
||||
fn apply(m: &mut Mob, values: &[(u8, i32, Value)]) {
|
||||
let species = m.species();
|
||||
let baby = super::lan::baby_index(&m.kind.id);
|
||||
for (index, _, value) in values {
|
||||
match (*index, value) {
|
||||
// Entity.DATA_SHARED_FLAGS_ID's FLAG_ONFIRE: Entity.isOnFire on a
|
||||
// client, which draws the flames. A client doesn't count the
|
||||
// fire down: the flag goes off when the server's fire is out.
|
||||
(0, Value::Byte(flags)) => m.fire_ticks = i32::from(flags & 1),
|
||||
// LivingEntity.DATA_HEALTH_ID: the boss bars and the death
|
||||
// animation follow it.
|
||||
(9, Value::Float(health)) => m.health = *health,
|
||||
(i, Value::Bool(b)) if Some(i) == baby => m.extra.age = if *b { -1 } else { 0 },
|
||||
_ => {}
|
||||
}
|
||||
match (species, *index, value) {
|
||||
// Sheep.DATA_WOOL_ID: the colour in the low nibble, 16 sheared.
|
||||
(Species::Sheep, 18, Value::Byte(wool)) => {
|
||||
m.extra.color = (*wool & 15) as u8;
|
||||
m.extra.sheared = wool & 16 != 0;
|
||||
}
|
||||
// TamableAnimal.DATA_FLAGS_ID: sitting 1, tame 4.
|
||||
(Species::Wolf | Species::Cat | Species::Parrot, 18, Value::Byte(flags)) => {
|
||||
m.extra.sitting = flags & 1 != 0;
|
||||
m.extra.tame = flags & 4 != 0;
|
||||
}
|
||||
// Wolf.DATA_COLLAR_COLOR and Cat's.
|
||||
(Species::Wolf, 21, Value::Int(c)) | (Species::Cat, 23, Value::Int(c)) => {
|
||||
m.extra.color = (*c & 15) as u8;
|
||||
}
|
||||
// AbstractCubeMob.ID_SIZE: its size and box (refreshDimensions).
|
||||
(Species::Slime | Species::MagmaCube, 18, Value::Int(size)) => {
|
||||
m.extra.size = (*size).clamp(1, 127);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
/// SetEntityData for a mob of the server's.
|
||||
pub(super) fn remote_mob_data(&mut self, id: i32, values: &[(u8, i32, Value)]) {
|
||||
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == id as u32) {
|
||||
apply(m, values);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A mob as a client constructs it before its data arrives: the synched
|
||||
/// defaults the server leaves out (`getNonDefaultValues`) that differ from
|
||||
/// what `Mob::new` gives, such as a wolf's and a cat's red collar.
|
||||
pub(super) fn client_defaults(m: &mut Mob) {
|
||||
if matches!(m.species(), Species::Wolf | Species::Cat) {
|
||||
m.extra.color = tame::RED;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use horizon_protocol::entity_data::serializer as s;
|
||||
|
||||
/// A mob of the prepared resources' type `kind`, as a client spawns it
|
||||
/// (None without the resources).
|
||||
fn mob(kind: &str) -> Option<Mob> {
|
||||
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../../.local/horizon-client")
|
||||
.join(horizon_core::VANILLA_VERSION);
|
||||
let types = horizon_client::mobs::MobTypes::load(&root).ok()?;
|
||||
let t = std::sync::Arc::new(types.types.get(kind)?.clone());
|
||||
let mut m = Mob::new(1, t, horizon_core::geometry::Vec3::ZERO, 0.0);
|
||||
client_defaults(&mut m);
|
||||
Some(m)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_sheep_takes_its_wool_and_a_calf_its_size() {
|
||||
let Some(mut m) = mob("minecraft:sheep") else {
|
||||
return;
|
||||
};
|
||||
apply(&mut m, &[(18, s::BYTE, Value::Byte(14 | 16))]);
|
||||
assert_eq!((m.extra.color, m.extra.sheared), (14, true));
|
||||
apply(&mut m, &[(18, s::BYTE, Value::Byte(0))]);
|
||||
assert_eq!((m.extra.color, m.extra.sheared), (0, false));
|
||||
let mut calf = mob("minecraft:cow").unwrap();
|
||||
apply(&mut calf, &[(16, s::BOOLEAN, Value::Bool(true))]);
|
||||
assert!(calf.is_baby());
|
||||
apply(&mut calf, &[(16, s::BOOLEAN, Value::Bool(false))]);
|
||||
assert!(!calf.is_baby());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_wolf_takes_its_owner_flags_and_collar() {
|
||||
let Some(mut m) = mob("minecraft:wolf") else {
|
||||
return;
|
||||
};
|
||||
assert_eq!(m.extra.color, tame::RED);
|
||||
apply(
|
||||
&mut m,
|
||||
&[
|
||||
(18, s::BYTE, Value::Byte(4 | 1)),
|
||||
(21, s::INT, Value::Int(11)),
|
||||
],
|
||||
);
|
||||
assert!(m.extra.tame && m.extra.sitting);
|
||||
assert_eq!(m.extra.color, 11);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fire_health_and_size_follow_the_server() {
|
||||
let Some(mut m) = mob("minecraft:slime") else {
|
||||
return;
|
||||
};
|
||||
apply(
|
||||
&mut m,
|
||||
&[
|
||||
(0, s::BYTE, Value::Byte(1)),
|
||||
(9, s::FLOAT, Value::Float(12.5)),
|
||||
(18, s::INT, Value::Int(4)),
|
||||
],
|
||||
);
|
||||
assert_eq!((m.fire_ticks, m.health, m.extra.size), (1, 12.5, 4));
|
||||
apply(&mut m, &[(0, s::BYTE, Value::Byte(0))]);
|
||||
assert_eq!(m.fire_ticks, 0);
|
||||
}
|
||||
}
|
||||
@@ -222,6 +222,7 @@ impl Game<'_> {
|
||||
hud_overlays::camera_overlays(size[0] as i32, size[1] as i32, &overlays, &mut scope);
|
||||
self.screen_fx.build(&ScreenInputs {
|
||||
sprites: &self.gui.atlas.sprites,
|
||||
flags: &self.gui.atlas.flags,
|
||||
aspect: size[0] as f32 / size[1].max(1) as f32,
|
||||
partial,
|
||||
block_overlay,
|
||||
|
||||
@@ -31,6 +31,33 @@ pub(super) struct RespawnConfig {
|
||||
pub forced: bool,
|
||||
}
|
||||
|
||||
/// A respawn point as a player's save keeps it (`respawn`): dimension,
|
||||
/// block, yaw and whether it is forced.
|
||||
pub(super) type SavedRespawn = (String, [i32; 3], f32, bool);
|
||||
|
||||
impl RespawnConfig {
|
||||
/// `RespawnData.of`: the yaw wrapped to [-180, 180).
|
||||
pub(super) fn wrapped(dim: Dim, pos: [i32; 3], yaw: f32, forced: bool) -> Self {
|
||||
Self {
|
||||
dim,
|
||||
pos,
|
||||
yaw: wrap_degrees(f64::from(yaw)) as f32,
|
||||
forced,
|
||||
}
|
||||
}
|
||||
pub(super) fn from_saved((dim, pos, yaw, forced): &SavedRespawn) -> Self {
|
||||
Self {
|
||||
dim: Dim::from_id(dim),
|
||||
pos: *pos,
|
||||
yaw: *yaw,
|
||||
forced: *forced,
|
||||
}
|
||||
}
|
||||
pub(super) fn saved(&self) -> SavedRespawn {
|
||||
(self.dim.id().to_owned(), self.pos, self.yaw, self.forced)
|
||||
}
|
||||
}
|
||||
|
||||
/// `RespawnAnchorBlock.RESPAWN_HORIZONTAL_OFFSETS`, then the same a block
|
||||
/// below, a block above, and straight above.
|
||||
const HORIZONTAL: [[i32; 3]; 8] = [
|
||||
@@ -62,7 +89,7 @@ pub(super) enum AnchorUse {
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
fn is_anchor(&self, state: BlockStateId) -> bool {
|
||||
pub(super) fn is_anchor(&self, state: BlockStateId) -> bool {
|
||||
&*self.res.facts().get(state).name == "minecraft:respawn_anchor"
|
||||
}
|
||||
fn anchor_charges(&self, state: BlockStateId) -> i32 {
|
||||
@@ -415,6 +442,16 @@ impl Game<'_> {
|
||||
let (lz, hz) = (bounds.min(Axis::Z), bounds.max(Axis::Z) - 1.0e-5);
|
||||
(self.border.contains(lo, lz) && self.border.contains(hi, hz)).then_some(position)
|
||||
}
|
||||
/// `Block.isPossibleToRespawnInThis`: neither solid nor liquid; banners,
|
||||
/// signs and pressure plates override it to always.
|
||||
fn possible_to_respawn_in(&self, state: BlockStateId) -> bool {
|
||||
let f = self.res.facts().get(state);
|
||||
let name = &*f.name;
|
||||
name.ends_with("_banner")
|
||||
|| name.ends_with("_sign")
|
||||
|| name.ends_with("_pressure_plate")
|
||||
|| !(f.legacy_solid || matches!(name, "minecraft:water" | "minecraft:lava"))
|
||||
}
|
||||
/// `RespawnAnchorBlock.findStandUpPosition`: a safe spot around the
|
||||
/// anchor, else any spot.
|
||||
pub(super) fn anchor_stand_up(&self, pos: [i32; 3]) -> Option<Vec3> {
|
||||
@@ -428,8 +465,87 @@ impl Game<'_> {
|
||||
}
|
||||
None
|
||||
}
|
||||
/// `ServerPlayer.findRespawnAndUseSpawnBlock` for an anchor config in the
|
||||
/// loaded dimension: the position and yaw, using a charge.
|
||||
/// `AbstractBedBlock.findStandUpPosition` for a bed whose head is at
|
||||
/// `pos`, facing `forward`, set from `yaw`: beside the bed on the side
|
||||
/// the player faced away from, around it, then on it; a safe spot
|
||||
/// first, else any spot (a bunk bed: around it, a block lower, then on
|
||||
/// it).
|
||||
pub(super) fn bed_stand_up(&self, pos: [i32; 3], forward: [i32; 3], yaw: f32) -> Option<Vec3> {
|
||||
// Direction.getClockWise.
|
||||
let right = [-forward[2], 0, forward[0]];
|
||||
// Direction.isFacingAngle.
|
||||
let radians = yaw * 0.017_453_292;
|
||||
let (dx, dz) = (
|
||||
-horizon_core::geometry::trig::sin(f64::from(radians)),
|
||||
horizon_core::geometry::trig::cos(f64::from(radians)),
|
||||
);
|
||||
let facing = right[0] as f32 * dx + right[2] as f32 * dz > 0.0;
|
||||
let side = if facing {
|
||||
[-right[0], 0, -right[2]]
|
||||
} else {
|
||||
right
|
||||
};
|
||||
let (f, s) = ([forward[0], forward[2]], [side[0], side[2]]);
|
||||
// bedSurroundStandUpOffsets and bedAboveStandUpOffsets.
|
||||
let surround = [
|
||||
[s[0], s[1]],
|
||||
[s[0] - f[0], s[1] - f[1]],
|
||||
[s[0] - f[0] * 2, s[1] - f[1] * 2],
|
||||
[-f[0] * 2, -f[1] * 2],
|
||||
[-s[0] - f[0] * 2, -s[1] - f[1] * 2],
|
||||
[-s[0] - f[0], -s[1] - f[1]],
|
||||
[-s[0], -s[1]],
|
||||
[-s[0] + f[0], -s[1] + f[1]],
|
||||
[f[0], f[1]],
|
||||
[s[0] + f[0], s[1] + f[1]],
|
||||
];
|
||||
let above = [[0, 0], [-f[0], -f[1]]];
|
||||
let at = |base: [i32; 3], offsets: &[[i32; 2]], check: bool| {
|
||||
offsets.iter().find_map(|o| {
|
||||
self.safe_dismount_location([base[0] + o[0], base[1], base[2] + o[1]], check)
|
||||
})
|
||||
};
|
||||
let below = [pos[0], pos[1] - 1, pos[2]];
|
||||
// isBunkBed: a bed below.
|
||||
if self.is_bed(self.world.block(below[0], below[1], below[2])) {
|
||||
return at(pos, &surround, true)
|
||||
.or_else(|| at(below, &surround, true))
|
||||
.or_else(|| at(pos, &above, true))
|
||||
.or_else(|| at(pos, &surround, false))
|
||||
.or_else(|| at(below, &surround, false))
|
||||
.or_else(|| at(pos, &above, false));
|
||||
}
|
||||
let all: Vec<[i32; 2]> = surround.into_iter().chain(above).collect();
|
||||
at(pos, &all, true).or_else(|| at(pos, &all, false))
|
||||
}
|
||||
pub(super) fn is_bed(&self, state: BlockStateId) -> bool {
|
||||
self.res.facts().get(state).name.ends_with("_bed")
|
||||
}
|
||||
/// The bed's `facing`, as a step.
|
||||
fn bed_forward(&self, state: BlockStateId) -> [i32; 3] {
|
||||
let facing = self
|
||||
.pack
|
||||
.assets
|
||||
.registry
|
||||
.state(state)
|
||||
.and_then(|s| s.properties.get("facing").cloned());
|
||||
match facing.as_deref() {
|
||||
Some("south") => [0, 0, 1],
|
||||
Some("west") => [-1, 0, 0],
|
||||
Some("east") => [1, 0, 0],
|
||||
_ => [0, 0, -1],
|
||||
}
|
||||
}
|
||||
/// `BedRule.canSetSpawn` of the bed's rule attribute (`bed_rule`,
|
||||
/// `straw_bed_rule`): only the Overworld's beds set the spawn, straw
|
||||
/// beds never.
|
||||
pub(super) fn bed_sets_spawn(&self, state: BlockStateId) -> bool {
|
||||
self.dim == Dim::Overworld && &*self.res.facts().get(state).name != "minecraft:straw_bed"
|
||||
}
|
||||
/// `ServerPlayer.findRespawnAndUseSpawnBlock` for a config in the loaded
|
||||
/// 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,
|
||||
@@ -440,7 +556,19 @@ impl Game<'_> {
|
||||
&& (c.forced || self.anchor_charges(state) > 0)
|
||||
&& self.anchor_works())
|
||||
{
|
||||
return Ok(None);
|
||||
// A bed where its bed rule lets it set the spawn.
|
||||
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))));
|
||||
}
|
||||
let [x, y, z] = c.pos;
|
||||
let free = c.forced
|
||||
&& !self.is_anchor(state)
|
||||
&& 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)));
|
||||
}
|
||||
let stand = self.anchor_stand_up(c.pos);
|
||||
if !c.forced
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
//! Worlds and the multiplayer server list from the control socket: what the Select World, Edit
|
||||
//! World and Multiplayer screens do, by world folder. Changing a world needs the title screen
|
||||
//! (no world open), as those screens do; deleting one needs `--confirm`.
|
||||
use super::Game;
|
||||
use horizon_client::{Result, title};
|
||||
use serde_json::{Value, json};
|
||||
use std::{path::PathBuf, sync::mpsc::Sender, time::UNIX_EPOCH};
|
||||
|
||||
pub(super) const HELP: &[&str] = &[
|
||||
"worlds the saved worlds, newest first: folder, name, mode, last played, version",
|
||||
"world-rename FOLDER NAME rename a world (Edit World; its folder stays)",
|
||||
"world-backup FOLDER zip a world into backups/ (Edit World > Make Backup)",
|
||||
"world-delete FOLDER --confirm delete a world for good (Select World > Delete)",
|
||||
"servers the multiplayer server list (servers.dat): name and address",
|
||||
];
|
||||
|
||||
fn saves() -> Result<PathBuf> {
|
||||
Ok(std::env::current_dir()?.join("saves"))
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
/// Some(reply) for these commands (Some(None): the reply comes later), None for others.
|
||||
pub(super) fn saves_control(
|
||||
&mut self,
|
||||
words: &[&str],
|
||||
line: &str,
|
||||
reply: &Sender<String>,
|
||||
) -> Result<Option<Option<Value>>> {
|
||||
let folder = || {
|
||||
words
|
||||
.get(1)
|
||||
.copied()
|
||||
.ok_or_else(|| format!("{} FOLDER: the folder from worlds", words[0]))
|
||||
};
|
||||
let on_title = || {
|
||||
if self.title.is_some() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(format!(
|
||||
"{} needs the title screen: save and leave the world first (pause, then Save and Quit to Title)",
|
||||
words[0]
|
||||
))
|
||||
}
|
||||
};
|
||||
let value = match words[0] {
|
||||
"worlds" => {
|
||||
let worlds: Vec<Value> = title::list_worlds(&saves()?)
|
||||
.iter()
|
||||
.map(|w| {
|
||||
json!({
|
||||
"folder": w.folder,
|
||||
"name": w.name,
|
||||
"mode": if w.creative { "creative" } else { "survival" },
|
||||
"last_played": w.modified.duration_since(UNIX_EPOCH).map_or(0, |d| d.as_millis() as u64),
|
||||
"commands": w.commands,
|
||||
"version": w.version,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
json!({ "worlds": worlds })
|
||||
}
|
||||
"world-rename" => {
|
||||
on_title()?;
|
||||
let folder = folder()?;
|
||||
let name = line
|
||||
.trim_start()
|
||||
.strip_prefix("world-rename")
|
||||
.and_then(|rest| rest.trim_start().strip_prefix(folder))
|
||||
.map(str::trim)
|
||||
.filter(|n| !n.is_empty())
|
||||
.ok_or("world-rename FOLDER NAME: missing the new name")?;
|
||||
title::world_management::rename(&saves()?, folder, name)?;
|
||||
if let Some(title) = self.title.as_mut() {
|
||||
title.worlds_changed();
|
||||
}
|
||||
json!({ "ok": true, "folder": folder, "name": name })
|
||||
}
|
||||
"world-delete" => {
|
||||
on_title()?;
|
||||
let folder = folder()?;
|
||||
if !words.contains(&"--confirm") {
|
||||
return Err(format!(
|
||||
"world-delete deletes {folder} for good: ask the player, then add --confirm"
|
||||
)
|
||||
.into());
|
||||
}
|
||||
title::world_management::delete(&saves()?, folder)?;
|
||||
if let Some(title) = self.title.as_mut() {
|
||||
title.worlds_changed();
|
||||
}
|
||||
json!({ "ok": true, "deleted": folder })
|
||||
}
|
||||
"world-backup" => {
|
||||
on_title()?;
|
||||
let folder = folder()?.to_owned();
|
||||
let saves = saves()?;
|
||||
let backups = title::world_management::backups_path(&saves);
|
||||
// A big world takes a while to zip: off the game thread, as Make Backup does.
|
||||
let reply = reply.clone();
|
||||
std::thread::spawn(move || {
|
||||
let answer = match title::world_management::backup(&saves, &folder) {
|
||||
Ok(bytes) => {
|
||||
json!({ "ok": true, "folder": folder, "backups": backups, "bytes": bytes })
|
||||
}
|
||||
Err(e) => json!({ "error": format!("backup of {folder} failed: {e}") }),
|
||||
};
|
||||
let _ = reply.send(answer.to_string());
|
||||
});
|
||||
return Ok(Some(None));
|
||||
}
|
||||
"servers" => {
|
||||
let file = horizon_client::multiplayer::servers_file(&saves()?);
|
||||
let servers: Vec<Value> = horizon_protocol::servers::load(&file)
|
||||
.iter()
|
||||
.map(|s| json!({ "name": s.name, "address": s.ip }))
|
||||
.collect();
|
||||
json!({ "servers": servers })
|
||||
}
|
||||
_ => return Ok(None),
|
||||
};
|
||||
Ok(Some(Some(value)))
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,9 @@ impl Screen {
|
||||
| Self::Inventory
|
||||
| Self::Dead
|
||||
| Self::CommandBlock => false,
|
||||
// WinScreen.extractBackground: the poem fills the screen with the End portal, the
|
||||
// credits alone blur. Game::menu_blur knows which one rolls.
|
||||
Self::Win => false,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -359,6 +362,7 @@ mod tests {
|
||||
Screen::Inventory,
|
||||
Screen::Dead,
|
||||
Screen::CommandBlock,
|
||||
Screen::Win,
|
||||
] {
|
||||
assert!(!screen.blurs_background(true));
|
||||
assert!(!screen.blurs_background(false));
|
||||
|
||||
@@ -7,9 +7,11 @@
|
||||
//! another level: a Respawn packet keeping all data, then a teleport to the
|
||||
//! exit once the destination has loaded around them.
|
||||
|
||||
use super::end::GatewayJob;
|
||||
use super::nether_portal::{PLAYER_DELAY, Plan};
|
||||
use super::respawn_anchor::RespawnConfig;
|
||||
use super::{Dim, Game, lan::LanHost};
|
||||
use horizon_client::lan::hub::TickCtx;
|
||||
use horizon_client::{lan::hub::TickCtx, text::Text};
|
||||
use horizon_core::{geometry::Vec3, position::BlockPos};
|
||||
|
||||
/// A trip under way: the player is in the destination's level, whose
|
||||
@@ -22,6 +24,12 @@ pub(super) struct Trip {
|
||||
end: bool,
|
||||
/// The Nether portal plan, whose exit is found or built on arrival.
|
||||
plan: Option<Plan>,
|
||||
/// Through an End gateway, within the level: its exit, found or built
|
||||
/// on arrival.
|
||||
gateway: Option<GatewayJob>,
|
||||
/// A respawn at this point outside the Overworld, resolved through its
|
||||
/// block on arrival.
|
||||
respawn: Option<RespawnConfig>,
|
||||
/// Sent by a command: the player is already where it goes.
|
||||
placed: bool,
|
||||
}
|
||||
@@ -95,7 +103,7 @@ impl Game<'_> {
|
||||
if let Some(pos) = self.nether_portal_inside(at) {
|
||||
self.portals.player.set_inside(pos, PLAYER_DELAY);
|
||||
}
|
||||
let (end_portal, _) = self.inside_end_portals(at, WIDTH, HEIGHT);
|
||||
let (end_portal, gateway) = self.inside_end_portals(at, WIDTH, HEIGHT);
|
||||
if end_portal {
|
||||
if self.portals.player.cooldown == 0 {
|
||||
let target = if self.dim == Dim::End {
|
||||
@@ -109,6 +117,19 @@ impl Game<'_> {
|
||||
self.portals.player.cooldown = 10;
|
||||
return;
|
||||
}
|
||||
// EndGatewayBlock.entityInside: a gateway that is not cooling down
|
||||
// takes the player at once (no transition time) and cools down
|
||||
// (triggerCooldown), even for a player still on its own cooldown.
|
||||
if let Some(g) = gateway
|
||||
&& self.gateway_enter(g)
|
||||
{
|
||||
if self.portals.player.cooldown == 0 {
|
||||
self.start_gateway_trip(host, conn, g);
|
||||
}
|
||||
// setPortalCooldown: after the trip, or again while cooling down.
|
||||
self.portals.player.cooldown = 10;
|
||||
return;
|
||||
}
|
||||
let rule = if invulnerable {
|
||||
"players_nether_portal_creative_delay"
|
||||
} else {
|
||||
@@ -172,11 +193,16 @@ impl Game<'_> {
|
||||
_ => 1.0,
|
||||
};
|
||||
self.end_portal_travel = end;
|
||||
let scaled = self.end_destination(target).unwrap_or(Vec3::new(
|
||||
from.x * scale,
|
||||
from.y,
|
||||
from.z * scale,
|
||||
));
|
||||
// Leaving the End: the player's own respawn point.
|
||||
let own = if end {
|
||||
self.as_guest(host, conn, |g| g.end_destination(target))
|
||||
.flatten()
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let scaled = own
|
||||
.or_else(|| self.end_destination(target))
|
||||
.unwrap_or(Vec3::new(from.x * scale, from.y, from.z * scale));
|
||||
self.end_portal_travel = false;
|
||||
if let Err(e) = self.enter_level(Some(host), target) {
|
||||
log::error!("could not open {}: {e}", target.id());
|
||||
@@ -197,6 +223,8 @@ impl Game<'_> {
|
||||
target: destination,
|
||||
end,
|
||||
plan,
|
||||
gateway: None,
|
||||
respawn: None,
|
||||
placed: false,
|
||||
});
|
||||
if let Err(e) = self.enter_level(Some(host), source) {
|
||||
@@ -204,6 +232,93 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// A respawned player waits at its point outside the Overworld for the
|
||||
/// level to load around it.
|
||||
pub(super) fn respawn_trip(&mut self, conn: u32, config: RespawnConfig) {
|
||||
let [x, y, z] = config.pos;
|
||||
self.server_trips.push(Trip {
|
||||
conn,
|
||||
dim: config.dim,
|
||||
target: Vec3::new(f64::from(x) + 0.5, f64::from(y), f64::from(z) + 0.5),
|
||||
end: false,
|
||||
plan: None,
|
||||
gateway: None,
|
||||
respawn: Some(config),
|
||||
placed: false,
|
||||
});
|
||||
}
|
||||
|
||||
/// `findRespawnAndUseSpawnBlock` for the player in place, at its point
|
||||
/// in the level in place: where it stands up, facing the anchor (with
|
||||
/// the deplete sound) or the bed; else the point is forgotten
|
||||
/// (`block.minecraft.spawn.not_valid`).
|
||||
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))) => {
|
||||
if !config.forced && anchor {
|
||||
let [x, y, z] = config.pos.map(f64::from);
|
||||
self.audio.play(
|
||||
"minecraft:block.respawn_anchor.deplete",
|
||||
Some([x, y, z]),
|
||||
1.0,
|
||||
1.0,
|
||||
);
|
||||
}
|
||||
Some((at, yaw))
|
||||
}
|
||||
Ok(None) | Err(_) => {
|
||||
self.respawn_config = None;
|
||||
self.system_message(Text::translate("block.minecraft.spawn.not_valid", &[]));
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// TeleportTransition.missingRespawnBlock: the world spawn, in the
|
||||
/// Overworld.
|
||||
fn respawn_at_world_spawn(&mut self, host: &mut LanHost, conn: u32) {
|
||||
let at = self.level.world_spawn.map_or(
|
||||
self.spawn.map_or([0.5, 80.0, 0.5], |s| [s.x, s.y, s.z]),
|
||||
|[x, y, z]| [f64::from(x) + 0.5, f64::from(y), f64::from(z) + 0.5],
|
||||
);
|
||||
self.level_teleports.push(LevelTeleport {
|
||||
conn,
|
||||
dim: Dim::Overworld,
|
||||
at,
|
||||
rotation: None,
|
||||
});
|
||||
self.run_level_teleports(host);
|
||||
}
|
||||
|
||||
/// `TheEndGatewayBlockEntity.getPortalPosition` and the teleport within
|
||||
/// the End (keeping the player's motion and look): straight to an exact
|
||||
/// exit, else once the destination has loaded around the player, who
|
||||
/// waits there (its exit is found among those blocks, or built).
|
||||
fn start_gateway_trip(&mut self, host: &mut LanHost, conn: u32, gateway: [i32; 3]) {
|
||||
let Some(i) = host.hub.guests.iter().position(|g| g.conn == conn) else {
|
||||
return;
|
||||
};
|
||||
let (job, destination) = self.gateway_job(gateway);
|
||||
let at = [destination.x, destination.y, destination.z];
|
||||
// Relative.union(DELTA, ROTATION).
|
||||
host.hub.teleport_keeping_motion(i, at);
|
||||
if let GatewayJob::Known { exact: true, .. } = job {
|
||||
return;
|
||||
}
|
||||
self.server_trips.push(Trip {
|
||||
conn,
|
||||
dim: self.dim,
|
||||
target: destination,
|
||||
end: false,
|
||||
plan: None,
|
||||
gateway: Some(job),
|
||||
respawn: None,
|
||||
placed: false,
|
||||
});
|
||||
}
|
||||
|
||||
/// The trips into the level in place whose destination has loaded
|
||||
/// around their target: the exit (the Nether portal found or built, the
|
||||
/// End platform, the Overworld's spawn column) and the teleport there.
|
||||
@@ -232,8 +347,28 @@ impl Game<'_> {
|
||||
}
|
||||
self.resolve_arrivals();
|
||||
let yaw = host.hub.guests[i].yaw;
|
||||
let (position, yaw) = if trip.end {
|
||||
match self.end_arrival(trip.target) {
|
||||
let (position, yaw) = if let Some(config) = trip.respawn {
|
||||
match self.as_guest(host, trip.conn, |g| g.resolve_server_respawn(config)) {
|
||||
Some(Some(found)) => found,
|
||||
_ => {
|
||||
self.respawn_at_world_spawn(host, trip.conn);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
} else if let Some(job) = trip.gateway {
|
||||
match self.resolve_gateway(job, trip.target) {
|
||||
Ok(at) => (at, yaw),
|
||||
Err(e) => {
|
||||
log::error!("End gateway exit: {e}");
|
||||
(trip.target, yaw)
|
||||
}
|
||||
}
|
||||
} else if trip.end {
|
||||
let arrival = match self.as_guest(host, trip.conn, |g| g.end_arrival(trip.target)) {
|
||||
Some(arrival) => arrival,
|
||||
None => self.end_arrival(trip.target),
|
||||
};
|
||||
match arrival {
|
||||
Ok((at, turned)) => (at, turned.unwrap_or(yaw)),
|
||||
Err(e) => {
|
||||
log::error!("End arrival: {e}");
|
||||
@@ -293,6 +428,8 @@ impl Game<'_> {
|
||||
target: Vec3::new(t.at[0], t.at[1], t.at[2]),
|
||||
end: false,
|
||||
plan: None,
|
||||
gateway: None,
|
||||
respawn: None,
|
||||
placed: true,
|
||||
});
|
||||
if let Err(e) = self.enter_level(Some(host), source) {
|
||||
|
||||
@@ -12,6 +12,8 @@ pub(super) struct StartupMenu {
|
||||
title: Option<Title>,
|
||||
options: OptionsUi,
|
||||
options_open: bool,
|
||||
/// The credits from Credits & Attribution, and their last frame.
|
||||
win: Option<(horizon_client::win_screen::WinScreen, Instant)>,
|
||||
root: PathBuf,
|
||||
cursor: (f32, f32),
|
||||
pointer: (f64, f64),
|
||||
@@ -370,7 +372,23 @@ impl Loading {
|
||||
let _ = std::fs::create_dir_all(&dir);
|
||||
packs::open_folder(&dir);
|
||||
}
|
||||
Event::ButtonClick => {}
|
||||
Event::Credits => {
|
||||
if let Some(gui) = &self.gui {
|
||||
let name = self.options.as_ref().map_or_else(
|
||||
|| horizon_client::offline_name::generate(0),
|
||||
Options::player_name,
|
||||
);
|
||||
let screen = horizon_client::win_screen::WinScreen::new(
|
||||
false,
|
||||
&self.menu.root,
|
||||
&name,
|
||||
&gui.atlas.font,
|
||||
);
|
||||
self.menu.win = Some((screen, Instant::now()));
|
||||
}
|
||||
}
|
||||
// The startup menu runs before the game opens a control socket.
|
||||
Event::ButtonClick | Event::CopyAgentDetails => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -456,6 +474,20 @@ impl Loading {
|
||||
}
|
||||
}
|
||||
_ if self.gui.is_none() || self.menu.pending.is_some() => {}
|
||||
WindowEvent::KeyboardInput { event, .. } if self.menu.win.is_some() => {
|
||||
if let PhysicalKey::Code(code) = event.physical_key {
|
||||
let pressed = event.state == ElementState::Pressed;
|
||||
if code == KeyCode::Escape && pressed {
|
||||
self.menu.win = None;
|
||||
} else if let Some(key) = horizon_client::win_screen::Key::from_code(code)
|
||||
&& let Some((win, _)) = &mut self.menu.win
|
||||
{
|
||||
win.key(key, pressed);
|
||||
}
|
||||
}
|
||||
}
|
||||
WindowEvent::MouseInput { .. } | WindowEvent::MouseWheel { .. }
|
||||
if self.menu.win.is_some() => {}
|
||||
WindowEvent::CursorMoved { position, .. } => {
|
||||
self.menu.pointer = (position.x, position.y);
|
||||
self.update_menu_cursor();
|
||||
@@ -575,6 +607,15 @@ impl Loading {
|
||||
self.progress.line(),
|
||||
self.progress.fraction(),
|
||||
);
|
||||
} else if let Some((win, frame)) = &mut self.menu.win {
|
||||
let now = Instant::now();
|
||||
// 20 ticks a second: the startup menu runs no tick rate.
|
||||
let delta_ticks = now.duration_since(*frame).as_secs_f32() * 20.0;
|
||||
*frame = now;
|
||||
win.draw(batch, &gui.atlas, delta_ticks, Default::default());
|
||||
if win.finished(batch.height) {
|
||||
self.menu.win = None;
|
||||
}
|
||||
} else if self.menu.options_open {
|
||||
self.menu
|
||||
.options
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
//! `WinScreen` in the game: the poem and credits after leaving the End for
|
||||
//! the first time (`EndPortalBlock.entityInside` -> `ServerPlayer.showEndCredits`,
|
||||
//! or a server's `WIN_GAME` event), and the credits from Credits &
|
||||
//! Attribution.
|
||||
use super::{Game, Screen};
|
||||
use horizon_client::{
|
||||
Result,
|
||||
hud::{EndPortalFill, HudBatch},
|
||||
win_screen::{Key, WinScreen},
|
||||
};
|
||||
use std::time::Instant;
|
||||
use winit::{
|
||||
event::{ElementState, KeyEvent},
|
||||
keyboard::{KeyCode, PhysicalKey},
|
||||
};
|
||||
|
||||
/// What closing the screen does (`WinScreen.onFinished`).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(super) enum Finish {
|
||||
/// `PERFORM_RESPAWN` in single player: the player, out of the End since
|
||||
/// the credits began, arrives in the Overworld.
|
||||
Respawn,
|
||||
/// `PERFORM_RESPAWN` sent to the server.
|
||||
RemoteRespawn,
|
||||
/// Back to Credits & Attribution.
|
||||
Options,
|
||||
/// Back to the game (`horizon-ctl credits`).
|
||||
Closed,
|
||||
}
|
||||
pub(super) struct Win {
|
||||
screen: WinScreen,
|
||||
finish: Finish,
|
||||
/// The last drawn frame, for the scroll's game-time delta.
|
||||
frame: Instant,
|
||||
}
|
||||
impl Game<'_> {
|
||||
pub(super) fn open_win_screen(&mut self, poem: bool, finish: Finish) {
|
||||
let screen = WinScreen::new(
|
||||
poem,
|
||||
&self.options.resources,
|
||||
&self.options.player_name(),
|
||||
&self.gui.atlas.font,
|
||||
);
|
||||
self.win = Some(Win {
|
||||
screen,
|
||||
finish,
|
||||
frame: Instant::now(),
|
||||
});
|
||||
self.keys.clear();
|
||||
self.screen = Screen::Win;
|
||||
self.capture(false);
|
||||
}
|
||||
/// `ServerPlayer.showEndCredits`: the player leaves the End now (no
|
||||
/// longer in any level until the credits end) and has seen the credits.
|
||||
pub(super) fn show_end_credits(&mut self) -> Result<()> {
|
||||
self.level.seen_credits = true;
|
||||
self.open_win_screen(true, Finish::Respawn);
|
||||
self.end_portal_travel = true;
|
||||
let result = self.enter_dimension(super::Dim::Overworld);
|
||||
self.end_portal_travel = false;
|
||||
result
|
||||
}
|
||||
/// Stop the scroll `scroll` pixels in (`horizon-ctl credits ... SCROLL`).
|
||||
pub(super) fn hold_win_screen(&mut self, scroll: f32) {
|
||||
if let Some(win) = self.win.as_mut() {
|
||||
win.screen.hold(scroll);
|
||||
}
|
||||
}
|
||||
/// `WinScreen.isPauseScreen`: only the credits without the poem pause.
|
||||
pub(super) fn win_screen_pauses(&self) -> bool {
|
||||
self.win.as_ref().is_some_and(|w| !w.screen.poem)
|
||||
}
|
||||
/// The player waits out of the world while the poem rolls.
|
||||
pub(super) fn win_screen_holds_arrival(&self) -> bool {
|
||||
self.win
|
||||
.as_ref()
|
||||
.is_some_and(|w| w.finish == Finish::Respawn)
|
||||
}
|
||||
pub(super) fn draw_win_screen(&mut self, batch: &mut HudBatch) {
|
||||
let Some(win) = self.win.as_mut() else {
|
||||
return;
|
||||
};
|
||||
let now = Instant::now();
|
||||
// DeltaTracker.getGameTimeDeltaTicks: the frame's length in ticks.
|
||||
let delta_ticks =
|
||||
now.duration_since(win.frame).as_secs_f32() / self.clock.tick_duration().as_secs_f32();
|
||||
win.frame = now;
|
||||
let sprite = |n: &str| {
|
||||
self.gui
|
||||
.atlas
|
||||
.sprites
|
||||
.get(n)
|
||||
.map_or([0.0; 4], |r| [r.u0, r.v0, r.u1 - r.u0, r.v1 - r.v0])
|
||||
};
|
||||
let fill = EndPortalFill {
|
||||
sprites: [
|
||||
sprite("environment/end_sky"),
|
||||
sprite("entity/end_portal/end_portal"),
|
||||
],
|
||||
game_time: (self.world.game_time % 24000) as f32 / 24000.0,
|
||||
};
|
||||
win.screen.draw(batch, &self.gui.atlas, delta_ticks, fill);
|
||||
}
|
||||
/// `WinScreen.keyPressed` / `keyReleased`; Escape closes it (`onClose`).
|
||||
pub(super) fn win_key(&mut self, event: &KeyEvent) -> Result<()> {
|
||||
let PhysicalKey::Code(code) = event.physical_key else {
|
||||
return Ok(());
|
||||
};
|
||||
let pressed = event.state == ElementState::Pressed;
|
||||
if code == KeyCode::Escape && pressed {
|
||||
return self.close_win_screen();
|
||||
}
|
||||
let Some(key) = Key::from_code(code) else {
|
||||
return Ok(());
|
||||
};
|
||||
if let Some(win) = self.win.as_mut() {
|
||||
win.screen.key(key, pressed);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
/// `WinScreen.tick`: done once everything has rolled past the top.
|
||||
pub(super) fn tick_win_screen(&mut self) -> Result<()> {
|
||||
let height = self.gui_size().1;
|
||||
if self.win.as_ref().is_some_and(|w| w.screen.finished(height)) {
|
||||
self.close_win_screen()?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
/// `WinScreen.respawn` (`onFinished`) and `removed`: the credits music stops.
|
||||
pub(super) fn close_win_screen(&mut self) -> Result<()> {
|
||||
let Some(win) = self.win.take() else {
|
||||
return Ok(());
|
||||
};
|
||||
let credits = horizon_client::music::Music::credits();
|
||||
self.music
|
||||
.stop_playing_music(&credits, None, &mut self.audio);
|
||||
match win.finish {
|
||||
Finish::Options => self.screen = Screen::Options,
|
||||
Finish::Closed if self.title.is_some() => self.screen = Screen::Playing,
|
||||
Finish::Respawn | Finish::RemoteRespawn | Finish::Closed => {
|
||||
if win.finish == Finish::RemoteRespawn {
|
||||
self.remote_respawn();
|
||||
}
|
||||
self.screen = Screen::Playing;
|
||||
self.capture(true);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
+146
-398
@@ -60,6 +60,7 @@ pub fn outline_vertices(edges: &[[f64; 6]], camera: glam::DVec3) -> Vec<[f32; 6]
|
||||
.collect()
|
||||
}
|
||||
|
||||
mod animate;
|
||||
mod arena;
|
||||
mod cull;
|
||||
mod end_sky;
|
||||
@@ -69,6 +70,7 @@ pub mod lightning;
|
||||
mod lod;
|
||||
mod occlusion;
|
||||
mod pack_stages;
|
||||
mod page_runs;
|
||||
pub mod panorama;
|
||||
mod resident;
|
||||
mod stars;
|
||||
@@ -355,8 +357,9 @@ pub struct Renderer {
|
||||
border_group: wgpu::BindGroup,
|
||||
border_uniform: wgpu::Buffer,
|
||||
border_vertices: wgpu::Buffer,
|
||||
/// Screen effects: alpha-blended and vignette (`dst * (1 - src)`) pipelines.
|
||||
screen_pipelines: [wgpu::RenderPipeline; 2],
|
||||
/// Screen effects: alpha-blended, then vignette (`dst * (1 - src)`)
|
||||
/// pipelines, each for every sampling (index `blend * 4 + sampling`).
|
||||
screen_pipelines: [wgpu::RenderPipeline; 8],
|
||||
screen_buffer: wgpu::Buffer,
|
||||
screen_capacity: u64,
|
||||
entity_group: wgpu::BindGroup,
|
||||
@@ -374,8 +377,6 @@ pub struct Renderer {
|
||||
sprite_textures: Vec<wgpu::Texture>,
|
||||
/// A 1-layer array bound where a pack needs fewer arrays.
|
||||
sprite_unused: wgpu::TextureView,
|
||||
/// Layers per sprite array ([`SPRITE_SPLIT`] but in tests).
|
||||
sprite_split: u32,
|
||||
mip_levels: u32,
|
||||
/// The mipmap levels sampled (the Mipmap Levels option), at most `mip_levels - 1`.
|
||||
mipmaps: u32,
|
||||
@@ -383,7 +384,7 @@ pub struct Renderer {
|
||||
sprite_sampler: wgpu::Sampler,
|
||||
lightmap_view: wgpu::TextureView,
|
||||
lightmap_sampler: wgpu::Sampler,
|
||||
animations: AnimatedSprites,
|
||||
animations: animate::SpriteAnimator,
|
||||
uploads: upload::Uploads,
|
||||
/// Section meshes are staged through their own belt into
|
||||
/// `section_copies`, which the next frame records its commands after:
|
||||
@@ -437,6 +438,9 @@ pub struct Renderer {
|
||||
/// built, and their pages.
|
||||
phase2_draws: Vec<[u32; 5]>,
|
||||
phase2_pages: Vec<(u32, Option<u32>)>,
|
||||
/// Each section's solid draws, to group them by arena page.
|
||||
solid_runs: page_runs::PageRuns,
|
||||
phase2_runs: page_runs::PageRuns,
|
||||
/// Block-entity blocks per meshed section (world position, state id).
|
||||
block_entities: HashMap<SectionKey, Vec<([i32; 3], u32)>>,
|
||||
/// Changes whenever `block_entities` does (unique across renderers).
|
||||
@@ -694,13 +698,15 @@ fn describe_adapter(adapter: &wgpu::Adapter) {
|
||||
info.backend,
|
||||
info.device_type
|
||||
);
|
||||
crate::telemetry::set_gpu(
|
||||
&info.name,
|
||||
format!("{} {}", info.driver, info.driver_info)
|
||||
crate::telemetry::set_gpu(crate::telemetry::Gpu {
|
||||
name: info.name.clone(),
|
||||
vendor: horizon_telemetry::system::gpu_vendor(&info.name, info.vendor).to_owned(),
|
||||
kind: crate::telemetry::gpu_kind(&format!("{:?}", info.device_type)).to_owned(),
|
||||
driver: format!("{} {}", info.driver, info.driver_info)
|
||||
.trim()
|
||||
.to_owned(),
|
||||
format!("{:?}", info.backend),
|
||||
);
|
||||
backend: format!("{:?}", info.backend),
|
||||
});
|
||||
crate::crash::set_detail(
|
||||
"Graphics Adapter",
|
||||
format!(
|
||||
@@ -864,6 +870,13 @@ impl Renderer {
|
||||
pub fn resident_sections(&self) -> usize {
|
||||
self.sections.len()
|
||||
}
|
||||
/// Quads of the far terrain tiles with GPU buffers.
|
||||
pub fn lod_quads(&self) -> u64 {
|
||||
self.lods
|
||||
.values()
|
||||
.map(|tile| u64::from(tile.mesh.solid + tile.mesh.translucent))
|
||||
.sum()
|
||||
}
|
||||
/// Bytes of the section vertex arena's buffers, and of the vertices
|
||||
/// resident sections hold in them.
|
||||
pub fn section_vertex_bytes(&self) -> [u64; 2] {
|
||||
@@ -930,6 +943,7 @@ impl Renderer {
|
||||
None => open_device(&window, offscreen, api, layers).await?,
|
||||
};
|
||||
let info = adapter.get_info();
|
||||
crate::graphics::suggest_reduced_far_detail(crate::graphics::lod_capped(&info));
|
||||
let size = window.inner_size();
|
||||
let (config, present_modes) = if let Some(surface) = &surface {
|
||||
let mut config = surface
|
||||
@@ -1283,7 +1297,9 @@ impl Renderer {
|
||||
sample_count: 1,
|
||||
dimension: wgpu::TextureDimension::D2,
|
||||
format: wgpu::TextureFormat::Rgba8Unorm,
|
||||
usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
|
||||
usage: wgpu::TextureUsages::TEXTURE_BINDING
|
||||
| wgpu::TextureUsages::COPY_DST
|
||||
| wgpu::TextureUsages::STORAGE_BINDING,
|
||||
view_formats: &[],
|
||||
},
|
||||
wgpu::util::TextureDataOrder::LayerMajor,
|
||||
@@ -1291,6 +1307,14 @@ impl Renderer {
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
let animations = animate::SpriteAnimator::new(
|
||||
&device,
|
||||
animations,
|
||||
sprite_size,
|
||||
sprite_mips,
|
||||
&sprite_textures,
|
||||
split,
|
||||
);
|
||||
let sprite_unused = device
|
||||
.create_texture(&wgpu::TextureDescriptor {
|
||||
label: Some("Block sprites (unused)"),
|
||||
@@ -1978,37 +2002,52 @@ impl Renderer {
|
||||
operation: wgpu::BlendOperation::Add,
|
||||
},
|
||||
};
|
||||
let screen_pipelines =
|
||||
[("fs_screen", alpha_blend), ("fs_vignette", vignette_blend)].map(|(entry, blend)| {
|
||||
device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
label: Some("Screen effects"),
|
||||
layout: Some(&entity_layout),
|
||||
vertex: wgpu::VertexState {
|
||||
module: &screen_shader,
|
||||
entry_point: Some("vs_screen"),
|
||||
compilation_options: Default::default(),
|
||||
buffers: &screen_vertex,
|
||||
},
|
||||
primitive: wgpu::PrimitiveState {
|
||||
cull_mode: None,
|
||||
..Default::default()
|
||||
},
|
||||
depth_stencil: None,
|
||||
multisample: Default::default(),
|
||||
fragment: Some(wgpu::FragmentState {
|
||||
module: &screen_shader,
|
||||
entry_point: Some(entry),
|
||||
compilation_options: Default::default(),
|
||||
targets: &[Some(wgpu::ColorTargetState {
|
||||
format: config.format.remove_srgb_suffix(),
|
||||
blend: Some(blend),
|
||||
write_mask: wgpu::ColorWrites::ALL,
|
||||
})],
|
||||
}),
|
||||
multiview_mask: None,
|
||||
cache: None,
|
||||
})
|
||||
});
|
||||
// One per blend and `.png.mcmeta` sampling (screen_effects::SAMPLE_*):
|
||||
// the shader is specialised, so a pixel pays for none of the others.
|
||||
let screen_pipelines: [wgpu::RenderPipeline; 8] = std::array::from_fn(|i| {
|
||||
let (entry, blend) = if i & 4 == 0 {
|
||||
("fs_screen", alpha_blend)
|
||||
} else {
|
||||
("fs_vignette", vignette_blend)
|
||||
};
|
||||
let on = |bit: u32| f64::from(u8::from(i as u32 & bit != 0));
|
||||
let constants = [
|
||||
("BLUR", on(crate::screen_effects::SAMPLE_BLUR)),
|
||||
("CLAMP", on(crate::screen_effects::SAMPLE_CLAMP)),
|
||||
];
|
||||
let compilation_options = wgpu::PipelineCompilationOptions {
|
||||
constants: &constants,
|
||||
..Default::default()
|
||||
};
|
||||
device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
label: Some("Screen effects"),
|
||||
layout: Some(&entity_layout),
|
||||
vertex: wgpu::VertexState {
|
||||
module: &screen_shader,
|
||||
entry_point: Some("vs_screen"),
|
||||
compilation_options: compilation_options.clone(),
|
||||
buffers: &screen_vertex,
|
||||
},
|
||||
primitive: wgpu::PrimitiveState {
|
||||
cull_mode: None,
|
||||
..Default::default()
|
||||
},
|
||||
depth_stencil: None,
|
||||
multisample: Default::default(),
|
||||
fragment: Some(wgpu::FragmentState {
|
||||
module: &screen_shader,
|
||||
entry_point: Some(entry),
|
||||
compilation_options,
|
||||
targets: &[Some(wgpu::ColorTargetState {
|
||||
format: config.format.remove_srgb_suffix(),
|
||||
blend: Some(blend),
|
||||
write_mask: wgpu::ColorWrites::ALL,
|
||||
})],
|
||||
}),
|
||||
multiview_mask: None,
|
||||
cache: None,
|
||||
})
|
||||
});
|
||||
let screen_capacity = 64;
|
||||
let screen_buffer = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("Screen effect vertices"),
|
||||
@@ -2171,14 +2210,13 @@ impl Renderer {
|
||||
lightmap,
|
||||
sprite_textures,
|
||||
sprite_unused,
|
||||
sprite_split: split,
|
||||
mip_levels,
|
||||
mipmaps: mip_levels - 1,
|
||||
terrain_layout: bindings,
|
||||
sprite_sampler,
|
||||
lightmap_view,
|
||||
lightmap_sampler,
|
||||
animations: AnimatedSprites::new(animations, sprite_size, sprite_mips),
|
||||
animations,
|
||||
uploads,
|
||||
section_uploads,
|
||||
section_copies: None,
|
||||
@@ -2213,6 +2251,8 @@ impl Renderer {
|
||||
draw_pages: Vec::new(),
|
||||
phase2_draws: Vec::new(),
|
||||
phase2_pages: Vec::new(),
|
||||
solid_runs: page_runs::PageRuns::default(),
|
||||
phase2_runs: page_runs::PageRuns::default(),
|
||||
block_entities: HashMap::new(),
|
||||
block_entity_revision: next_block_entity_revision(),
|
||||
occlusion: occlusion::Occlusion::new(crate::chunk::MIN_Y, crate::chunk::HEIGHT / 16),
|
||||
@@ -2770,29 +2810,7 @@ impl Renderer {
|
||||
self.lightmap_uploaded = true;
|
||||
}
|
||||
fn animate(&mut self, encoder: &mut wgpu::CommandEncoder, tick: u64) {
|
||||
animate_sprites(
|
||||
&mut self.animations,
|
||||
tick,
|
||||
self.mip_levels,
|
||||
|layer, level, size, pixels| {
|
||||
self.uploads.texture(
|
||||
encoder,
|
||||
wgpu::TexelCopyTextureInfo {
|
||||
texture: &self.sprite_textures[(layer / self.sprite_split) as usize],
|
||||
mip_level: level,
|
||||
origin: wgpu::Origin3d {
|
||||
x: 0,
|
||||
y: 0,
|
||||
z: layer % self.sprite_split,
|
||||
},
|
||||
aspect: wgpu::TextureAspect::All,
|
||||
},
|
||||
size,
|
||||
size,
|
||||
pixels,
|
||||
);
|
||||
},
|
||||
);
|
||||
self.animations.animate(encoder, &mut self.uploads, tick);
|
||||
}
|
||||
pub fn render(&mut self, frame: &Frame) -> Result<bool> {
|
||||
let rendered = self.draw_frame(frame);
|
||||
@@ -3091,6 +3109,8 @@ impl Renderer {
|
||||
.and_then(|cull| cull.begin(&self.device).then_some(cull));
|
||||
self.phase2_draws.clear();
|
||||
self.phase2_pages.clear();
|
||||
self.solid_runs.clear();
|
||||
self.phase2_runs.clear();
|
||||
for (i, v) in visible.iter().enumerate() {
|
||||
let s = self.sections.get(v.slot);
|
||||
let first_phase = cull.as_ref().is_none_or(|c| c.first(v.slot));
|
||||
@@ -3109,22 +3129,53 @@ impl Renderer {
|
||||
pages.push((s.page, None));
|
||||
indices += quads * 6;
|
||||
});
|
||||
match &mut cull {
|
||||
Some(c) if first_phase => c.drawn(i, v.slot, indices),
|
||||
Some(c) => c.deferred(i, v.slot, start, draws.len() - start, indices),
|
||||
None => {}
|
||||
let runs = if first_phase {
|
||||
&mut self.solid_runs
|
||||
} else {
|
||||
&mut self.phase2_runs
|
||||
};
|
||||
runs.push(
|
||||
v.distance,
|
||||
s.page,
|
||||
start,
|
||||
draws.len() - start,
|
||||
[i as u32, v.slot, indices],
|
||||
);
|
||||
if let Some(c) = &mut cull
|
||||
&& first_phase
|
||||
{
|
||||
c.drawn(i, v.slot, indices);
|
||||
}
|
||||
}
|
||||
self.solid_runs
|
||||
.regroup(&mut self.draws, &mut self.draw_pages, 0);
|
||||
// Phase 2's draws are tested where grouping left them.
|
||||
let runs = self
|
||||
.phase2_runs
|
||||
.regroup(&mut self.phase2_draws, &mut self.phase2_pages, 0);
|
||||
if let Some(c) = &mut cull {
|
||||
for r in runs {
|
||||
let [i, slot, indices] = r.tag;
|
||||
c.deferred(i as usize, slot, r.start as usize, r.len as usize, indices);
|
||||
}
|
||||
}
|
||||
// Distant terrain tiles are never occlusion tested: phase 1.
|
||||
let tiles = self.draws.len();
|
||||
self.solid_runs.clear();
|
||||
for (i, v) in visible_lods.iter().enumerate() {
|
||||
let s = &self.lods[&v.key].mesh;
|
||||
let start = self.draws.len();
|
||||
s.faces.ranges(eye - Vec3::from(v.origin), |first, quads| {
|
||||
let first = s.start + u64::from(first) * 4;
|
||||
self.draws
|
||||
.push([quads * 6, 1, 0, first as u32, (visible.len() + i) as u32]);
|
||||
self.draw_pages.push((s.page, None));
|
||||
});
|
||||
self.solid_runs
|
||||
.push(v.distance, s.page, start, self.draws.len() - start, [0; 3]);
|
||||
}
|
||||
self.solid_runs
|
||||
.regroup(&mut self.draws, &mut self.draw_pages, tiles);
|
||||
let phase1_draws = self.draws.len() as u32;
|
||||
self.draws.extend_from_slice(&self.phase2_draws);
|
||||
self.draw_pages.extend_from_slice(&self.phase2_pages);
|
||||
@@ -3876,11 +3927,11 @@ impl Renderer {
|
||||
pass.set_index_buffer(self.indices.slice(..), wgpu::IndexFormat::Uint32);
|
||||
pass.set_vertex_buffer(0, self.screen_buffer.slice(..));
|
||||
for (i, quad) in frame.screen.iter().enumerate() {
|
||||
let pipeline = match quad.blend {
|
||||
crate::screen_effects::Blend::Alpha => &self.screen_pipelines[0],
|
||||
crate::screen_effects::Blend::Vignette => &self.screen_pipelines[1],
|
||||
let blend = match quad.blend {
|
||||
crate::screen_effects::Blend::Alpha => 0,
|
||||
crate::screen_effects::Blend::Vignette => 4,
|
||||
};
|
||||
pass.set_pipeline(pipeline);
|
||||
pass.set_pipeline(&self.screen_pipelines[blend + (quad.sampling & 3) as usize]);
|
||||
pass.draw_indexed(0..6, (i * 4) as i32, 0..1);
|
||||
}
|
||||
}
|
||||
@@ -3954,7 +4005,11 @@ impl Renderer {
|
||||
cull.end(&self.device, &mut encoder);
|
||||
}
|
||||
if let Some(timer) = &mut self.gpu_timer {
|
||||
timer.end(&mut encoder);
|
||||
timer.end(
|
||||
&self.device,
|
||||
&mut encoder,
|
||||
(&view_target, self.config.format.remove_srgb_suffix()),
|
||||
);
|
||||
}
|
||||
let readback = self.screenshot.take().and_then(|path| {
|
||||
// The copy and PNG writer both assume 8-bit pixels; a float surface would make
|
||||
@@ -3985,7 +4040,7 @@ impl Renderer {
|
||||
cull.submitted();
|
||||
}
|
||||
if let Some(timer) = &mut self.gpu_timer {
|
||||
timer.submitted();
|
||||
timer.submitted(&self.queue);
|
||||
timer.collect(&self.device, &mut self.gpu_times);
|
||||
}
|
||||
if let Some((path, buffer)) = readback {
|
||||
@@ -4127,6 +4182,20 @@ impl Frustum {
|
||||
p.truncate().dot(v) + p.w < 0.0
|
||||
})
|
||||
}
|
||||
/// Whether the camera-relative box `min..max` is entirely inside. Then
|
||||
/// `intersects` accepts every box within it: each plane's distance only
|
||||
/// grows from this box's nearest corner to the inner box's farthest, and
|
||||
/// rounding keeps that order.
|
||||
pub fn contains(&self, min: Vec3, max: Vec3) -> bool {
|
||||
self.planes.iter().all(|p| {
|
||||
let v = Vec3::new(
|
||||
if p.x >= 0.0 { min.x } else { max.x },
|
||||
if p.y >= 0.0 { min.y } else { max.y },
|
||||
if p.z >= 0.0 { min.z } else { max.z },
|
||||
);
|
||||
p.truncate().dot(v) + p.w >= 0.0
|
||||
})
|
||||
}
|
||||
}
|
||||
/// Vanilla's `Projection`: reversed depth (near maps to 1, far to 0, tests
|
||||
/// are GREATER_THAN_OR_EQUAL), which keeps far faces a hair apart, like
|
||||
@@ -4495,171 +4564,10 @@ pub(crate) fn sprite_mipmap_limit(requested: u32, size: u32, minimum: u32, avail
|
||||
let upscale = (size / minimum.max(1)).max(1).ilog2();
|
||||
requested.saturating_add(upscale).min(available - 1)
|
||||
}
|
||||
/// Room for every frame of the vanilla animations (594 frames, 13 MB of mip
|
||||
/// chains in 64-pixel layers), so no frame's chain is rebuilt on the render
|
||||
/// thread when its animation comes back to it; larger packs stay bounded.
|
||||
const ANIMATION_MIP_BUDGET: usize = 32 << 20;
|
||||
const ANIMATION_MIP_ENTRIES: usize = 4096;
|
||||
|
||||
/// One immutable resource snapshot and its lazily generated frame mips. A new
|
||||
/// renderer on resource reload drops this entire snapshot, including its cache:
|
||||
/// frame indices can never alias another resource's pixels, size or settings.
|
||||
struct AnimatedSprites {
|
||||
/// Each animated sprite and what its layer holds (`None`: not uploaded yet).
|
||||
animations: Vec<(SpriteAnimation, Option<crate::sprites::FrameBlend>)>,
|
||||
size: u32,
|
||||
settings: Vec<crate::mipmap::Settings>,
|
||||
// Cache bookkeeping must not consume randomness or affect other maps' seeding.
|
||||
cache: crate::fast_hash::FxHashMap<(usize, usize), Vec<u8>>,
|
||||
/// Retained pixel-buffer capacities, not just their initialized lengths.
|
||||
cache_bytes: usize,
|
||||
budget: usize,
|
||||
}
|
||||
impl AnimatedSprites {
|
||||
fn new(
|
||||
animations: &[SpriteAnimation],
|
||||
size: u32,
|
||||
settings: &[crate::mipmap::Settings],
|
||||
) -> Self {
|
||||
Self {
|
||||
animations: animations.iter().cloned().map(|a| (a, None)).collect(),
|
||||
size,
|
||||
settings: settings.to_vec(),
|
||||
cache: Default::default(),
|
||||
cache_bytes: 0,
|
||||
budget: ANIMATION_MIP_BUDGET,
|
||||
}
|
||||
}
|
||||
}
|
||||
fn animate_sprites(
|
||||
state: &mut AnimatedSprites,
|
||||
tick: u64,
|
||||
max_levels: u32,
|
||||
mut write: impl FnMut(u32, u32, u32, &[u8]),
|
||||
) {
|
||||
let size = state.size;
|
||||
let levels = size.trailing_zeros() + 1;
|
||||
let AnimatedSprites {
|
||||
animations,
|
||||
settings,
|
||||
cache,
|
||||
cache_bytes,
|
||||
budget,
|
||||
..
|
||||
} = state;
|
||||
for (index, (animation, previous)) in animations.iter_mut().enumerate() {
|
||||
let blend = animation.blend(tick);
|
||||
if *previous == Some(blend) {
|
||||
continue;
|
||||
}
|
||||
*previous = Some(blend);
|
||||
let (animation, settings) = (&*animation, &**settings);
|
||||
let chain = |frame: usize| {
|
||||
move || mip_chain(animation.pixels(frame), size, settings, animation.layer, 1).0
|
||||
};
|
||||
let (current, mixed);
|
||||
let mips: &[u8] = if blend.millis > 0 {
|
||||
let mut mix = frame_mips(
|
||||
cache,
|
||||
cache_bytes,
|
||||
*budget,
|
||||
(index, blend.frame),
|
||||
chain(blend.frame),
|
||||
)
|
||||
.into_owned();
|
||||
let next = frame_mips(
|
||||
cache,
|
||||
cache_bytes,
|
||||
*budget,
|
||||
(index, blend.next),
|
||||
chain(blend.next),
|
||||
);
|
||||
mix_frames(&mut mix, &next, blend.millis);
|
||||
mixed = mix;
|
||||
&mixed
|
||||
} else {
|
||||
current = frame_mips(
|
||||
cache,
|
||||
cache_bytes,
|
||||
*budget,
|
||||
(index, blend.frame),
|
||||
chain(blend.frame),
|
||||
);
|
||||
¤t
|
||||
};
|
||||
let mut offset = 0;
|
||||
for level in 0..levels.min(max_levels) {
|
||||
let edge = size >> level;
|
||||
let bytes = (edge * edge * 4) as usize;
|
||||
write(animation.layer, level, edge, &mips[offset..offset + bytes]);
|
||||
offset += bytes;
|
||||
}
|
||||
}
|
||||
}
|
||||
/// `animate_sprite_interpolate.fsh`: `mix(current, next, millis / 1000)` per
|
||||
/// channel of every mipmap level, stored as 8-bit unorm.
|
||||
/// One frame's mip chain: from the cache, or generated and kept while the budget has room.
|
||||
/// Frames that do not fit still go through the same generation path.
|
||||
fn frame_mips<'a>(
|
||||
cache: &'a mut crate::fast_hash::FxHashMap<(usize, usize), Vec<u8>>,
|
||||
cache_bytes: &mut usize,
|
||||
budget: usize,
|
||||
key: (usize, usize),
|
||||
generate: impl FnOnce() -> Vec<u8>,
|
||||
) -> std::borrow::Cow<'a, [u8]> {
|
||||
let room = cache.len() < ANIMATION_MIP_ENTRIES;
|
||||
match cache.entry(key) {
|
||||
std::collections::hash_map::Entry::Occupied(entry) => entry.into_mut().as_slice().into(),
|
||||
std::collections::hash_map::Entry::Vacant(entry) => {
|
||||
let pixels = generate();
|
||||
// Bound both pixel storage and map metadata. Keep admitted frames
|
||||
// rather than churning them on large/unique workloads.
|
||||
if room && pixels.capacity() <= budget - *cache_bytes {
|
||||
*cache_bytes += pixels.capacity();
|
||||
entry.insert(pixels).as_slice().into()
|
||||
} else {
|
||||
pixels.into()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
fn mix_frames(current: &mut [u8], next: &[u8], millis: u32) {
|
||||
let t = millis as f32 / 1000.0;
|
||||
for (c, n) in current.iter_mut().zip(next) {
|
||||
let (a, b) = (f32::from(*c) / 255.0, f32::from(*n) / 255.0);
|
||||
*c = round_unorm8((a * (1.0 - t) + b * t) * 255.0);
|
||||
}
|
||||
}
|
||||
/// `x.round() as u8` for `x >= 0`, without the `roundf` library call that
|
||||
/// x86-64 makes for every `f32::round` (it has no rounding instruction before
|
||||
/// SSE4.1): the fraction `x - trunc(x)` is exact below 2^23, so rounding half
|
||||
/// away from zero is adding one when it is at least a half.
|
||||
#[inline]
|
||||
fn round_unorm8(x: f32) -> u8 {
|
||||
let whole = x as u8;
|
||||
whole.saturating_add(u8::from(x - f32::from(whole) >= 0.5))
|
||||
}
|
||||
#[cfg(test)]
|
||||
mod sprite_upload_tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn blended_frames_round_like_f32_round() {
|
||||
for millis in (0..1000).step_by(7).chain([999]) {
|
||||
let t = millis as f32 / 1000.0;
|
||||
for a in 0..=255u8 {
|
||||
for b in 0..=255u8 {
|
||||
let x = (f32::from(a) / 255.0 * (1.0 - t) + f32::from(b) / 255.0 * t) * 255.0;
|
||||
assert_eq!(round_unorm8(x), x.round() as u8, "{a} {b} {millis}");
|
||||
}
|
||||
}
|
||||
}
|
||||
for x in [
|
||||
0.0, 0.49999997, 0.5, 1.5, 2.5, 254.49998, 254.5, 255.0, 255.00002, 300.0,
|
||||
] {
|
||||
assert_eq!(round_unorm8(x), x.round() as u8, "{x}");
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn real_rail_mips_do_not_change_with_larger_array_layers() {
|
||||
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../../.local/horizon-client")
|
||||
@@ -4722,84 +4630,6 @@ mod sprite_upload_tests {
|
||||
assert_eq!(sprite_mipmap_limit(levels, 16, 16, 5), levels);
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn animation_updates_entire_layer_every_mip_once() {
|
||||
for size in [16, 32] {
|
||||
let animation = crate::sprites::test_animation(size, 7);
|
||||
let pixels = animation.pixels(animation.frame(1)).to_vec();
|
||||
let settings = [];
|
||||
let (expected, levels) = mip_chain(&pixels, size, &settings, 7, 1);
|
||||
let mut animations = AnimatedSprites::new(&[animation], size, &settings);
|
||||
animations.animations[0].1 = Some(animations.animations[0].0.blend(0));
|
||||
let mut writes = Vec::new();
|
||||
let mut record = |layer, level, edge, data: &[u8]| {
|
||||
assert_eq!(layer, 7);
|
||||
assert_eq!(edge, size >> level);
|
||||
assert_eq!(data.len(), (edge * edge * 4) as usize);
|
||||
writes.push(data.to_vec());
|
||||
};
|
||||
animate_sprites(&mut animations, 0, levels, &mut record);
|
||||
animate_sprites(&mut animations, 1, levels, &mut record);
|
||||
animate_sprites(&mut animations, 1, levels, &mut record);
|
||||
assert_eq!(writes.len(), levels as usize);
|
||||
assert_eq!(writes[0], pixels);
|
||||
assert_eq!(writes.concat(), expected);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cached_and_uncached_uploads_match_through_loops_and_mip_changes() {
|
||||
for size in [16, 32] {
|
||||
let animations = [crate::sprites::test_animation(size, 7)];
|
||||
let settings = vec![
|
||||
crate::mipmap::Settings {
|
||||
strategy: crate::mipmap::Strategy::StrictCutout,
|
||||
alpha_cutoff_bias: -0.2,
|
||||
..Default::default()
|
||||
};
|
||||
8
|
||||
];
|
||||
let mut cached = AnimatedSprites::new(&animations, size, &settings);
|
||||
let mut uncached = AnimatedSprites::new(&animations, size, &settings);
|
||||
uncached.budget = 0;
|
||||
for tick in [0, 0, 1, 2, 3, 33, 32, 32, 1, 0] {
|
||||
let levels = (tick % 7) as u32;
|
||||
let mut expected = Vec::new();
|
||||
let mut actual = Vec::new();
|
||||
animate_sprites(&mut uncached, tick, levels, |layer, level, edge, bytes| {
|
||||
expected.push((layer, level, edge, bytes.to_vec()));
|
||||
});
|
||||
animate_sprites(&mut cached, tick, levels, |layer, level, edge, bytes| {
|
||||
actual.push((layer, level, edge, bytes.to_vec()));
|
||||
});
|
||||
assert_eq!(actual, expected);
|
||||
}
|
||||
assert_eq!(cached.cache.len(), 2);
|
||||
assert_eq!(uncached.cache_bytes, 0);
|
||||
assert_eq!(
|
||||
cached.cache_bytes,
|
||||
cached.cache.values().map(Vec::capacity).sum::<usize>()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn budget_exhaustion_keeps_admitted_frames_and_uploads_misses() {
|
||||
let size = 16;
|
||||
let animation = crate::sprites::test_animation(size, 0);
|
||||
let bytes = mip_chain(animation.pixels(0), size, &[], 0, 1).0.capacity();
|
||||
let mut state = AnimatedSprites::new(&[animation], size, &[]);
|
||||
state.budget = bytes;
|
||||
for tick in 0..8 {
|
||||
let mut writes = 0;
|
||||
animate_sprites(&mut state, tick, 5, |_, _, _, _| writes += 1);
|
||||
assert_eq!(writes, 5);
|
||||
assert_eq!(state.cache_bytes, bytes);
|
||||
assert_eq!(state.cache.len(), 1);
|
||||
assert!(state.cache.contains_key(&(0, 0)));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prepared_texture_uploads_preserve_layer_order_and_settings() {
|
||||
let size = 4;
|
||||
@@ -4852,86 +4682,4 @@ mod sprite_upload_tests {
|
||||
assert_eq!(chain.capacity(), chain.len());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_vanilla_animation_frame_stays_cached() {
|
||||
// Vanilla's 594 animation frames in 64-pixel layers: 297 two-frame clips.
|
||||
let animations: Vec<_> = (0..297)
|
||||
.map(|layer| crate::sprites::test_animation(64, layer))
|
||||
.collect();
|
||||
let mut state = AnimatedSprites::new(&animations, 64, &[]);
|
||||
for tick in 0..4 {
|
||||
animate_sprites(&mut state, tick, 7, |_, _, _, _| {});
|
||||
}
|
||||
assert_eq!(state.cache.len(), 594);
|
||||
assert!(state.cache_bytes <= ANIMATION_MIP_BUDGET);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn entry_limit_also_bounds_metadata_for_tiny_frames() {
|
||||
let animation = crate::sprites::test_animation(1, 0);
|
||||
let animations = vec![animation; ANIMATION_MIP_ENTRIES + 1];
|
||||
let mut state = AnimatedSprites::new(&animations, 1, &[]);
|
||||
for tick in 0..2 {
|
||||
let mut writes = 0;
|
||||
animate_sprites(&mut state, tick, 1, |_, _, _, _| writes += 1);
|
||||
assert_eq!(writes, animations.len());
|
||||
assert_eq!(state.cache.len(), ANIMATION_MIP_ENTRIES);
|
||||
assert!(state.cache_bytes <= ANIMATION_MIP_BUDGET);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resource_snapshot_replacement_discards_pixels_size_and_settings() {
|
||||
let old = crate::sprites::test_animation(16, 0);
|
||||
let mut state = AnimatedSprites::new(&[old], 16, &[]);
|
||||
animate_sprites(&mut state, 0, 5, |_, _, _, _| {});
|
||||
assert!(!state.cache.is_empty());
|
||||
let animation = crate::sprites::test_animation(32, 0);
|
||||
let settings = [crate::mipmap::Settings {
|
||||
strategy: crate::mipmap::Strategy::StrictCutout,
|
||||
alpha_cutoff_bias: -0.4,
|
||||
..Default::default()
|
||||
}];
|
||||
let expected = mip_chain(animation.pixels(0), 32, &settings, 0, 1).0;
|
||||
state = AnimatedSprites::new(&[animation], 32, &settings);
|
||||
assert_eq!(state.cache_bytes, 0);
|
||||
assert!(state.cache.is_empty());
|
||||
let mut actual = Vec::new();
|
||||
animate_sprites(&mut state, 0, 6, |_, _, _, bytes| {
|
||||
actual.extend_from_slice(bytes)
|
||||
});
|
||||
assert_eq!(actual, expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn interpolated_animation_uploads_the_mix_every_tick() {
|
||||
let animation = crate::sprites::test_clip(16, 3, r#"{"frametime":4,"interpolate":true}"#);
|
||||
let settings = [];
|
||||
let (_, levels) = mip_chain(animation.pixels(0), 16, &settings, 3, 1);
|
||||
let mut cached = AnimatedSprites::new(std::slice::from_ref(&animation), 16, &settings);
|
||||
let mut uncached = AnimatedSprites::new(std::slice::from_ref(&animation), 16, &settings);
|
||||
uncached.budget = 0;
|
||||
for state in [&mut cached, &mut uncached] {
|
||||
state.animations[0].1 = Some(animation.blend(0));
|
||||
}
|
||||
let mut blues = Vec::new();
|
||||
let mut uncached_blues = Vec::new();
|
||||
let mut record = |_, level, _, data: &[u8]| {
|
||||
if level == 0 {
|
||||
blues.push(data[2]);
|
||||
}
|
||||
};
|
||||
for tick in [0, 1, 1, 2, 4] {
|
||||
animate_sprites(&mut cached, tick, levels, &mut record);
|
||||
animate_sprites(&mut uncached, tick, levels, |_, level, _, data: &[u8]| {
|
||||
if level == 0 {
|
||||
uncached_blues.push(data[2]);
|
||||
}
|
||||
});
|
||||
}
|
||||
// Frame 0 is blue 0, frame 1 blue 99: a quarter, a half, then frame 1.
|
||||
assert_eq!(blues, [25, 50, 99]);
|
||||
assert_eq!(uncached_blues, blues);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,590 @@
|
||||
//! Animated block sprites, drawn on the GPU as vanilla 26.3 draws them:
|
||||
//! `SpriteContents.AnimatedTexture` keeps every frame in video memory, and
|
||||
//! each tick `animate_sprite_blit` or `animate_sprite_interpolate` draws the
|
||||
//! frame shown (mixed with the next one for `interpolate`) into the atlas.
|
||||
//!
|
||||
//! Every frame's mip chain is uploaded once, at the sprite's own size. A tick
|
||||
//! that changes sprites writes one small job per changed sprite and runs one
|
||||
//! compute dispatch per mip level (`animate.wgsl`), which repeats the chain
|
||||
//! into the array's larger layers by whole pixels and mixes interpolated
|
||||
//! frames. Blending on the CPU at the array's size and copying every changed
|
||||
//! sprite level by level (a copy command each, a blit each in Mesa) was about
|
||||
//! a fifth of the render thread on an integrated GPU.
|
||||
use super::upload::Uploads;
|
||||
use crate::mipmap::Settings;
|
||||
use crate::sprites::{FrameBlend, SpriteAnimation};
|
||||
use wgpu::util::DeviceExt;
|
||||
|
||||
/// Bytes of `animate.wgsl`'s `Job`.
|
||||
const JOB_BYTES: usize = 20;
|
||||
/// `animate.wgsl`'s workgroup edge.
|
||||
const WORKGROUP: u32 = 8;
|
||||
|
||||
struct Sprite {
|
||||
animation: SpriteAnimation,
|
||||
/// What its layer shows (`None`: the frame the array was created with,
|
||||
/// rewritten on the first tick).
|
||||
shown: Option<FrameBlend>,
|
||||
/// Its layer in its sprite array.
|
||||
layer: u32,
|
||||
/// Each frame's mip chain: its offset in words in the group's frames.
|
||||
frames: Vec<u32>,
|
||||
/// Edge of the chains' first level.
|
||||
edge: u32,
|
||||
group: usize,
|
||||
}
|
||||
|
||||
/// The animated sprites of one sprite array whose frames share one buffer.
|
||||
struct Group {
|
||||
/// Where its jobs start in `SpriteAnimator::jobs`, in bytes.
|
||||
offset: u64,
|
||||
/// One bind group per mip level of the array.
|
||||
levels: Vec<wgpu::BindGroup>,
|
||||
/// This tick's jobs.
|
||||
jobs: Vec<u8>,
|
||||
}
|
||||
|
||||
pub(super) struct SpriteAnimator {
|
||||
sprites: Vec<Sprite>,
|
||||
groups: Vec<Group>,
|
||||
jobs: wgpu::Buffer,
|
||||
pipeline: wgpu::ComputePipeline,
|
||||
/// Edge of the arrays' layers.
|
||||
size: u32,
|
||||
}
|
||||
|
||||
impl SpriteAnimator {
|
||||
/// `textures` are the sprite arrays, `split` layers each, `size` pixels
|
||||
/// wide; `animations` hold their frames at that size.
|
||||
pub fn new(
|
||||
device: &wgpu::Device,
|
||||
animations: &[SpriteAnimation],
|
||||
size: u32,
|
||||
settings: &[Settings],
|
||||
textures: &[wgpu::Texture],
|
||||
split: u32,
|
||||
) -> Self {
|
||||
let limits = device.limits();
|
||||
let limit = limits
|
||||
.max_storage_buffer_binding_size
|
||||
.min(limits.max_buffer_size);
|
||||
let mut frames: Vec<Vec<u8>> = Vec::new();
|
||||
// Each group's sprite array and frames buffer.
|
||||
let mut keys: Vec<(u32, usize)> = Vec::new();
|
||||
let mut counts: Vec<usize> = Vec::new();
|
||||
let mut sprites = Vec::with_capacity(animations.len());
|
||||
for animation in animations {
|
||||
let chains: Vec<_> = (0..animation.frame_count())
|
||||
.map(|frame| source_chain(animation.pixels(frame), size, settings, animation.layer))
|
||||
.collect();
|
||||
let bytes: u64 = chains.iter().map(|(chain, _)| chain.len() as u64).sum();
|
||||
if bytes > limit {
|
||||
log::warn!(
|
||||
"Horizon: layer {}'s animation ({bytes} bytes) does not fit in a storage buffer; it stays still",
|
||||
animation.layer
|
||||
);
|
||||
continue;
|
||||
}
|
||||
if frames
|
||||
.last()
|
||||
.is_none_or(|data| data.len() as u64 + bytes > limit)
|
||||
{
|
||||
frames.push(Vec::new());
|
||||
}
|
||||
let buffer = frames.len() - 1;
|
||||
let data = &mut frames[buffer];
|
||||
let offsets = chains
|
||||
.iter()
|
||||
.map(|(chain, _)| {
|
||||
let offset = (data.len() / 4) as u32;
|
||||
data.extend_from_slice(chain);
|
||||
offset
|
||||
})
|
||||
.collect();
|
||||
let key = (animation.layer / split, buffer);
|
||||
let group = keys.iter().position(|&k| k == key).unwrap_or_else(|| {
|
||||
keys.push(key);
|
||||
counts.push(0);
|
||||
keys.len() - 1
|
||||
});
|
||||
counts[group] += 1;
|
||||
sprites.push(Sprite {
|
||||
animation: animation.clone(),
|
||||
shown: None,
|
||||
layer: animation.layer % split,
|
||||
frames: offsets,
|
||||
edge: chains[0].1,
|
||||
group,
|
||||
});
|
||||
}
|
||||
let frames: Vec<_> = frames
|
||||
.iter()
|
||||
.map(|data| {
|
||||
device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
|
||||
label: Some("Animated sprite frames"),
|
||||
contents: data,
|
||||
usage: wgpu::BufferUsages::STORAGE,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
let align = u64::from(limits.min_storage_buffer_offset_alignment);
|
||||
let mut offsets = Vec::with_capacity(counts.len());
|
||||
let mut total = 0;
|
||||
for &count in &counts {
|
||||
offsets.push(total);
|
||||
total = (total + (count * JOB_BYTES) as u64).next_multiple_of(align);
|
||||
}
|
||||
let jobs = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("Animated sprite jobs"),
|
||||
size: total.max(align),
|
||||
usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
let module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
label: Some("Animated sprites"),
|
||||
source: wgpu::ShaderSource::Wgsl(include_str!("animate.wgsl").into()),
|
||||
});
|
||||
let pipeline = device.create_compute_pipeline(&wgpu::ComputePipelineDescriptor {
|
||||
label: Some("Animated sprites"),
|
||||
layout: None,
|
||||
module: &module,
|
||||
entry_point: Some("animate"),
|
||||
compilation_options: Default::default(),
|
||||
cache: None,
|
||||
});
|
||||
let layout = pipeline.get_bind_group_layout(0);
|
||||
let groups = keys
|
||||
.iter()
|
||||
.zip(&counts)
|
||||
.zip(offsets)
|
||||
.map(|((&(array, buffer), &count), offset)| {
|
||||
let texture = &textures[array as usize];
|
||||
let levels = (0..texture.mip_level_count())
|
||||
.map(|level| {
|
||||
let view = texture.create_view(&wgpu::TextureViewDescriptor {
|
||||
label: Some("Animated sprites"),
|
||||
dimension: Some(wgpu::TextureViewDimension::D2Array),
|
||||
base_mip_level: level,
|
||||
mip_level_count: Some(1),
|
||||
..Default::default()
|
||||
});
|
||||
device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
label: Some("Animated sprites"),
|
||||
layout: &layout,
|
||||
entries: &[
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 0,
|
||||
resource: frames[buffer].as_entire_binding(),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 1,
|
||||
resource: wgpu::BindingResource::Buffer(wgpu::BufferBinding {
|
||||
buffer: &jobs,
|
||||
offset,
|
||||
size: wgpu::BufferSize::new((count * JOB_BYTES) as u64),
|
||||
}),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 2,
|
||||
resource: wgpu::BindingResource::TextureView(&view),
|
||||
},
|
||||
],
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
Group {
|
||||
offset,
|
||||
levels,
|
||||
jobs: Vec::with_capacity(count * JOB_BYTES),
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
Self {
|
||||
sprites,
|
||||
groups,
|
||||
jobs,
|
||||
pipeline,
|
||||
size,
|
||||
}
|
||||
}
|
||||
|
||||
/// Show every sprite's frame at `tick`, rewriting only the sprites whose
|
||||
/// frame or mix changed (`SpriteContents.AnimationState.tick`).
|
||||
pub fn animate(
|
||||
&mut self,
|
||||
encoder: &mut wgpu::CommandEncoder,
|
||||
uploads: &mut Uploads,
|
||||
tick: u64,
|
||||
) {
|
||||
if self.collect(tick) == 0 {
|
||||
return;
|
||||
}
|
||||
for group in &self.groups {
|
||||
uploads.buffer(encoder, &self.jobs, group.offset, &group.jobs);
|
||||
}
|
||||
let mut pass = encoder.begin_compute_pass(&wgpu::ComputePassDescriptor {
|
||||
label: Some("Animated sprites"),
|
||||
timestamp_writes: None,
|
||||
});
|
||||
pass.set_pipeline(&self.pipeline);
|
||||
for group in &mut self.groups {
|
||||
let count = (group.jobs.len() / JOB_BYTES) as u32;
|
||||
if count == 0 {
|
||||
continue;
|
||||
}
|
||||
for (level, bind) in group.levels.iter().enumerate() {
|
||||
let groups = (self.size >> level).div_ceil(WORKGROUP);
|
||||
pass.set_bind_group(0, bind, &[]);
|
||||
pass.dispatch_workgroups(groups, groups, count);
|
||||
}
|
||||
group.jobs.clear();
|
||||
}
|
||||
}
|
||||
|
||||
/// Queue a job for every sprite whose blend at `tick` differs from what
|
||||
/// its layer shows; the number of jobs.
|
||||
fn collect(&mut self, tick: u64) -> usize {
|
||||
let mut count = 0;
|
||||
for sprite in &mut self.sprites {
|
||||
let blend = sprite.animation.blend(tick);
|
||||
if sprite.shown == Some(blend) {
|
||||
continue;
|
||||
}
|
||||
sprite.shown = Some(blend);
|
||||
let job = [
|
||||
sprite.layer,
|
||||
sprite.frames[blend.frame],
|
||||
sprite.frames[blend.next],
|
||||
blend.millis,
|
||||
sprite.edge,
|
||||
];
|
||||
let jobs = &mut self.groups[sprite.group].jobs;
|
||||
for word in job {
|
||||
jobs.extend_from_slice(&word.to_le_bytes());
|
||||
}
|
||||
count += 1;
|
||||
}
|
||||
count
|
||||
}
|
||||
}
|
||||
|
||||
/// The mip chain a frame's layer is made of, and its first level's edge.
|
||||
/// `mipmap::generate` makes a sprite scaled up by whole pixels to the
|
||||
/// array's size from its own size's chain, its first level repeated into the
|
||||
/// larger levels: that chain, which the shader repeats. Any other sprite: its
|
||||
/// chain at the array's size.
|
||||
fn source_chain(pixels: &[u8], size: u32, settings: &[Settings], layer: u32) -> (Vec<u8>, u32) {
|
||||
let native = settings.get(layer as usize).map_or(0, |s| s.native_size);
|
||||
let edge = if native.is_power_of_two() && native < size {
|
||||
native
|
||||
} else {
|
||||
size
|
||||
};
|
||||
let frame = crate::sprites::resample(pixels, size, edge);
|
||||
(super::mip_chain(&frame, edge, settings, layer, 1).0, edge)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::mipmap::Strategy;
|
||||
|
||||
/// `animate.wgsl` on the CPU: `chain` (first level `edge` wide) as the
|
||||
/// full chain of a `size` layer, mixed `millis` thousandths of the way
|
||||
/// towards `next`.
|
||||
fn reference(chain: &[u8], next: &[u8], millis: u32, edge: u32, size: u32) -> Vec<u8> {
|
||||
let levels = size.trailing_zeros() + 1;
|
||||
let mut out = Vec::new();
|
||||
for level in 0..levels {
|
||||
let width = size >> level;
|
||||
for y in 0..width {
|
||||
for x in 0..width {
|
||||
let texel = if width >= edge {
|
||||
let scale = width / edge;
|
||||
y / scale * edge + x / scale
|
||||
} else {
|
||||
4 * (edge * edge - width * width) / 3 + y * width + x
|
||||
} as usize;
|
||||
for c in 0..4 {
|
||||
let (a, b) = (
|
||||
u32::from(chain[texel * 4 + c]),
|
||||
u32::from(next[texel * 4 + c]),
|
||||
);
|
||||
out.push(((a * (1000 - millis) + b * millis + 500) / 1000) as u8);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn frame(size: u32, seed: u32) -> Vec<u8> {
|
||||
(0..size * size)
|
||||
.flat_map(|i| {
|
||||
let v = i
|
||||
.wrapping_mul(2654435761)
|
||||
.wrapping_add(seed.wrapping_mul(40503));
|
||||
let alpha = match v >> 29 {
|
||||
0 => 0,
|
||||
1 => 77,
|
||||
_ => 255,
|
||||
};
|
||||
[(v >> 3) as u8, (v >> 11) as u8, (v >> 19) as u8, alpha]
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn settings(native: u32, strategy: Strategy) -> Settings {
|
||||
Settings {
|
||||
native_size: native,
|
||||
strategy,
|
||||
alpha_cutoff_bias: -0.1,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn source_chains_repeat_into_the_array_chain() {
|
||||
// Sprites scaled up by whole pixels, at the array's size, and of a
|
||||
// size that is not a power of two (scaled up to the next one).
|
||||
for (native, size) in [(16, 16), (16, 64), (8, 32), (32, 64), (1, 16), (24, 32)] {
|
||||
for strategy in [
|
||||
Strategy::Auto,
|
||||
Strategy::Mean,
|
||||
Strategy::Cutout,
|
||||
Strategy::StrictCutout,
|
||||
Strategy::DarkCutout,
|
||||
] {
|
||||
for item in [false, true] {
|
||||
let settings = [
|
||||
Settings::default(),
|
||||
Settings {
|
||||
item,
|
||||
..settings(native, strategy)
|
||||
},
|
||||
];
|
||||
let pixels = crate::sprites::resample(&frame(native, 3), native, size);
|
||||
let (expected, _) = super::super::mip_chain(&pixels, size, &settings, 1, 1);
|
||||
let (chain, edge) = source_chain(&pixels, size, &settings, 1);
|
||||
let fits = native.is_power_of_two() && native < size;
|
||||
assert_eq!(edge, if fits { native } else { size });
|
||||
assert_eq!(
|
||||
reference(&chain, &chain, 0, edge, size),
|
||||
expected,
|
||||
"{native} in {size}, {strategy:?}, item {item}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn real_animation_chains_repeat_into_the_array_chain() {
|
||||
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../../.local/horizon-client")
|
||||
.join(horizon_core::VANILLA_VERSION);
|
||||
if !root
|
||||
.join("assets/minecraft/textures/block/water_still.png")
|
||||
.exists()
|
||||
{
|
||||
return;
|
||||
}
|
||||
let mut sprites = crate::sprites::Sprites::default();
|
||||
for name in [
|
||||
"water_still",
|
||||
"water_flow",
|
||||
"lava_still",
|
||||
"fire_0",
|
||||
"nether_portal",
|
||||
"sea_lantern",
|
||||
"prismarine",
|
||||
"magma",
|
||||
"kelp",
|
||||
"straw_bed",
|
||||
] {
|
||||
sprites
|
||||
.insert(&root, &format!("minecraft:block/{name}"))
|
||||
.unwrap();
|
||||
}
|
||||
sprites.finish();
|
||||
assert_eq!(sprites.size, 64);
|
||||
assert_eq!(sprites.animations.len(), 9);
|
||||
for animation in &sprites.animations {
|
||||
for frame in 0..animation.frame_count() {
|
||||
let pixels = animation.pixels(frame);
|
||||
let (expected, _) =
|
||||
super::super::mip_chain(pixels, 64, &sprites.mips, animation.layer, 1);
|
||||
let (chain, edge) = source_chain(pixels, 64, &sprites.mips, animation.layer);
|
||||
assert!(edge < 64, "layer {}", animation.layer);
|
||||
assert_eq!(reference(&chain, &chain, 0, edge, 64), expected);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rounding_matches_f32_mix_but_for_exact_halves() {
|
||||
// The CPU blend this replaced: f32 mix, then rounded. They differ
|
||||
// only where the exact mix is a half, which f32 rounded either way.
|
||||
for millis in 1..1000u32 {
|
||||
let t = millis as f32 / 1000.0;
|
||||
for a in 0..=255u32 {
|
||||
for b in (0..=255u32).step_by(3) {
|
||||
let f = ((a as f32 / 255.0) * (1.0 - t) + (b as f32 / 255.0) * t) * 255.0;
|
||||
let exact = a * (1000 - millis) + b * millis;
|
||||
let ours = (exact + 500) / 1000;
|
||||
if exact % 1000 != 500 {
|
||||
assert_eq!(ours, f.round() as u32, "{a} {b} {millis}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn device() -> Option<(wgpu::Device, wgpu::Queue)> {
|
||||
let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle());
|
||||
let adapter = pollster::block_on(instance.request_adapter(&Default::default())).ok()?;
|
||||
pollster::block_on(adapter.request_device(&Default::default())).ok()
|
||||
}
|
||||
|
||||
/// Every layer's full mip chain, layer major.
|
||||
fn read_back(
|
||||
device: &wgpu::Device,
|
||||
queue: &wgpu::Queue,
|
||||
texture: &wgpu::Texture,
|
||||
) -> Vec<Vec<u8>> {
|
||||
let layers = texture.depth_or_array_layers();
|
||||
let mut out = vec![Vec::new(); layers as usize];
|
||||
for level in 0..texture.mip_level_count() {
|
||||
let edge = texture.width() >> level;
|
||||
let padded = (edge * 4).next_multiple_of(wgpu::COPY_BYTES_PER_ROW_ALIGNMENT);
|
||||
let buffer = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: None,
|
||||
size: u64::from(padded * edge * layers),
|
||||
usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
let mut encoder = device.create_command_encoder(&Default::default());
|
||||
encoder.copy_texture_to_buffer(
|
||||
wgpu::TexelCopyTextureInfo {
|
||||
texture,
|
||||
mip_level: level,
|
||||
origin: wgpu::Origin3d::ZERO,
|
||||
aspect: wgpu::TextureAspect::All,
|
||||
},
|
||||
wgpu::TexelCopyBufferInfo {
|
||||
buffer: &buffer,
|
||||
layout: wgpu::TexelCopyBufferLayout {
|
||||
offset: 0,
|
||||
bytes_per_row: Some(padded),
|
||||
rows_per_image: Some(edge),
|
||||
},
|
||||
},
|
||||
wgpu::Extent3d {
|
||||
width: edge,
|
||||
height: edge,
|
||||
depth_or_array_layers: layers,
|
||||
},
|
||||
);
|
||||
queue.submit([encoder.finish()]);
|
||||
buffer
|
||||
.slice(..)
|
||||
.map_async(wgpu::MapMode::Read, |r| r.unwrap());
|
||||
device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
|
||||
let data = buffer.slice(..).get_mapped_range().unwrap();
|
||||
for (layer, image) in data.chunks((padded * edge) as usize).enumerate() {
|
||||
for row in image.chunks(padded as usize) {
|
||||
out[layer].extend_from_slice(&row[..edge as usize * 4]);
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gpu_layers_match_the_cpu_chains_every_tick() {
|
||||
let Some((device, queue)) = device() else {
|
||||
return;
|
||||
};
|
||||
let size = 32;
|
||||
// Layers 1 and 3: 8-pixel sprites with and without interpolation;
|
||||
// layer 4: one at the array's size. Two layers per array.
|
||||
let clips = [
|
||||
(
|
||||
1,
|
||||
8,
|
||||
r#"{"frametime":3,"interpolate":true,"frames":[0,2,1]}"#,
|
||||
),
|
||||
(3, 8, r#"{"frametime":2}"#),
|
||||
(4, 32, r#"{"frametime":5,"interpolate":true}"#),
|
||||
];
|
||||
let mut mips = vec![Settings::default(); 6];
|
||||
let animations: Vec<_> = clips
|
||||
.iter()
|
||||
.map(|&(layer, native, metadata)| {
|
||||
mips[layer as usize] = settings(native, Strategy::Cutout);
|
||||
let frames = (0..3)
|
||||
.map(|f| crate::sprites::resample(&frame(native, layer * 7 + f), native, size))
|
||||
.collect();
|
||||
crate::sprites::test_frames(layer, frames, metadata)
|
||||
})
|
||||
.collect();
|
||||
let split = 2;
|
||||
let textures: Vec<_> = (0..3)
|
||||
.map(|_| {
|
||||
device.create_texture(&wgpu::TextureDescriptor {
|
||||
label: None,
|
||||
size: wgpu::Extent3d {
|
||||
width: size,
|
||||
height: size,
|
||||
depth_or_array_layers: split,
|
||||
},
|
||||
mip_level_count: size.trailing_zeros() + 1,
|
||||
sample_count: 1,
|
||||
dimension: wgpu::TextureDimension::D2,
|
||||
format: wgpu::TextureFormat::Rgba8Unorm,
|
||||
usage: wgpu::TextureUsages::STORAGE_BINDING | wgpu::TextureUsages::COPY_SRC,
|
||||
view_formats: &[],
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
let mut animator = SpriteAnimator::new(&device, &animations, size, &mips, &textures, split);
|
||||
let mut uploads = Uploads::new(&device);
|
||||
let mut writes = Vec::new();
|
||||
for tick in [0, 0, 1, 2, 3, 4, 7, 7, 12, 30] {
|
||||
let mut encoder = device.create_command_encoder(&Default::default());
|
||||
let shown: Vec<_> = animator.sprites.iter().map(|s| s.shown).collect();
|
||||
animator.animate(&mut encoder, &mut uploads, tick);
|
||||
writes.push(
|
||||
animator
|
||||
.sprites
|
||||
.iter()
|
||||
.zip(shown)
|
||||
.filter(|(s, shown)| s.shown != *shown)
|
||||
.count(),
|
||||
);
|
||||
uploads.finish(&encoder);
|
||||
queue.submit([encoder.finish()]);
|
||||
for animation in &animations {
|
||||
let blend = animation.blend(tick);
|
||||
let chain = |f| source_chain(animation.pixels(f), size, &mips, animation.layer);
|
||||
let ((current, edge), (next, _)) = (chain(blend.frame), chain(blend.next));
|
||||
let layers = read_back(
|
||||
&device,
|
||||
&queue,
|
||||
&textures[(animation.layer / split) as usize],
|
||||
);
|
||||
assert_eq!(
|
||||
layers[(animation.layer % split) as usize],
|
||||
reference(¤t, &next, blend.millis, edge, size),
|
||||
"layer {} at tick {tick}",
|
||||
animation.layer
|
||||
);
|
||||
}
|
||||
}
|
||||
// Every sprite on the first tick, then only those whose frame or mix
|
||||
// changed since they were written: the interpolated ones every tick
|
||||
// but layer 1 at tick 30 (two 9-tick loops after 12), layer 3 when
|
||||
// its frame turns over (every 2 ticks, but 12 shows 7's frame).
|
||||
assert_eq!(writes, [3, 0, 2, 3, 2, 3, 3, 0, 2, 1]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
// Animated block sprites (gfx/animate.rs), as vanilla's animate_sprite_blit
|
||||
// and animate_sprite_interpolate shaders draw them: each job writes its
|
||||
// frame, or the frame mixed with the next one, into one mip level of its
|
||||
// sprite array layer. One invocation per texel; the job is the z coordinate.
|
||||
struct Job {
|
||||
layer: u32,
|
||||
// Word offsets of the two frames' mip chains in `frames`.
|
||||
current: u32,
|
||||
next: u32,
|
||||
// Thousandths of the way from `current` to `next` (0: `current` alone).
|
||||
millis: u32,
|
||||
// Edge of the chains' first level.
|
||||
edge: u32,
|
||||
}
|
||||
@group(0) @binding(0) var<storage, read> frames: array<u32>;
|
||||
@group(0) @binding(1) var<storage, read> jobs: array<Job>;
|
||||
@group(0) @binding(2) var level: texture_storage_2d_array<rgba8unorm, write>;
|
||||
|
||||
fn channels(rgba: u32) -> vec4<u32> {
|
||||
return vec4(rgba & 0xffu, (rgba >> 8u) & 0xffu, (rgba >> 16u) & 0xffu, rgba >> 24u);
|
||||
}
|
||||
|
||||
@compute @workgroup_size(8, 8)
|
||||
fn animate(@builtin(global_invocation_id) id: vec3<u32>) {
|
||||
let size = textureDimensions(level).x;
|
||||
if id.x >= size || id.y >= size {
|
||||
return;
|
||||
}
|
||||
let job = jobs[id.z];
|
||||
// mipmap::generate: the levels at least as large as the chain's first
|
||||
// repeat it by whole pixels, the smaller ones are the chain's own.
|
||||
var texel: u32;
|
||||
if size >= job.edge {
|
||||
let scale = size / job.edge;
|
||||
texel = id.y / scale * job.edge + id.x / scale;
|
||||
} else {
|
||||
// Every level above this one, each a quarter of the one before.
|
||||
texel = 4u * (job.edge * job.edge - size * size) / 3u + id.y * size + id.x;
|
||||
}
|
||||
var color = channels(frames[job.current + texel]);
|
||||
if job.millis > 0u {
|
||||
// mix(current, next, millis / 1000), rounded to the nearest 8-bit
|
||||
// value (halves up) in integers: the same on every GPU.
|
||||
let next = channels(frames[job.next + texel]);
|
||||
color = (color * (1000u - job.millis) + next * job.millis + 500u) / 1000u;
|
||||
}
|
||||
textureStore(level, id.xy, job.layer, vec4<f32>(color) / 255.0);
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
//! Solid draws grouped by vertex arena page within distance bands.
|
||||
//!
|
||||
//! Sections sit in arena pages of 256 MiB, so render distance 32 spreads
|
||||
//! them over half a dozen pages, and a list sorted only by distance switches
|
||||
//! page every few sections: about a thousand indirect multi-draws of two or
|
||||
//! three entries a frame. Mesa's Intel driver (and wgpu-core, for each
|
||||
//! vertex buffer change) pays per call, and below four entries Mesa unrolls
|
||||
//! the call on the CPU entry by entry. Grouping the sections of one band of
|
||||
//! distance by page keeps the list front to back to within the band (what
|
||||
//! the depth test needs to reject hidden fragments early) with one call per
|
||||
//! page and band.
|
||||
//!
|
||||
//! The picture does not change: solid draws write depth and are tested
|
||||
//! against it, so their order only decides which of two fragments at the
|
||||
//! same depth stays, and sections do not share faces.
|
||||
|
||||
/// The width of a band, in blocks.
|
||||
const BAND: f64 = 64.0;
|
||||
|
||||
/// One section's draws: `start..start + len` of the list.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(super) struct Run {
|
||||
key: u64,
|
||||
pub start: u32,
|
||||
pub len: u32,
|
||||
/// The caller's data, carried along (occlusion culling's test inputs).
|
||||
pub tag: [u32; 3],
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub(super) struct PageRuns {
|
||||
runs: Vec<Run>,
|
||||
scratch: Vec<([u32; 5], (u32, Option<u32>))>,
|
||||
}
|
||||
|
||||
impl PageRuns {
|
||||
pub fn clear(&mut self) {
|
||||
self.runs.clear();
|
||||
}
|
||||
|
||||
/// The draws `start..start + len` of a section `distance` from the
|
||||
/// camera (squared, as bits) in arena `page`; runs come nearest first.
|
||||
pub fn push(&mut self, distance: u64, page: u32, start: usize, len: usize, tag: [u32; 3]) {
|
||||
if len > 0 {
|
||||
let band = (f64::from_bits(distance).sqrt() / BAND) as u64;
|
||||
self.runs.push(Run {
|
||||
key: band << 32 | u64::from(page),
|
||||
start: start as u32,
|
||||
len: len as u32,
|
||||
tag,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Reorders `draws[base..]` and their `pages`, which the pushed runs
|
||||
/// cover in order, so that each band's runs in one page are adjacent
|
||||
/// (in distance order); returns the runs at their new places.
|
||||
pub fn regroup(
|
||||
&mut self,
|
||||
draws: &mut [[u32; 5]],
|
||||
pages: &mut [(u32, Option<u32>)],
|
||||
base: usize,
|
||||
) -> &[Run] {
|
||||
let page = |r: &Run| r.key as u32;
|
||||
if self.runs.windows(2).all(|w| page(&w[0]) == page(&w[1])) {
|
||||
return &self.runs;
|
||||
}
|
||||
// Stable: within a band and page the runs stay nearest first.
|
||||
self.runs.sort_by_key(|r| r.key);
|
||||
self.scratch.clear();
|
||||
for r in &mut self.runs {
|
||||
let (s, e) = (r.start as usize, (r.start + r.len) as usize);
|
||||
r.start = (base + self.scratch.len()) as u32;
|
||||
self.scratch
|
||||
.extend(draws[s..e].iter().copied().zip(pages[s..e].iter().copied()));
|
||||
}
|
||||
for (i, &(draw, p)) in self.scratch.iter().enumerate() {
|
||||
draws[base + i] = draw;
|
||||
pages[base + i] = p;
|
||||
}
|
||||
&self.runs
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn bits(blocks: f64) -> u64 {
|
||||
(blocks * blocks).to_bits()
|
||||
}
|
||||
|
||||
/// Runs of (distance in blocks, page, draws), as the list builder pushes them.
|
||||
fn list(runs: &[(f64, u32, u32)]) -> (PageRuns, Vec<[u32; 5]>, Vec<(u32, Option<u32>)>) {
|
||||
let mut grouped = PageRuns::default();
|
||||
let (mut draws, mut pages) = (Vec::new(), Vec::new());
|
||||
for (n, &(distance, page, count)) in runs.iter().enumerate() {
|
||||
let start = draws.len();
|
||||
for k in 0..count {
|
||||
draws.push([n as u32, k, 0, 0, 0]);
|
||||
pages.push((page, None));
|
||||
}
|
||||
grouped.push(
|
||||
bits(distance),
|
||||
page,
|
||||
start,
|
||||
count as usize,
|
||||
[n as u32, 0, 0],
|
||||
);
|
||||
}
|
||||
(grouped, draws, pages)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_band_draws_each_page_once_keeping_the_order_inside_it() {
|
||||
let (mut grouped, mut draws, mut pages) = list(&[
|
||||
(10.0, 1, 2),
|
||||
(20.0, 0, 1),
|
||||
(30.0, 1, 3),
|
||||
(40.0, 0, 2),
|
||||
(70.0, 1, 1),
|
||||
]);
|
||||
let runs: Vec<_> = grouped
|
||||
.regroup(&mut draws, &mut pages, 0)
|
||||
.iter()
|
||||
.map(|r| (r.tag[0], r.start, r.len))
|
||||
.collect();
|
||||
assert_eq!(
|
||||
runs,
|
||||
[(1, 0, 1), (3, 1, 2), (0, 3, 2), (2, 5, 3), (4, 8, 1)]
|
||||
);
|
||||
let order: Vec<_> = draws.iter().map(|d| (d[0], d[1])).collect();
|
||||
assert_eq!(
|
||||
order,
|
||||
[
|
||||
(1, 0),
|
||||
(3, 0),
|
||||
(3, 1),
|
||||
(0, 0),
|
||||
(0, 1),
|
||||
(2, 0),
|
||||
(2, 1),
|
||||
(2, 2),
|
||||
(4, 0)
|
||||
]
|
||||
);
|
||||
let pages: Vec<_> = pages.iter().map(|p| p.0).collect();
|
||||
assert_eq!(pages, [0, 0, 0, 1, 1, 1, 1, 1, 1]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bands_stay_in_distance_order() {
|
||||
let (mut grouped, mut draws, mut pages) =
|
||||
list(&[(10.0, 1, 1), (100.0, 0, 1), (200.0, 0, 1)]);
|
||||
grouped.regroup(&mut draws, &mut pages, 0);
|
||||
let order: Vec<_> = draws.iter().map(|d| d[0]).collect();
|
||||
assert_eq!(order, [0, 1, 2]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn one_page_and_an_offset_list() {
|
||||
let (mut grouped, mut draws, mut pages) = list(&[(10.0, 3, 2), (90.0, 3, 1)]);
|
||||
let before = draws.clone();
|
||||
assert_eq!(grouped.regroup(&mut draws, &mut pages, 0)[1].start, 2);
|
||||
assert_eq!(draws, before);
|
||||
// A list after other draws: starts are absolute.
|
||||
let mut grouped = PageRuns::default();
|
||||
let mut draws = vec![[9; 5], [7, 0, 0, 0, 0], [8, 0, 0, 0, 0]];
|
||||
let mut pages = vec![(5, None), (1, None), (0, None)];
|
||||
grouped.push(bits(5.0), 1, 1, 1, [0; 3]);
|
||||
grouped.push(bits(6.0), 0, 2, 1, [1, 0, 0]);
|
||||
let runs = grouped.regroup(&mut draws, &mut pages, 1).to_vec();
|
||||
assert_eq!(draws, [[9; 5], [8, 0, 0, 0, 0], [7, 0, 0, 0, 0]]);
|
||||
assert_eq!(pages, [(5, None), (0, None), (1, None)]);
|
||||
assert_eq!((runs[0].tag[0], runs[0].start), (1, 1));
|
||||
// Empty runs are left out.
|
||||
grouped.clear();
|
||||
grouped.push(bits(1.0), 0, 0, 0, [0; 3]);
|
||||
assert!(grouped.regroup(&mut [], &mut [], 0).is_empty());
|
||||
}
|
||||
}
|
||||
@@ -169,14 +169,20 @@ fn in_view(key: SectionKey, camera: DVec3, radius: i32, frustum: &Frustum) -> Op
|
||||
if !in_view_distance(camera_column(camera), radius, key.0, key.2) {
|
||||
return None;
|
||||
}
|
||||
in_frustum(key, camera, frustum)
|
||||
in_frustum(key, camera, frustum, false)
|
||||
}
|
||||
|
||||
/// `in_view` without the distance test.
|
||||
fn in_frustum(key: SectionKey, camera: DVec3, frustum: &Frustum) -> Option<(f64, Vec3)> {
|
||||
/// `in_view` without the distance test; `inside`: the section's column is
|
||||
/// entirely inside the frustum, so the section is too.
|
||||
fn in_frustum(
|
||||
key: SectionKey,
|
||||
camera: DVec3,
|
||||
frustum: &Frustum,
|
||||
inside: bool,
|
||||
) -> Option<(f64, Vec3)> {
|
||||
let origin = origin(key, camera);
|
||||
let min = origin.as_vec3();
|
||||
if !frustum.intersects(min, min + Vec3::splat(16.0)) {
|
||||
if !inside && !frustum.intersects(min, min + Vec3::splat(16.0)) {
|
||||
return None;
|
||||
}
|
||||
Some(((origin + DVec3::splat(8.0)).length_squared(), min))
|
||||
@@ -192,8 +198,9 @@ impl<T> Resident<T> {
|
||||
/// Columns are tested first, with the same distance test (it depends
|
||||
/// only on x and z) and a box spanning their sections: every section box
|
||||
/// lies inside it and the frustum test rounds monotonically, so a column
|
||||
/// the frustum rejects holds no section it would accept. `radius` is the
|
||||
/// render distance in chunks.
|
||||
/// the frustum rejects holds no section it would accept, and one
|
||||
/// entirely inside holds none it would reject (its sections skip the
|
||||
/// test). `radius` is the render distance in chunks.
|
||||
pub fn visible(
|
||||
&self,
|
||||
camera: DVec3,
|
||||
@@ -211,7 +218,9 @@ impl<T> Resident<T> {
|
||||
}
|
||||
let min = origin((column.x, column.low, column.z), camera).as_vec3();
|
||||
let top = origin((column.x, column.high, column.z), camera).as_vec3();
|
||||
if !frustum.intersects(min, top + Vec3::splat(16.0)) {
|
||||
let max = top + Vec3::splat(16.0);
|
||||
let inside = frustum.contains(min, max);
|
||||
if !inside && !frustum.intersects(min, max) {
|
||||
continue;
|
||||
}
|
||||
for &(y, slot) in &column.sections {
|
||||
@@ -219,7 +228,7 @@ impl<T> Resident<T> {
|
||||
if near && !reachable(key) {
|
||||
continue;
|
||||
}
|
||||
if let Some((d, o)) = in_frustum(key, camera, frustum) {
|
||||
if let Some((d, o)) = in_frustum(key, camera, frustum, inside) {
|
||||
out.push(Visible {
|
||||
distance: d.to_bits(),
|
||||
key,
|
||||
@@ -277,7 +286,7 @@ mod tests {
|
||||
if near {
|
||||
in_view(k, camera, radius, &frustum).filter(|_| reachable(k))
|
||||
} else if beyond(k.0, k.2) {
|
||||
in_frustum(k, camera, &frustum)
|
||||
in_frustum(k, camera, &frustum, false)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
@@ -298,6 +307,49 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn columns_entirely_inside_skip_no_section() {
|
||||
// Short columns ahead of the camera: most lie entirely inside.
|
||||
let mut r = Resident::default();
|
||||
for x in -12i32..=12 {
|
||||
for z in 1..=12 {
|
||||
for y in 3..6 {
|
||||
if (x + y + z).rem_euclid(3) != 0 {
|
||||
r.insert((x, y, z), ());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let camera = DVec3::new(0.5, -8.0, 0.5);
|
||||
let projection = super::super::world_projection(70.0, 16.0 / 9.0, 512.0);
|
||||
let frustum = Frustum {
|
||||
planes: super::super::frustum(projection * crate::camera::view_matrix(0.0, 5.0)),
|
||||
};
|
||||
let inside = r
|
||||
.columns
|
||||
.iter()
|
||||
.filter(|c| {
|
||||
let min = origin((c.x, c.low, c.z), camera).as_vec3();
|
||||
let top = origin((c.x, c.high, c.z), camera).as_vec3();
|
||||
frustum.contains(min, top + Vec3::splat(16.0))
|
||||
})
|
||||
.count();
|
||||
assert!(inside > 50 && inside < r.columns.len(), "{inside}");
|
||||
let mut fast = Vec::new();
|
||||
r.visible(camera, 12, &frustum, |_| true, |_, _| false, &mut fast);
|
||||
let mut slow: Vec<_> = r
|
||||
.items
|
||||
.iter()
|
||||
.filter_map(|&(k, _)| in_view(k, camera, 12, &frustum).map(|(d, _)| (d, k)))
|
||||
.collect();
|
||||
slow.sort_unstable_by(|a, b| a.0.total_cmp(&b.0).then(a.1.cmp(&b.1)));
|
||||
let fast: Vec<_> = fast
|
||||
.iter()
|
||||
.map(|v| (f64::from_bits(v.distance), v.key))
|
||||
.collect();
|
||||
assert_eq!(fast, slow);
|
||||
}
|
||||
|
||||
/// Every key reaches its value through its slot and column, and every
|
||||
/// column's bounds and sections match the keys.
|
||||
fn check(r: &Resident<u32>, model: &HashMap<SectionKey, u32>) {
|
||||
|
||||
@@ -1,18 +1,35 @@
|
||||
//! GPU frame time from timestamp queries, read back a few frames late so the
|
||||
//! CPU never waits for it. Frames whose readback slot is still busy go untimed.
|
||||
//!
|
||||
//! Where the GPU can only time pass boundaries (Apple GPUs on Metal), the
|
||||
//! frame is bracketed by two one-thread compute passes that write the
|
||||
//! timestamps (Metal leaves an empty pass's timestamps at zero).
|
||||
//! Where the GPU can only time pass boundaries (Apple GPUs on Metal), a
|
||||
//! one-thread compute pass stamps the frame's start, and a render pass over
|
||||
//! the frame's target that shades one pixel with every write masked stamps
|
||||
//! its end (Metal leaves the timestamps of a pass that does nothing at zero).
|
||||
//! A pass that loads the target runs after every pass that draws into it; a
|
||||
//! compute pass, which depends on nothing, ran alongside them. Metal also
|
||||
//! resolves timestamps without waiting for the passes that write them, so
|
||||
//! they are resolved in a later frame, once the GPU has finished this one.
|
||||
//! Resolved in the frame itself, the end was mostly the one an earlier frame
|
||||
//! left in the query, and frames read about 0.01 ms.
|
||||
use std::sync::{
|
||||
Arc,
|
||||
atomic::{AtomicU8, Ordering},
|
||||
};
|
||||
|
||||
const SLOTS: usize = 4;
|
||||
/// Frames in flight at once; on Metal a slot is busy for about five frames.
|
||||
const SLOTS: usize = 8;
|
||||
/// Each slot's timestamps have their own queries and resolve range.
|
||||
const RESOLVE_STRIDE: u64 = wgpu::QUERY_RESOLVE_BUFFER_ALIGNMENT;
|
||||
/// The frame's target and its format.
|
||||
pub type Target<'a> = (&'a wgpu::TextureView, wgpu::TextureFormat);
|
||||
const FREE: u8 = 0;
|
||||
const PENDING: u8 = 1;
|
||||
const MAPPED: u8 = 2;
|
||||
/// Its frame is submitted; with markers, until the GPU has finished it.
|
||||
const RUNNING: u8 = 1;
|
||||
/// Its frame is finished: its timestamps can be resolved.
|
||||
const DONE: u8 = 2;
|
||||
/// Resolved and on its way back.
|
||||
const PENDING: u8 = 3;
|
||||
const MAPPED: u8 = 4;
|
||||
|
||||
struct Slot {
|
||||
buffer: wgpu::Buffer,
|
||||
@@ -27,10 +44,29 @@ pub struct GpuTimer {
|
||||
current: Option<usize>,
|
||||
next: usize,
|
||||
period_ns: f32,
|
||||
/// Timestamps between commands, or else this pass's boundaries.
|
||||
marker: Option<wgpu::ComputePipeline>,
|
||||
/// Timestamps between commands, or else these passes' boundaries.
|
||||
markers: Option<Markers>,
|
||||
/// Slots resolved in this frame's commands, read back once it is submitted.
|
||||
resolved: Vec<usize>,
|
||||
}
|
||||
|
||||
/// The marker passes: the start's compute pipeline, and the end's shader and
|
||||
/// pipeline, for the target format it was made for.
|
||||
struct Markers {
|
||||
module: wgpu::ShaderModule,
|
||||
start: wgpu::ComputePipeline,
|
||||
end: Option<(wgpu::TextureFormat, wgpu::RenderPipeline)>,
|
||||
}
|
||||
|
||||
/// The start's one thread; the end's triangle over the whole target (the
|
||||
/// pass's scissor keeps one pixel), shading nothing it writes.
|
||||
const MARKER: &str =
|
||||
"@vertex fn cover(@builtin(vertex_index) i: u32) -> @builtin(position) vec4<f32> {
|
||||
return vec4<f32>(f32(i & 1u) * 4.0 - 1.0, f32(i >> 1u) * 4.0 - 1.0, 0.0, 1.0);
|
||||
}
|
||||
@fragment fn nothing() -> @location(0) vec4<f32> { return vec4<f32>(0.0); }
|
||||
@compute @workgroup_size(1) fn mark() {}";
|
||||
|
||||
impl GpuTimer {
|
||||
/// The features timing uses that the adapter has (none: no timing).
|
||||
pub fn features(adapter: &wgpu::Adapter) -> wgpu::Features {
|
||||
@@ -44,11 +80,11 @@ impl GpuTimer {
|
||||
let queries = device.create_query_set(&wgpu::QuerySetDescriptor {
|
||||
label: Some("Frame timestamps"),
|
||||
ty: wgpu::QueryType::Timestamp,
|
||||
count: 2,
|
||||
count: 2 * SLOTS as u32,
|
||||
});
|
||||
let resolve = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("Frame timestamp resolve"),
|
||||
size: 16,
|
||||
size: RESOLVE_STRIDE * SLOTS as u64,
|
||||
usage: wgpu::BufferUsages::QUERY_RESOLVE | wgpu::BufferUsages::COPY_SRC,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
@@ -70,42 +106,43 @@ impl GpuTimer {
|
||||
current: None,
|
||||
next: 0,
|
||||
period_ns: queue.get_timestamp_period(),
|
||||
marker: (!device
|
||||
markers: (!device
|
||||
.features()
|
||||
.contains(wgpu::Features::TIMESTAMP_QUERY_INSIDE_ENCODERS))
|
||||
.then(|| {
|
||||
let module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
label: Some("Frame timestamp"),
|
||||
source: wgpu::ShaderSource::Wgsl(
|
||||
"@compute @workgroup_size(1) fn mark() {}".into(),
|
||||
),
|
||||
source: wgpu::ShaderSource::Wgsl(MARKER.into()),
|
||||
});
|
||||
device.create_compute_pipeline(&wgpu::ComputePipelineDescriptor {
|
||||
label: Some("Frame timestamp"),
|
||||
layout: None,
|
||||
module: &module,
|
||||
entry_point: Some("mark"),
|
||||
compilation_options: Default::default(),
|
||||
cache: None,
|
||||
})
|
||||
Markers {
|
||||
start: device.create_compute_pipeline(&wgpu::ComputePipelineDescriptor {
|
||||
label: Some("Frame timestamp"),
|
||||
layout: None,
|
||||
module: &module,
|
||||
entry_point: Some("mark"),
|
||||
compilation_options: Default::default(),
|
||||
cache: None,
|
||||
}),
|
||||
module,
|
||||
end: None,
|
||||
}
|
||||
}),
|
||||
resolved: Vec::new(),
|
||||
})
|
||||
}
|
||||
pub fn begin(&mut self, encoder: &mut wgpu::CommandEncoder) {
|
||||
if self.markers.is_some() {
|
||||
for slot in 0..SLOTS {
|
||||
if self.slots[slot].state.load(Ordering::Acquire) == DONE {
|
||||
self.resolve(encoder, slot);
|
||||
}
|
||||
}
|
||||
}
|
||||
let slot = self.next;
|
||||
self.current = (self.slots[slot].state.load(Ordering::Acquire) == FREE).then_some(slot);
|
||||
if self.current.is_some() {
|
||||
self.stamp(encoder, 0);
|
||||
}
|
||||
}
|
||||
pub fn end(&mut self, encoder: &mut wgpu::CommandEncoder) {
|
||||
let Some(slot) = self.current else { return };
|
||||
self.stamp(encoder, 1);
|
||||
encoder.resolve_query_set(&self.queries, 0..2, &self.resolve, 0);
|
||||
encoder.copy_buffer_to_buffer(&self.resolve, 0, &self.slots[slot].buffer, 0, 16);
|
||||
}
|
||||
fn stamp(&self, encoder: &mut wgpu::CommandEncoder, index: u32) {
|
||||
let Some(marker) = &self.marker else {
|
||||
let index = 2 * slot as u32;
|
||||
let Some(markers) = &self.markers else {
|
||||
encoder.write_timestamp(&self.queries, index);
|
||||
return;
|
||||
};
|
||||
@@ -113,29 +150,85 @@ impl GpuTimer {
|
||||
label: Some("Frame timestamp"),
|
||||
timestamp_writes: Some(wgpu::ComputePassTimestampWrites {
|
||||
query_set: &self.queries,
|
||||
beginning_of_pass_write_index: (index == 0).then_some(0),
|
||||
end_of_pass_write_index: (index == 1).then_some(1),
|
||||
beginning_of_pass_write_index: Some(index),
|
||||
end_of_pass_write_index: None,
|
||||
}),
|
||||
});
|
||||
pass.set_pipeline(marker);
|
||||
pass.set_pipeline(&markers.start);
|
||||
pass.dispatch_workgroups(1, 1, 1);
|
||||
}
|
||||
/// After the frame's submission: start reading its timestamps back.
|
||||
pub fn submitted(&mut self) {
|
||||
/// After the frame's last pass into `target` (the frame's own, of `format`).
|
||||
pub fn end(
|
||||
&mut self,
|
||||
device: &wgpu::Device,
|
||||
encoder: &mut wgpu::CommandEncoder,
|
||||
(target, format): Target,
|
||||
) {
|
||||
let Some(slot) = self.current else { return };
|
||||
let index = 2 * slot as u32 + 1;
|
||||
let Some(markers) = &mut self.markers else {
|
||||
encoder.write_timestamp(&self.queries, index);
|
||||
self.resolve(encoder, slot);
|
||||
return;
|
||||
};
|
||||
if markers.end.as_ref().is_none_or(|(f, _)| *f != format) {
|
||||
markers.end = Some((format, end_pipeline(device, &markers.module, format)));
|
||||
}
|
||||
let (_, pipeline) = markers.end.as_ref().expect("made above");
|
||||
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
label: Some("Frame timestamp"),
|
||||
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
|
||||
view: target,
|
||||
depth_slice: None,
|
||||
resolve_target: None,
|
||||
ops: wgpu::Operations {
|
||||
load: wgpu::LoadOp::Load,
|
||||
store: wgpu::StoreOp::Store,
|
||||
},
|
||||
})],
|
||||
timestamp_writes: Some(wgpu::RenderPassTimestampWrites {
|
||||
query_set: &self.queries,
|
||||
beginning_of_pass_write_index: None,
|
||||
end_of_pass_write_index: Some(index),
|
||||
}),
|
||||
..Default::default()
|
||||
});
|
||||
pass.set_pipeline(pipeline);
|
||||
pass.set_scissor_rect(0, 0, 1, 1);
|
||||
pass.draw(0..3, 0..1);
|
||||
}
|
||||
/// Copy `slot`'s timestamps to its readback buffer.
|
||||
fn resolve(&mut self, encoder: &mut wgpu::CommandEncoder, slot: usize) {
|
||||
let (first, at) = (2 * slot as u32, RESOLVE_STRIDE * slot as u64);
|
||||
encoder.resolve_query_set(&self.queries, first..first + 2, &self.resolve, at);
|
||||
encoder.copy_buffer_to_buffer(&self.resolve, at, &self.slots[slot].buffer, 0, 16);
|
||||
self.slots[slot].state.store(RUNNING, Ordering::Release);
|
||||
self.resolved.push(slot);
|
||||
}
|
||||
/// After the frame's submission: start reading back what it resolved,
|
||||
/// and with markers, wait for the GPU to finish the frame it timed.
|
||||
pub fn submitted(&mut self, queue: &wgpu::Queue) {
|
||||
for slot in self.resolved.drain(..) {
|
||||
let state = self.slots[slot].state.clone();
|
||||
state.store(PENDING, Ordering::Release);
|
||||
self.slots[slot]
|
||||
.buffer
|
||||
.slice(..)
|
||||
.map_async(wgpu::MapMode::Read, move |result| {
|
||||
state.store(
|
||||
if result.is_ok() { MAPPED } else { FREE },
|
||||
Ordering::Release,
|
||||
);
|
||||
});
|
||||
}
|
||||
let Some(slot) = self.current.take() else {
|
||||
return;
|
||||
};
|
||||
let state = self.slots[slot].state.clone();
|
||||
state.store(PENDING, Ordering::Release);
|
||||
self.slots[slot]
|
||||
.buffer
|
||||
.slice(..)
|
||||
.map_async(wgpu::MapMode::Read, move |result| {
|
||||
state.store(
|
||||
if result.is_ok() { MAPPED } else { FREE },
|
||||
Ordering::Release,
|
||||
);
|
||||
});
|
||||
if self.markers.is_some() {
|
||||
let state = self.slots[slot].state.clone();
|
||||
state.store(RUNNING, Ordering::Release);
|
||||
queue.on_submitted_work_done(move || state.store(DONE, Ordering::Release));
|
||||
}
|
||||
self.next = (slot + 1) % SLOTS;
|
||||
}
|
||||
/// GPU milliseconds of frames whose timestamps have arrived, oldest first.
|
||||
@@ -149,11 +242,46 @@ impl GpuTimer {
|
||||
if let Ok(data) = slot.buffer.slice(..).get_mapped_range() {
|
||||
// Read unaligned: the mapping isn't guaranteed to be 8-byte aligned.
|
||||
let stamps: [u64; 2] = bytemuck::pod_read_unaligned(&data[..16]);
|
||||
let ticks = stamps[1].saturating_sub(stamps[0]);
|
||||
out.push((ticks as f64 * f64::from(self.period_ns) / 1e6) as f32);
|
||||
// A start marker that ran late (rare on Metal) leaves the frame untimed.
|
||||
if let Some(ticks) = stamps[1].checked_sub(stamps[0]) {
|
||||
out.push((ticks as f64 * f64::from(self.period_ns) / 1e6) as f32);
|
||||
}
|
||||
}
|
||||
slot.buffer.unmap();
|
||||
slot.state.store(FREE, Ordering::Release);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The end marker's pipeline for a `format` target, every write masked.
|
||||
fn end_pipeline(
|
||||
device: &wgpu::Device,
|
||||
module: &wgpu::ShaderModule,
|
||||
format: wgpu::TextureFormat,
|
||||
) -> wgpu::RenderPipeline {
|
||||
device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
label: Some("Frame timestamp"),
|
||||
layout: None,
|
||||
vertex: wgpu::VertexState {
|
||||
module,
|
||||
entry_point: Some("cover"),
|
||||
compilation_options: Default::default(),
|
||||
buffers: &[],
|
||||
},
|
||||
primitive: wgpu::PrimitiveState::default(),
|
||||
depth_stencil: None,
|
||||
multisample: wgpu::MultisampleState::default(),
|
||||
fragment: Some(wgpu::FragmentState {
|
||||
module,
|
||||
entry_point: Some("nothing"),
|
||||
compilation_options: Default::default(),
|
||||
targets: &[Some(wgpu::ColorTargetState {
|
||||
format,
|
||||
blend: None,
|
||||
write_mask: wgpu::ColorWrites::empty(),
|
||||
})],
|
||||
}),
|
||||
multiview_mask: None,
|
||||
cache: None,
|
||||
})
|
||||
}
|
||||
@@ -156,6 +156,48 @@ pub enum Overlay {
|
||||
Graph,
|
||||
}
|
||||
|
||||
/// How finely far terrain is drawn (`FarDetail` in the LOD options).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
|
||||
pub enum FarDetail {
|
||||
/// Each tile level starts at eight times its size from the camera.
|
||||
#[default]
|
||||
Full,
|
||||
/// Coarser levels start at half that distance: about a quarter of the
|
||||
/// far quads, for GPUs that far terrain slows down. Changes the picture.
|
||||
Reduced,
|
||||
}
|
||||
impl FarDetail {
|
||||
pub fn name(self) -> &'static str {
|
||||
match self {
|
||||
FarDetail::Full => "full",
|
||||
FarDetail::Reduced => "reduced",
|
||||
}
|
||||
}
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
FarDetail::Full => "Full",
|
||||
FarDetail::Reduced => "Reduced",
|
||||
}
|
||||
}
|
||||
fn parse(s: &str) -> Self {
|
||||
if s == "reduced" {
|
||||
FarDetail::Reduced
|
||||
} else {
|
||||
FarDetail::Full
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static REDUCED_SUGGESTED: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
|
||||
/// Whether the settings suggest Reduced far detail: set when the renderer
|
||||
/// starts, on the GPUs `lod_capped` names (far terrain slows them most).
|
||||
pub fn suggest_reduced_far_detail(on: bool) {
|
||||
REDUCED_SUGGESTED.store(on, std::sync::atomic::Ordering::Relaxed);
|
||||
}
|
||||
pub fn reduced_far_detail_suggested() -> bool {
|
||||
REDUCED_SUGGESTED.load(std::sync::atomic::Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// The farthest LOD Distance, in chunks, on integrated GPUs other than
|
||||
/// Apple's: they share the CPU's memory bandwidth, and past it far terrain
|
||||
/// costs them more frame time than it is worth.
|
||||
@@ -181,6 +223,8 @@ pub struct Graphics {
|
||||
pub preset: Preset,
|
||||
/// Distant surface terrain range, in chunks; 0 disables LODs.
|
||||
pub lod_distance: u32,
|
||||
/// How finely far terrain is drawn; Full unless the player picks Reduced.
|
||||
pub far_detail: FarDetail,
|
||||
/// Skip sections hidden behind closed ones (vanilla's occlusion graph).
|
||||
pub cave_culling: bool,
|
||||
/// Hide shared leaf and mangrove-root faces, reducing foliage density.
|
||||
@@ -219,6 +263,7 @@ impl Default for Graphics {
|
||||
Self {
|
||||
preset: Preset::Custom,
|
||||
lod_distance: 0,
|
||||
far_detail: FarDetail::Full,
|
||||
cave_culling: true,
|
||||
cull_leaves: false,
|
||||
entity_culling: false,
|
||||
@@ -265,6 +310,7 @@ impl Graphics {
|
||||
match key.trim() {
|
||||
"preset" => g.preset = Preset::parse(value),
|
||||
"lodDistance" => g.lod_distance = number(g.lod_distance).min(1024),
|
||||
"farDetail" => g.far_detail = FarDetail::parse(value),
|
||||
"caveCulling" => g.cave_culling = flag(g.cave_culling),
|
||||
"cullLeaves" => g.cull_leaves = flag(g.cull_leaves),
|
||||
"entityCulling" => g.entity_culling = flag(g.entity_culling),
|
||||
@@ -302,9 +348,10 @@ impl Graphics {
|
||||
}
|
||||
pub fn serialize(&self) -> String {
|
||||
format!(
|
||||
"preset:{}\nlodDistance:{}\ncaveCulling:{}\ncullLeaves:{}\nentityCulling:{}\nchunkUpdates:{}\nuploadBudgetKiB:{}\nsky:{}\nfog:{}\nweather:{}\nanimatedTextures:{}\npresentMode:{}\nframePacing:{}\nworkers:{}\noverlay:{}\nresolutionScale:{}\nfxaa:{}\nbatteryMode:{}\nshaderPack:{}\ngraphicsApi:{}\n",
|
||||
"preset:{}\nlodDistance:{}\nfarDetail:{}\ncaveCulling:{}\ncullLeaves:{}\nentityCulling:{}\nchunkUpdates:{}\nuploadBudgetKiB:{}\nsky:{}\nfog:{}\nweather:{}\nanimatedTextures:{}\npresentMode:{}\nframePacing:{}\nworkers:{}\noverlay:{}\nresolutionScale:{}\nfxaa:{}\nbatteryMode:{}\nshaderPack:{}\ngraphicsApi:{}\n",
|
||||
self.preset.name(),
|
||||
self.lod_distance,
|
||||
self.far_detail.name(),
|
||||
bool_text(self.cave_culling),
|
||||
bool_text(self.cull_leaves),
|
||||
bool_text(self.entity_culling),
|
||||
@@ -405,6 +452,7 @@ mod tests {
|
||||
let g = Graphics {
|
||||
preset: Preset::Balanced,
|
||||
lod_distance: 256,
|
||||
far_detail: FarDetail::Reduced,
|
||||
cave_culling: false,
|
||||
cull_leaves: true,
|
||||
entity_culling: true,
|
||||
@@ -487,6 +535,24 @@ mod tests {
|
||||
assert_eq!(Graphics::parse("lodDistance:invalid\n").lod_distance, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn far_detail_is_full_unless_reduced_is_picked() {
|
||||
assert_eq!(Graphics::default().far_detail, FarDetail::Full);
|
||||
assert_eq!(
|
||||
Graphics::parse("farDetail:reduced\n").far_detail,
|
||||
FarDetail::Reduced
|
||||
);
|
||||
for text in ["farDetail:full\n", "farDetail:other\n", ""] {
|
||||
assert_eq!(Graphics::parse(text).far_detail, FarDetail::Full);
|
||||
}
|
||||
// No preset changes it.
|
||||
for preset in Preset::ALL {
|
||||
let mut s = crate::settings::Settings::default();
|
||||
apply_preset(preset, &mut s);
|
||||
assert_eq!(s.horizon.far_detail, FarDetail::Full, "{preset:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn automatic_workers_leave_cores_for_the_render_and_server_threads() {
|
||||
let g = Graphics::default();
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
//! they show, in what groups, with what description and cost, and their
|
||||
//! input. How they look comes from `graphics_style`.
|
||||
use crate::{
|
||||
graphics::{GraphicsApi, Overlay, Pacing, PresentMode, Preset},
|
||||
graphics::{FarDetail, GraphicsApi, Overlay, Pacing, PresentMode, Preset},
|
||||
graphics_style::{Cost, STYLE},
|
||||
hud::{HudAtlas, HudBatch},
|
||||
options_ui::Opt,
|
||||
@@ -22,6 +22,7 @@ pub enum Level {
|
||||
pub enum H {
|
||||
Preset,
|
||||
LodDistance,
|
||||
FarDetail,
|
||||
CaveCulling,
|
||||
CullLeaves,
|
||||
EntityCulling,
|
||||
@@ -85,6 +86,16 @@ pub fn rows(level: Level) -> Vec<Row> {
|
||||
Medium,
|
||||
"Coarse distant terrain in local worlds. Integrated GPUs stop at 128 chunks.",
|
||||
);
|
||||
// Suggested, never switched on, where far terrain costs the GPU most.
|
||||
let far = h(
|
||||
H::FarDetail,
|
||||
Medium,
|
||||
if crate::graphics::reduced_far_detail_suggested() {
|
||||
"Reduced draws far terrain coarser: faster on this integrated GPU."
|
||||
} else {
|
||||
"Reduced draws far terrain coarser, for GPUs it slows down."
|
||||
},
|
||||
);
|
||||
let fov = v(
|
||||
Opt::Fov,
|
||||
Low,
|
||||
@@ -107,11 +118,12 @@ pub fn rows(level: Level) -> Vec<Row> {
|
||||
"Sets the options below together. Changing one makes it Custom.",
|
||||
);
|
||||
let mut rows = match level {
|
||||
Level::Simple => vec![preset, render, lod, fov, fps, vsync, gui],
|
||||
Level::Simple => vec![preset, render, lod, far, fov, fps, vsync, gui],
|
||||
Level::Advanced => vec![
|
||||
Row::Header("Rendering"),
|
||||
render,
|
||||
lod,
|
||||
far,
|
||||
v(
|
||||
Opt::SimulationDistance,
|
||||
Medium,
|
||||
@@ -335,6 +347,7 @@ impl H {
|
||||
0 => labelled("LOD Distance", "Off"),
|
||||
n => labelled("LOD Distance", format!("{n} chunks")),
|
||||
},
|
||||
H::FarDetail => labelled("Far Detail", g.far_detail.label()),
|
||||
H::CaveCulling => labelled("Cave Culling", on_off(g.cave_culling)),
|
||||
H::CullLeaves => labelled("Cull Leaves", on_off(g.cull_leaves)),
|
||||
H::EntityCulling => labelled("Entity Culling", on_off(g.entity_culling)),
|
||||
@@ -411,6 +424,9 @@ impl H {
|
||||
H::LodDistance => {
|
||||
g.lod_distance = step(&[0, 64, 128, 256, 512, 1024], g.lod_distance, back)
|
||||
}
|
||||
H::FarDetail => {
|
||||
g.far_detail = step(&[FarDetail::Full, FarDetail::Reduced], g.far_detail, back)
|
||||
}
|
||||
H::CaveCulling => g.cave_culling = !g.cave_culling,
|
||||
H::CullLeaves => g.cull_leaves = !g.cull_leaves,
|
||||
H::EntityCulling => g.entity_culling = !g.entity_culling,
|
||||
|
||||
@@ -18,11 +18,35 @@ pub struct Rect {
|
||||
pub w: u32,
|
||||
pub h: u32,
|
||||
}
|
||||
/// `TextureMetadataSection`'s `blur` and `clamp`: how vanilla samples a
|
||||
/// standalone texture (`ReloadableTexture.setSampler`: linear rather than
|
||||
/// nearest filtering, clamp-to-edge rather than repeat addressing).
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub struct TextureFlags {
|
||||
pub blur: bool,
|
||||
pub clamp: bool,
|
||||
}
|
||||
impl TextureFlags {
|
||||
/// The `texture` section of a `.png.mcmeta` file, if it sets either flag.
|
||||
pub fn from_mcmeta(json: &str) -> Option<Self> {
|
||||
let meta: serde_json::Value = serde_json::from_str(json).ok()?;
|
||||
let section = meta.get("texture")?;
|
||||
let flag = |key| section.get(key).and_then(serde_json::Value::as_bool) == Some(true);
|
||||
let flags = Self {
|
||||
blur: flag("blur"),
|
||||
clamp: flag("clamp"),
|
||||
};
|
||||
(flags != Self::default()).then_some(flags)
|
||||
}
|
||||
}
|
||||
pub struct HudAtlas {
|
||||
pub pixels: Vec<u8>,
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
pub sprites: HashMap<String, Rect>,
|
||||
/// Sprites whose `.png.mcmeta` sets `blur` or `clamp`; the others
|
||||
/// sample as vanilla's defaults (nearest, repeat).
|
||||
pub flags: HashMap<String, TextureFlags>,
|
||||
pub fire_animations: Vec<(Rect, crate::sprites::SpriteAnimation)>,
|
||||
/// The default font (bitmap sheets in this atlas, unifont rasterized on demand).
|
||||
pub font: crate::font::Font,
|
||||
@@ -40,6 +64,9 @@ pub const MINIMAP_SPRITE: &str = "horizon/minimap";
|
||||
pub const SKIN_SLOTS: usize = 32;
|
||||
/// Reused by the visible rows of the classic world selector.
|
||||
pub const WORLD_ICON_SLOTS: usize = 32;
|
||||
/// Atlas slots for armour trim textures made at run time (`trims`), each the
|
||||
/// size of the largest trim texture.
|
||||
pub const TRIM_SLOTS: usize = 32;
|
||||
pub fn decode(path: &Path) -> Result<(u32, u32, Vec<u8>)> {
|
||||
decode_bytes(&fs::read(path)?)
|
||||
}
|
||||
@@ -77,6 +104,20 @@ fn walk(dir: &Path, out: &mut Vec<PathBuf>) {
|
||||
}
|
||||
/// A packed atlas image: sprite name, size and RGBA pixels.
|
||||
type PackedImage = (String, u32, u32, Vec<u8>);
|
||||
/// `image` inside a one-texel border copied from its opposite edges, the
|
||||
/// texels repeat addressing reads past each edge.
|
||||
fn wrap_border((name, w, h, data): &PackedImage) -> PackedImage {
|
||||
let (bw, bh) = (w + 2, h + 2);
|
||||
let mut out = Vec::with_capacity((bw * bh * 4) as usize);
|
||||
for y in 0..bh {
|
||||
let row = (y + h - 1) % h;
|
||||
for x in 0..bw {
|
||||
let at = ((row * w + (x + w - 1) % w) * 4) as usize;
|
||||
out.extend_from_slice(&data[at..at + 4]);
|
||||
}
|
||||
}
|
||||
(name.clone(), bw, bh, out)
|
||||
}
|
||||
/// Sprites the atlas definitions (`atlases/*.json`) add beyond the texture
|
||||
/// files already packed: generated ones (`paletted_permutations` such as the
|
||||
/// armour trim sprites, `unstitch` regions) and files listed under another id.
|
||||
@@ -84,11 +125,13 @@ fn atlas_sprites(root: &Path, packed: &[PackedImage]) -> Vec<PackedImage> {
|
||||
use crate::atlas_sources::{self, SpriteOrigin};
|
||||
let have: std::collections::HashSet<&str> = packed.iter().map(|i| i.0.as_str()).collect();
|
||||
let textures = atlas_sources::list_textures(root);
|
||||
let load = |texture: &str, directory: &str| {
|
||||
decode(&atlas_sources::texture_path(root, texture, directory)).ok()
|
||||
};
|
||||
let mut out: Vec<PackedImage> = Vec::new();
|
||||
let mut added = std::collections::HashSet::new();
|
||||
// Each new name with its origins in the atlases' order: the first that
|
||||
// loads is the sprite. They load at once (a content pack's atlases list
|
||||
// tens of thousands of files), and come out in the order a load of one
|
||||
// after another would give.
|
||||
let mut names: Vec<(String, Vec<(usize, SpriteOrigin)>)> = Vec::new();
|
||||
let mut slots = std::collections::HashMap::new();
|
||||
let mut order = 0;
|
||||
for (atlas, sources) in atlas_sources::atlases(root) {
|
||||
// The other atlases' renamed files (GUI sprites, particles...) are
|
||||
// looked up by their texture paths here.
|
||||
@@ -98,18 +141,33 @@ fn atlas_sprites(root: &Path, packed: &[PackedImage]) -> Vec<PackedImage> {
|
||||
continue;
|
||||
}
|
||||
let name = id.strip_prefix("minecraft:").unwrap_or(&id).to_owned();
|
||||
if have.contains(name.as_str()) || added.contains(&name) {
|
||||
if have.contains(name.as_str()) {
|
||||
continue;
|
||||
}
|
||||
let image = match &origin {
|
||||
// A vanilla file is packed under its own name already: only a
|
||||
// renamed one is new. Other namespaces' files (content mods) are
|
||||
// never in the walked directories.
|
||||
if let SpriteOrigin::File(texture) = &origin
|
||||
&& texture.strip_prefix("minecraft:") == Some(name.as_str())
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let slot = *slots.entry(name.clone()).or_insert_with(|| {
|
||||
names.push((name, Vec::new()));
|
||||
names.len() - 1
|
||||
});
|
||||
names[slot].1.push((order, origin));
|
||||
order += 1;
|
||||
}
|
||||
}
|
||||
let load = |texture: &str, directory: &str| {
|
||||
decode(&atlas_sources::texture_path(root, texture, directory)).ok()
|
||||
};
|
||||
let first = |(name, origins): &(String, Vec<(usize, SpriteOrigin)>)| {
|
||||
origins.iter().find_map(|(order, origin)| {
|
||||
let image = match origin {
|
||||
SpriteOrigin::File(texture) => {
|
||||
let path = texture.strip_prefix("minecraft:").unwrap_or(texture);
|
||||
// A vanilla file is packed under its own name already:
|
||||
// only a renamed one is new. Other namespaces' files
|
||||
// (content mods) are never in the walked directories.
|
||||
if texture.starts_with("minecraft:") && path == name {
|
||||
continue;
|
||||
}
|
||||
load(texture, "textures").map(|(w, h, p)| {
|
||||
// Animated strips keep their first frame, like the packed files.
|
||||
if h > w && (path.starts_with("block/") || path.starts_with("item/")) {
|
||||
@@ -121,13 +179,15 @@ fn atlas_sprites(root: &Path, packed: &[PackedImage]) -> Vec<PackedImage> {
|
||||
}
|
||||
generated => atlas_sources::generate(generated, &load),
|
||||
};
|
||||
if let Some((w, h, pixels)) = image {
|
||||
added.insert(name.clone());
|
||||
out.push((name, w, h, pixels));
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
image.map(|(w, h, pixels)| (*order, (name.clone(), w, h, pixels)))
|
||||
})
|
||||
};
|
||||
let mut out: Vec<_> = crate::par::par_map(&names, first)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.collect();
|
||||
out.sort_unstable_by_key(|(order, _)| *order);
|
||||
out.into_iter().map(|(_, image)| image).collect()
|
||||
}
|
||||
impl HudAtlas {
|
||||
/// A font glyph's atlas cell (8x8 GUI pixels) and advance.
|
||||
@@ -161,7 +221,7 @@ impl HudAtlas {
|
||||
}
|
||||
/// Empty slots written at run time: map textures (game/maps.rs), the
|
||||
/// minimap, downloaded skins, world icons and the on-screen keyboard's text.
|
||||
fn slots(images: &mut Vec<PackedImage>) {
|
||||
fn slots(images: &mut Vec<PackedImage>, trim: (u32, u32)) {
|
||||
for i in 0..MAP_SLOTS {
|
||||
images.push((format!("horizon/map{i}"), 128, 128, vec![0; 128 * 128 * 4]));
|
||||
}
|
||||
@@ -176,6 +236,14 @@ impl HudAtlas {
|
||||
for i in 0..SKIN_SLOTS {
|
||||
images.push((format!("horizon/skin{i}"), 64, 64, vec![0; 64 * 64 * 4]));
|
||||
}
|
||||
for i in 0..TRIM_SLOTS {
|
||||
images.push((
|
||||
format!("horizon/trim{i}"),
|
||||
trim.0,
|
||||
trim.1,
|
||||
vec![0; (trim.0 * trim.1 * 4) as usize],
|
||||
));
|
||||
}
|
||||
for i in 0..WORLD_ICON_SLOTS {
|
||||
images.push((
|
||||
format!("horizon/world-icon{i}"),
|
||||
@@ -213,7 +281,7 @@ impl HudAtlas {
|
||||
let s = p.to_string_lossy();
|
||||
!s.contains("title/background") && !s.contains("realms") && !s.contains("presets")
|
||||
});
|
||||
let decode_one = |path: &PathBuf| -> Option<(String, u32, u32, Vec<u8>)> {
|
||||
let decode_one = |path: &PathBuf| -> Option<(PackedImage, Option<TextureFlags>)> {
|
||||
let name = path
|
||||
.strip_prefix(&textures)
|
||||
.ok()?
|
||||
@@ -228,11 +296,15 @@ impl HudAtlas {
|
||||
} else {
|
||||
pixels
|
||||
};
|
||||
(w <= 1024 && (h <= 512 || name.starts_with("font/"))).then_some((name, w, h, pixels))
|
||||
let flags = fs::read_to_string(path.with_extension("png.mcmeta"))
|
||||
.ok()
|
||||
.and_then(|json| TextureFlags::from_mcmeta(&json));
|
||||
(w <= 1024 && (h <= 512 || name.starts_with("font/")))
|
||||
.then_some(((name, w, h, pixels), flags))
|
||||
};
|
||||
let threads = std::thread::available_parallelism().map_or(2, |n| n.get());
|
||||
let chunk = files.len().div_ceil(threads).max(1);
|
||||
let mut images: Vec<_> = std::thread::scope(|scope| {
|
||||
let decoded: Vec<_> = std::thread::scope(|scope| {
|
||||
let handles: Vec<_> = files
|
||||
.chunks(chunk)
|
||||
.map(|part| {
|
||||
@@ -244,6 +316,14 @@ impl HudAtlas {
|
||||
.flat_map(|h| h.join().unwrap_or_default())
|
||||
.collect()
|
||||
});
|
||||
let mut flags = HashMap::new();
|
||||
let mut images: Vec<PackedImage> = Vec::with_capacity(decoded.len());
|
||||
for (image, image_flags) in decoded {
|
||||
if let Some(f) = image_flags {
|
||||
flags.insert(image.0.clone(), f);
|
||||
}
|
||||
images.push(image);
|
||||
}
|
||||
if full {
|
||||
images.extend(atlas_sprites(root, &images));
|
||||
}
|
||||
@@ -258,7 +338,17 @@ impl HudAtlas {
|
||||
}
|
||||
images.push(("horizon/white".into(), 4, 4, vec![255; 64]));
|
||||
if full {
|
||||
Self::slots(&mut images);
|
||||
Self::slots(&mut images, crate::trims::slot_size(root));
|
||||
}
|
||||
// Blurred sprites are sampled linearly in the atlas (screen.wgsl): a
|
||||
// border wrapped from their own edges keeps the filter off their
|
||||
// neighbours, as repeat addressing of their own texture would.
|
||||
let mut bordered = std::collections::HashSet::new();
|
||||
for image in &mut images {
|
||||
if flags.get(&image.0).is_some_and(|f: &TextureFlags| f.blur) {
|
||||
*image = wrap_border(image);
|
||||
bordered.insert(image.0.clone());
|
||||
}
|
||||
}
|
||||
images.sort_by(|a, b| b.2.cmp(&a.2).then(b.1.cmp(&a.1)));
|
||||
let width = 4096u32;
|
||||
@@ -284,6 +374,8 @@ impl HudAtlas {
|
||||
let dst = (((py + row) * width + px) * 4) as usize;
|
||||
pixels[dst..dst + (w * 4) as usize].copy_from_slice(src);
|
||||
}
|
||||
let b = u32::from(bordered.contains(name));
|
||||
let (px, py, w, h) = (px + b, py + b, w - 2 * b, h - 2 * b);
|
||||
sprites.insert(
|
||||
name.clone(),
|
||||
Rect {
|
||||
@@ -291,8 +383,8 @@ impl HudAtlas {
|
||||
v0: py as f32 / height as f32,
|
||||
u1: (px + w) as f32 / width as f32,
|
||||
v1: (py + h) as f32 / height as f32,
|
||||
w: *w,
|
||||
h: *h,
|
||||
w,
|
||||
h,
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -339,6 +431,7 @@ impl HudAtlas {
|
||||
width,
|
||||
height,
|
||||
sprites,
|
||||
flags,
|
||||
fire_animations,
|
||||
font,
|
||||
white,
|
||||
@@ -365,6 +458,8 @@ pub struct HudBatch {
|
||||
pub patches: Vec<crate::font::Patch>,
|
||||
/// Vertex ranges with another blend or depth/scissor state, in order.
|
||||
pub blends: Vec<(std::ops::Range<u32>, Blend)>,
|
||||
/// The textures and time of the `Blend::EndPortal` runs.
|
||||
pub end_portal: EndPortalFill,
|
||||
/// Screen's first vertex: the preceding HUD is part of its blurred background.
|
||||
pub blur_at: u32,
|
||||
}
|
||||
@@ -379,6 +474,19 @@ pub enum Blend {
|
||||
Additive,
|
||||
/// Alpha blending with an isolated depth buffer and a pixel scissor.
|
||||
Depth([u32; 4]),
|
||||
/// `RenderPipelines.VIGNETTE`: the destination darkened by the texture
|
||||
/// (`ZERO, ONE_MINUS_SRC_COLOR`).
|
||||
Vignette,
|
||||
/// `RenderPipelines.END_PORTAL` as a GUI fill: the End portal's layers
|
||||
/// projected from the screen (`HudBatch::end_portal` sets the textures).
|
||||
EndPortal,
|
||||
}
|
||||
/// What a `Blend::EndPortal` fill samples: the atlas rects (u0, v0, width,
|
||||
/// height) of `end_sky.png` and `end_portal.png`, and the `GameTime` uniform.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq)]
|
||||
pub struct EndPortalFill {
|
||||
pub sprites: [[f32; 4]; 2],
|
||||
pub game_time: f32,
|
||||
}
|
||||
pub fn rgba(r: u8, g: u8, b: u8, a: u8) -> u32 {
|
||||
u32::from(a) << 24 | u32::from(b) << 16 | u32::from(g) << 8 | u32::from(r)
|
||||
@@ -402,6 +510,7 @@ impl HudBatch {
|
||||
height: (window[1] as f32 / scale as f32).ceil(),
|
||||
patches: Vec::new(),
|
||||
blends: Vec::new(),
|
||||
end_portal: EndPortalFill::default(),
|
||||
blur_at: 0,
|
||||
}
|
||||
}
|
||||
@@ -708,7 +817,13 @@ impl HudBatch {
|
||||
let mut glyphs = Vec::new();
|
||||
let mut cursor = x;
|
||||
for (i, (ch, style)) in text.chars().enumerate() {
|
||||
let g = font.glyph(ch);
|
||||
// The style's font (`Style.getFont`), the default one unless set.
|
||||
let mut g = font.glyph_styled(ch, style);
|
||||
// Font.StringRenderOutput.accept: obfuscated characters draw a
|
||||
// random glyph of the same width each frame.
|
||||
if style.obfuscated == Some(true) && ch != ' ' {
|
||||
g = font.random_glyph(style, g.advance);
|
||||
}
|
||||
let c = style.color.map_or(color, |rgb| {
|
||||
rgba((rgb >> 16) as u8, (rgb >> 8) as u8, rgb as u8, alpha)
|
||||
});
|
||||
@@ -802,6 +917,20 @@ impl HudBatch {
|
||||
#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
|
||||
struct HudUniform {
|
||||
size: [f32; 4],
|
||||
/// `EndPortalFill`: the two sprites, then the game time in x.
|
||||
end_portal: [[f32; 4]; 3],
|
||||
}
|
||||
impl HudUniform {
|
||||
fn new(size: [f32; 4], fill: EndPortalFill) -> Self {
|
||||
Self {
|
||||
size,
|
||||
end_portal: [
|
||||
fill.sprites[0],
|
||||
fill.sprites[1],
|
||||
[fill.game_time, 0.0, 0.0, 0.0],
|
||||
],
|
||||
}
|
||||
}
|
||||
}
|
||||
pub struct HudRenderer {
|
||||
pub fire_animations: Vec<(Rect, crate::sprites::SpriteAnimation, usize)>,
|
||||
@@ -812,6 +941,8 @@ pub struct HudRenderer {
|
||||
invert_pipeline: wgpu::RenderPipeline,
|
||||
additive_pipeline: wgpu::RenderPipeline,
|
||||
depth_pipeline: wgpu::RenderPipeline,
|
||||
vignette_pipeline: wgpu::RenderPipeline,
|
||||
end_portal_pipeline: wgpu::RenderPipeline,
|
||||
group: wgpu::BindGroup,
|
||||
uniform: wgpu::Buffer,
|
||||
buffer: wgpu::Buffer,
|
||||
@@ -834,7 +965,7 @@ impl HudRenderer {
|
||||
});
|
||||
let uniform = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
|
||||
label: Some("HUD size"),
|
||||
contents: bytemuck::bytes_of(&HudUniform { size: [1.0; 4] }),
|
||||
contents: bytemuck::bytes_of(&HudUniform::new([1.0; 4], EndPortalFill::default())),
|
||||
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
|
||||
});
|
||||
let layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
|
||||
@@ -842,7 +973,7 @@ impl HudRenderer {
|
||||
entries: &[
|
||||
wgpu::BindGroupLayoutEntry {
|
||||
binding: 0,
|
||||
visibility: wgpu::ShaderStages::VERTEX,
|
||||
visibility: wgpu::ShaderStages::VERTEX_FRAGMENT,
|
||||
ty: wgpu::BindingType::Buffer {
|
||||
ty: wgpu::BufferBindingType::Uniform,
|
||||
has_dynamic_offset: false,
|
||||
@@ -970,6 +1101,31 @@ impl HudRenderer {
|
||||
"fs_main",
|
||||
false,
|
||||
);
|
||||
// RenderPipelines.VIGNETTE: ZERO, ONE_MINUS_SRC_COLOR, ZERO, ONE.
|
||||
let vignette_pipeline = create(
|
||||
"HUD vignette",
|
||||
wgpu::BlendState {
|
||||
color: wgpu::BlendComponent {
|
||||
src_factor: wgpu::BlendFactor::Zero,
|
||||
dst_factor: wgpu::BlendFactor::OneMinusSrc,
|
||||
operation: wgpu::BlendOperation::Add,
|
||||
},
|
||||
alpha: wgpu::BlendComponent {
|
||||
src_factor: wgpu::BlendFactor::Zero,
|
||||
dst_factor: wgpu::BlendFactor::One,
|
||||
operation: wgpu::BlendOperation::Add,
|
||||
},
|
||||
},
|
||||
"fs_main",
|
||||
false,
|
||||
);
|
||||
// RenderPipelines.END_PORTAL: opaque.
|
||||
let end_portal_pipeline = create(
|
||||
"HUD end portal",
|
||||
wgpu::BlendState::REPLACE,
|
||||
"fs_end_portal",
|
||||
false,
|
||||
);
|
||||
let capacity = 1 << 16;
|
||||
let buffer = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("HUD vertices"),
|
||||
@@ -984,6 +1140,8 @@ impl HudRenderer {
|
||||
invert_pipeline,
|
||||
additive_pipeline,
|
||||
depth_pipeline,
|
||||
vignette_pipeline,
|
||||
end_portal_pipeline,
|
||||
group,
|
||||
uniform,
|
||||
buffer,
|
||||
@@ -1077,9 +1235,10 @@ impl HudRenderer {
|
||||
encoder,
|
||||
&self.uniform,
|
||||
0,
|
||||
bytemuck::bytes_of(&HudUniform {
|
||||
size: [size[0] as f32, size[1] as f32, batch.scale, 0.0],
|
||||
}),
|
||||
bytemuck::bytes_of(&HudUniform::new(
|
||||
[size[0] as f32, size[1] as f32, batch.scale, 0.0],
|
||||
batch.end_portal,
|
||||
)),
|
||||
);
|
||||
}
|
||||
/// Draw the batch `prepare` staged.
|
||||
@@ -1167,6 +1326,8 @@ impl HudRenderer {
|
||||
Blend::Invert => &self.invert_pipeline,
|
||||
Blend::Additive => &self.additive_pipeline,
|
||||
Blend::Depth(_) => &self.depth_pipeline,
|
||||
Blend::Vignette => &self.vignette_pipeline,
|
||||
Blend::EndPortal => &self.end_portal_pipeline,
|
||||
});
|
||||
pass.draw(range.clone(), 0..1);
|
||||
}
|
||||
@@ -1180,9 +1341,33 @@ mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn the_fonts_only_atlas_measures_text_like_the_full_one() {
|
||||
// A synthetic default font: two 8x8 glyphs, 3 and 5 pixels wide.
|
||||
let root = std::env::temp_dir().join(format!("horizon-fonts-atlas-{}", std::process::id()));
|
||||
fn a_wrapped_border_repeats_the_opposite_edges() {
|
||||
// 2x2 texels a b / c d, one byte each in red.
|
||||
let image = (
|
||||
"x".to_owned(),
|
||||
2,
|
||||
2,
|
||||
[1u8, 2, 3, 4]
|
||||
.iter()
|
||||
.flat_map(|&v| [v, 0, 0, 255])
|
||||
.collect(),
|
||||
);
|
||||
let (_, w, h, data) = wrap_border(&image);
|
||||
assert_eq!((w, h), (4, 4));
|
||||
let red: Vec<u8> = data.chunks(4).map(|t| t[0]).collect();
|
||||
#[rustfmt::skip]
|
||||
assert_eq!(red, [
|
||||
4, 3, 4, 3,
|
||||
2, 1, 2, 1,
|
||||
4, 3, 4, 3,
|
||||
2, 1, 2, 1,
|
||||
]);
|
||||
}
|
||||
|
||||
/// A synthetic default font: two 8x8 glyphs, 3 and 5 pixels wide.
|
||||
fn two_glyph_font(name: &str) -> std::path::PathBuf {
|
||||
let root =
|
||||
std::env::temp_dir().join(format!("horizon-fonts-{name}-{}", std::process::id()));
|
||||
let font = root.join("assets/minecraft/font");
|
||||
let textures = root.join("assets/minecraft/textures/font");
|
||||
fs::create_dir_all(&font).unwrap();
|
||||
@@ -1207,6 +1392,11 @@ mod tests {
|
||||
.unwrap()
|
||||
.write_image_data(&pixels)
|
||||
.unwrap();
|
||||
root
|
||||
}
|
||||
#[test]
|
||||
fn the_fonts_only_atlas_measures_text_like_the_full_one() {
|
||||
let root = two_glyph_font("atlas");
|
||||
let full = HudAtlas::build(&root).unwrap();
|
||||
let fonts = HudAtlas::fonts(&root).unwrap();
|
||||
let _ = fs::remove_dir_all(&root);
|
||||
@@ -1215,6 +1405,21 @@ mod tests {
|
||||
assert!(width > 0.0);
|
||||
assert_eq!(fonts.font.width("abba"), width);
|
||||
}
|
||||
#[test]
|
||||
fn obfuscated_glyphs_keep_the_width() {
|
||||
let root = two_glyph_font("obfuscated");
|
||||
let fonts = HudAtlas::fonts(&root).unwrap();
|
||||
let _ = fs::remove_dir_all(&root);
|
||||
let (a, b) = (fonts.font.glyph('a').advance, fonts.font.glyph('b').advance);
|
||||
assert_ne!(a, b);
|
||||
for _ in 0..20 {
|
||||
assert_eq!(fonts.font.random_glyph(&Default::default(), a).advance, a);
|
||||
assert_eq!(fonts.font.random_glyph(&Default::default(), b).advance, b);
|
||||
}
|
||||
// No glyph that wide: FontSet's missing glyph.
|
||||
let missing = fonts.font.random_glyph(&Default::default(), 30.0);
|
||||
assert!(missing.rect.is_none() && missing.right == 5.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn effect_icons_are_painted_from_the_mob_effect_directory() {
|
||||
@@ -1399,6 +1604,7 @@ mod tests {
|
||||
height: height as f32,
|
||||
patches: Vec::new(),
|
||||
blends: Vec::new(),
|
||||
end_portal: EndPortalFill::default(),
|
||||
blur_at: 0,
|
||||
};
|
||||
for (image_width, image_height) in [(195, 136), (176, 166), (176, 222)] {
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
struct Hud { size: vec4<f32> }
|
||||
struct Hud {
|
||||
size: vec4<f32>,
|
||||
// Blend::EndPortal: atlas rects (u0, v0, width, height) of end_sky.png and
|
||||
// end_portal.png, then vanilla's GameTime in x.
|
||||
end_portal: array<vec4<f32>, 3>,
|
||||
}
|
||||
@group(0) @binding(0) var<uniform> hud: Hud;
|
||||
@group(0) @binding(1) var atlas: texture_2d<f32>;
|
||||
@group(0) @binding(2) var atlas_sampler: sampler;
|
||||
@@ -6,6 +11,8 @@ struct Out {
|
||||
@builtin(position) clip: vec4<f32>,
|
||||
@location(0) uv: vec2<f32>,
|
||||
@location(1) color: vec4<f32>,
|
||||
// projection_from_position(gl_Position): the End portal's texProj0.
|
||||
@location(2) proj: vec4<f32>,
|
||||
}
|
||||
@vertex fn vs_main(@location(0) pos: vec2<f32>, @location(1) uv: vec2<f32>, @location(2) color: vec4<f32>, @location(3) depth: f32) -> Out {
|
||||
let pixels = pos * hud.size.z;
|
||||
@@ -13,6 +20,7 @@ struct Out {
|
||||
out.clip = vec4(pixels.x / hud.size.x * 2.0 - 1.0, 1.0 - pixels.y / hud.size.y * 2.0, depth, 1.0);
|
||||
out.uv = uv;
|
||||
out.color = color;
|
||||
out.proj = vec4(out.clip.xy * 0.5 + 0.5, out.clip.zw);
|
||||
return out;
|
||||
}
|
||||
@fragment fn fs_main(in: Out) -> @location(0) vec4<f32> {
|
||||
@@ -31,3 +39,44 @@ struct Out {
|
||||
if c.a == 0.0 { discard; }
|
||||
return c;
|
||||
}
|
||||
// rendertype_end_portal.fsh (entity.wgsl has the same for portal blocks).
|
||||
const PORTAL_COLORS = array<vec3<f32>, 16>(
|
||||
vec3(0.022087, 0.098399, 0.110818),
|
||||
vec3(0.011892, 0.095924, 0.089485),
|
||||
vec3(0.027636, 0.101689, 0.100326),
|
||||
vec3(0.046564, 0.109883, 0.114838),
|
||||
vec3(0.064901, 0.117696, 0.097189),
|
||||
vec3(0.063761, 0.086895, 0.123646),
|
||||
vec3(0.084817, 0.111994, 0.166380),
|
||||
vec3(0.097489, 0.154120, 0.091064),
|
||||
vec3(0.106152, 0.131144, 0.195191),
|
||||
vec3(0.097721, 0.110188, 0.187229),
|
||||
vec3(0.133516, 0.138278, 0.148582),
|
||||
vec3(0.070006, 0.243332, 0.235792),
|
||||
vec3(0.196766, 0.142899, 0.214696),
|
||||
vec3(0.047281, 0.315338, 0.321970),
|
||||
vec3(0.204675, 0.390010, 0.302066),
|
||||
vec3(0.080955, 0.314821, 0.661491),
|
||||
);
|
||||
fn portal_sample(rect: vec4<f32>, p: vec4<f32>) -> vec3<f32> {
|
||||
let st = fract(p.xy / p.w);
|
||||
return textureSampleLevel(atlas, atlas_sampler, rect.xy + st * rect.zw, 0.0).rgb;
|
||||
}
|
||||
fn portal_layer(p: vec4<f32>, layer: f32) -> vec4<f32> {
|
||||
let a = radians((layer * layer * 4321.0 + layer * 9.0) * 2.0);
|
||||
let c = cos(a);
|
||||
let s = sin(a);
|
||||
let k = (4.5 - layer / 4.0) * 2.0;
|
||||
var v = vec4(k * (c * p.x - s * p.y), k * (s * p.x + c * p.y), p.z, p.w);
|
||||
v = vec4(v.x + 17.0 / layer * v.w, v.y + (2.0 + layer / 1.5) * (hud.end_portal[2].x * 1.5) * v.w, v.z, v.w);
|
||||
return vec4(0.5 * v.x + 0.25 * v.w, 0.5 * v.y + 0.25 * v.w, v.z, v.w);
|
||||
}
|
||||
// RenderPipelines.END_PORTAL (WinScreen's poem background): end_sky and 15
|
||||
// layers of end_portal.png.
|
||||
@fragment fn fs_end_portal(in: Out) -> @location(0) vec4<f32> {
|
||||
var c = portal_sample(hud.end_portal[0], in.proj) * PORTAL_COLORS[0];
|
||||
for (var i = 0; i < 15; i++) {
|
||||
c += portal_sample(hud.end_portal[1], portal_layer(in.proj, f32(i + 1))) * PORTAL_COLORS[i];
|
||||
}
|
||||
return vec4(c, 1.0);
|
||||
}
|
||||
@@ -12,8 +12,8 @@ use horizon_protocol::{
|
||||
item::Stack,
|
||||
packets::play::{
|
||||
self, AddEntity, AttributeSnapshot, EntityPositionSync, MoveEntity, MoveEntityPos,
|
||||
MoveEntityPosRot, MoveEntityRot, PositionPath, RotateHead, SetEntityData, SetEntityMotion,
|
||||
SetEquipment, SetPassengers, UpdateAttributes, VecDelta,
|
||||
MoveEntityPosRot, MoveEntityRot, PositionPath, RotateHead, SetEntityData, SetEntityLink,
|
||||
SetEntityMotion, SetEquipment, SetPassengers, UpdateAttributes, VecDelta,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -50,6 +50,10 @@ pub struct EntityState {
|
||||
pub data: i32,
|
||||
/// SynchedEntityData entries that differ from the defaults.
|
||||
pub meta: Vec<(u8, i32, Value)>,
|
||||
/// The defaults of entries `meta` leaves out while they are at them,
|
||||
/// sent when one goes back to its default (a calf grown up, a mob healed
|
||||
/// back to 1, wool grown back white) beyond Entity's own below.
|
||||
pub defaults: Vec<(u8, i32, Value)>,
|
||||
/// EquipmentSlot ordinals (0 main hand, 1 off hand, 2 feet, 3 legs,
|
||||
/// 4 chest, 5 head, 6 body, 7 saddle) and their stacks.
|
||||
pub equipment: Vec<(u8, Option<Stack>)>,
|
||||
@@ -59,6 +63,10 @@ pub struct EntityState {
|
||||
/// `Entity.getRequiresPrecisePosition` (primed TNT and falling blocks
|
||||
/// touching a block): moves are sent as full position syncs.
|
||||
pub precise: bool,
|
||||
/// `Entity.needsSync`: send its changes this tick, due or not.
|
||||
pub needs_sync: bool,
|
||||
/// `Leashable.getLeashHolder`: the entity holding its lead.
|
||||
pub leash: Option<i32>,
|
||||
}
|
||||
|
||||
impl EntityState {
|
||||
@@ -76,9 +84,12 @@ impl EntityState {
|
||||
self.velocity = [0.0; 3];
|
||||
self.data = 0;
|
||||
self.meta.clear();
|
||||
self.defaults.clear();
|
||||
self.equipment.clear();
|
||||
self.attributes.clear();
|
||||
self.precise = false;
|
||||
self.needs_sync = false;
|
||||
self.leash = None;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -211,6 +222,8 @@ struct ServerEntity {
|
||||
/// and the first update after it dismounts is a full position sync.
|
||||
passengers: Vec<i32>,
|
||||
was_riding: bool,
|
||||
/// The lead's holder the clients were told of.
|
||||
leash: Option<i32>,
|
||||
/// The level tick it was last seen in, its place in that tick's list, and
|
||||
/// whether it was added in it (its first update is the next tick's).
|
||||
seen: u32,
|
||||
@@ -254,6 +267,7 @@ impl ServerEntity {
|
||||
teleport_delay: 0,
|
||||
passengers: riding.passengers(e.id).to_vec(),
|
||||
was_riding: false,
|
||||
leash: e.leash,
|
||||
seen: 0,
|
||||
index: 0,
|
||||
added: true,
|
||||
@@ -306,6 +320,13 @@ impl ServerEntity {
|
||||
if let Some(&vehicle) = riding.vehicle.get(&e.id) {
|
||||
out.push(riding.set_passengers(vehicle));
|
||||
}
|
||||
// A leashed entity: who holds its lead.
|
||||
if let Some(dest) = e.leash {
|
||||
out.push(play::Clientbound::SetEntityLink(SetEntityLink {
|
||||
source: e.id,
|
||||
dest,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
/// Changes since the last update (ServerEntity.sendChanges): movement and
|
||||
@@ -319,10 +340,18 @@ impl ServerEntity {
|
||||
self.passengers.clear();
|
||||
self.passengers.extend_from_slice(passengers);
|
||||
}
|
||||
// Leashable.setLeashedTo / dropLeash: sendToTrackingPlayers at once.
|
||||
if e.leash != self.leash {
|
||||
out.push(play::Clientbound::SetEntityLink(SetEntityLink {
|
||||
source: e.id,
|
||||
dest: e.leash.unwrap_or(0),
|
||||
}));
|
||||
self.leash = e.leash;
|
||||
}
|
||||
// SynchedEntityData.isDirty: once for every client.
|
||||
let dirty = e.meta != self.meta;
|
||||
let due = self.interval != 0 && self.tick.is_multiple_of(self.interval);
|
||||
if due || dirty {
|
||||
if due || dirty || e.needs_sync {
|
||||
let rotation = (pack_degrees(e.pitch), pack_degrees(e.yaw));
|
||||
let turned = |a: i8, b: i8| (i32::from(a) - i32::from(b)).abs() >= 1;
|
||||
let rotate = self.arrow
|
||||
@@ -398,7 +427,7 @@ impl ServerEntity {
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if self.track_deltas {
|
||||
if self.track_deltas || e.needs_sync {
|
||||
let diff: f64 = (0..3)
|
||||
.map(|k| (e.velocity[k] - self.velocity[k]).powi(2))
|
||||
.sum();
|
||||
@@ -466,7 +495,10 @@ impl ServerEntity {
|
||||
(0, serializer::BYTE) => Value::Byte(0),
|
||||
(6, serializer::POSE) => Value::Id(0),
|
||||
(14, serializer::OPTIONAL_BLOCK_POS) => Value::OptPos(None),
|
||||
_ => continue,
|
||||
_ => match e.defaults.iter().find(|d| (d.0, d.1) == (*index, *kind)) {
|
||||
Some(d) => d.2.clone(),
|
||||
None => continue,
|
||||
},
|
||||
};
|
||||
if !e.meta.iter().any(|m| m.0 == *index) {
|
||||
changed.push((*index, *kind, default));
|
||||
@@ -852,9 +884,12 @@ mod tests {
|
||||
velocity: [0.0; 3],
|
||||
data: 0,
|
||||
meta: Vec::new(),
|
||||
defaults: Vec::new(),
|
||||
equipment: Vec::new(),
|
||||
attributes: Vec::new(),
|
||||
precise: false,
|
||||
needs_sync: false,
|
||||
leash: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -918,6 +953,37 @@ mod tests {
|
||||
assert!(matches!(jump[0], play::Clientbound::EntityPositionSync(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_knocked_back_mob_is_sent_at_once() {
|
||||
let mut s = added(&state(0.0));
|
||||
changes(&mut s, &state(0.0));
|
||||
// Not due (tick 1 of 3), but LivingEntity.knockback set needsSync:
|
||||
// its move and its new motion go out now.
|
||||
let mut knocked = state(0.3);
|
||||
knocked.velocity = [0.2, 0.3, 0.0];
|
||||
knocked.needs_sync = true;
|
||||
let out = changes(&mut s, &knocked);
|
||||
assert!(
|
||||
matches!(
|
||||
out[..],
|
||||
[
|
||||
play::Clientbound::SetEntityMotion(_),
|
||||
play::Clientbound::MoveEntityPos(_)
|
||||
]
|
||||
),
|
||||
"{out:?}"
|
||||
);
|
||||
// Then every third tick again.
|
||||
assert!(changes(&mut s, &state(0.5)).is_empty());
|
||||
assert!(matches!(
|
||||
changes(&mut s, &state(0.6))[..],
|
||||
[
|
||||
play::Clientbound::SetEntityMotion(_),
|
||||
play::Clientbound::MoveEntityPos(_)
|
||||
]
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fireballs_bundle_their_motion_with_their_power() {
|
||||
let mut e = state(0.0);
|
||||
@@ -1040,6 +1106,29 @@ mod tests {
|
||||
assert!(changes(&mut s, &e).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_value_back_at_a_listed_default_is_sent() {
|
||||
let mut e = state(0.0);
|
||||
e.defaults = vec![(16, serializer::BOOLEAN, Value::Bool(false))];
|
||||
// A calf: paired with its baby flag, then grown up.
|
||||
e.meta = vec![(16, serializer::BOOLEAN, Value::Bool(true))];
|
||||
let mut s = added(&e);
|
||||
changes(&mut s, &e);
|
||||
e.meta.clear();
|
||||
let data = changes(&mut s, &e)
|
||||
.into_iter()
|
||||
.find_map(|p| match p {
|
||||
play::Clientbound::SetEntityData(d) => Some(d),
|
||||
_ => None,
|
||||
})
|
||||
.expect("growing up is sent");
|
||||
assert_eq!(
|
||||
data.values,
|
||||
vec![(16, serializer::BOOLEAN, Value::Bool(false))]
|
||||
);
|
||||
assert!(changes(&mut s, &e).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn metadata_and_equipment_send_only_the_changes() {
|
||||
let mut e = state(0.0);
|
||||
@@ -1065,6 +1154,24 @@ mod tests {
|
||||
assert_eq!(eq.slots, vec![(0, None)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_lead_tied_and_untied_reaches_the_clients() {
|
||||
let link = |out: &[play::Clientbound]| {
|
||||
out.iter().find_map(|p| match p {
|
||||
play::Clientbound::SetEntityLink(l) => Some((l.source, l.dest)),
|
||||
_ => None,
|
||||
})
|
||||
};
|
||||
let mut e = state(0.0);
|
||||
let mut s = added(&e);
|
||||
changes(&mut s, &e);
|
||||
e.leash = Some(7);
|
||||
assert_eq!(link(&changes(&mut s, &e)), Some((e.id, 7)));
|
||||
assert_eq!(link(&changes(&mut s, &e)), None);
|
||||
e.leash = None;
|
||||
assert_eq!(link(&changes(&mut s, &e)), Some((e.id, 0)));
|
||||
}
|
||||
|
||||
/// Two clients at the origin; the chunks they hold.
|
||||
struct Clients {
|
||||
chunks: crate::fast_hash::FxHashSet<(i32, i32)>,
|
||||
|
||||
@@ -93,6 +93,10 @@ pub struct Guest {
|
||||
/// Where the player respawns (a bed's spot; `ServerPlayer.respawnConfig`),
|
||||
/// None for the world spawn.
|
||||
pub spawn: Option<[f64; 3]>,
|
||||
/// A respawn point outside the Overworld (a respawn anchor, or one
|
||||
/// `spawnpoint` set there): its dimension, block, yaw and whether it is
|
||||
/// forced, as the player's save keeps it (`respawn`).
|
||||
pub respawn_elsewhere: Option<(String, [i32; 3], f32, bool)>,
|
||||
/// Pending ClientboundPlayerPositionPacket id; movement is ignored until
|
||||
/// the client accepts it (ServerGamePacketListenerImpl.awaitingPositionFromClient).
|
||||
pub awaiting_teleport: Option<(i32, [f64; 3])>,
|
||||
@@ -149,6 +153,9 @@ pub struct Guest {
|
||||
/// The ender chest inventory (`PlayerEnderChestContainer`, 27 slots).
|
||||
pub ender_items: Vec<Option<ItemStack>>,
|
||||
pub health: f32,
|
||||
/// The saved `AbsorptionAmount`, until the player's first body on the
|
||||
/// server takes it (the body holds the live amount).
|
||||
pub saved_absorption: Option<f32>,
|
||||
pub food: i32,
|
||||
pub saturation: f32,
|
||||
pub xp_level: i32,
|
||||
@@ -168,6 +175,10 @@ pub struct Guest {
|
||||
/// `lastSentHealth`, `lastSentFood` and `lastFoodSaturationZero`.
|
||||
pub sent_health: Option<(f32, i32, bool)>,
|
||||
pub sent_xp: Option<(f32, i32, i32)>,
|
||||
/// The air supply and absorption the player's client was last sent
|
||||
/// (Entity.DATA_AIR_SUPPLY_ID, Player.DATA_PLAYER_ABSORPTION_ID); their
|
||||
/// defaults until either changes.
|
||||
pub sent_air: (i32, f32),
|
||||
/// Where the player last died (Player.lastDeathLocation), sent with the respawn.
|
||||
pub last_death: Option<[i32; 3]>,
|
||||
/// The last action sequence to acknowledge at the next tick (-1: none).
|
||||
@@ -252,6 +263,7 @@ impl Guest {
|
||||
sleep_timer: 0,
|
||||
sent_bed: None,
|
||||
spawn: None,
|
||||
respawn_elsewhere: None,
|
||||
awaiting_teleport: None,
|
||||
teleport_counter: 0,
|
||||
teleport_tick: 0,
|
||||
@@ -280,6 +292,7 @@ impl Guest {
|
||||
craft_result: None,
|
||||
ender_items: vec![None; 27],
|
||||
health: 20.0,
|
||||
saved_absorption: None,
|
||||
food: 20,
|
||||
saturation: 5.0,
|
||||
xp_level: 0,
|
||||
@@ -292,6 +305,7 @@ impl Guest {
|
||||
menu_state: 0,
|
||||
sent_health: None,
|
||||
sent_xp: None,
|
||||
sent_air: (300, 0.0),
|
||||
last_death: None,
|
||||
ack_up_to: std::cell::Cell::new(-1),
|
||||
sent_selected: 0,
|
||||
|
||||
@@ -842,6 +842,20 @@ impl Hub {
|
||||
|
||||
/// ServerGamePacketListenerImpl.teleport: move the client and wait for its confirmation.
|
||||
pub fn teleport(&mut self, i: usize, position: Option<[f64; 3]>) {
|
||||
self.teleport_relative(i, position, 0);
|
||||
}
|
||||
|
||||
/// A teleport to `position` that keeps the player's motion and look
|
||||
/// (`Relative.DELTA` and `Relative.ROTATION`: an End gateway's).
|
||||
pub fn teleport_keeping_motion(&mut self, i: usize, position: [f64; 3]) {
|
||||
use play::relative::{DELTA_X, DELTA_Y, DELTA_Z, ROTATE_DELTA, X_ROT, Y_ROT};
|
||||
let relatives = Y_ROT | X_ROT | DELTA_X | DELTA_Y | DELTA_Z | ROTATE_DELTA;
|
||||
self.teleport_relative(i, Some(position), relatives);
|
||||
}
|
||||
|
||||
/// ServerGamePacketListenerImpl.teleport with `relatives`: the rotation
|
||||
/// and motion they name are sent as zero changes.
|
||||
fn teleport_relative(&mut self, i: usize, position: Option<[f64; 3]>, relatives: i32) {
|
||||
let ticks = self.ticks;
|
||||
let g = &mut self.guests[i];
|
||||
g.teleport_tick = ticks;
|
||||
@@ -852,13 +866,14 @@ impl Hub {
|
||||
g.teleport_counter = (g.teleport_counter + 1) & 0x7FFF_FFFF;
|
||||
let id = g.teleport_counter;
|
||||
g.awaiting_teleport = Some((id, g.position));
|
||||
let turn = relatives & (play::relative::Y_ROT | play::relative::X_ROT) != 0;
|
||||
g.send(&C::PlayerPosition(play::PlayerPosition {
|
||||
teleport: id,
|
||||
position: g.position,
|
||||
delta: [0.0; 3],
|
||||
yaw: g.yaw,
|
||||
pitch: g.pitch,
|
||||
relatives: 0,
|
||||
yaw: if turn { 0.0 } else { g.yaw },
|
||||
pitch: if turn { 0.0 } else { g.pitch },
|
||||
relatives,
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -1544,9 +1559,12 @@ impl Hub {
|
||||
velocity: [0.0; 3],
|
||||
data: 0,
|
||||
meta,
|
||||
defaults: Vec::new(),
|
||||
equipment,
|
||||
attributes,
|
||||
precise: false,
|
||||
needs_sync: false,
|
||||
leash: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1755,6 +1773,27 @@ impl Hub {
|
||||
}));
|
||||
}
|
||||
}
|
||||
// ServerEntity.sendDirtyEntityData: the air supply and the absorption
|
||||
// amount the server's body changed go to the player, whose HUD draws
|
||||
// them (bubbles, golden hearts).
|
||||
let g = &mut self.guests[i];
|
||||
if let Some(m) = g.motion.as_ref() {
|
||||
let now = (m.air, m.health.absorption());
|
||||
let mut values = Vec::new();
|
||||
if now.0 != g.sent_air.0 {
|
||||
values.push((1, serializer::INT, Value::Int(now.0)));
|
||||
}
|
||||
if now.1.to_bits() != g.sent_air.1.to_bits() {
|
||||
values.push((17, serializer::FLOAT, Value::Float(now.1)));
|
||||
}
|
||||
if !values.is_empty() {
|
||||
g.sent_air = now;
|
||||
g.send(&C::SetEntityData(play::SetEntityData {
|
||||
id: g.entity_id,
|
||||
values,
|
||||
}));
|
||||
}
|
||||
}
|
||||
// Sent when the health or the food level changes, or the saturation
|
||||
// reaches or leaves zero; a dead player's health of 0 too.
|
||||
let g = &mut self.guests[i];
|
||||
@@ -2465,9 +2504,11 @@ impl Hub {
|
||||
global: false,
|
||||
}));
|
||||
}
|
||||
// lastSentExp, lastSentHealth and lastSentFood.
|
||||
// lastSentExp, lastSentHealth and lastSentFood; the client's new
|
||||
// player starts with the default air and no absorption.
|
||||
g.sent_health = None;
|
||||
g.sent_xp = None;
|
||||
g.sent_air = (300, 0.0);
|
||||
let (cx, cz) = g.chunk();
|
||||
g.send(&C::SetChunkCacheCenter(play::SetChunkCacheCenter {
|
||||
x: cx,
|
||||
|
||||
@@ -61,6 +61,7 @@ pub fn apply(guest: &mut Guest, level: &Level) {
|
||||
guest.head_yaw = guest.yaw;
|
||||
guest.on_ground = level.on_ground;
|
||||
guest.health = level.health;
|
||||
guest.saved_absorption = Some(level.absorption);
|
||||
guest.food = level.food;
|
||||
guest.saturation = level.saturation;
|
||||
guest.xp_level = level.xp_level;
|
||||
@@ -71,7 +72,13 @@ pub fn apply(guest: &mut Guest, level: &Level) {
|
||||
guest.game_mode = level.mode();
|
||||
guest.flying = level.flying;
|
||||
guest.selected = (level.selected_slot.max(0) as usize).min(8);
|
||||
guest.spawn = level.spawn.map(|s| [s[0] + 0.5, s[1], s[2] + 0.5]);
|
||||
// A point that is the world spawn is none of the player's own: it
|
||||
// respawns wherever the world spawn is then (`setworldspawn`).
|
||||
guest.spawn = level
|
||||
.spawn
|
||||
.filter(|s| Some(s.map(|v| v.floor() as i32)) != level.world_spawn)
|
||||
.map(|s| [s[0] + 0.5, s[1], s[2] + 0.5]);
|
||||
guest.respawn_elsewhere = level.respawn_elsewhere.clone();
|
||||
guest.saved = level.player_extra.clone();
|
||||
guest.recipe_book = level.recipe_book.clone();
|
||||
guest.dimension = match level.dimension.as_str() {
|
||||
@@ -123,6 +130,12 @@ pub fn store(guest: &Guest, level: &mut Level) {
|
||||
level.dimension = guest.dimension.clone();
|
||||
level.rotation = [guest.yaw, guest.pitch];
|
||||
level.health = guest.health;
|
||||
level.absorption = guest
|
||||
.motion
|
||||
.as_ref()
|
||||
.map_or(guest.saved_absorption.unwrap_or(0.0), |m| {
|
||||
m.health.absorption()
|
||||
});
|
||||
level.food = guest.food;
|
||||
level.saturation = guest.saturation;
|
||||
level.xp_level = guest.xp_level;
|
||||
@@ -135,6 +148,7 @@ pub fn store(guest: &Guest, level: &mut Level) {
|
||||
level.flying = guest.flying;
|
||||
level.selected_slot = guest.selected as i32;
|
||||
level.spawn = guest.spawn.map(|s| [s[0] - 0.5, s[1], s[2] - 0.5]);
|
||||
level.respawn_elsewhere = guest.respawn_elsewhere.clone();
|
||||
level.on_ground = guest.on_ground;
|
||||
level.player_extra = guest.saved.clone();
|
||||
level.recipe_book = guest.recipe_book.clone();
|
||||
|
||||
@@ -104,6 +104,7 @@ pub mod landing;
|
||||
pub mod lang;
|
||||
pub mod level_loading;
|
||||
pub mod light;
|
||||
pub mod locator_bar;
|
||||
pub mod lod;
|
||||
pub mod logging;
|
||||
pub mod menu;
|
||||
@@ -132,6 +133,7 @@ pub mod osk;
|
||||
pub mod overlay;
|
||||
pub mod pack_screen;
|
||||
pub mod packs;
|
||||
pub mod par;
|
||||
pub mod particles;
|
||||
pub mod patrol;
|
||||
pub mod perf_overlay;
|
||||
@@ -179,12 +181,14 @@ pub mod ticket_levels;
|
||||
pub mod title;
|
||||
pub mod title_edition;
|
||||
pub mod toasts;
|
||||
pub mod trims;
|
||||
pub mod ui;
|
||||
pub mod ui_theme;
|
||||
pub mod vanillagen;
|
||||
pub mod vault;
|
||||
pub mod weather;
|
||||
pub mod widgets;
|
||||
pub mod win_screen;
|
||||
pub mod window;
|
||||
pub mod world;
|
||||
pub mod world_spawn;
|
||||
|
||||
@@ -0,0 +1,742 @@
|
||||
//! The locator bar: `ClientWaypointManager` (the waypoints the server
|
||||
//! tracks for this player, from `ClientboundTrackedWaypointPacket`),
|
||||
//! `WaypointStyleManager` (`waypoint_style/*.json`: near and far distances
|
||||
//! and the dot sprites between them) and `LocatorBar`, the contextual bar
|
||||
//! that draws a dot per waypoint within 60 degrees of the view, with an
|
||||
//! arrow when it is above or below the screen.
|
||||
use crate::hud_layout::{Blend, GuiDraw};
|
||||
use horizon_assets::animation::{AnimationClip, AnimationMetadata};
|
||||
use horizon_core::geometry::trig;
|
||||
use horizon_protocol::packets::play::{
|
||||
TrackedWaypoint, WaypointId, WaypointTarget, waypoint_operation,
|
||||
};
|
||||
use std::{borrow::Cow, collections::HashMap, fs, path::Path};
|
||||
|
||||
/// `WaypointStyle`: the sprites (under `hud/locator_bar_dot/`) from near to far.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct WaypointStyle {
|
||||
pub near_distance: i32,
|
||||
pub far_distance: i32,
|
||||
pub sprites: Vec<String>,
|
||||
}
|
||||
impl WaypointStyle {
|
||||
/// `WaypointStyleManager.MISSING`.
|
||||
fn missing() -> Self {
|
||||
Self {
|
||||
near_distance: 0,
|
||||
far_distance: 1,
|
||||
sprites: vec!["hud/locator_bar_dot/missingno".into()],
|
||||
}
|
||||
}
|
||||
/// `WaypointStyle.CODEC` and `validate`: None when the file is invalid.
|
||||
pub fn parse(json: &str) -> Option<Self> {
|
||||
let v: serde_json::Value = serde_json::from_str(json).ok()?;
|
||||
let distance = |key: &str, default: i32| match v.get(key) {
|
||||
None => Some(default),
|
||||
Some(d) => d
|
||||
.as_i64()
|
||||
.filter(|d| (0..=60_000_000).contains(d))
|
||||
.map(|d| d as i32),
|
||||
};
|
||||
let near_distance = distance("near_distance", 128)?;
|
||||
let far_distance = distance("far_distance", 332)?;
|
||||
let sprites = v
|
||||
.get("sprites")?
|
||||
.as_array()?
|
||||
.iter()
|
||||
.map(|s| {
|
||||
let id = s.as_str()?;
|
||||
let (namespace, path) = id.split_once(':').unwrap_or(("minecraft", id));
|
||||
// Sprites of the gui atlas are named by path (minecraft only).
|
||||
(namespace == "minecraft").then(|| format!("hud/locator_bar_dot/{path}"))
|
||||
})
|
||||
.collect::<Option<Vec<_>>>()?;
|
||||
(!sprites.is_empty() && near_distance > 0 && near_distance < far_distance).then_some(Self {
|
||||
near_distance,
|
||||
far_distance,
|
||||
sprites,
|
||||
})
|
||||
}
|
||||
/// `WaypointStyle.sprite`.
|
||||
pub fn sprite(&self, distance: f32) -> &str {
|
||||
let (first, last) = (&self.sprites[0], &self.sprites[self.sprites.len() - 1]);
|
||||
if distance < self.near_distance as f32 {
|
||||
return first;
|
||||
}
|
||||
if distance >= self.far_distance as f32 {
|
||||
return last;
|
||||
}
|
||||
match self.sprites.len() {
|
||||
1 => first,
|
||||
3 => &self.sprites[1],
|
||||
n => {
|
||||
let t = (distance - self.near_distance as f32)
|
||||
/ (self.far_distance - self.near_distance) as f32;
|
||||
// Mth.lerpInt(t, 1, n - 1).
|
||||
&self.sprites[(1 + (t * (n as f32 - 2.0)).floor() as i32) as usize]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// An animated GUI sprite (`.png.mcmeta` animation, ticked with the GUI
|
||||
/// atlas): its image and frame sizes and the frame sequence.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct SpriteFrames {
|
||||
image: [i32; 2],
|
||||
frame: [i32; 2],
|
||||
clip: AnimationClip,
|
||||
}
|
||||
impl SpriteFrames {
|
||||
/// None for a still sprite or invalid metadata.
|
||||
fn load(png: &Path) -> Option<Self> {
|
||||
let text = fs::read_to_string(png.with_extension("png.mcmeta")).ok()?;
|
||||
let meta: HashMap<String, Box<serde_json::value::RawValue>> =
|
||||
serde_json::from_str(&text).ok()?;
|
||||
let animation = AnimationMetadata::from_json(meta.get("animation")?.get()).ok()?;
|
||||
// The PNG header: width and height at bytes 16..24 of IHDR.
|
||||
let mut header = [0u8; 24];
|
||||
std::io::Read::read_exact(&mut fs::File::open(png).ok()?, &mut header).ok()?;
|
||||
let dim = |at: usize| i32::from_be_bytes(header[at..at + 4].try_into().unwrap_or_default());
|
||||
let image = [dim(16), dim(20)];
|
||||
let frame = animation.frame_size(image[0], image[1]);
|
||||
if frame[0] <= 0 || frame[1] <= 0 || image[0] % frame[0] != 0 || image[1] % frame[1] != 0 {
|
||||
return None;
|
||||
}
|
||||
let count = (image[0] / frame[0]) * (image[1] / frame[1]);
|
||||
let clip = animation.clip(count as u32).ok()??;
|
||||
Some(Self { image, frame, clip })
|
||||
}
|
||||
/// The `region` (image width, height, u, v) of the frame shown at `tick`.
|
||||
fn region(&self, tick: u64) -> [i32; 4] {
|
||||
let index = self.clip.sample(tick).frame as i32;
|
||||
let columns = self.image[0] / self.frame[0];
|
||||
[
|
||||
self.image[0],
|
||||
self.image[1],
|
||||
index % columns * self.frame[0],
|
||||
index / columns * self.frame[1],
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
/// `WaypointStyleManager`: every `assets/<namespace>/waypoint_style/*.json`,
|
||||
/// plus the animations of the bar's own sprites (the arrows blink).
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct WaypointStyles(
|
||||
HashMap<String, WaypointStyle>,
|
||||
HashMap<String, SpriteFrames>,
|
||||
);
|
||||
impl WaypointStyles {
|
||||
pub fn load(root: &Path) -> Self {
|
||||
let mut frames = HashMap::new();
|
||||
let hud = root.join("assets/minecraft/textures/gui/sprites/hud");
|
||||
let dots = fs::read_dir(hud.join("locator_bar_dot"))
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.flatten()
|
||||
.map(|e| e.path());
|
||||
let arrows = ["locator_bar_arrow_up", "locator_bar_arrow_down"]
|
||||
.map(|a| hud.join(format!("{a}.png")));
|
||||
for png in arrows.into_iter().chain(dots) {
|
||||
if png.extension().is_none_or(|e| e != "png") {
|
||||
continue;
|
||||
}
|
||||
let Some(f) = SpriteFrames::load(&png) else {
|
||||
continue;
|
||||
};
|
||||
let name = png
|
||||
.strip_prefix(hud.parent().unwrap_or(&hud))
|
||||
.map(|r| r.with_extension("").to_string_lossy().replace('\\', "/"))
|
||||
.unwrap_or_default();
|
||||
frames.insert(name, f);
|
||||
}
|
||||
let mut out = HashMap::new();
|
||||
for ns in fs::read_dir(root.join("assets"))
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.flatten()
|
||||
{
|
||||
let namespace = ns.file_name().to_string_lossy().into_owned();
|
||||
for f in fs::read_dir(ns.path().join("waypoint_style"))
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.flatten()
|
||||
{
|
||||
let path = f.path();
|
||||
if path.extension().is_none_or(|e| e != "json") {
|
||||
continue;
|
||||
}
|
||||
let name = path.file_stem().unwrap_or_default().to_string_lossy();
|
||||
if let Some(style) = fs::read_to_string(&path)
|
||||
.ok()
|
||||
.and_then(|t| WaypointStyle::parse(&t))
|
||||
{
|
||||
out.insert(format!("{namespace}:{name}"), style);
|
||||
} else {
|
||||
log::warn!("Horizon: invalid waypoint style {}", path.display());
|
||||
}
|
||||
}
|
||||
}
|
||||
Self(out, frames)
|
||||
}
|
||||
/// The sprite at `tick`, with its animation frame when it has one.
|
||||
fn sprite(&self, name: &str, x: i32, y: i32, w: i32, h: i32, color: u32, tick: u64) -> GuiDraw {
|
||||
let mut d = sprite(name, x, y, w, h, color);
|
||||
if let (Some(f), GuiDraw::Sprite { region, .. }) = (self.1.get(name), &mut d) {
|
||||
*region = Some(f.region(tick));
|
||||
}
|
||||
d
|
||||
}
|
||||
/// `get`: the style, or the missing one.
|
||||
pub fn get(&self, id: &str) -> Cow<'_, WaypointStyle> {
|
||||
let id = if id.contains(':') {
|
||||
Cow::Borrowed(id)
|
||||
} else {
|
||||
Cow::Owned(format!("minecraft:{id}"))
|
||||
};
|
||||
self.0
|
||||
.get(&*id)
|
||||
.map_or_else(|| Cow::Owned(WaypointStyle::missing()), Cow::Borrowed)
|
||||
}
|
||||
}
|
||||
|
||||
/// `ClientWaypointManager`: the tracked waypoints by identifier.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct ClientWaypoints {
|
||||
waypoints: Vec<TrackedWaypoint>,
|
||||
}
|
||||
impl ClientWaypoints {
|
||||
/// `ClientboundTrackedWaypointPacket.apply`.
|
||||
pub fn apply(&mut self, packet: &TrackedWaypoint) {
|
||||
let at = self.waypoints.iter().position(|w| w.id == packet.id);
|
||||
match packet.operation {
|
||||
waypoint_operation::TRACK => match at {
|
||||
Some(i) => self.waypoints[i] = packet.clone(),
|
||||
None => self.waypoints.push(packet.clone()),
|
||||
},
|
||||
waypoint_operation::UNTRACK => {
|
||||
if let Some(i) = at {
|
||||
self.waypoints.remove(i);
|
||||
}
|
||||
}
|
||||
// `TrackedWaypoint.update`: only the same kind of waypoint moves.
|
||||
_ => {
|
||||
if let Some(w) = at.map(|i| &mut self.waypoints[i]) {
|
||||
use WaypointTarget as T;
|
||||
if matches!(
|
||||
(w.target, packet.target),
|
||||
(T::Block(_), T::Block(_))
|
||||
| (T::Chunk(_), T::Chunk(_))
|
||||
| (T::Azimuth(_), T::Azimuth(_))
|
||||
) {
|
||||
w.target = packet.target;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
pub fn has_waypoints(&self) -> bool {
|
||||
!self.waypoints.is_empty()
|
||||
}
|
||||
pub fn clear(&mut self) {
|
||||
self.waypoints.clear();
|
||||
}
|
||||
}
|
||||
|
||||
/// What the locator bar needs of the camera and the world this frame.
|
||||
pub struct View<'a> {
|
||||
pub gui_width: i32,
|
||||
pub gui_height: i32,
|
||||
/// The camera entity's UUID, its block position and its position.
|
||||
pub camera_uuid: u128,
|
||||
pub camera_entity_pos: [f64; 3],
|
||||
/// `Camera.position()` and `yRot()`, `xRot()`, `getFov()` in degrees.
|
||||
pub camera_pos: [f64; 3],
|
||||
pub yaw: f32,
|
||||
pub pitch: f32,
|
||||
pub fov: f32,
|
||||
/// `Camera.getViewRotationProjectionMatrix`, applied to a point
|
||||
/// relative to the camera: normalised device coordinates.
|
||||
pub project: &'a dyn Fn([f64; 3]) -> [f64; 3],
|
||||
/// An entity with this UUID in the level: (block position, eye position).
|
||||
pub entity: &'a dyn Fn(u128) -> Option<([i32; 3], [f64; 3])>,
|
||||
pub styles: &'a WaypointStyles,
|
||||
/// Client ticks, for animated sprites.
|
||||
pub tick: u64,
|
||||
}
|
||||
|
||||
/// `TrackedWaypoint.PitchDirection`.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Pitch {
|
||||
None,
|
||||
Up,
|
||||
Down,
|
||||
}
|
||||
|
||||
/// `UUID.hashCode`.
|
||||
fn uuid_hash(uuid: u128) -> i32 {
|
||||
let hilo = (uuid >> 64) as u64 ^ uuid as u64;
|
||||
((hilo >> 32) as i32) ^ (hilo as i32)
|
||||
}
|
||||
/// `String.hashCode`.
|
||||
fn string_hash(s: &str) -> i32 {
|
||||
s.encode_utf16()
|
||||
.fold(0i32, |h, c| h.wrapping_mul(31).wrapping_add(i32::from(c)))
|
||||
}
|
||||
|
||||
/// `ARGB.setBrightness`.
|
||||
pub fn set_brightness(color: u32, brightness: f32) -> u32 {
|
||||
let [alpha, r, g, b] = color.to_be_bytes().map(i32::from);
|
||||
let max = r.max(g).max(b);
|
||||
let min = r.min(g).min(b);
|
||||
let range = (max - min) as f32;
|
||||
let saturation = if max != 0 { range / max as f32 } else { 0.0 };
|
||||
let argb =
|
||||
|r: i32, g: i32, b: i32| u32::from_be_bytes([alpha as u8, r as u8, g as u8, b as u8]);
|
||||
let round = |v: f32| (v * 255.0).round() as i32;
|
||||
if saturation == 0.0 {
|
||||
let v = round(brightness);
|
||||
return argb(v, v, v);
|
||||
}
|
||||
let (cr, cg, cb) = (
|
||||
(max - r) as f32 / range,
|
||||
(max - g) as f32 / range,
|
||||
(max - b) as f32 / range,
|
||||
);
|
||||
let mut hue = if r == max {
|
||||
cb - cg
|
||||
} else if g == max {
|
||||
2.0 + cr - cb
|
||||
} else {
|
||||
4.0 + cg - cr
|
||||
} / 6.0;
|
||||
if hue < 0.0 {
|
||||
hue += 1.0;
|
||||
}
|
||||
let segment = (hue - hue.floor()) * 6.0;
|
||||
let offset = segment - segment.floor();
|
||||
let primary = brightness * (1.0 - saturation);
|
||||
let secondary = brightness * (1.0 - saturation * offset);
|
||||
let tertiary = brightness * (1.0 - saturation * (1.0 - offset));
|
||||
let full = round(brightness);
|
||||
match segment as i32 {
|
||||
0 => argb(full, round(tertiary), round(primary)),
|
||||
1 => argb(round(secondary), full, round(primary)),
|
||||
2 => argb(round(primary), full, round(tertiary)),
|
||||
3 => argb(round(primary), round(secondary), full),
|
||||
4 => argb(round(tertiary), round(primary), full),
|
||||
5 => argb(full, round(primary), round(secondary)),
|
||||
_ => argb(r, g, b),
|
||||
}
|
||||
}
|
||||
|
||||
/// `Mth.wrapDegrees`.
|
||||
fn wrap_degrees(angle: f32) -> f32 {
|
||||
let mut a = angle % 360.0;
|
||||
if a >= 180.0 {
|
||||
a -= 360.0;
|
||||
}
|
||||
if a < -180.0 {
|
||||
a += 360.0;
|
||||
}
|
||||
a
|
||||
}
|
||||
|
||||
fn centre(v: [i32; 3]) -> [f64; 3] {
|
||||
v.map(|c| f64::from(c) + 0.5)
|
||||
}
|
||||
|
||||
impl View<'_> {
|
||||
/// `Vec3iWaypoint.position`: the tracked entity's eyes when it is within
|
||||
/// 3 blocks (Manhattan) of the waypoint, else the block's centre.
|
||||
fn block_position(&self, w: &TrackedWaypoint, v: [i32; 3]) -> [f64; 3] {
|
||||
if let WaypointId::Uuid(uuid) = w.id
|
||||
&& let Some((block, eyes)) = (self.entity)(uuid)
|
||||
{
|
||||
let manhattan: i32 = (0..3).map(|i| (block[i] - v[i]).abs()).sum();
|
||||
if manhattan <= 3 {
|
||||
return eyes;
|
||||
}
|
||||
}
|
||||
centre(v)
|
||||
}
|
||||
/// `yawAngleToCamera` of a point: `degreesDifference(camera yaw, the
|
||||
/// angle of camera - point turned clockwise by 90 degrees)`.
|
||||
fn yaw_to(&self, p: [f64; 3]) -> f64 {
|
||||
let c = self.camera_pos;
|
||||
// Vec3.rotateClockwise90: (-z, y, x).
|
||||
let (x, z) = (-(c[2] - p[2]), c[0] - p[0]);
|
||||
let angle = trig::atan2(z, x) as f32 * (180.0 / std::f32::consts::PI);
|
||||
f64::from(wrap_degrees(angle - self.yaw))
|
||||
}
|
||||
/// `ChunkPos.getMiddleBlockPosition(y)`'s centre.
|
||||
fn chunk_middle(chunk: [i32; 2], y: i32) -> [f64; 3] {
|
||||
centre([chunk[0] * 16 + 8, y, chunk[1] * 16 + 8])
|
||||
}
|
||||
fn yaw_angle(&self, w: &TrackedWaypoint) -> f64 {
|
||||
match w.target {
|
||||
WaypointTarget::Empty => f64::NAN,
|
||||
WaypointTarget::Block(v) => self.yaw_to(self.block_position(w, v)),
|
||||
WaypointTarget::Chunk(c) => {
|
||||
self.yaw_to(Self::chunk_middle(c, self.camera_pos[1] as i32))
|
||||
}
|
||||
WaypointTarget::Azimuth(a) => {
|
||||
f64::from(wrap_degrees(a * (180.0 / std::f32::consts::PI) - self.yaw))
|
||||
}
|
||||
}
|
||||
}
|
||||
/// `GameRenderer.projectHorizonToScreen`.
|
||||
fn horizon(&self) -> f64 {
|
||||
if self.pitch <= -90.0 {
|
||||
return f64::NEG_INFINITY;
|
||||
}
|
||||
if self.pitch >= 90.0 {
|
||||
return f64::INFINITY;
|
||||
}
|
||||
// Math.tan of the float products, in double.
|
||||
let rad = std::f32::consts::PI / 180.0;
|
||||
f64::from(self.pitch * rad).tan() / f64::from(self.fov / 2.0 * rad).tan()
|
||||
}
|
||||
fn pitch_direction(&self, w: &TrackedWaypoint) -> Pitch {
|
||||
let by = |y: f64| {
|
||||
if y < -1.0 {
|
||||
Pitch::Down
|
||||
} else if y > 1.0 {
|
||||
Pitch::Up
|
||||
} else {
|
||||
Pitch::None
|
||||
}
|
||||
};
|
||||
match w.target {
|
||||
WaypointTarget::Empty => Pitch::None,
|
||||
WaypointTarget::Chunk(_) | WaypointTarget::Azimuth(_) => by(self.horizon()),
|
||||
WaypointTarget::Block(v) => {
|
||||
let p = self.block_position(w, v);
|
||||
let c = self.camera_pos;
|
||||
let s = (self.project)([p[0] - c[0], p[1] - c[1], p[2] - c[2]]);
|
||||
let behind = s[2] > 1.0;
|
||||
match by(if behind { -s[1] } else { s[1] }) {
|
||||
Pitch::None if behind && s[1] > 0.0 => Pitch::Up,
|
||||
Pitch::None if behind && s[1] < 0.0 => Pitch::Down,
|
||||
p => p,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
/// `distanceSquared` from the camera entity.
|
||||
fn distance_sq(&self, w: &TrackedWaypoint) -> f64 {
|
||||
let e = self.camera_entity_pos;
|
||||
let d = |p: [f64; 3]| (0..3).map(|i| (p[i] - e[i]).powi(2)).sum::<f64>();
|
||||
match w.target {
|
||||
WaypointTarget::Block(v) => d(centre(v)),
|
||||
WaypointTarget::Chunk(c) => d(Self::chunk_middle(c, e[1].floor() as i32)),
|
||||
WaypointTarget::Empty | WaypointTarget::Azimuth(_) => f64::INFINITY,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `LocatorBar.extractBackground`: the nine-sliced sprite and its rectangle.
|
||||
pub fn background(gui_width: i32, gui_height: i32) -> (&'static str, [i32; 4]) {
|
||||
(
|
||||
"gui/sprites/hud/locator_bar_background",
|
||||
[gui_width / 2 - 91, gui_height - 32 + 3, 182, 5],
|
||||
)
|
||||
}
|
||||
|
||||
fn sprite(name: &str, x: i32, y: i32, w: i32, h: i32, color: u32) -> GuiDraw {
|
||||
GuiDraw::Sprite {
|
||||
sprite: Cow::Owned(name.to_owned()),
|
||||
x,
|
||||
y,
|
||||
w,
|
||||
h,
|
||||
region: None,
|
||||
color,
|
||||
blend: Blend::Textured,
|
||||
}
|
||||
}
|
||||
|
||||
impl ClientWaypoints {
|
||||
/// `LocatorBar.extractRenderState`: the farthest waypoint first.
|
||||
pub fn draw(&self, view: &View) -> Vec<GuiDraw> {
|
||||
let top = view.gui_height - 32 + 3;
|
||||
let mut sorted: Vec<(f64, &TrackedWaypoint)> = self
|
||||
.waypoints
|
||||
.iter()
|
||||
.map(|w| (view.distance_sq(w), w))
|
||||
.collect();
|
||||
sorted.sort_by(|a, b| b.0.total_cmp(&a.0));
|
||||
let mut out = Vec::new();
|
||||
for (distance_sq, w) in sorted {
|
||||
if w.id == WaypointId::Uuid(view.camera_uuid) {
|
||||
continue;
|
||||
}
|
||||
let angle = view.yaw_angle(w);
|
||||
// `!(angle <= -60) && !(angle > 60)`: NaN draws, at the centre.
|
||||
if angle <= -60.0 || angle > 60.0 {
|
||||
continue;
|
||||
}
|
||||
let middle = ((view.gui_width - 9) as f32 / 2.0).ceil() as i32;
|
||||
let style = view.styles.get(&w.style);
|
||||
let distance = (distance_sq as f32).sqrt();
|
||||
let color = w.color.map_or_else(
|
||||
|| {
|
||||
let hash = match &w.id {
|
||||
WaypointId::Uuid(u) => uuid_hash(*u),
|
||||
WaypointId::Name(n) => string_hash(n),
|
||||
};
|
||||
set_brightness(0xFF00_0000 | hash as u32, 0.9)
|
||||
},
|
||||
|c| c as u32,
|
||||
);
|
||||
let dot = (angle * 173.0 / 2.0 / 60.0).floor() as i32;
|
||||
out.push(view.styles.sprite(
|
||||
style.sprite(distance),
|
||||
middle + dot,
|
||||
top - 2,
|
||||
9,
|
||||
9,
|
||||
color,
|
||||
view.tick,
|
||||
));
|
||||
let arrow = match view.pitch_direction(w) {
|
||||
Pitch::None => continue,
|
||||
Pitch::Down => (6, "hud/locator_bar_arrow_down"),
|
||||
Pitch::Up => (-6, "hud/locator_bar_arrow_up"),
|
||||
};
|
||||
out.push(view.styles.sprite(
|
||||
arrow.1,
|
||||
middle + dot + 1,
|
||||
top + arrow.0,
|
||||
7,
|
||||
5,
|
||||
u32::MAX,
|
||||
view.tick,
|
||||
));
|
||||
}
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
/// `Hud.ContextualInfo`: which bar sits above the hotbar.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum ContextualBar {
|
||||
Empty,
|
||||
Experience,
|
||||
Locator,
|
||||
JumpableVehicle,
|
||||
}
|
||||
impl ContextualBar {
|
||||
/// `Hud.nextContextualInfoState`: `recent_experience` is
|
||||
/// `willPrioritizeExperienceInfo` (experience changed in the last 100
|
||||
/// ticks), `jumping` is `willPrioritizeJumpInfo`.
|
||||
pub fn next(
|
||||
waypoints: bool,
|
||||
jumpable_vehicle: bool,
|
||||
jumping: bool,
|
||||
has_experience: bool,
|
||||
recent_experience: bool,
|
||||
) -> Self {
|
||||
if waypoints {
|
||||
if jumpable_vehicle && jumping {
|
||||
Self::JumpableVehicle
|
||||
} else if has_experience && recent_experience {
|
||||
Self::Experience
|
||||
} else {
|
||||
Self::Locator
|
||||
}
|
||||
} else if jumpable_vehicle {
|
||||
Self::JumpableVehicle
|
||||
} else if has_experience {
|
||||
Self::Experience
|
||||
} else {
|
||||
Self::Empty
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn tracked(operation: i32, id: u128, target: WaypointTarget) -> TrackedWaypoint {
|
||||
TrackedWaypoint {
|
||||
operation,
|
||||
id: WaypointId::Uuid(id),
|
||||
style: "minecraft:default".into(),
|
||||
color: None,
|
||||
target,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn styles_pick_sprites_by_distance() {
|
||||
let default = WaypointStyle::parse(
|
||||
r#"{"sprites":["minecraft:default_0","minecraft:default_1","minecraft:default_2","minecraft:default_3"]}"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!((default.near_distance, default.far_distance), (128, 332));
|
||||
assert_eq!(default.sprite(10.0), "hud/locator_bar_dot/default_0");
|
||||
assert_eq!(default.sprite(128.0), "hud/locator_bar_dot/default_1");
|
||||
// lerpInt((d - 128) / 204, 1, 3): halfway is the third sprite.
|
||||
assert_eq!(default.sprite(229.0), "hud/locator_bar_dot/default_1");
|
||||
assert_eq!(default.sprite(230.0), "hud/locator_bar_dot/default_2");
|
||||
assert_eq!(default.sprite(332.0), "hud/locator_bar_dot/default_3");
|
||||
let bowtie = WaypointStyle::parse(
|
||||
r#"{"near_distance":64,"sprites":["minecraft:bowtie","minecraft:default_0","minecraft:default_1","minecraft:default_2","minecraft:default_3"]}"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(bowtie.sprite(63.0), "hud/locator_bar_dot/bowtie");
|
||||
// lerpInt((d - 64) / 268, 1, 4).
|
||||
assert_eq!(bowtie.sprite(64.0), "hud/locator_bar_dot/default_0");
|
||||
assert_eq!(bowtie.sprite(200.0), "hud/locator_bar_dot/default_1");
|
||||
assert_eq!(bowtie.sprite(300.0), "hud/locator_bar_dot/default_2");
|
||||
// Three sprites: the middle one between near and far.
|
||||
let three = WaypointStyle::parse(r#"{"sprites":["a","b","c"]}"#).unwrap();
|
||||
assert_eq!(three.sprite(200.0), "hud/locator_bar_dot/b");
|
||||
// validate: sprites, near > 0, far > near.
|
||||
assert!(WaypointStyle::parse(r#"{"sprites":[]}"#).is_none());
|
||||
assert!(WaypointStyle::parse(r#"{"near_distance":0,"sprites":["a"]}"#).is_none());
|
||||
assert!(WaypointStyle::parse(r#"{"near_distance":400,"sprites":["a"]}"#).is_none());
|
||||
assert_eq!(
|
||||
WaypointStyles::default().get("minecraft:none").sprite(5.0),
|
||||
"hud/locator_bar_dot/missingno"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn packets_track_update_and_untrack() {
|
||||
let mut w = ClientWaypoints::default();
|
||||
w.apply(&tracked(
|
||||
waypoint_operation::TRACK,
|
||||
1,
|
||||
WaypointTarget::Block([1, 2, 3]),
|
||||
));
|
||||
assert!(w.has_waypoints());
|
||||
w.apply(&tracked(
|
||||
waypoint_operation::UPDATE,
|
||||
1,
|
||||
WaypointTarget::Block([4, 5, 6]),
|
||||
));
|
||||
assert_eq!(w.waypoints[0].target, WaypointTarget::Block([4, 5, 6]));
|
||||
// Another kind of waypoint doesn't update it.
|
||||
w.apply(&tracked(
|
||||
waypoint_operation::UPDATE,
|
||||
1,
|
||||
WaypointTarget::Azimuth(1.0),
|
||||
));
|
||||
assert_eq!(w.waypoints[0].target, WaypointTarget::Block([4, 5, 6]));
|
||||
w.apply(&tracked(
|
||||
waypoint_operation::UNTRACK,
|
||||
1,
|
||||
WaypointTarget::Empty,
|
||||
));
|
||||
assert!(!w.has_waypoints());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn brightness_like_argb() {
|
||||
// Pure red at 0.9: Math.round(0.9 * 255) = 230.
|
||||
assert_eq!(set_brightness(0xFFFF_0000, 0.9), 0xFFE6_0000);
|
||||
assert_eq!(set_brightness(0xFF80_8080, 0.9), 0xFFE6_E6E6);
|
||||
assert_eq!(uuid_hash(0x0000_0001_0000_0000_0000_0000_0000_0002), 3);
|
||||
assert_eq!(string_hash("ab"), 97 * 31 + 98);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn arrows_blink_through_their_frames() {
|
||||
// locator_bar_arrow_up.png.mcmeta: 7x10, two 5-pixel frames.
|
||||
let meta = r#"{"frames":[{"index":0,"time":10},{"index":1,"time":4}],"height":5}"#;
|
||||
let animation = AnimationMetadata::from_json(meta).unwrap();
|
||||
let frames = SpriteFrames {
|
||||
image: [7, 10],
|
||||
frame: animation.frame_size(7, 10),
|
||||
clip: animation.clip(2).unwrap().unwrap(),
|
||||
};
|
||||
assert_eq!(frames.region(0), [7, 10, 0, 0]);
|
||||
assert_eq!(frames.region(9), [7, 10, 0, 0]);
|
||||
assert_eq!(frames.region(10), [7, 10, 0, 5]);
|
||||
assert_eq!(frames.region(14), [7, 10, 0, 0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dots_sit_on_the_bar_by_angle() {
|
||||
let styles = WaypointStyles(
|
||||
[(
|
||||
"minecraft:default".to_owned(),
|
||||
WaypointStyle {
|
||||
near_distance: 128,
|
||||
far_distance: 332,
|
||||
sprites: vec!["hud/locator_bar_dot/default_0".into()],
|
||||
},
|
||||
)]
|
||||
.into(),
|
||||
HashMap::new(),
|
||||
);
|
||||
let project = |p: [f64; 3]| [p[0], p[1], 0.5];
|
||||
let entity = |_: u128| None;
|
||||
let view = View {
|
||||
gui_width: 427,
|
||||
gui_height: 240,
|
||||
camera_uuid: 9,
|
||||
camera_entity_pos: [0.5, 64.0, 0.5],
|
||||
camera_pos: [0.5, 65.62, 0.5],
|
||||
yaw: 0.0,
|
||||
pitch: 0.0,
|
||||
fov: 70.0,
|
||||
project: &project,
|
||||
entity: &entity,
|
||||
styles: &styles,
|
||||
tick: 0,
|
||||
};
|
||||
let mut w = ClientWaypoints::default();
|
||||
// Yaw 0 looks towards +z: a waypoint straight ahead sits in the middle.
|
||||
w.apply(&tracked(
|
||||
waypoint_operation::TRACK,
|
||||
1,
|
||||
WaypointTarget::Block([0, 65, 40]),
|
||||
));
|
||||
// One behind the camera and the camera's own waypoint are not drawn.
|
||||
w.apply(&tracked(
|
||||
waypoint_operation::TRACK,
|
||||
2,
|
||||
WaypointTarget::Block([0, 65, -40]),
|
||||
));
|
||||
w.apply(&tracked(
|
||||
waypoint_operation::TRACK,
|
||||
9,
|
||||
WaypointTarget::Block([0, 65, 40]),
|
||||
));
|
||||
let draws = w.draw(&view);
|
||||
assert_eq!(draws.len(), 1);
|
||||
let GuiDraw::Sprite { x, y, .. } = draws[0] else {
|
||||
panic!("{draws:?}");
|
||||
};
|
||||
assert_eq!((x, y), (209, 209));
|
||||
// 30 degrees to the right: floor(30 * 173 / 120) = 43 pixels.
|
||||
let east = ClientWaypoints {
|
||||
waypoints: vec![tracked(
|
||||
waypoint_operation::TRACK,
|
||||
3,
|
||||
WaypointTarget::Azimuth(30f32.to_radians()),
|
||||
)],
|
||||
};
|
||||
let GuiDraw::Sprite { x, .. } = east.draw(&view)[0] else {
|
||||
panic!();
|
||||
};
|
||||
assert_eq!(x, 209 + 43);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn contextual_bar_priorities() {
|
||||
use ContextualBar as B;
|
||||
assert_eq!(B::next(true, false, false, true, false), B::Locator);
|
||||
assert_eq!(B::next(true, false, false, true, true), B::Experience);
|
||||
assert_eq!(B::next(true, true, true, true, true), B::JumpableVehicle);
|
||||
assert_eq!(B::next(false, false, false, true, true), B::Experience);
|
||||
assert_eq!(B::next(false, false, false, false, false), B::Empty);
|
||||
// Creative players see the bar too.
|
||||
assert_eq!(B::next(true, false, false, false, false), B::Locator);
|
||||
}
|
||||
}
|
||||
@@ -326,14 +326,17 @@ struct Plan {
|
||||
impl Plan {
|
||||
#[cfg(test)]
|
||||
fn new(camera: [i32; 2], detail: i32, distance: u32) -> Self {
|
||||
Self::with_previous(camera, detail, distance, &Self::default(), 0)
|
||||
Self::with_previous(camera, detail, distance, &Self::default(), 0, false)
|
||||
}
|
||||
/// `reduced`: Reduced far detail (`graphics::FarDetail`), each level from
|
||||
/// half the distance.
|
||||
fn with_previous(
|
||||
camera: [i32; 2],
|
||||
detail: i32,
|
||||
distance: u32,
|
||||
previous: &Self,
|
||||
elevation: i32,
|
||||
reduced: bool,
|
||||
) -> Self {
|
||||
let range = distance.min(1024) as i32 * 16;
|
||||
let mut level = 0;
|
||||
@@ -349,25 +352,35 @@ impl Plan {
|
||||
for x in (camera[0] - range).div_euclid(size)..=(camera[0] + range).div_euclid(size) {
|
||||
let key = TileKey { x, z, level };
|
||||
result.roots.push(key);
|
||||
result.visit(key, camera, detail, range, previous);
|
||||
result.visit(key, camera, detail, range, previous, reduced);
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
fn visit(&mut self, key: TileKey, camera: [i32; 2], detail: i32, range: i32, previous: &Self) {
|
||||
fn visit(
|
||||
&mut self,
|
||||
key: TileKey,
|
||||
camera: [i32; 2],
|
||||
detail: i32,
|
||||
range: i32,
|
||||
previous: &Self,
|
||||
reduced: bool,
|
||||
) {
|
||||
if key.distance(camera) > range {
|
||||
return;
|
||||
}
|
||||
self.nodes.insert(key);
|
||||
// Keep distant cells small on screen; finest tiles retain individual blocks.
|
||||
let threshold = key.size() * if previous.split.contains(&key) { 10 } else { 8 };
|
||||
// Keep distant cells small on screen; finest tiles retain individual
|
||||
// blocks. Reduced keeps them twice as large (a quarter of the cells).
|
||||
let split = if previous.split.contains(&key) { 10 } else { 8 };
|
||||
let threshold = key.size() * if reduced { split / 2 } else { split };
|
||||
if key.level > 0
|
||||
&& (key.distance(camera) < detail * 16 + 16
|
||||
|| key.distance(camera).max(self.elevation) < threshold)
|
||||
{
|
||||
self.split.insert(key);
|
||||
for child in key.children() {
|
||||
self.visit(child, camera, detail, range, previous);
|
||||
self.visit(child, camera, detail, range, previous, reduced);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -517,8 +530,10 @@ pub struct Lod {
|
||||
/// The plan or the covered columns changed: covered leaves need stand-ins.
|
||||
replanned: bool,
|
||||
invalidated: FxHashSet<(i32, i32)>,
|
||||
area: Option<([i32; 2], i32, u32, i32)>,
|
||||
area: Option<([i32; 2], i32, u32, i32, bool)>,
|
||||
plan: Plan,
|
||||
/// Reduced far detail (`set_reduced_detail`).
|
||||
reduced_detail: bool,
|
||||
pending: FxHashSet<TileKey>,
|
||||
sample_nearby: bool,
|
||||
detail_masks: FxHashMap<TileKey, [u64; 4]>,
|
||||
@@ -714,6 +729,7 @@ impl Lod {
|
||||
replanned: true,
|
||||
invalidated: FxHashSet::default(),
|
||||
area: None,
|
||||
reduced_detail: false,
|
||||
plan: Plan::default(),
|
||||
pending: FxHashSet::default(),
|
||||
sample_nearby: true,
|
||||
@@ -930,6 +946,11 @@ impl Lod {
|
||||
pub fn set_near_loading(&mut self, loading: bool) {
|
||||
self.near_loading = loading;
|
||||
}
|
||||
/// Reduced far detail (`graphics::FarDetail::Reduced`): the next update
|
||||
/// replans when it changes.
|
||||
pub fn set_reduced_detail(&mut self, reduced: bool) {
|
||||
self.reduced_detail = reduced;
|
||||
}
|
||||
fn held(&self) -> bool {
|
||||
self.small && self.near_loading
|
||||
}
|
||||
@@ -1021,11 +1042,19 @@ impl Lod {
|
||||
detail,
|
||||
distance,
|
||||
elevation,
|
||||
self.reduced_detail,
|
||||
);
|
||||
if self.area != Some(area) {
|
||||
crate::spans::time("lod_plan", || {
|
||||
self.area = Some(area);
|
||||
self.plan = Plan::with_previous(camera, detail, distance, &self.plan, elevation);
|
||||
self.plan = Plan::with_previous(
|
||||
camera,
|
||||
detail,
|
||||
distance,
|
||||
&self.plan,
|
||||
elevation,
|
||||
self.reduced_detail,
|
||||
);
|
||||
self.ready.retain(|key, _| self.plan.nodes.contains(key));
|
||||
self.detail_masks
|
||||
.retain(|key, _| self.plan.nodes.contains(key));
|
||||
@@ -1421,6 +1450,35 @@ mod tests {
|
||||
use super::*;
|
||||
use crate::mesher::TerrainVertex;
|
||||
#[test]
|
||||
fn reduced_far_detail_only_merges_tiles_away_from_the_near_terrain() {
|
||||
let leaves = |plan: &Plan| {
|
||||
plan.nodes
|
||||
.iter()
|
||||
.filter(|k| !plan.split.contains(k))
|
||||
.count()
|
||||
};
|
||||
for camera in [[0, 0], [100, -40]] {
|
||||
for distance in [64, 128, 512, 1024] {
|
||||
let full = Plan::new(camera, 12, distance);
|
||||
let reduced = Plan::with_previous(camera, 12, distance, &Plan::default(), 0, true);
|
||||
// Never finer than Full anywhere, and as fine next to the
|
||||
// full-detail area, whose edge the finest tiles match.
|
||||
assert!(reduced.split.is_subset(&full.split));
|
||||
assert_eq!(reduced.roots, full.roots);
|
||||
for key in reduced.nodes.iter().filter(|k| !reduced.split.contains(k)) {
|
||||
if key.distance(camera) < 12 * 16 + 16 {
|
||||
assert_eq!(key.level, 0, "{key:?}");
|
||||
}
|
||||
}
|
||||
let (full, reduced) = (leaves(&full), leaves(&reduced));
|
||||
assert!(
|
||||
reduced * 2 < full,
|
||||
"{reduced} of {full} tiles at {distance}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn far_plan_is_bounded_and_near_tiles_align_with_chunks() {
|
||||
for camera in [[0, 0], [-1, -17], [1023, -4097]] {
|
||||
for distance in [64, 256, 1024] {
|
||||
@@ -1453,7 +1511,7 @@ mod tests {
|
||||
#[test]
|
||||
fn aerial_distance_avoids_unseen_full_detail_outside_the_near_area() {
|
||||
let ground = Plan::new([0, 0], 12, 1024);
|
||||
let high = Plan::with_previous([0, 0], 12, 1024, &Plan::default(), 1024);
|
||||
let high = Plan::with_previous([0, 0], 12, 1024, &Plan::default(), 1024, false);
|
||||
assert!(high.nodes.len() < ground.nodes.len());
|
||||
assert!(
|
||||
high.nodes.contains(&TileKey {
|
||||
@@ -1505,7 +1563,7 @@ mod tests {
|
||||
#[test]
|
||||
fn detail_survives_movement_across_a_split_threshold() {
|
||||
let before = Plan::new([0, 0], 2, 256);
|
||||
let after = Plan::with_previous([16, 0], 2, 256, &before, 0);
|
||||
let after = Plan::with_previous([16, 0], 2, 256, &before, 0, false);
|
||||
let mut retained = 0;
|
||||
for key in &before.split {
|
||||
if key.distance([16, 0]) < key.size() * 10 {
|
||||
@@ -1804,7 +1862,7 @@ mod tests {
|
||||
// Exercise the actual worker/update seam: a parent stays displayed
|
||||
// until all four child meshes can replace it in the same update.
|
||||
let camera = [-256.0, 100.0, -256.0];
|
||||
lod.area = Some(([-16, -16], 2, 64, 0));
|
||||
lod.area = Some(([-16, -16], 2, 64, 0, false));
|
||||
lod.plan.roots = vec![key];
|
||||
lod.plan.nodes.insert(key);
|
||||
// As a replan does (`update` only selects after a change).
|
||||
|
||||
@@ -792,6 +792,9 @@ pub struct Mob {
|
||||
pub on_ground: bool,
|
||||
pub horizontal_collision: bool,
|
||||
pub vertical_collision_below: bool,
|
||||
/// `Entity.needsSync`: knocked back, pushed or jumped since its server
|
||||
/// last sent it, so the next update goes out at once.
|
||||
pub needs_sync: bool,
|
||||
pub health: f32,
|
||||
pub hurt_time: i32,
|
||||
pub invulnerable: i32,
|
||||
@@ -1361,8 +1364,10 @@ impl PendingPushes {
|
||||
}
|
||||
/// Adds the pending pushes aimed at `m` to its velocity, in push order.
|
||||
pub fn apply_mob_pushes(m: &mut Mob, pending: &mut PendingPushes) {
|
||||
// Entity.push(x, y, z) sets needsSync.
|
||||
let mut add = |v: Vec3| {
|
||||
m.velocity = Vec3::new(m.velocity.x + v.x, m.velocity.y + v.y, m.velocity.z + v.z);
|
||||
m.needs_sync = true;
|
||||
};
|
||||
if !pending.list.is_empty() {
|
||||
pending.list.retain(|(id, v)| {
|
||||
@@ -1529,6 +1534,7 @@ impl Mob {
|
||||
on_ground: false,
|
||||
horizontal_collision: false,
|
||||
vertical_collision_below: false,
|
||||
needs_sync: false,
|
||||
hurt_time: 0,
|
||||
invulnerable: 0,
|
||||
death_time: 0,
|
||||
@@ -1769,6 +1775,7 @@ impl Mob {
|
||||
if power <= 0.0 {
|
||||
return;
|
||||
}
|
||||
self.needs_sync = true;
|
||||
while xd * xd + zd * zd < f64::from(1.0e-5_f32) {
|
||||
xd = (self.random.next_double() - self.random.next_double()) * 0.01;
|
||||
zd = (self.random.next_double() - self.random.next_double()) * 0.01;
|
||||
|
||||
@@ -832,6 +832,9 @@ impl Mob {
|
||||
};
|
||||
}
|
||||
pub fn jump_from_ground(&mut self, w: &MobWorld) {
|
||||
// LivingEntity.jumpFromGround (and the rabbit's and cubes' own,
|
||||
// which call it or do the same): needsSync.
|
||||
self.needs_sync = true;
|
||||
if self.species() == super::Species::Rabbit {
|
||||
super::rabbit::jump(self, w);
|
||||
return;
|
||||
|
||||
@@ -351,6 +351,9 @@ fn spawns_far_from_player(s: Species, category: Category) -> bool {
|
||||
}
|
||||
|
||||
/// What natural spawning reads from a level beyond the spawn rules.
|
||||
/// An entity type's species, width, height and category.
|
||||
pub type MobType = (Species, f32, f32, Category);
|
||||
|
||||
pub trait NaturalLevel: SpawnLevel {
|
||||
fn min_y(&self) -> i32;
|
||||
/// `getMaxY()`: the highest buildable block.
|
||||
@@ -359,7 +362,7 @@ pub trait NaturalLevel: SpawnLevel {
|
||||
fn ambient_light(&self) -> f32;
|
||||
fn has_ceiling(&self) -> bool;
|
||||
/// The entity type's species, width, height and category.
|
||||
fn mob_type(&self, entity: &str) -> Option<(Species, f32, f32, Category)>;
|
||||
fn mob_type(&self, entity: &str) -> Option<MobType>;
|
||||
/// The `natural_mob_spawns` lists by category at a position.
|
||||
fn biome_spawns(&self, x: i32, y: i32, z: i32) -> Option<&HashMap<String, Vec<SpawnEntry>>>;
|
||||
/// The `natural_mob_spawns` spawn cost of a type at a position.
|
||||
@@ -376,9 +379,9 @@ pub trait NaturalLevel: SpawnLevel {
|
||||
wanted: &dyn Fn(&str) -> bool,
|
||||
) -> Vec<StructureAt>;
|
||||
fn structure_spawns(&self) -> &StructureSpawns;
|
||||
/// `getNearestPlayer(x, y, z, -1, false)`: the squared distance to the
|
||||
/// nearest player that is not a spectator.
|
||||
fn nearest_player_distance_sqr(&self, x: f64, y: f64, z: f64) -> Option<f64>;
|
||||
/// Where the players `getNearestPlayer(x, y, z, -1, false)` measures to
|
||||
/// are: every one that is not a spectator.
|
||||
fn nearest_player_positions(&self) -> &[[f64; 3]];
|
||||
/// The world spawn point, when it is in this dimension.
|
||||
fn respawn(&self) -> Option<[i32; 3]>;
|
||||
/// `ServerLevel.canSpawnEntitiesInChunk`.
|
||||
@@ -870,9 +873,13 @@ impl SpawnState {
|
||||
/// `ChunkMap.getPlayersCloseForSpawning`: none past the natural spawn
|
||||
/// chunk tracker's range of 8 chunks from every player, else the
|
||||
/// non-spectators within 128 blocks of the chunk's centre.
|
||||
fn players_near(&mut self, cx: i32, cz: i32) -> &[usize] {
|
||||
let players = &self.players;
|
||||
self.players_near.entry((cx, cz)).or_insert_with(|| {
|
||||
fn players_near<'a>(
|
||||
near: &'a mut FxHashMap<(i32, i32), Vec<usize>>,
|
||||
players: &[SpawnPlayer],
|
||||
cx: i32,
|
||||
cz: i32,
|
||||
) -> &'a [usize] {
|
||||
near.entry((cx, cz)).or_insert_with(|| {
|
||||
let tracked = players.iter().any(|p| {
|
||||
let (px, pz) = (
|
||||
(p.position[0].floor() as i32) >> 4,
|
||||
@@ -896,8 +903,7 @@ impl SpawnState {
|
||||
})
|
||||
}
|
||||
fn add_local(&mut self, cx: i32, cz: i32, category: Category) {
|
||||
let near = self.players_near(cx, cz).to_vec();
|
||||
for p in near {
|
||||
for &p in Self::players_near(&mut self.players_near, &self.players, cx, cz) {
|
||||
self.player_counts.entry(p).or_insert([0; 8])[category.index()] += 1;
|
||||
}
|
||||
}
|
||||
@@ -908,10 +914,10 @@ impl SpawnState {
|
||||
}
|
||||
/// `canSpawnForCategoryLocal` (`LocalMobCapCalculator.canSpawn`).
|
||||
pub fn can_spawn_local(&mut self, category: Category, cx: i32, cz: i32) -> bool {
|
||||
let near = self.players_near(cx, cz).to_vec();
|
||||
near.into_iter().any(|p| {
|
||||
let near = Self::players_near(&mut self.players_near, &self.players, cx, cz);
|
||||
near.iter().any(|p| {
|
||||
self.player_counts
|
||||
.get(&p)
|
||||
.get(p)
|
||||
.is_none_or(|c| c[category.index()] < category.max())
|
||||
})
|
||||
}
|
||||
@@ -1080,9 +1086,11 @@ pub fn spawn_category_for_position<L: NaturalLevel, R: RandomSource>(
|
||||
}
|
||||
let mut spawned: Vec<Aabb> = Vec::new();
|
||||
let mut cluster = 0u32;
|
||||
let mut nearest = Nearest::new(start);
|
||||
for _ in 0..3 {
|
||||
let (mut x, mut z) = (x0, z0);
|
||||
let mut current: Option<SpawnEntry> = None;
|
||||
// The group's entry, its type and where it was picked.
|
||||
let mut current: Option<(SpawnEntry, Option<MobType>, [i32; 3])> = None;
|
||||
let mut group_index = 0u32;
|
||||
let mut max = (random.next_float() * 4.0).ceil() as i32;
|
||||
let mut ll = 0;
|
||||
@@ -1092,16 +1100,15 @@ pub fn spawn_category_for_position<L: NaturalLevel, R: RandomSource>(
|
||||
z += random.next_int_bound(6).unwrap_or(0) - random.next_int_bound(6).unwrap_or(0);
|
||||
let (xx, zz) = (f64::from(x) + 0.5, f64::from(z) + 0.5);
|
||||
trace(Event::Try(x, z));
|
||||
let Some(distance) = l.nearest_player_distance_sqr(xx, f64::from(y), zz) else {
|
||||
let Some(distance) = nearest.distance_sqr(l, xx, f64::from(y), zz) else {
|
||||
continue;
|
||||
};
|
||||
if !right_distance(l, chunk, [x, y, z], distance) {
|
||||
continue;
|
||||
}
|
||||
if current.is_none() {
|
||||
let reduced = l.biome_tag("minecraft:reduce_water_ambient_spawns", x, y, z);
|
||||
let list = if category == Category::WaterAmbient
|
||||
&& reduced
|
||||
&& l.biome_tag("minecraft:reduce_water_ambient_spawns", x, y, z)
|
||||
&& random.next_float() < 0.98
|
||||
{
|
||||
None
|
||||
@@ -1112,21 +1119,32 @@ pub fn spawn_category_for_position<L: NaturalLevel, R: RandomSource>(
|
||||
break;
|
||||
};
|
||||
max = sample_count(&entry, random) as i32;
|
||||
current = Some(entry);
|
||||
let kind = l.mob_type(&entry.entity);
|
||||
current = Some((entry, kind, [x, y, z]));
|
||||
}
|
||||
let entry = current.as_ref().unwrap_or_else(|| unreachable!());
|
||||
if !valid_position_for_type(l, random, category, entry, [x, y, z], distance) {
|
||||
let (entry, kind, picked_at) = current.as_ref().unwrap_or_else(|| unreachable!());
|
||||
// The list the entry was picked from holds it where it was picked.
|
||||
let listed = *picked_at == [x, y, z];
|
||||
let valid = valid_position_for_type(
|
||||
l,
|
||||
random,
|
||||
category,
|
||||
entry,
|
||||
*kind,
|
||||
listed,
|
||||
[x, y, z],
|
||||
distance,
|
||||
);
|
||||
let Some((species, width, height, type_category)) = (*kind).filter(|_| valid) else {
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let passed = state.can_spawn(l, &entry.entity, [x, y, z]);
|
||||
trace(Event::Test(entry.entity.clone(), [x, y, z], passed));
|
||||
if !passed {
|
||||
continue;
|
||||
}
|
||||
// getMobForSpawn: a type the game cannot create ends the pass.
|
||||
let Some((species, width, height, type_category)) = l.mob_type(&entry.entity) else {
|
||||
return;
|
||||
};
|
||||
// getMobForSpawn (a type the game cannot create, which would end
|
||||
// the pass, never passes isValidSpawnPostitionForType).
|
||||
let yaw = random.next_float() * 360.0;
|
||||
let bounds = spawn_box(xx, f64::from(y), zz, width, height);
|
||||
let despawn = f64::from(type_category.despawn_distance().pow(2));
|
||||
@@ -1167,6 +1185,52 @@ pub fn spawn_category_for_position<L: NaturalLevel, R: RandomSource>(
|
||||
}
|
||||
}
|
||||
}
|
||||
/// `getNearestPlayer(x, y, z, -1, false)`'s squared distance for the
|
||||
/// attempts of one start. The spawn checks only compare it with 24² and the
|
||||
/// despawn distances (128² at most), so it is measured only to the players
|
||||
/// that can be within 128 blocks of an attempt near the start: when none is,
|
||||
/// the nearest reads as infinitely far, which every check answers as it
|
||||
/// answers the real distance. An attempt that wanders farther than
|
||||
/// `MARGIN` from the start measures every player.
|
||||
struct Nearest {
|
||||
/// The players that can be within 128 blocks, once an attempt asked.
|
||||
nearby: Option<Vec<[f64; 3]>>,
|
||||
start: (f64, f64),
|
||||
}
|
||||
impl Nearest {
|
||||
const REACH: f64 = 128.0;
|
||||
const MARGIN: f64 = 64.0;
|
||||
fn new(start: [i32; 3]) -> Self {
|
||||
Self {
|
||||
nearby: None,
|
||||
start: (f64::from(start[0]) + 0.5, f64::from(start[2]) + 0.5),
|
||||
}
|
||||
}
|
||||
fn distance_sqr<L: NaturalLevel>(&mut self, l: &L, x: f64, y: f64, z: f64) -> Option<f64> {
|
||||
let all = l.nearest_player_positions();
|
||||
if all.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let distance = |p: &[f64; 3]| (p[0] - x).powi(2) + (p[1] - y).powi(2) + (p[2] - z).powi(2);
|
||||
let (sx, sz) = self.start;
|
||||
if (x - sx).abs() > Self::MARGIN || (z - sz).abs() > Self::MARGIN {
|
||||
return all.iter().map(distance).reduce(f64::min);
|
||||
}
|
||||
// The others are farther than REACH, across, from every point within
|
||||
// MARGIN of the start.
|
||||
let nearby = self.nearby.get_or_insert_with(|| {
|
||||
all.iter()
|
||||
.filter(|p| {
|
||||
let dx = ((p[0] - sx).abs() - Self::MARGIN).max(0.0);
|
||||
let dz = ((p[2] - sz).abs() - Self::MARGIN).max(0.0);
|
||||
dx * dx + dz * dz <= Self::REACH * Self::REACH
|
||||
})
|
||||
.copied()
|
||||
.collect()
|
||||
});
|
||||
Some(nearby.iter().map(distance).fold(f64::INFINITY, f64::min))
|
||||
}
|
||||
}
|
||||
/// `isRightDistanceToPlayerAndSpawnPoint`.
|
||||
fn right_distance<L: NaturalLevel>(l: &L, chunk: (i32, i32), pos: [i32; 3], distance: f64) -> bool {
|
||||
if distance <= 576.0 {
|
||||
@@ -1185,16 +1249,20 @@ fn right_distance<L: NaturalLevel>(l: &L, chunk: (i32, i32), pos: [i32; 3], dist
|
||||
let (cx, cz) = (pos[0] >> 4, pos[2] >> 4);
|
||||
(cx, cz) == chunk || l.can_spawn_in_chunk(cx, cz)
|
||||
}
|
||||
/// `isValidSpawnPostitionForType`.
|
||||
/// `isValidSpawnPostitionForType`, for the entry's type (`mob_type`).
|
||||
/// `listed`: the position is where the entry was picked from `mobsAt`.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn valid_position_for_type<L: NaturalLevel, R: RandomSource>(
|
||||
l: &L,
|
||||
random: &mut R,
|
||||
category: Category,
|
||||
entry: &SpawnEntry,
|
||||
kind: Option<MobType>,
|
||||
listed: bool,
|
||||
pos: [i32; 3],
|
||||
distance: f64,
|
||||
) -> bool {
|
||||
let Some((species, width, height, type_category)) = l.mob_type(&entry.entity) else {
|
||||
let Some((species, width, height, type_category)) = kind else {
|
||||
return false;
|
||||
};
|
||||
if type_category == Category::Misc {
|
||||
@@ -1205,9 +1273,10 @@ fn valid_position_for_type<L: NaturalLevel, R: RandomSource>(
|
||||
return false;
|
||||
}
|
||||
let [x, y, z] = pos;
|
||||
if !mobs_at(l, category, x, y, z)
|
||||
.iter()
|
||||
.any(|e| same_spawner(e, entry))
|
||||
if !listed
|
||||
&& !mobs_at(l, category, x, y, z)
|
||||
.iter()
|
||||
.any(|e| same_spawner(e, entry))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -1028,6 +1028,66 @@ impl TagMerge {
|
||||
}
|
||||
}
|
||||
|
||||
/// Threads rewriting the tables at once. Each holds a whole table as a tree
|
||||
/// (gameplay-facts.json's is over a gigabyte with ATM10's mods), so more
|
||||
/// would mostly add memory.
|
||||
const TABLE_THREADS: usize = 3;
|
||||
|
||||
/// Rewrites the base's per-state tables for the mods' states into the
|
||||
/// overlay. They are independent: [`TABLE_THREADS`] threads take them,
|
||||
/// the largest first.
|
||||
fn tables(base: &Path, plan: &Plan, overlay: &Overlay<'_>) -> Result<()> {
|
||||
let mut jobs: Vec<(&str, u64)> = JSON_TABLES
|
||||
.iter()
|
||||
.chain(&NBT_TABLES)
|
||||
.filter_map(|name| Some((*name, fs::metadata(base.join(name)).ok()?.len())))
|
||||
.collect();
|
||||
jobs.sort_by_key(|(_, size)| std::cmp::Reverse(*size));
|
||||
// Their directories become real here, so the threads only write files.
|
||||
for (name, _) in &jobs {
|
||||
if let Some(parent) = Path::new(name).parent() {
|
||||
overlay.dir(parent)?;
|
||||
}
|
||||
}
|
||||
let table = |name: &str| -> Result<()> {
|
||||
let path = base.join(name);
|
||||
let bytes = if JSON_TABLES.contains(&name) {
|
||||
let mut doc = read_json(&path)?;
|
||||
plan.json(name, &mut doc)
|
||||
.map_err(|e| format!("{name}: {e}"))?;
|
||||
serde_json::to_vec(&doc)?
|
||||
} else {
|
||||
let (mut named, _) = nbt::read_named(&fs::read(&path)?, Limits::tables())
|
||||
.map_err(|e| format!("{name}: {e}"))?;
|
||||
plan.nbt(&mut named.tag);
|
||||
nbt::write_named(&named, Limits::tables()).map_err(|e| format!("{name}: {e}"))?
|
||||
};
|
||||
overlay.write(name, &bytes)
|
||||
};
|
||||
let next = std::sync::atomic::AtomicUsize::new(0);
|
||||
let threads = std::thread::available_parallelism().map_or(1, |n| n.get());
|
||||
std::thread::scope(|scope| {
|
||||
let workers: Vec<_> = (0..threads.min(TABLE_THREADS))
|
||||
.map(|_| {
|
||||
scope.spawn(|| -> std::result::Result<(), String> {
|
||||
loop {
|
||||
let i = next.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||
let Some((name, _)) = jobs.get(i) else {
|
||||
return Ok(());
|
||||
};
|
||||
table(name).map_err(|e| e.to_string())?;
|
||||
}
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
workers.into_iter().try_for_each(|w| {
|
||||
w.join()
|
||||
.unwrap_or_else(|_| Err("a table thread panicked".into()))
|
||||
})
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Builds the content root for `mods` over `base` in `out`. Returns the
|
||||
/// warnings for the log: the shared tags a mod replaced.
|
||||
pub fn build(base: &Path, mods: &[ContentMod], out: &Path) -> Result<Vec<String>> {
|
||||
@@ -1047,28 +1107,7 @@ pub fn build(base: &Path, mods: &[ContentMod], out: &Path) -> Result<Vec<String>
|
||||
let base = &std::path::absolute(base)?;
|
||||
let plan = Plan::new(base, mods)?;
|
||||
let overlay = Overlay::new(base, out)?;
|
||||
for name in JSON_TABLES {
|
||||
let path = base.join(name);
|
||||
if !path.exists() {
|
||||
continue;
|
||||
}
|
||||
let mut doc = read_json(&path)?;
|
||||
plan.json(name, &mut doc)
|
||||
.map_err(|e| format!("{name}: {e}"))?;
|
||||
overlay.write(name, &serde_json::to_vec(&doc)?)?;
|
||||
}
|
||||
for name in NBT_TABLES {
|
||||
let path = base.join(name);
|
||||
if !path.exists() {
|
||||
continue;
|
||||
}
|
||||
let (mut named, _) = nbt::read_named(&fs::read(&path)?, Limits::tables())
|
||||
.map_err(|e| format!("{name}: {e}"))?;
|
||||
plan.nbt(&mut named.tag);
|
||||
let bytes =
|
||||
nbt::write_named(&named, Limits::tables()).map_err(|e| format!("{name}: {e}"))?;
|
||||
overlay.write(name, &bytes)?;
|
||||
}
|
||||
tables(base, &plan, &overlay)?;
|
||||
// Mod files, and every tag addition gathered per tag file.
|
||||
let mut tags: BTreeMap<String, TagMerge> = BTreeMap::new();
|
||||
let mut warnings = Vec::new();
|
||||
@@ -1076,8 +1115,9 @@ pub fn build(base: &Path, mods: &[ContentMod], out: &Path) -> Result<Vec<String>
|
||||
let mut additions: BTreeMap<&str, Vec<Addition>> = BTreeMap::new();
|
||||
for m in mods {
|
||||
let declarations = format!("data/{}/horizon/", m.id);
|
||||
let mut reader = m.content.reader()?;
|
||||
for file in &m.content.files {
|
||||
let bytes = m.content.file(file)?;
|
||||
let bytes = reader.read(file)?;
|
||||
let is_tag = file.starts_with("data/") && file.contains("/tags/");
|
||||
if is_tag && file.ends_with(".json") {
|
||||
let doc: J = serde_json::from_slice(&bytes).map_err(|e| format!("{file}: {e}"))?;
|
||||
|
||||
@@ -10,10 +10,12 @@ pub(crate) struct ParsedModels {
|
||||
impl ParsedModels {
|
||||
/// Every `models/**.json` of a namespace directory, by identifier.
|
||||
pub(crate) fn load(root: &Path) -> Self {
|
||||
let mut out = BTreeMap::new();
|
||||
let Ok(namespaces) = fs::read_dir(root.join("assets")) else {
|
||||
return Self { models: out };
|
||||
return Self::default();
|
||||
};
|
||||
// Listed first, then read and parsed at once: a content pack has tens
|
||||
// of thousands of models.
|
||||
let mut files = Vec::new();
|
||||
for ns in namespaces.flatten() {
|
||||
let namespace = ns.file_name().to_string_lossy().into_owned();
|
||||
let base = ns.path().join("models");
|
||||
@@ -24,20 +26,33 @@ impl ParsedModels {
|
||||
};
|
||||
for e in entries.flatten() {
|
||||
let path = e.path();
|
||||
if path.is_dir() {
|
||||
// The entry's own type saves a stat per file; a link
|
||||
// (content roots link into the base) is followed.
|
||||
let is_dir = match e.file_type() {
|
||||
Ok(t) if !t.is_symlink() => t.is_dir(),
|
||||
_ => path.is_dir(),
|
||||
};
|
||||
if is_dir {
|
||||
stack.push(path);
|
||||
} else if path.extension().is_some_and(|x| x == "json")
|
||||
&& let Ok(rel) = path.strip_prefix(&base)
|
||||
&& let Ok(text) = fs::read_to_string(&path)
|
||||
&& let Ok(model) = CuboidModel::from_json(&text)
|
||||
{
|
||||
let rel = rel.with_extension("").to_string_lossy().replace('\\', "/");
|
||||
out.insert(format!("{namespace}:{rel}"), Arc::new(model));
|
||||
files.push((format!("{namespace}:{rel}"), path));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Self { models: out }
|
||||
let parsed = crate::par::par_map(&files, |(_, path)| {
|
||||
let text = fs::read_to_string(path).ok()?;
|
||||
CuboidModel::from_json(&text).ok().map(Arc::new)
|
||||
});
|
||||
let models = files
|
||||
.into_iter()
|
||||
.zip(parsed)
|
||||
.filter_map(|((id, _), model)| Some((id, model?)))
|
||||
.collect();
|
||||
Self { models }
|
||||
}
|
||||
|
||||
/// Blocks require every referenced resource, including parents. Items instead
|
||||
|
||||
@@ -338,6 +338,8 @@ impl PlayerMotion {
|
||||
.and_then(|t| crate::save::number(t.get("Air")))
|
||||
.unwrap_or(300) as i32;
|
||||
self.set_health(level.health, false)?;
|
||||
// LivingEntity.readAdditionalSaveData: internalSetAbsorptionAmount.
|
||||
self.internal_set_absorption(level.absorption)?;
|
||||
self.health.food.food = level.food;
|
||||
self.health.food.saturation = level.saturation;
|
||||
self.flying = level.flying;
|
||||
@@ -1029,9 +1031,22 @@ impl MotionWorld<'_, '_, '_> {
|
||||
}
|
||||
/// `Entity.applyEffectsFromBlocks` for the blocks Horizon's player reacts
|
||||
/// to inside its movement path: powder snow (`PowderSnowBlock.entityInside`
|
||||
/// and the FREEZE / EXTINGUISH effects), cobwebs and sweet berry bushes. Pressure plates, tripwires and
|
||||
/// rails are the world tick's scan.
|
||||
/// and the FREEZE / EXTINGUISH effects), cobwebs, sweet berry bushes,
|
||||
/// cactus and lit campfires, and the magma block it stands on
|
||||
/// (`stepOn`). Pressure plates, tripwires and rails are the world tick's
|
||||
/// scan.
|
||||
pub fn apply_effects_from_blocks(&mut self) -> Result<()> {
|
||||
self.effects_from_blocks(false)
|
||||
}
|
||||
/// What `applyEffectsFromBlocks` does to a server's player along the
|
||||
/// moves its client sent since `from` (in its history): the damage of
|
||||
/// magma, cactus, lit campfires and grown sweet berry bushes. Its client
|
||||
/// moves it, so the slowing and the rest are the client's.
|
||||
pub fn server_block_damage(&mut self, from: Vec3) -> Result<()> {
|
||||
self.player.old_position = from;
|
||||
self.effects_from_blocks(true)
|
||||
}
|
||||
fn effects_from_blocks(&mut self, damage_only: bool) -> Result<()> {
|
||||
use horizon_world::entity_move::MovementRecord;
|
||||
let mut movements: Vec<MovementRecord> =
|
||||
self.player.history.records().iter().copied().collect();
|
||||
@@ -1056,6 +1071,19 @@ impl MotionWorld<'_, '_, '_> {
|
||||
if self.player.spectator {
|
||||
return Ok(());
|
||||
}
|
||||
// Block.stepOn for the block under an entity on the ground:
|
||||
// MagmaBlock's hot floor hurts unless it steps carefully (sneaks).
|
||||
if self.player.ground.on_ground {
|
||||
let on = self.legacy_on_position()?;
|
||||
let magma = self
|
||||
.assets
|
||||
.registry
|
||||
.state(self.block(on))
|
||||
.is_some_and(|s| s.name == "minecraft:magma_block");
|
||||
if magma && !self.player.shift_key_down {
|
||||
self.environment_hurt("minecraft:hot_floor", 1.0)?;
|
||||
}
|
||||
}
|
||||
let mut visited = std::collections::HashSet::new();
|
||||
let mut entered = Vec::new();
|
||||
for movement in &movements {
|
||||
@@ -1081,6 +1109,7 @@ impl MotionWorld<'_, '_, '_> {
|
||||
&mut visited,
|
||||
&mut entered,
|
||||
budget,
|
||||
damage_only,
|
||||
)?;
|
||||
from = to;
|
||||
}
|
||||
@@ -1093,11 +1122,19 @@ impl MotionWorld<'_, '_, '_> {
|
||||
&mut visited,
|
||||
&mut entered,
|
||||
16,
|
||||
damage_only,
|
||||
)?;
|
||||
}
|
||||
}
|
||||
if budget <= 0 {
|
||||
self.check_inside_blocks(movement.to, movement.to, &mut visited, &mut entered, 1)?;
|
||||
self.check_inside_blocks(
|
||||
movement.to,
|
||||
movement.to,
|
||||
&mut visited,
|
||||
&mut entered,
|
||||
1,
|
||||
damage_only,
|
||||
)?;
|
||||
}
|
||||
}
|
||||
// InsideBlockEffectApplier.StepBasedCollector: the blocks entered
|
||||
@@ -1137,6 +1174,7 @@ impl MotionWorld<'_, '_, '_> {
|
||||
visited: &mut std::collections::HashSet<i64>,
|
||||
entered: &mut Vec<(i32, BlockPos)>,
|
||||
max_iterations: i32,
|
||||
damage_only: bool,
|
||||
) -> Result<i32> {
|
||||
let deflated = self.player.dimensions.bounds(to).deflate(Vec3::new(
|
||||
f64::from(1.0e-5_f32),
|
||||
@@ -1213,6 +1251,33 @@ impl MotionWorld<'_, '_, '_> {
|
||||
}
|
||||
};
|
||||
if inside && visited.insert(p.pack()) {
|
||||
// CactusBlock and CampfireBlock.entityInside (`entity.hurt`).
|
||||
match name {
|
||||
"minecraft:cactus" => self.environment_hurt("minecraft:cactus", 1.0)?,
|
||||
"minecraft:campfire" | "minecraft:soul_campfire" => {
|
||||
let lit = self
|
||||
.assets
|
||||
.registry
|
||||
.state(state)
|
||||
.and_then(|s| s.properties.get("lit"))
|
||||
.is_some_and(|v| v == "true");
|
||||
let damage = if name == "minecraft:campfire" {
|
||||
1.0
|
||||
} else {
|
||||
2.0
|
||||
};
|
||||
if lit {
|
||||
self.environment_hurt("minecraft:campfire", damage)?;
|
||||
}
|
||||
}
|
||||
"minecraft:sweet_berry_bush" if damage_only => {
|
||||
self.sweet_berry_bush_damage(state)?;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
if damage_only {
|
||||
continue;
|
||||
}
|
||||
match name {
|
||||
"minecraft:powder_snow" => {
|
||||
self.powder_snow_inside(p)?;
|
||||
@@ -1261,6 +1326,10 @@ impl MotionWorld<'_, '_, '_> {
|
||||
/// is at least 0.003 on X or Z.
|
||||
fn sweet_berry_bush_inside(&mut self, state: BlockStateId) -> Result<()> {
|
||||
self.make_stuck_in_block(Vec3::new(f64::from(0.8_f32), 0.75, f64::from(0.8_f32)));
|
||||
self.sweet_berry_bush_damage(state)
|
||||
}
|
||||
/// The server's half of `SweetBerryBushBlock.entityInside`: the prick.
|
||||
fn sweet_berry_bush_damage(&mut self, state: BlockStateId) -> Result<()> {
|
||||
let age = self
|
||||
.assets
|
||||
.registry
|
||||
|
||||
@@ -6,7 +6,7 @@ use horizon_world::{
|
||||
living_fluid::{LivingFluidWorld, TravelFluidTag},
|
||||
};
|
||||
impl MotionWorld<'_, '_, '_> {
|
||||
fn environment_hurt(&mut self, source: &str, damage: f32) -> Result<()> {
|
||||
pub(super) fn environment_hurt(&mut self, source: &str, damage: f32) -> Result<()> {
|
||||
if self.player.invulnerable || self.player.spectator {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
@@ -871,9 +871,13 @@ pub enum Page {
|
||||
Controller,
|
||||
PadBinds,
|
||||
Extras,
|
||||
/// CreditsAndAttributionScreen.
|
||||
Credits,
|
||||
/// Horizon Settings: every page Horizon adds, as a mod's settings
|
||||
/// screen hangs off vanilla's Options.
|
||||
Horizon,
|
||||
/// AI Agent: agent control on or off, whether an agent is connected, the connection details.
|
||||
Agent,
|
||||
}
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
enum Act {
|
||||
@@ -894,6 +898,9 @@ enum Act {
|
||||
/// Pick shader pack `i` of `OptionsUi::shader_packs` (again: off).
|
||||
ToggleShaderPack(usize),
|
||||
OpenShaderPackFolder,
|
||||
/// Credits & Attribution's Credits button (WinScreen without the poem).
|
||||
Credits,
|
||||
CopyAgentDetails,
|
||||
}
|
||||
enum WKind {
|
||||
Button {
|
||||
@@ -943,6 +950,10 @@ pub enum Event {
|
||||
OpenModFolder,
|
||||
/// Create `shaderpacks/` if needed and open it.
|
||||
OpenShaderPackFolder,
|
||||
/// Roll the credits (`CreditsAndAttributionScreen.openCreditsScreen`).
|
||||
Credits,
|
||||
/// Copy how an agent connects (the AI Agent page).
|
||||
CopyAgentDetails,
|
||||
}
|
||||
/// `SliderableValueSet.applyValueImmediately`: these apply 600 ms after
|
||||
/// the last change (`OptionInstanceSliderButton.delayedApplyAt`).
|
||||
@@ -998,6 +1009,8 @@ pub struct OptionsUi {
|
||||
pub shader_packs: crate::shader_pack::Folder,
|
||||
/// The pack that failed to load last and why (set by the game).
|
||||
pub shader_pack_error: Option<(String, String)>,
|
||||
/// The AI Agent page's status lines (set by the game while it shows).
|
||||
pub agent_status: Vec<String>,
|
||||
}
|
||||
impl Default for OptionsUi {
|
||||
fn default() -> Self {
|
||||
@@ -1028,6 +1041,7 @@ impl Default for OptionsUi {
|
||||
graphics: Default::default(),
|
||||
shader_packs: Default::default(),
|
||||
shader_pack_error: None,
|
||||
agent_status: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1086,6 +1100,7 @@ fn horizon_page_tooltip(page: Page) -> Option<&'static str> {
|
||||
Page::Extras => {
|
||||
"Optional additions, all off by default: the game stays vanilla until you turn one on."
|
||||
}
|
||||
Page::Agent => "Let your own AI agent play: horizon-ctl and MCP clients on this computer.",
|
||||
Page::Mods => {
|
||||
"Mods made for Horizon, from the horizon-mods folder. Each runs in a sandbox and stays off until you turn it on."
|
||||
}
|
||||
@@ -1107,6 +1122,11 @@ fn pairs(opts: &[Opt]) -> Vec<Row> {
|
||||
.collect()
|
||||
}
|
||||
impl OptionsUi {
|
||||
/// A switch of `horizon-extras.txt` as the Extras page draws it: "Caption: On".
|
||||
fn extra_button(&self, s: &Settings, item: crate::extras::Item) -> Widget {
|
||||
let label = format!("{}: {}", item.caption(), item.value(&s.extras));
|
||||
button(0.0, 0.0, 150.0, Text::literal(label), Act::Extra(item))
|
||||
}
|
||||
fn settings_search_active(&self) -> bool {
|
||||
self.settings_search.focused
|
||||
&& matches!(self.page(), Page::Video | Page::Controls | Page::KeyBinds)
|
||||
@@ -1199,7 +1219,9 @@ impl OptionsUi {
|
||||
Page::Controller => "Controller Settings",
|
||||
Page::PadBinds => "Controller Binds",
|
||||
Page::Extras => "Horizon Extras",
|
||||
Page::Credits => "credits_and_attribution.screen.title",
|
||||
Page::Horizon => "Horizon Settings",
|
||||
Page::Agent => "AI Agent",
|
||||
}
|
||||
}
|
||||
fn scrollbar_x(&self, width: f32) -> f32 {
|
||||
@@ -1316,8 +1338,31 @@ impl OptionsUi {
|
||||
open("Extras...", Page::Extras),
|
||||
Some(open("Mods...", Page::Mods)),
|
||||
),
|
||||
// No AI hides the AI Agent page and everything AI.
|
||||
if s.extras.no_ai {
|
||||
Row::Buttons(self.extra_button(s, crate::extras::Item::NoAi), None)
|
||||
} else {
|
||||
Row::Buttons(
|
||||
open("AI Agent...", Page::Agent),
|
||||
Some(self.extra_button(s, crate::extras::Item::NoAi)),
|
||||
)
|
||||
},
|
||||
]
|
||||
}
|
||||
Page::Agent => {
|
||||
let mut rows = vec![Row::Buttons(
|
||||
self.extra_button(s, crate::extras::Item::AgentControl),
|
||||
Some(button(
|
||||
0.0,
|
||||
0.0,
|
||||
150.0,
|
||||
Text::literal("Copy Connection Details"),
|
||||
Act::CopyAgentDetails,
|
||||
)),
|
||||
)];
|
||||
rows.extend(self.agent_status.iter().cloned().map(Row::Note));
|
||||
rows
|
||||
}
|
||||
Page::Video => {
|
||||
// Horizon's graphics screens, Simple and Advanced.
|
||||
let mut rows = vec![Row::Buttons(
|
||||
@@ -1595,10 +1640,13 @@ impl OptionsUi {
|
||||
("options.accessibility", Some(Page::Accessibility)),
|
||||
// Vanilla's Telemetry Data and Credits slots: Horizon's
|
||||
// settings and its mods, as a mod adds its button here.
|
||||
// Credits & Attribution (WinScreen's credits) follows them.
|
||||
("Horizon Settings...", Some(Page::Horizon)),
|
||||
("Mods...", Some(Page::Mods)),
|
||||
("options.credits_and_attribution", Some(Page::Credits)),
|
||||
];
|
||||
let gy = (61.0 + 30.0f32).min(h - 33.0 - 120.0);
|
||||
let rows = entries.len().div_ceil(2) as f32;
|
||||
let gy = (61.0 + 30.0f32).min(h - 33.0 - rows * 24.0);
|
||||
for (i, (key, page)) in entries.iter().enumerate() {
|
||||
let x = cx - 158.0 + 4.0 + (i % 2) as f32 * 158.0;
|
||||
let y = gy + (i / 2) as f32 * 24.0;
|
||||
@@ -1609,6 +1657,46 @@ impl OptionsUi {
|
||||
top = 0.0;
|
||||
bottom = 0.0;
|
||||
}
|
||||
Page::Credits => {
|
||||
// CreditsAndAttributionScreen: the title header, three 210-wide
|
||||
// buttons 8 apart (HeaderAndFooterLayout: 30 below the header
|
||||
// unless the footer is closer), Done.
|
||||
out.push(Widget {
|
||||
x: 0.0,
|
||||
y: 12.0,
|
||||
w,
|
||||
h: 9.0,
|
||||
kind: WKind::Text {
|
||||
text: tr(Self::title(page)),
|
||||
color: u32::MAX,
|
||||
centered: true,
|
||||
},
|
||||
list: false,
|
||||
});
|
||||
let y = (33.0 + 30.0f32).min(h - 33.0 - 76.0);
|
||||
let x = cx - 105.0;
|
||||
out.push(button(
|
||||
x,
|
||||
y,
|
||||
210.0,
|
||||
tr("credits_and_attribution.button.credits"),
|
||||
Act::Credits,
|
||||
));
|
||||
// ConfirmLinkScreen to minecraft.net: Horizon opens no links.
|
||||
for (i, key) in [
|
||||
"credits_and_attribution.button.attribution",
|
||||
"credits_and_attribution.button.licenses",
|
||||
]
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
{
|
||||
let y = y + 28.0 * (i + 1) as f32;
|
||||
out.push(inactive(button(x, y, 210.0, tr(key), Act::None)));
|
||||
}
|
||||
out.push(done(h - 27.0));
|
||||
top = 0.0;
|
||||
bottom = 0.0;
|
||||
}
|
||||
Page::KeyBinds => {
|
||||
top = 38.0;
|
||||
out.push(Widget {
|
||||
@@ -2499,6 +2587,8 @@ impl OptionsUi {
|
||||
events.push(Event::Changed);
|
||||
}
|
||||
Act::OpenShaderPackFolder => events.push(Event::OpenShaderPackFolder),
|
||||
Act::Credits => events.push(Event::Credits),
|
||||
Act::CopyAgentDetails => events.push(Event::CopyAgentDetails),
|
||||
Act::None => {}
|
||||
}
|
||||
}
|
||||
@@ -4133,9 +4223,13 @@ mod tests {
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
// Vanilla's ten Options buttons, the last two Horizon's.
|
||||
// Vanilla's Options buttons, Horizon's two in the Telemetry and Credits slots, then
|
||||
// Credits & Attribution (WinScreen's credits).
|
||||
let main = pushes(&mut ui);
|
||||
assert_eq!(&main[main.len() - 2..], [Page::Horizon, Page::Mods]);
|
||||
assert_eq!(
|
||||
&main[main.len() - 3..],
|
||||
[Page::Horizon, Page::Mods, Page::Credits]
|
||||
);
|
||||
ui.push(Page::Horizon);
|
||||
assert_eq!(
|
||||
pushes(&mut ui),
|
||||
@@ -4146,8 +4240,33 @@ mod tests {
|
||||
Page::Controller,
|
||||
Page::Extras,
|
||||
Page::Mods,
|
||||
Page::Agent,
|
||||
]
|
||||
);
|
||||
// No AI hides the AI Agent page.
|
||||
let mut no_ai = Settings::default();
|
||||
no_ai.extras.no_ai = true;
|
||||
let (widgets, _, _, _) = ui.layout(&no_ai, 427.0, 240.0);
|
||||
assert!(!widgets.iter().any(|w| matches!(
|
||||
&w.kind,
|
||||
WKind::Button {
|
||||
act: Act::Push(Page::Agent),
|
||||
..
|
||||
}
|
||||
)));
|
||||
// The AI Agent page: the switch, Copy and the status the game sets.
|
||||
ui.push(Page::Agent);
|
||||
ui.agent_status = vec!["Waiting for an agent.".into()];
|
||||
let (widgets, _, _, _) = ui.layout(&s, 427.0, 240.0);
|
||||
let acts: Vec<&Act> = widgets
|
||||
.iter()
|
||||
.filter_map(|w| match &w.kind {
|
||||
WKind::Button { act, .. } => Some(act),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
assert!(acts.contains(&&Act::Extra(crate::extras::Item::AgentControl)));
|
||||
assert!(acts.contains(&&Act::CopyAgentDetails));
|
||||
// Vanilla's own pages keep the Horizon pages they had.
|
||||
ui.stack = vec![Page::Main, Page::Video];
|
||||
let video = pushes(&mut ui);
|
||||
|
||||
@@ -87,6 +87,7 @@ fn style_of(o: &serde_json::Map<String, serde_json::Value>) -> Style {
|
||||
underlined: flag("underlined"),
|
||||
strikethrough: flag("strikethrough"),
|
||||
obfuscated: flag("obfuscated"),
|
||||
font: o.get("font").and_then(crate::text::font_id),
|
||||
..Style::default()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
//! Loading many small files at once: startup reads tens of thousands of
|
||||
//! textures and models with a content pack, and one at a time it waits on
|
||||
//! each open and read in turn.
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
|
||||
/// `f` over `items` on the machine's threads, results in `items`' order.
|
||||
/// Threads take the next item as they finish one, so a run of slow items
|
||||
/// doesn't hold back the others.
|
||||
pub fn par_map<T: Sync, R: Send>(items: &[T], f: impl Fn(&T) -> R + Sync) -> Vec<R> {
|
||||
let threads = std::thread::available_parallelism()
|
||||
.map_or(1, |n| n.get())
|
||||
.min(items.len());
|
||||
if threads <= 1 {
|
||||
return items.iter().map(f).collect();
|
||||
}
|
||||
let next = AtomicUsize::new(0);
|
||||
let mut out: Vec<(usize, R)> = std::thread::scope(|scope| {
|
||||
let workers: Vec<_> = (0..threads)
|
||||
.map(|_| {
|
||||
scope.spawn(|| {
|
||||
let mut done = Vec::new();
|
||||
loop {
|
||||
let i = next.fetch_add(1, Ordering::Relaxed);
|
||||
let Some(item) = items.get(i) else {
|
||||
return done;
|
||||
};
|
||||
done.push((i, f(item)));
|
||||
}
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
workers
|
||||
.into_iter()
|
||||
.flat_map(|w| w.join().unwrap_or_else(|e| std::panic::resume_unwind(e)))
|
||||
.collect()
|
||||
});
|
||||
out.sort_unstable_by_key(|(i, _)| *i);
|
||||
out.into_iter().map(|(_, r)| r).collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::par_map;
|
||||
|
||||
#[test]
|
||||
fn results_keep_the_items_order() {
|
||||
let items: Vec<u32> = (0..1000).collect();
|
||||
assert_eq!(
|
||||
par_map(&items, |x| x * 2),
|
||||
items.iter().map(|x| x * 2).collect::<Vec<_>>()
|
||||
);
|
||||
assert!(par_map(&[] as &[u32], |x| *x).is_empty());
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
//! Memoized palette eligibility for the game thread's random-tick snapshot.
|
||||
//! Which sections of the game thread's chunks random-tick
|
||||
//! (`LevelChunkSection.isRandomlyTicking`), memoized per section.
|
||||
use crate::{
|
||||
blocks::BlockFacts,
|
||||
chunk::{Chunk, MIN_Y, SECTIONS},
|
||||
chunk::{Chunk, MIN_Y, SECTIONS, Section},
|
||||
fast_hash::FxHashMap,
|
||||
};
|
||||
use horizon_world::registry::BlockStateId;
|
||||
@@ -10,6 +11,13 @@ use std::sync::{Arc, Weak};
|
||||
/// A value snapshot, in ascending section order, with no per-chunk allocation.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub struct Sections(u32);
|
||||
impl Sections {
|
||||
/// The minimum Y of the lowest section in the set from block `min_y` up.
|
||||
pub fn from(self, min_y: i32) -> Option<i32> {
|
||||
let index = ((min_y - MIN_Y) >> 4).max(0) as u32;
|
||||
Sections(self.0 & u32::MAX.checked_shl(index).unwrap_or(0)).next()
|
||||
}
|
||||
}
|
||||
impl Iterator for Sections {
|
||||
type Item = i32;
|
||||
fn next(&mut self) -> Option<i32> {
|
||||
@@ -29,12 +37,20 @@ impl ExactSizeIterator for Sections {}
|
||||
|
||||
struct Cached {
|
||||
chunk: Weak<Chunk>,
|
||||
/// The section each bit of `sections` was read from.
|
||||
read: [Weak<Section>; SECTIONS],
|
||||
sections: Sections,
|
||||
}
|
||||
/// Resident chunks are immutable behind `Arc`; edits use `Arc::make_mut`.
|
||||
/// A weak reference detects both edits and replacement, without retaining block
|
||||
/// data or forcing a deep copy on the next edit. It also prevents address reuse.
|
||||
/// Clear this cache whenever the gameplay facts change (including resource reload).
|
||||
/// Vanilla keeps a count of randomly ticking blocks and fluids per section,
|
||||
/// updated by every `setBlockState`, and ticks the sections where it is above
|
||||
/// zero. Here a section's answer is read from its blocks after any edit:
|
||||
/// resident chunks and sections are immutable behind `Arc` and edited through
|
||||
/// `Arc::make_mut`, so a weak reference tells an edited or replaced one from
|
||||
/// the one read, without retaining block data or forcing a deep copy on the
|
||||
/// next edit, and prevents address reuse. An unchanged chunk costs one
|
||||
/// pointer comparison; a changed one, a comparison per section and a read of
|
||||
/// the sections that changed. Clear this cache whenever the gameplay facts
|
||||
/// change (including resource reload).
|
||||
#[derive(Default)]
|
||||
pub struct RandomTickSections {
|
||||
chunks: FxHashMap<(i32, i32), Cached>,
|
||||
@@ -46,6 +62,7 @@ impl RandomTickSections {
|
||||
pub fn remove(&mut self, key: &(i32, i32)) {
|
||||
self.chunks.remove(key);
|
||||
}
|
||||
/// The sections of `chunk` holding a randomly ticking block or fluid.
|
||||
pub fn get(&mut self, key: (i32, i32), chunk: &Arc<Chunk>, facts: &BlockFacts) -> Sections {
|
||||
self.get_with(key, chunk, |id| facts.get(BlockStateId(id)).random_ticks)
|
||||
}
|
||||
@@ -60,17 +77,23 @@ impl RandomTickSections {
|
||||
}
|
||||
let cached = self.chunks.entry(key).or_insert_with(|| Cached {
|
||||
chunk: Weak::new(),
|
||||
read: std::array::from_fn(|_| Weak::new()),
|
||||
sections: Sections::default(),
|
||||
});
|
||||
if cached.chunk.as_ptr() != Arc::as_ptr(chunk) {
|
||||
let mut mask = 0;
|
||||
for (i, section) in chunk.sections.iter().enumerate() {
|
||||
// Preserve maybe_has, including unused entries and global palettes.
|
||||
if section.blocks.maybe_has(&mut random) {
|
||||
mask |= 1 << i;
|
||||
for (i, (section, read)) in chunk.sections.iter().zip(&mut cached.read).enumerate() {
|
||||
if read.as_ptr() == Arc::as_ptr(section) {
|
||||
continue;
|
||||
}
|
||||
// `isRandomlyTicking`: the blocks themselves, not the palette,
|
||||
// which keeps the states no block uses any more.
|
||||
if section.blocks.has(&mut random) {
|
||||
cached.sections.0 |= 1 << i;
|
||||
} else {
|
||||
cached.sections.0 &= !(1 << i);
|
||||
}
|
||||
*read = Arc::downgrade(section);
|
||||
}
|
||||
cached.sections = Sections(mask);
|
||||
cached.chunk = Arc::downgrade(chunk);
|
||||
}
|
||||
cached.sections
|
||||
@@ -97,10 +120,24 @@ mod tests {
|
||||
[MIN_Y]
|
||||
);
|
||||
assert_eq!(worker.get(0, MIN_Y, 0), 0);
|
||||
// Even after the last ticking block is removed, its unused palette entry counts.
|
||||
// Once the last ticking block is gone the section stops ticking, as
|
||||
// vanilla's count drops to zero, though its palette still lists it.
|
||||
Arc::make_mut(&mut chunk).set(0, MIN_Y, 0, 0);
|
||||
assert_eq!(cache.get_with((0, 0), &chunk, |id| id == 1).len(), 1);
|
||||
Arc::make_mut(&mut Arc::make_mut(&mut chunk).sections[0]).compact();
|
||||
assert!(chunk.sections[0].blocks.maybe_has(|id| id == 1));
|
||||
assert_eq!(cache.get_with((0, 0), &chunk, |id| id == 1).len(), 0);
|
||||
// Only the edited section is read again (its palette: air, then 1).
|
||||
Arc::make_mut(&mut chunk).set(0, MIN_Y + 16, 0, 1);
|
||||
let mut asked = Vec::new();
|
||||
let sections = cache.get_with((0, 0), &chunk, |id| {
|
||||
asked.push(id);
|
||||
id == 1
|
||||
});
|
||||
assert_eq!(
|
||||
(sections.collect::<Vec<_>>(), asked),
|
||||
(vec![MIN_Y + 16], vec![0, 1])
|
||||
);
|
||||
Arc::make_mut(&mut chunk).set(0, MIN_Y + 16, 0, 0);
|
||||
Arc::make_mut(&mut Arc::make_mut(&mut chunk).sections[1]).compact();
|
||||
assert_eq!(cache.get_with((0, 0), &chunk, |id| id == 1).len(), 0);
|
||||
Arc::make_mut(&mut chunk).sections[SECTIONS - 1] = Arc::new(Section::default());
|
||||
cache.clear(); // resource reload, with changed facts for the same state IDs
|
||||
@@ -138,13 +175,36 @@ mod tests {
|
||||
}
|
||||
let chunk = Arc::new(chunk);
|
||||
let mut cache = RandomTickSections::default();
|
||||
// Global maybe_has considers the entire ID range, including absent states.
|
||||
// Global palettes: the states the blocks hold, not the whole ID range.
|
||||
assert_eq!(cache.get_with((0, 0), &chunk, |id| id == 40000).len(), 0);
|
||||
cache.clear();
|
||||
assert_eq!(
|
||||
cache
|
||||
.get_with((0, 0), &chunk, |id| id == 40000)
|
||||
.get_with((0, 0), &chunk, |id| id == 299)
|
||||
.collect::<Vec<_>>(),
|
||||
[MIN_Y, MIN_Y + 9 * 16, MIN_Y + (SECTIONS as i32 - 1) * 16]
|
||||
);
|
||||
let sections = cache.get_with((0, 0), &chunk, |id| id == 299);
|
||||
let top = MIN_Y + (SECTIONS as i32 - 1) * 16;
|
||||
let from = |y| sections.from(y);
|
||||
assert_eq!(
|
||||
[
|
||||
from(MIN_Y - 16),
|
||||
from(MIN_Y),
|
||||
from(MIN_Y + 16),
|
||||
from(MIN_Y + 9 * 16)
|
||||
],
|
||||
[
|
||||
Some(MIN_Y),
|
||||
Some(MIN_Y),
|
||||
Some(MIN_Y + 9 * 16),
|
||||
Some(MIN_Y + 9 * 16)
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
[from(top), from(top + 16), from(top + 1000)],
|
||||
[Some(top), None, None]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -201,7 +261,7 @@ mod tick_parity {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn actual_random_ticks_match_original_scan() {
|
||||
fn actual_random_ticks_match_a_block_scan() {
|
||||
let root = std::env::var_os("HORIZON_RESOURCES")
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(|| {
|
||||
@@ -228,6 +288,7 @@ mod tick_parity {
|
||||
let registry = data::load_registry(&root).unwrap();
|
||||
let (mut facts, behaviours) = BlockFacts::load_pair(&root, ®istry).unwrap();
|
||||
let grass = registry.default_state("minecraft:grass_block").unwrap();
|
||||
let stone = registry.default_state("minecraft:stone").unwrap();
|
||||
let chunk = |x| {
|
||||
let mut c = Chunk::empty(x, 0);
|
||||
c.lit = true;
|
||||
@@ -280,6 +341,15 @@ mod tick_parity {
|
||||
40 | 41 => {
|
||||
Arc::make_mut(level.chunks.get_mut(&(0, 0)).unwrap()).lit = step == 41;
|
||||
}
|
||||
70 => {
|
||||
// Every block of a ticking section replaced: its
|
||||
// palette still lists grass, but nothing ticks there.
|
||||
let c = Arc::make_mut(level.chunks.get_mut(&(-1, 0)).unwrap());
|
||||
for i in 0..4096 {
|
||||
c.set(i & 15, MIN_Y + 4 * 16 + (i >> 8), i >> 4 & 15, stone.0);
|
||||
}
|
||||
assert!(c.sections[4].blocks.maybe_has(|id| id == grass.0));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
@@ -290,47 +360,43 @@ mod tick_parity {
|
||||
facts.states[grass.0 as usize].random_ticks = step == 60;
|
||||
cache.clear();
|
||||
}
|
||||
let old: Vec<_> = original
|
||||
.chunks
|
||||
.iter()
|
||||
.filter(|(_, c)| c.lit)
|
||||
.map(|(&key, c)| {
|
||||
let sections: Vec<_> = c
|
||||
.sections
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, s)| {
|
||||
s.blocks
|
||||
.maybe_has(|id| facts.get(BlockStateId(id)).random_ticks)
|
||||
})
|
||||
.map(|(i, _)| MIN_Y + i as i32 * 16)
|
||||
.collect();
|
||||
(key, sections)
|
||||
})
|
||||
.collect();
|
||||
let new: Vec<_> = cached
|
||||
.chunks
|
||||
.iter()
|
||||
.filter(|(_, c)| c.lit)
|
||||
.map(|(&key, c)| (key, cache.get(key, c, &facts)))
|
||||
.collect();
|
||||
assert_eq!(
|
||||
old,
|
||||
new.iter()
|
||||
.map(|(k, s)| (*k, s.collect::<Vec<_>>()))
|
||||
.collect::<Vec<_>>()
|
||||
);
|
||||
// Stand in for an inside-block/scheduled write AFTER eligibility capture.
|
||||
// A write between ticks.
|
||||
if step == 20 {
|
||||
for level in [&mut original, &mut cached] {
|
||||
level.set_raw(BlockPos::new(0, MIN_Y + 16, 0), grass);
|
||||
}
|
||||
}
|
||||
for ((x, z), sections) in old {
|
||||
behaviours.tick_chunk_random(&mut original, &mut a, ChunkPos::new(x, z), sections);
|
||||
let lit = |level: &Level| {
|
||||
let mut keys: Vec<_> = level
|
||||
.chunks
|
||||
.iter()
|
||||
.filter(|(_, c)| c.lit)
|
||||
.map(|(&key, _)| key)
|
||||
.collect();
|
||||
keys.sort();
|
||||
keys
|
||||
};
|
||||
let random = |id| facts.get(BlockStateId(id)).random_ticks;
|
||||
for (x, z) in lit(&original) {
|
||||
// Vanilla's rule, asked as each section is reached: any of its
|
||||
// 4,096 blocks ticks randomly.
|
||||
let reference = |level: &mut Level, from: i32| {
|
||||
let chunk = &level.chunks[&(x, z)];
|
||||
(from..MIN_Y + SECTIONS as i32 * 16)
|
||||
.step_by(16)
|
||||
.find(|&min_y| {
|
||||
(0..16).any(|y| {
|
||||
(0..16).any(|z| (0..16).any(|x| random(chunk.get(x, min_y + y, z))))
|
||||
})
|
||||
})
|
||||
};
|
||||
behaviours.tick_chunk_random(&mut original, &mut a, ChunkPos::new(x, z), reference);
|
||||
}
|
||||
for ((x, z), sections) in new {
|
||||
behaviours.tick_chunk_random(&mut cached, &mut b, ChunkPos::new(x, z), sections);
|
||||
for (x, z) in lit(&cached) {
|
||||
let memoized = |level: &mut Level, from| {
|
||||
cache.get((x, z), &level.chunks[&(x, z)], &facts).from(from)
|
||||
};
|
||||
behaviours.tick_chunk_random(&mut cached, &mut b, ChunkPos::new(x, z), memoized);
|
||||
}
|
||||
assert_eq!(original.reads, cached.reads, "ordered reads at tick {step}");
|
||||
assert_eq!(original.events, cached.events, "events at tick {step}");
|
||||
|
||||
@@ -2258,14 +2258,18 @@ pub struct Level {
|
||||
pub game_mode: i32,
|
||||
pub time: i64,
|
||||
pub day_time: i64,
|
||||
/// The player's respawn point (bed, or the world spawn).
|
||||
/// Where the player respawns when it has no bed or anchor: its forced
|
||||
/// Overworld point (`spawnpoint`), or the world spawn.
|
||||
pub spawn: Option<[f64; 3]>,
|
||||
/// A respawn point in another dimension than the Overworld (a respawn
|
||||
/// anchor): dimension, block, yaw and `forced`.
|
||||
/// A respawn point resolved through its block when the player respawns
|
||||
/// (a bed, a respawn anchor, a forced point outside the Overworld):
|
||||
/// 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,
|
||||
/// LivingEntity `AbsorptionAmount`: the golden hearts left.
|
||||
pub absorption: f32,
|
||||
pub food: i32,
|
||||
pub saturation: f32,
|
||||
/// (slot, item, count, damage): 0..36 inventory, 100..103 armor (feet..head), 150 offhand.
|
||||
@@ -2274,6 +2278,8 @@ pub struct Level {
|
||||
pub inventory_components: HashMap<u8, Box<crate::item_data::StackData>>,
|
||||
/// Player.XpSeed: the enchanting table seed.
|
||||
pub xp_seed: i32,
|
||||
/// ServerPlayer.seenCredits: the end poem has been shown.
|
||||
pub seen_credits: bool,
|
||||
/// Player.active_effects: (id, duration, amplifier, ambient, show_icon).
|
||||
pub active_effects: Vec<(String, i32, i32, bool, bool)>,
|
||||
/// Player.EnderItems: the ender chest inventory (27 slots).
|
||||
@@ -2288,6 +2294,8 @@ pub struct Level {
|
||||
pub dimension: String,
|
||||
/// World spawn (`level.dat` `spawn`), distinct from a bed respawn point.
|
||||
pub world_spawn: Option<[i32; 3]>,
|
||||
/// The world spawn's yaw and pitch (`setworldspawn`'s rotation).
|
||||
pub world_spawn_angle: [f32; 2],
|
||||
pub difficulty: String,
|
||||
pub hardcore: bool,
|
||||
/// `difficulty_settings.locked` (LevelData.isDifficultyLocked).
|
||||
@@ -2365,6 +2373,11 @@ impl Level {
|
||||
]
|
||||
}),
|
||||
};
|
||||
let angle = |k: &str| match data.get("spawn").and_then(|s| s.get(k)) {
|
||||
Some(Tag::Float(b)) => f32::from_bits(*b),
|
||||
_ => 0.0,
|
||||
};
|
||||
level.world_spawn_angle = [angle("yaw"), angle("pitch")];
|
||||
if let Some(d) = data.get("difficulty_settings") {
|
||||
level.difficulty = text(d.get("difficulty")).unwrap_or_else(|| "normal".into());
|
||||
level.hardcore = n(d.get("hardcore")) != 0;
|
||||
@@ -2455,6 +2468,7 @@ impl Level {
|
||||
}
|
||||
self.motion = read_doubles::<3>(p.get("Motion")).unwrap_or_default();
|
||||
self.health = f("Health").map_or(20.0, |v| v as f32);
|
||||
self.absorption = f("AbsorptionAmount").map_or(0.0, |v| v as f32);
|
||||
self.food = n("foodLevel").map_or(20, |v| v as i32);
|
||||
self.saturation = f("foodSaturationLevel").map_or(5.0, |v| v as f32);
|
||||
self.food_exhaustion = f("foodExhaustionLevel").unwrap_or(0.0) as f32;
|
||||
@@ -2465,6 +2479,7 @@ impl Level {
|
||||
self.selected_slot = n("SelectedItemSlot").unwrap_or(0) as i32;
|
||||
self.fall_distance = f("fall_distance").unwrap_or(0.0);
|
||||
self.on_ground = n("OnGround").unwrap_or(0) != 0;
|
||||
self.seen_credits = n("seenCredits").unwrap_or(0) != 0;
|
||||
if let Some(mode) = n("playerGameType") {
|
||||
self.creative = mode == 1;
|
||||
self.game_mode = mode as i32;
|
||||
@@ -2482,14 +2497,17 @@ impl Level {
|
||||
{
|
||||
let dimension =
|
||||
text(respawn.get("dimension")).unwrap_or_else(|| "minecraft:overworld".into());
|
||||
if dimension == "minecraft:overworld" {
|
||||
let forced = number(respawn.get("forced")).is_some_and(|v| v != 0);
|
||||
// A forced point in the Overworld (`spawnpoint`) is where the
|
||||
// player respawns; anything else (a bed, an anchor) is resolved
|
||||
// through its block then.
|
||||
if dimension == "minecraft:overworld" && forced {
|
||||
self.spawn = Some([f64::from(pos[0]), f64::from(pos[1]), f64::from(pos[2])]);
|
||||
} else {
|
||||
let yaw = match respawn.get("yaw") {
|
||||
Some(Tag::Float(b)) => f32::from_bits(*b),
|
||||
_ => 0.0,
|
||||
};
|
||||
let forced = number(respawn.get("forced")).is_some_and(|v| v != 0);
|
||||
self.respawn_elsewhere = Some((dimension, [pos[0], pos[1], pos[2]], yaw, forced));
|
||||
}
|
||||
}
|
||||
@@ -2626,6 +2644,7 @@ impl Level {
|
||||
("OnGround", Tag::Byte(i8::from(self.on_ground))),
|
||||
("fall_distance", Tag::Double(self.fall_distance.to_bits())),
|
||||
("Health", Tag::Float(self.health.to_bits())),
|
||||
("AbsorptionAmount", Tag::Float(self.absorption.to_bits())),
|
||||
("foodLevel", Tag::Int(self.food)),
|
||||
("foodSaturationLevel", Tag::Float(self.saturation.to_bits())),
|
||||
(
|
||||
@@ -2686,6 +2705,7 @@ impl Level {
|
||||
if let Some(pos) = self.position {
|
||||
entries.push(("Pos", doubles(&pos)));
|
||||
}
|
||||
entries.push(("seenCredits", Tag::Byte(i8::from(self.seen_credits))));
|
||||
// A respawn point other than the world spawn: an anchor elsewhere, or a bed.
|
||||
if let Some((dimension, block, yaw, forced)) = &self.respawn_elsewhere {
|
||||
let mut respawn = vec![
|
||||
@@ -2704,6 +2724,8 @@ impl Level {
|
||||
s[1].floor() as i32,
|
||||
s[2].floor() as i32,
|
||||
];
|
||||
// A point of the player's own in the Overworld other than a bed
|
||||
// (`respawn_elsewhere`): `spawnpoint`'s, forced.
|
||||
if Some(block) != self.world_spawn {
|
||||
entries.push((
|
||||
"respawn",
|
||||
@@ -2712,6 +2734,7 @@ impl Level {
|
||||
("pos", Tag::IntArray(block.to_vec())),
|
||||
("yaw", Tag::Float(0f32.to_bits())),
|
||||
("pitch", Tag::Float(0f32.to_bits())),
|
||||
("forced", Tag::Byte(1)),
|
||||
]),
|
||||
));
|
||||
}
|
||||
@@ -2731,7 +2754,6 @@ impl Level {
|
||||
("seenCredits", Tag::Byte(0)),
|
||||
("HurtTime", Tag::Short(0)),
|
||||
("DeathTime", Tag::Short(0)),
|
||||
("AbsorptionAmount", Tag::Float(0)),
|
||||
("FallFlying", Tag::Byte(0)),
|
||||
] {
|
||||
map.entry(key.into()).or_insert(value);
|
||||
@@ -2857,8 +2879,8 @@ impl Level {
|
||||
Tag::compound([
|
||||
("pos", Tag::IntArray(spawn.to_vec())),
|
||||
("dimension", string("minecraft:overworld")),
|
||||
("yaw", Tag::Float(0f32.to_bits())),
|
||||
("pitch", Tag::Float(0f32.to_bits())),
|
||||
("yaw", Tag::Float(self.world_spawn_angle[0].to_bits())),
|
||||
("pitch", Tag::Float(self.world_spawn_angle[1].to_bits())),
|
||||
]),
|
||||
),
|
||||
("difficulty_settings", difficulty),
|
||||
@@ -4114,6 +4136,7 @@ mod tests {
|
||||
position: Some([1.5, 70.0, -3.25]),
|
||||
rotation: [90.0, 10.0],
|
||||
health: 17.0,
|
||||
absorption: 5.0,
|
||||
food: 15,
|
||||
saturation: 2.5,
|
||||
inventory: vec![
|
||||
@@ -4126,6 +4149,7 @@ mod tests {
|
||||
xp_total: 30,
|
||||
dimension: "minecraft:the_nether".into(),
|
||||
world_spawn: Some([8, 64, 8]),
|
||||
world_spawn_angle: [45.0, -10.0],
|
||||
spawn: Some([20.0, 70.0, 20.0]),
|
||||
difficulty_locked: true,
|
||||
flying: true,
|
||||
@@ -4144,6 +4168,7 @@ mod tests {
|
||||
assert_eq!(back.position, Some([1.5, 70.0, -3.25]));
|
||||
assert_eq!(back.rotation, [90.0, 10.0]);
|
||||
assert_eq!((back.health, back.food, back.saturation), (17.0, 15, 2.5));
|
||||
assert_eq!(back.absorption, 5.0);
|
||||
let mut inv = back.inventory.clone();
|
||||
inv.sort();
|
||||
assert_eq!(
|
||||
@@ -4160,6 +4185,7 @@ mod tests {
|
||||
);
|
||||
assert_eq!(back.dimension, "minecraft:the_nether");
|
||||
assert_eq!(back.world_spawn, Some([8, 64, 8]));
|
||||
assert_eq!(back.world_spawn_angle, [45.0, -10.0]);
|
||||
assert_eq!(back.spawn, Some([20.0, 70.0, 20.0]));
|
||||
assert!(back.difficulty_locked);
|
||||
assert!(back.weather.raining);
|
||||
@@ -4178,10 +4204,13 @@ mod tests {
|
||||
let mut again = Level {
|
||||
player_extra: Some(extra),
|
||||
ender_items: ender,
|
||||
// The live amount, not the one the loaded compound held (#1307).
|
||||
absorption: 2.0,
|
||||
..back
|
||||
};
|
||||
again.write(&dir).unwrap();
|
||||
let third = Level::read(&dir).unwrap();
|
||||
assert_eq!(third.absorption, 2.0);
|
||||
assert!(matches!(
|
||||
third
|
||||
.player_extra
|
||||
|
||||
@@ -118,6 +118,7 @@ pub fn style_from_json(v: &Value) -> Style {
|
||||
s.underlined = flag("underlined");
|
||||
s.strikethrough = flag("strikethrough");
|
||||
s.obfuscated = flag("obfuscated");
|
||||
s.font = o.get("font").and_then(text::font_id);
|
||||
s
|
||||
}
|
||||
impl NumberFormat {
|
||||
|
||||
@@ -1,12 +1,25 @@
|
||||
// Full-screen camera effects (screen_effects.rs): clip-space quads sampling
|
||||
// the shared 2D atlas with repeat inside each sprite rectangle.
|
||||
// Full-screen camera effects (screen_effects.rs): clip-space quads reading
|
||||
// one sprite rectangle of the shared 2D atlas as if it were its own texture,
|
||||
// with the sampler vanilla gives it (ReloadableTexture.setSampler: linear
|
||||
// when `blur`, clamp-to-edge when `clamp`, else nearest and repeat).
|
||||
@group(0) @binding(1) var atlas: texture_2d<f32>;
|
||||
@group(0) @binding(2) var atlas_sampler: sampler;
|
||||
// The block sprites' sampler (nearest), and the entity group's lightmap
|
||||
// sampler (linear) for `blur`. A blurred sprite has a one-texel border in the
|
||||
// atlas, wrapped as repeat reads it (hud.rs), so the hardware's bilinear
|
||||
// sample is GL_LINEAR with repeat; clamp-to-edge keeps the sample point half
|
||||
// a texel inside. Each sampling has its own pipeline (gfx.rs), so a pixel
|
||||
// costs what it did with nearest and repeat only: one sample.
|
||||
@group(0) @binding(2) var nearest_sampler: sampler;
|
||||
@group(0) @binding(4) var linear_sampler: sampler;
|
||||
override BLUR: bool = false;
|
||||
override CLAMP: bool = false;
|
||||
struct Out {
|
||||
@builtin(position) clip: vec4<f32>,
|
||||
@location(0) uv: vec2<f32>,
|
||||
@location(1) @interpolate(flat) rect: vec4<f32>,
|
||||
@location(2) color: vec4<f32>,
|
||||
// With clamp-to-edge, the sprite's first and last texel centres.
|
||||
@location(3) @interpolate(flat) edges: vec4<f32>,
|
||||
}
|
||||
@vertex fn vs_screen(@location(0) pos: vec2<f32>, @location(1) uv: vec2<f32>,
|
||||
@location(2) rect: vec4<f32>, @location(3) color: vec4<f32>) -> Out {
|
||||
@@ -15,12 +28,24 @@ struct Out {
|
||||
out.uv = uv;
|
||||
out.rect = rect;
|
||||
out.color = color;
|
||||
let half = 0.5 / vec2<f32>(textureDimensions(atlas));
|
||||
out.edges = vec4(rect.xy + half, rect.zw - half);
|
||||
return out;
|
||||
}
|
||||
fn sample(in: Out) -> vec4<f32> {
|
||||
let f = fract(in.uv);
|
||||
let uv = in.rect.xy + f * (in.rect.zw - in.rect.xy);
|
||||
return textureSampleLevel(atlas, atlas_sampler, uv, 0.0) * in.color;
|
||||
var f = in.uv;
|
||||
if !CLAMP {
|
||||
// Repeat: one period of the sprite.
|
||||
f = fract(f);
|
||||
}
|
||||
var uv = in.rect.xy + f * (in.rect.zw - in.rect.xy);
|
||||
if CLAMP {
|
||||
uv = clamp(uv, in.edges.xy, in.edges.zw);
|
||||
}
|
||||
if BLUR {
|
||||
return textureSampleLevel(atlas, linear_sampler, uv, 0.0) * in.color;
|
||||
}
|
||||
return textureSampleLevel(atlas, nearest_sampler, uv, 0.0) * in.color;
|
||||
}
|
||||
@fragment fn fs_screen(in: Out) -> @location(0) vec4<f32> {
|
||||
let c = sample(in);
|
||||
|
||||
@@ -4,7 +4,10 @@
|
||||
//! `hud_overlays::camera_overlays` (vignette, pumpkin blur, powder-snow
|
||||
//! outline, nether portal swirl). Positions are clip-space quads; textures
|
||||
//! come from the shared 2D atlas and may repeat.
|
||||
use crate::{hud::Rect, hud_layout::GuiDraw};
|
||||
use crate::{
|
||||
hud::{Rect, TextureFlags},
|
||||
hud_layout::GuiDraw,
|
||||
};
|
||||
use std::collections::HashMap;
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
@@ -25,15 +28,28 @@ pub struct ScreenVertex {
|
||||
pub rect: [f32; 4],
|
||||
pub color: [f32; 4],
|
||||
}
|
||||
/// Linear filtering (`TextureMetadataSection.blur`), else nearest.
|
||||
pub const SAMPLE_BLUR: u32 = 1;
|
||||
/// Clamp-to-edge addressing (`TextureMetadataSection.clamp`), else repeat.
|
||||
pub const SAMPLE_CLAMP: u32 = 2;
|
||||
/// The sampling bits of a standalone texture's flags.
|
||||
pub fn sampling(flags: TextureFlags) -> u32 {
|
||||
let bit = |on: bool, bit: u32| if on { bit } else { 0 };
|
||||
bit(flags.blur, SAMPLE_BLUR) | bit(flags.clamp, SAMPLE_CLAMP)
|
||||
}
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct ScreenQuad {
|
||||
pub vertices: [ScreenVertex; 4],
|
||||
pub blend: Blend,
|
||||
/// How the sprite is sampled: [`SAMPLE_BLUR`] and [`SAMPLE_CLAMP`].
|
||||
pub sampling: u32,
|
||||
}
|
||||
|
||||
/// What the camera sees this frame.
|
||||
pub struct ScreenInputs<'a> {
|
||||
pub sprites: &'a HashMap<String, Rect>,
|
||||
/// `.png.mcmeta` sampling of the standalone textures among `sprites`.
|
||||
pub flags: &'a HashMap<String, TextureFlags>,
|
||||
pub aspect: f32,
|
||||
pub partial: f32,
|
||||
/// Particle sprite of the view-blocking block around the eye.
|
||||
@@ -92,12 +108,17 @@ impl ScreenEffects {
|
||||
pub fn build(&self, input: &ScreenInputs) -> Vec<ScreenQuad> {
|
||||
let mut out = Vec::new();
|
||||
let sprite = |name: &str| input.sprites.get(name).map(|r| [r.u0, r.v0, r.u1, r.v1]);
|
||||
// Standalone textures (`ReloadableTexture.setSampler`) follow their
|
||||
// metadata; atlas sprites (block overlay, fire, portal) sample
|
||||
// nearest with repeat.
|
||||
let texture_sampling = |name: &str| input.flags.get(name).copied().map_or(0, sampling);
|
||||
let scale = hand_scale(input.aspect);
|
||||
// Screen-effect quads live at view depth 0.5 in the hand projection.
|
||||
let hand_quad = |corners: [[f32; 2]; 4],
|
||||
uv: [[f32; 2]; 4],
|
||||
rect: [f32; 4],
|
||||
color: [f32; 4]| ScreenQuad {
|
||||
color: [f32; 4],
|
||||
sampling: u32| ScreenQuad {
|
||||
vertices: std::array::from_fn(|i| ScreenVertex {
|
||||
pos: [corners[i][0] * scale[0], corners[i][1] * scale[1]],
|
||||
uv: uv[i],
|
||||
@@ -105,6 +126,7 @@ impl ScreenEffects {
|
||||
color,
|
||||
}),
|
||||
blend: Blend::Alpha,
|
||||
sampling,
|
||||
};
|
||||
let full = [[-1.0, -1.0], [1.0, -1.0], [1.0, 1.0], [-1.0, 1.0]];
|
||||
// ScreenEffectRenderer.submitBlockSprite: u1/v1 at (-1, -1), colour 0xFF191919.
|
||||
@@ -117,6 +139,7 @@ impl ScreenEffects {
|
||||
[[1.0, 1.0], [0.0, 1.0], [0.0, 0.0], [1.0, 0.0]],
|
||||
rect,
|
||||
[c, c, c, 1.0],
|
||||
0,
|
||||
));
|
||||
}
|
||||
if let Some((brightness, yaw, pitch)) = input.water
|
||||
@@ -129,6 +152,7 @@ impl ScreenEffects {
|
||||
uv,
|
||||
rect,
|
||||
[brightness, brightness, brightness, 0.1],
|
||||
texture_sampling("misc/underwater"),
|
||||
));
|
||||
}
|
||||
if input.on_fire
|
||||
@@ -150,11 +174,12 @@ impl ScreenEffects {
|
||||
[[1.0, 1.0], [0.0, 1.0], [0.0, 0.0], [1.0, 0.0]],
|
||||
rect,
|
||||
[1.0, 1.0, 1.0, alpha],
|
||||
0,
|
||||
));
|
||||
}
|
||||
}
|
||||
// Hud camera overlays: GUI-space full-screen blits (v grows downwards).
|
||||
let gui = |rect: [f32; 4], color: [f32; 4], blend: Blend| ScreenQuad {
|
||||
let gui = |rect: [f32; 4], color: [f32; 4], blend: Blend, sampling: u32| ScreenQuad {
|
||||
vertices: std::array::from_fn(|i| {
|
||||
let p = [[-1.0f32, 1.0], [1.0, 1.0], [1.0, -1.0], [-1.0, -1.0]][i];
|
||||
ScreenVertex {
|
||||
@@ -165,6 +190,7 @@ impl ScreenEffects {
|
||||
}
|
||||
}),
|
||||
blend,
|
||||
sampling,
|
||||
};
|
||||
let rgba = |c: u32| {
|
||||
let [a, r, g, b] = c.to_be_bytes();
|
||||
@@ -187,8 +213,14 @@ impl ScreenEffects {
|
||||
} else {
|
||||
Blend::Alpha
|
||||
};
|
||||
// The vignette darkens by texture × colour: black (in
|
||||
// daylight its brightness fades to 0 within half a
|
||||
// minute) leaves every pixel as it was.
|
||||
if blend == Blend::Vignette && color & 0x00ff_ffff == 0 {
|
||||
continue;
|
||||
}
|
||||
if let Some(rect) = sprite(texture) {
|
||||
out.push(gui(rect, rgba(*color), blend));
|
||||
out.push(gui(rect, rgba(*color), blend, texture_sampling(texture)));
|
||||
}
|
||||
}
|
||||
GuiDraw::AtlasSprite {
|
||||
@@ -197,7 +229,7 @@ impl ScreenEffects {
|
||||
..
|
||||
} => {
|
||||
if let Some(rect) = sprite(name) {
|
||||
out.push(gui(rect, rgba(*color), Blend::Alpha));
|
||||
out.push(gui(rect, rgba(*color), Blend::Alpha, 0));
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
@@ -243,8 +275,10 @@ mod tests {
|
||||
},
|
||||
);
|
||||
let e = ScreenEffects::default();
|
||||
let flags = HashMap::new();
|
||||
let input = ScreenInputs {
|
||||
sprites: &sprites,
|
||||
flags: &flags,
|
||||
aspect: 1.6,
|
||||
partial: 0.0,
|
||||
block_overlay: None,
|
||||
@@ -255,5 +289,80 @@ mod tests {
|
||||
let quads = e.build(&input);
|
||||
assert_eq!(quads.len(), 1);
|
||||
assert_eq!(quads[0].blend, Blend::Vignette);
|
||||
assert_eq!(quads[0].sampling, 0);
|
||||
// A black vignette (faded out in daylight) changes no pixel: no quad.
|
||||
// Below 1/255 the colour's channels are 0; a border warning tints it.
|
||||
for (brightness, warning, drawn) in [(0.003, 0.0, 0), (0.004, 0.0, 1), (0.0, 0.5, 1)] {
|
||||
let overlays = [crate::hud_overlays::vignette(320, 240, brightness, warning)];
|
||||
let input = ScreenInputs {
|
||||
block_overlay: None,
|
||||
gui_overlays: &overlays,
|
||||
..input
|
||||
};
|
||||
assert_eq!(e.build(&input).len(), drawn, "{brightness} {warning}");
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn standalone_textures_sample_by_their_metadata() {
|
||||
let rect = Rect {
|
||||
u0: 0.0,
|
||||
v0: 0.0,
|
||||
u1: 1.0,
|
||||
v1: 1.0,
|
||||
w: 16,
|
||||
h: 16,
|
||||
};
|
||||
let sprites: HashMap<_, _> = ["misc/vignette", "misc/underwater", "block/stone"]
|
||||
.map(|name| (name.to_owned(), rect))
|
||||
.into();
|
||||
let blur = TextureFlags {
|
||||
blur: true,
|
||||
clamp: false,
|
||||
};
|
||||
let clamp = TextureFlags {
|
||||
blur: false,
|
||||
clamp: true,
|
||||
};
|
||||
let flags: HashMap<_, _> = [
|
||||
("misc/vignette".to_owned(), blur),
|
||||
("misc/underwater".to_owned(), clamp),
|
||||
// Atlas sprites ignore the texture section.
|
||||
("block/stone".to_owned(), blur),
|
||||
]
|
||||
.into();
|
||||
let quads = ScreenEffects::default().build(&ScreenInputs {
|
||||
sprites: &sprites,
|
||||
flags: &flags,
|
||||
aspect: 1.6,
|
||||
partial: 0.0,
|
||||
block_overlay: Some("block/stone".to_owned()),
|
||||
water: Some((1.0, 0.0, 0.0)),
|
||||
on_fire: false,
|
||||
gui_overlays: &[crate::hud_overlays::vignette(320, 240, 1.0, 0.0)],
|
||||
});
|
||||
let sampling: Vec<_> = quads.iter().map(|q| q.sampling).collect();
|
||||
assert_eq!(sampling, [0, SAMPLE_CLAMP, SAMPLE_BLUR]);
|
||||
}
|
||||
#[test]
|
||||
fn mcmeta_texture_section() {
|
||||
let parse = TextureFlags::from_mcmeta;
|
||||
assert_eq!(
|
||||
parse(r#"{"texture":{"blur":true}}"#),
|
||||
Some(TextureFlags {
|
||||
blur: true,
|
||||
clamp: false
|
||||
})
|
||||
);
|
||||
assert_eq!(
|
||||
parse(r#"{"texture":{"clamp":true,"mipmap_strategy":"mean"}}"#),
|
||||
Some(TextureFlags {
|
||||
blur: false,
|
||||
clamp: true
|
||||
})
|
||||
);
|
||||
// Defaults (false, false) or no section: the default sampler.
|
||||
assert_eq!(parse(r#"{"texture":{"blur":false}}"#), None);
|
||||
assert_eq!(parse(r#"{"animation":{}}"#), None);
|
||||
assert_eq!(parse("not json"), None);
|
||||
}
|
||||
}
|
||||
@@ -63,6 +63,9 @@ impl SpriteAnimation {
|
||||
pub fn pixels(&self, frame: usize) -> &[u8] {
|
||||
&self.frames[frame]
|
||||
}
|
||||
pub fn frame_count(&self) -> usize {
|
||||
self.frames.len()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
@@ -93,7 +96,7 @@ pub const SPRITE_SIZE: u32 = 16;
|
||||
/// with bigger textures are sampled down to fit.
|
||||
const MAX_ARRAY_BYTES: u64 = 256 << 20;
|
||||
/// Nearest-resample a square RGBA frame to another square size.
|
||||
fn resample(frame: &[u8], from: u32, to: u32) -> Vec<u8> {
|
||||
pub(crate) fn resample(frame: &[u8], from: u32, to: u32) -> Vec<u8> {
|
||||
if from == to {
|
||||
return frame.to_vec();
|
||||
}
|
||||
@@ -327,11 +330,15 @@ pub(crate) fn test_clip(size: u32, layer: u32, metadata: &str) -> SpriteAnimatio
|
||||
.flat_map(|i| [(i % size) as u8, (i / size) as u8, blue, 255])
|
||||
.collect()
|
||||
};
|
||||
test_frames(layer, vec![frame(0), frame(99)], metadata)
|
||||
}
|
||||
#[cfg(test)]
|
||||
pub(crate) fn test_frames(layer: u32, frames: Vec<Vec<u8>>, metadata: &str) -> SpriteAnimation {
|
||||
let metadata = AnimationMetadata::from_json(metadata).unwrap();
|
||||
SpriteAnimation {
|
||||
layer,
|
||||
frames: Arc::new(vec![frame(0), frame(99)]),
|
||||
clip: Arc::new(metadata.clip(2).unwrap().unwrap()),
|
||||
clip: Arc::new(metadata.clip(frames.len() as u32).unwrap().unwrap()),
|
||||
frames: Arc::new(frames),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ use std::sync::atomic::{AtomicU32, Ordering};
|
||||
use std::sync::{Mutex, OnceLock};
|
||||
use std::time::Instant;
|
||||
|
||||
use horizon_telemetry::payload::{Crash, Perf, Settings, System, Timings};
|
||||
use horizon_telemetry::payload::{Crash, Display, Perf, Settings, System, Timings};
|
||||
use horizon_telemetry::scrub::Scrubber;
|
||||
use horizon_telemetry::{Item, Payload, Source, outbox};
|
||||
|
||||
@@ -25,8 +25,21 @@ static STARTED: OnceLock<Instant> = OnceLock::new();
|
||||
static UI_READY_MS: AtomicU32 = AtomicU32::new(u32::MAX);
|
||||
static CORE_READY_MS: AtomicU32 = AtomicU32::new(u32::MAX);
|
||||
static REST_READY_MS: AtomicU32 = AtomicU32::new(u32::MAX);
|
||||
/// The GPU's name, driver and backend, once the renderer has its adapter.
|
||||
static GPU: Mutex<Option<(String, String, String)>> = Mutex::new(None);
|
||||
/// The GPU, once the renderer has its adapter: name, maker, kind, driver, backend, its own memory.
|
||||
static GPU: Mutex<Option<Gpu>> = Mutex::new(None);
|
||||
/// The screen the window opened on.
|
||||
static DISPLAY: Mutex<Option<Display>> = Mutex::new(None);
|
||||
|
||||
/// The adapter as the renderer found it.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct Gpu {
|
||||
pub name: String,
|
||||
pub vendor: String,
|
||||
/// `integrated`, `discrete`, `virtual`, `cpu` or `other`.
|
||||
pub kind: String,
|
||||
pub driver: String,
|
||||
pub backend: String,
|
||||
}
|
||||
|
||||
/// When the game started, for the time to the title screen. Call first thing in `main`.
|
||||
pub fn mark_start() {
|
||||
@@ -61,18 +74,73 @@ fn readiness_ms(mark: &AtomicU32) -> Option<u32> {
|
||||
(ms != u32::MAX).then_some(ms)
|
||||
}
|
||||
|
||||
pub fn set_gpu(name: &str, driver: String, backend: String) {
|
||||
*GPU.lock().unwrap_or_else(|e| e.into_inner()) = Some((name.to_owned(), driver, backend));
|
||||
pub fn set_gpu(gpu: Gpu) {
|
||||
*GPU.lock().unwrap_or_else(|e| e.into_inner()) = Some(gpu);
|
||||
}
|
||||
|
||||
/// This machine, with the GPU once the renderer knows it.
|
||||
pub fn system() -> System {
|
||||
let mut system = System::detect();
|
||||
if let Some((gpu, driver, backend)) = GPU.lock().unwrap_or_else(|e| e.into_inner()).clone() {
|
||||
system.gpu = gpu;
|
||||
system.driver = driver;
|
||||
system.backend = backend;
|
||||
/// The screen the window is on: size in physical pixels, refresh rate, scale.
|
||||
pub fn set_display(width: u32, height: u32, refresh_hz: u32, scale: f64) {
|
||||
*DISPLAY.lock().unwrap_or_else(|e| e.into_inner()) = Some(Display {
|
||||
width,
|
||||
height,
|
||||
refresh_hz,
|
||||
scale: scale as f32,
|
||||
});
|
||||
}
|
||||
|
||||
/// The screen `window` is on, else the primary one, else the only one: right after the window
|
||||
/// is made, Wayland doesn't say yet which screen it is on.
|
||||
pub fn monitor(window: &winit::window::Window) -> Option<winit::monitor::MonitorHandle> {
|
||||
let only = || {
|
||||
let mut all = window.available_monitors();
|
||||
all.next().filter(|_| all.next().is_none())
|
||||
};
|
||||
window
|
||||
.current_monitor()
|
||||
.or_else(|| window.primary_monitor())
|
||||
.or_else(only)
|
||||
}
|
||||
|
||||
/// Keeps [`monitor`]'s size, refresh rate and scale for the items' `system.display`.
|
||||
pub fn note_display(window: &winit::window::Window) {
|
||||
if let Some(m) = monitor(window) {
|
||||
set_display(
|
||||
m.size().width,
|
||||
m.size().height,
|
||||
m.refresh_rate_millihertz().map_or(0, |r| r.div_ceil(1000)),
|
||||
m.scale_factor(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// wgpu's device type in the words items use.
|
||||
pub fn gpu_kind(device_type: &str) -> &'static str {
|
||||
match device_type {
|
||||
"IntegratedGpu" => "integrated",
|
||||
"DiscreteGpu" => "discrete",
|
||||
"VirtualGpu" => "virtual",
|
||||
"Cpu" => "cpu",
|
||||
_ => "other",
|
||||
}
|
||||
}
|
||||
|
||||
/// This machine, with the GPU and the screen once the renderer knows them. The drive is the one
|
||||
/// under the game folder (the current folder).
|
||||
pub fn system() -> System {
|
||||
let home = std::env::current_dir().ok();
|
||||
let mut system = System::detect_in(home.as_deref());
|
||||
if let Some(gpu) = GPU.lock().unwrap_or_else(|e| e.into_inner()).clone() {
|
||||
system.vram_mb = match gpu.kind.as_str() {
|
||||
"discrete" => horizon_telemetry::system::vram_mb(&gpu.vendor).unwrap_or(0),
|
||||
_ => 0,
|
||||
};
|
||||
system.gpu = gpu.name;
|
||||
system.gpu_vendor = gpu.vendor;
|
||||
system.gpu_kind = gpu.kind;
|
||||
system.driver = gpu.driver;
|
||||
system.backend = gpu.backend;
|
||||
}
|
||||
system.display = DISPLAY.lock().unwrap_or_else(|e| e.into_inner()).clone();
|
||||
system
|
||||
}
|
||||
|
||||
|
||||
@@ -61,6 +61,13 @@ pub struct Style {
|
||||
pub click: Option<ClickEvent>,
|
||||
/// Hover text.
|
||||
pub hover: Option<Box<Text>>,
|
||||
/// The font (`FontDescription.Resource`), a full id like
|
||||
/// `minecraft:alt`; the default font when unset.
|
||||
pub font: Option<String>,
|
||||
}
|
||||
/// A style's `font` field: an identifier (`alt` is `minecraft:alt`).
|
||||
pub fn font_id(v: &Value) -> Option<String> {
|
||||
crate::command::args::parse_identifier(v.as_str()?)
|
||||
}
|
||||
impl Style {
|
||||
pub fn color(c: u32) -> Self {
|
||||
@@ -83,6 +90,9 @@ impl Style {
|
||||
if self.hover.is_none() {
|
||||
self.hover.clone_from(&base.hover);
|
||||
}
|
||||
if self.font.is_none() {
|
||||
self.font.clone_from(&base.font);
|
||||
}
|
||||
}
|
||||
pub fn is_bold(&self) -> bool {
|
||||
self.bold == Some(true)
|
||||
@@ -451,7 +461,7 @@ impl Text {
|
||||
}
|
||||
|
||||
/// The style fields of a component's JSON.
|
||||
const STYLE_KEYS: [&str; 8] = [
|
||||
const STYLE_KEYS: [&str; 9] = [
|
||||
"color",
|
||||
"bold",
|
||||
"italic",
|
||||
@@ -460,6 +470,7 @@ const STYLE_KEYS: [&str; 8] = [
|
||||
"obfuscated",
|
||||
"click_event",
|
||||
"hover_event",
|
||||
"font",
|
||||
];
|
||||
/// Style.Serializer: the fields a style sets.
|
||||
fn style_json(style: &Style) -> Vec<(String, Value)> {
|
||||
@@ -500,6 +511,9 @@ fn style_json(style: &Style) -> Vec<(String, Value)> {
|
||||
json!({ "action": "show_text", "value": hover.component() }),
|
||||
));
|
||||
}
|
||||
if let Some(font) = &style.font {
|
||||
out.push(("font".to_owned(), font.clone().into()));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ use std::{
|
||||
mod create_world;
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
mod world_management;
|
||||
pub mod world_management;
|
||||
pub use create_world::{DIFFICULTIES, WorldOptions};
|
||||
|
||||
/// SelectWorldScreen's `HeaderAndFooterLayout(8 + 9 + 8 + 20 + 4, 60)`.
|
||||
@@ -500,6 +500,14 @@ impl Title {
|
||||
}
|
||||
}
|
||||
/// The worlds, newest first, cached for two seconds (drawn every frame).
|
||||
/// Saves changed behind the screens' back (agent control renamed or deleted a world): the
|
||||
/// list shows them again.
|
||||
pub fn worlds_changed(&mut self) {
|
||||
if matches!(self.mode, Mode::Select { .. }) {
|
||||
self.return_to_list(None);
|
||||
}
|
||||
*self.recent.borrow_mut() = None;
|
||||
}
|
||||
pub fn recent_worlds(&self) -> Vec<WorldEntry> {
|
||||
let mut recent = self.recent.borrow_mut();
|
||||
match &*recent {
|
||||
|
||||
@@ -23,7 +23,7 @@ fn world_path(saves: &Path, folder: &str) -> crate::Result<PathBuf> {
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
pub(super) fn rename(saves: &Path, folder: &str, name: &str) -> crate::Result<()> {
|
||||
pub fn rename(saves: &Path, folder: &str, name: &str) -> crate::Result<()> {
|
||||
let path = world_path(saves, folder)?.join("level.dat");
|
||||
let mut root = read_dat(&path).ok_or("Could not read level.dat")?;
|
||||
let Some(Tag::Compound(data)) = root.get_mut("Data") else {
|
||||
@@ -45,7 +45,7 @@ pub(super) fn reset_icon(saves: &Path, folder: &str) -> crate::Result<()> {
|
||||
/// file briefly held by a virus scanner, say) is retried up to five times,
|
||||
/// half a second apart, as vanilla does. Only the named folder directly in
|
||||
/// `saves` is touched: symbolic links inside it are removed, never followed.
|
||||
pub(super) fn delete(saves: &Path, folder: &str) -> crate::Result<()> {
|
||||
pub fn delete(saves: &Path, folder: &str) -> crate::Result<()> {
|
||||
let world = world_path(saves, folder)?;
|
||||
let lock = crate::save::SessionLock::acquire(&world)?;
|
||||
let lock_path = world.join("session.lock");
|
||||
@@ -86,7 +86,7 @@ fn remove_contents(world: &Path, lock: &Path) -> io::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) fn backups_path(saves: &Path) -> PathBuf {
|
||||
pub fn backups_path(saves: &Path) -> PathBuf {
|
||||
saves.parent().unwrap_or(saves).join("backups")
|
||||
}
|
||||
|
||||
@@ -112,7 +112,7 @@ pub(super) fn open_folder(path: &Path) -> crate::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) fn backup(saves: &Path, folder: &str) -> crate::Result<u64> {
|
||||
pub fn backup(saves: &Path, folder: &str) -> crate::Result<u64> {
|
||||
let world = world_path(saves, folder)?;
|
||||
let backups = backups_path(saves);
|
||||
fs::create_dir_all(&backups)?;
|
||||
|
||||
@@ -0,0 +1,354 @@
|
||||
//! Armour trims on worn armour: `EquipmentLayerRenderer`'s trim layer. A
|
||||
//! trim's texture is `textures/trims/entity/<layer>/<pattern>.png` recoloured
|
||||
//! by `PalettedTextureManager`: every pixel whose colour is in the texture's
|
||||
//! base palette (its `palette` metadata section, `trim_base`) takes the colour
|
||||
//! at the same index of the material's palette (`textures/palettes/trim/*`),
|
||||
//! unless the equipment asset's `trim_overrides` pick another texture or
|
||||
//! palette (iron armour with an iron trim uses `trim/iron_darker`).
|
||||
use horizon_assets::palette::apply_palette;
|
||||
use serde_json::Value;
|
||||
use std::{collections::HashMap, fs, path::Path};
|
||||
|
||||
/// An RGBA image: width, height, pixels.
|
||||
pub type Image = (u32, u32, Vec<u8>);
|
||||
|
||||
/// `minecraft:` added to an identifier without a namespace.
|
||||
fn qualified(id: &str) -> String {
|
||||
if id.contains(':') {
|
||||
id.to_owned()
|
||||
} else {
|
||||
format!("minecraft:{id}")
|
||||
}
|
||||
}
|
||||
|
||||
/// `assets/<namespace>/<folder>/<path><suffix>` of an identifier.
|
||||
fn asset_path(root: &Path, folder: &str, id: &str, suffix: &str) -> std::path::PathBuf {
|
||||
let id = qualified(id);
|
||||
let (namespace, path) = id.split_once(':').unwrap_or(("minecraft", &id));
|
||||
root.join("assets")
|
||||
.join(namespace)
|
||||
.join(folder)
|
||||
.join(format!("{path}{suffix}"))
|
||||
}
|
||||
|
||||
/// `EquipmentClientInfo.TrimOverride`: when the trim matches (`when`'s
|
||||
/// material and pattern, each optional), use `texture` instead of the
|
||||
/// pattern's asset id and `palette` (none when absent) as the palette.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
pub struct TrimOverride {
|
||||
pub material: Option<String>,
|
||||
pub pattern: Option<String>,
|
||||
pub texture: Option<String>,
|
||||
pub palette: Option<String>,
|
||||
}
|
||||
impl TrimOverride {
|
||||
/// An equipment asset's `trim_overrides` list.
|
||||
pub fn list(equipment: &Value) -> Vec<Self> {
|
||||
let Some(list) = equipment.get("trim_overrides").and_then(Value::as_array) else {
|
||||
return Vec::new();
|
||||
};
|
||||
let id = |v: &Value, key: &str| v.get(key).and_then(Value::as_str).map(qualified);
|
||||
list.iter()
|
||||
.filter_map(|o| {
|
||||
let when = o.get("when")?;
|
||||
Some(Self {
|
||||
material: id(when, "material"),
|
||||
pattern: id(when, "pattern"),
|
||||
texture: id(o, "texture"),
|
||||
palette: id(o, "palette"),
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
/// `TrimPredicate.matches`.
|
||||
fn matches(&self, material: &str, pattern: &str) -> bool {
|
||||
self.material.as_deref().is_none_or(|m| m == material)
|
||||
&& self.pattern.as_deref().is_none_or(|p| p == pattern)
|
||||
}
|
||||
}
|
||||
|
||||
/// The `trim_pattern` (asset id) and `trim_material` (palette id) registries.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct TrimRegistry {
|
||||
patterns: HashMap<String, String>,
|
||||
materials: HashMap<String, String>,
|
||||
}
|
||||
impl TrimRegistry {
|
||||
/// Every `data/<namespace>/trim_pattern` and `trim_material` entry.
|
||||
pub fn load(root: &Path) -> Self {
|
||||
let mut out = Self::default();
|
||||
let Ok(namespaces) = fs::read_dir(root.join("data")) else {
|
||||
return out;
|
||||
};
|
||||
for ns in namespaces.flatten() {
|
||||
let namespace = ns.file_name().to_string_lossy().into_owned();
|
||||
for (folder, key, map) in [
|
||||
("trim_pattern", "asset_id", &mut out.patterns),
|
||||
("trim_material", "palette_id", &mut out.materials),
|
||||
] {
|
||||
let Ok(files) = fs::read_dir(ns.path().join(folder)) else {
|
||||
continue;
|
||||
};
|
||||
for f in files.flatten() {
|
||||
let path = f.path();
|
||||
if path.extension().is_none_or(|e| e != "json") {
|
||||
continue;
|
||||
}
|
||||
let Some(value) = fs::read(&path)
|
||||
.ok()
|
||||
.and_then(|b| serde_json::from_slice::<Value>(&b).ok())
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
if let Some(v) = value.get(key).and_then(Value::as_str) {
|
||||
let name = path.file_stem().unwrap_or_default().to_string_lossy();
|
||||
map.insert(format!("{namespace}:{name}"), qualified(v));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
#[cfg(test)]
|
||||
fn of(patterns: &[(&str, &str)], materials: &[(&str, &str)]) -> Self {
|
||||
let map = |l: &[(&str, &str)]| {
|
||||
l.iter()
|
||||
.map(|(k, v)| (k.to_string(), v.to_string()))
|
||||
.collect()
|
||||
};
|
||||
Self {
|
||||
patterns: map(patterns),
|
||||
materials: map(materials),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The texture and palette of one trim layer.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
|
||||
pub struct TrimTexture {
|
||||
/// `<namespace>:trims/entity/<layer>/<path>`, under `textures/`.
|
||||
pub texture: String,
|
||||
/// The target palette under `textures/palettes/`; none draws the
|
||||
/// texture's own colours.
|
||||
pub palette: Option<String>,
|
||||
}
|
||||
impl TrimTexture {
|
||||
/// `TrimTextureKey.getOrPrepareTexture`: the trim (`material`,
|
||||
/// `pattern` registry ids) on the `layer` (`humanoid`,
|
||||
/// `humanoid_leggings`...) of an equipment asset with `overrides`.
|
||||
pub fn resolve(
|
||||
registry: &TrimRegistry,
|
||||
overrides: &[TrimOverride],
|
||||
material: &str,
|
||||
pattern: &str,
|
||||
layer: &str,
|
||||
) -> Option<Self> {
|
||||
let (material, pattern) = (qualified(material), qualified(pattern));
|
||||
let mut texture = registry.patterns.get(&pattern)?.clone();
|
||||
let mut palette = Some(registry.materials.get(&material)?.clone());
|
||||
if let Some(o) = overrides.iter().find(|o| o.matches(&material, &pattern)) {
|
||||
if let Some(t) = &o.texture {
|
||||
texture.clone_from(t);
|
||||
}
|
||||
palette.clone_from(&o.palette);
|
||||
}
|
||||
let (namespace, path) = texture.split_once(':')?;
|
||||
Some(Self {
|
||||
texture: format!("{namespace}:trims/entity/{layer}/{path}"),
|
||||
palette,
|
||||
})
|
||||
}
|
||||
/// The atlas sprite name the generated texture is stored under.
|
||||
pub fn sprite(&self) -> String {
|
||||
format!(
|
||||
"horizon/trim/{}/{}",
|
||||
self.texture,
|
||||
self.palette.as_deref().unwrap_or("-")
|
||||
)
|
||||
}
|
||||
/// `PalettedTextureManager.prepareSlot`: the texture with its base
|
||||
/// palette's colours swapped for the target palette's (`PaletteMapping`;
|
||||
/// unchanged when a palette is missing or the sizes differ). None when
|
||||
/// the texture itself is missing.
|
||||
pub fn generate(&self, root: &Path) -> Option<Image> {
|
||||
let path = asset_path(root, "textures", &self.texture, ".png");
|
||||
let (w, h, pixels) = crate::hud::decode(&path).ok()?;
|
||||
let Some(palette) = &self.palette else {
|
||||
return Some((w, h, pixels));
|
||||
};
|
||||
let base = fs::read(path.with_extension("png.mcmeta"))
|
||||
.ok()
|
||||
.and_then(|b| serde_json::from_slice::<Value>(&b).ok())
|
||||
.and_then(|m| {
|
||||
m.get("palette")?
|
||||
.get("base_palette")?
|
||||
.as_str()
|
||||
.map(str::to_owned)
|
||||
});
|
||||
let load = |id: &str| {
|
||||
crate::hud::decode(&asset_path(root, "textures/palettes", id, ".png"))
|
||||
.ok()
|
||||
.map(|(_, _, p)| argb(&p))
|
||||
};
|
||||
let mut mapped = argb(&pixels);
|
||||
if let (Some(key), Some(target)) = (base.as_deref().and_then(load), load(palette)) {
|
||||
// Different sizes: PaletteMapping.NONE, the texture as it is.
|
||||
let _ = apply_palette(&mut mapped, &key, &target);
|
||||
}
|
||||
let out = mapped
|
||||
.iter()
|
||||
.flat_map(|p| {
|
||||
let [a, r, g, b] = p.to_be_bytes();
|
||||
[r, g, b, a]
|
||||
})
|
||||
.collect();
|
||||
Some((w, h, out))
|
||||
}
|
||||
}
|
||||
|
||||
fn argb(rgba: &[u8]) -> Vec<u32> {
|
||||
rgba.as_chunks::<4>()
|
||||
.0
|
||||
.iter()
|
||||
.map(|p| u32::from_be_bytes([p[3], p[0], p[1], p[2]]))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The largest trim texture of the resources (the size of the atlas's trim
|
||||
/// slots), at least vanilla's 64x32.
|
||||
pub fn slot_size(root: &Path) -> (u32, u32) {
|
||||
let mut size = (64, 32);
|
||||
let Ok(namespaces) = fs::read_dir(root.join("assets")) else {
|
||||
return size;
|
||||
};
|
||||
for ns in namespaces.flatten() {
|
||||
let Ok(layers) = fs::read_dir(ns.path().join("textures/trims/entity")) else {
|
||||
continue;
|
||||
};
|
||||
for layer in layers.flatten() {
|
||||
for f in fs::read_dir(layer.path()).into_iter().flatten().flatten() {
|
||||
let path = f.path();
|
||||
if path.extension().is_some_and(|e| e == "png")
|
||||
&& let Some((w, h)) = png_size(&path)
|
||||
{
|
||||
size = (size.0.max(w.min(512)), size.1.max(h.min(512)));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
size
|
||||
}
|
||||
|
||||
/// The width and height in a PNG's IHDR chunk.
|
||||
fn png_size(path: &Path) -> Option<(u32, u32)> {
|
||||
use std::io::Read;
|
||||
let mut head = [0u8; 24];
|
||||
fs::File::open(path).ok()?.read_exact(&mut head).ok()?;
|
||||
let be = |i: usize| u32::from_be_bytes(head[i..i + 4].try_into().unwrap());
|
||||
(&head[12..16] == b"IHDR").then(|| (be(16), be(20)))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn registry() -> TrimRegistry {
|
||||
TrimRegistry::of(
|
||||
&[("minecraft:coast", "minecraft:coast")],
|
||||
&[
|
||||
("minecraft:iron", "minecraft:trim/iron"),
|
||||
("minecraft:gold", "minecraft:trim/gold"),
|
||||
],
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn material_palette_and_layer_folder() {
|
||||
let t = TrimTexture::resolve(
|
||||
®istry(),
|
||||
&[],
|
||||
"minecraft:gold",
|
||||
"minecraft:coast",
|
||||
"humanoid_leggings",
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(t.texture, "minecraft:trims/entity/humanoid_leggings/coast");
|
||||
assert_eq!(t.palette.as_deref(), Some("minecraft:trim/gold"));
|
||||
// Unknown registry entries draw nothing.
|
||||
assert!(
|
||||
TrimTexture::resolve(
|
||||
®istry(),
|
||||
&[],
|
||||
"minecraft:wood",
|
||||
"minecraft:coast",
|
||||
"humanoid"
|
||||
)
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn equipment_overrides_pick_the_darker_palette() {
|
||||
let iron = serde_json::json!({"trim_overrides": [
|
||||
{"palette": "minecraft:trim/iron_darker", "when": {"material": "minecraft:iron"}},
|
||||
]});
|
||||
let overrides = TrimOverride::list(&iron);
|
||||
let resolve =
|
||||
|m| TrimTexture::resolve(®istry(), &overrides, m, "coast", "humanoid").unwrap();
|
||||
assert_eq!(
|
||||
resolve("iron").palette.as_deref(),
|
||||
Some("minecraft:trim/iron_darker")
|
||||
);
|
||||
assert_eq!(
|
||||
resolve("gold").palette.as_deref(),
|
||||
Some("minecraft:trim/gold")
|
||||
);
|
||||
// An override without a palette leaves the texture's own colours.
|
||||
let plain = TrimOverride::list(&serde_json::json!({"trim_overrides": [
|
||||
{"texture": "minecraft:other", "when": {"pattern": "minecraft:coast"}},
|
||||
]}));
|
||||
let t = TrimTexture::resolve(®istry(), &plain, "gold", "coast", "humanoid").unwrap();
|
||||
assert_eq!(t.texture, "minecraft:trims/entity/humanoid/other");
|
||||
assert_eq!(t.palette, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vanilla_trims_are_recoloured() {
|
||||
let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../.local/horizon-client/26.3");
|
||||
if !root.join("assets/minecraft/textures/trims").is_dir() {
|
||||
return;
|
||||
}
|
||||
let registry = TrimRegistry::load(&root);
|
||||
let t = TrimTexture::resolve(
|
||||
®istry,
|
||||
&[],
|
||||
"minecraft:gold",
|
||||
"minecraft:coast",
|
||||
"humanoid",
|
||||
)
|
||||
.unwrap();
|
||||
let (w, h, gold) = t.generate(&root).unwrap();
|
||||
assert_eq!((w, h), (64, 32));
|
||||
let plain = TrimTexture {
|
||||
palette: None,
|
||||
..t.clone()
|
||||
};
|
||||
let (_, _, grey) = plain.generate(&root).unwrap();
|
||||
// Every opaque pixel of the grey pattern takes a gold palette colour.
|
||||
let gold_palette = argb(
|
||||
&crate::hud::decode(&root.join("assets/minecraft/textures/palettes/trim/gold.png"))
|
||||
.unwrap()
|
||||
.2,
|
||||
);
|
||||
let mut opaque = 0;
|
||||
for (g, c) in grey.chunks(4).zip(gold.chunks(4)) {
|
||||
if g[3] != 0 {
|
||||
opaque += 1;
|
||||
let c = u32::from_be_bytes([0xFF, c[0], c[1], c[2]]);
|
||||
assert!(gold_palette.contains(&c), "{c:08x}");
|
||||
}
|
||||
}
|
||||
assert!(opaque > 0);
|
||||
assert_eq!(slot_size(&root), (64, 32));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,452 @@
|
||||
//! `WinScreen`: the end poem, the credits and the post-credits text rolling up
|
||||
//! under the logo, after the player leaves the End for the first time (with
|
||||
//! the poem) or from Credits & Attribution (the credits alone). The texts come
|
||||
//! from the resource root, so resource packs can replace them.
|
||||
use crate::{
|
||||
font::Font,
|
||||
hud::{Blend, EndPortalFill, HudAtlas, HudBatch},
|
||||
text::{self, Style, Text},
|
||||
};
|
||||
use horizon_core::random::{LegacyRandom, RandomSource};
|
||||
use std::{collections::HashSet, path::Path};
|
||||
|
||||
/// `WinScreen.OBFUSCATE_TOKEN`: `§f§k§a§b` marks where the poem hides a name.
|
||||
const OBFUSCATE_TOKEN: &str = "\u{a7}f\u{a7}k\u{a7}a\u{a7}b";
|
||||
/// `WinScreen.NAME_PREFIX`.
|
||||
const NAME_PREFIX: &str = " ";
|
||||
/// `WinScreen.SECTION_HEADING`.
|
||||
const SECTION_HEADING: &str = "============";
|
||||
/// A key the screen reacts to (`keyPressed` / `keyReleased`).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
|
||||
pub enum Key {
|
||||
/// `KeyEvent.isUp`: the up arrow scrolls back.
|
||||
Up,
|
||||
/// Space speeds the scroll up.
|
||||
Space,
|
||||
/// Each held Control key speeds it up more.
|
||||
LeftControl,
|
||||
RightControl,
|
||||
}
|
||||
impl Key {
|
||||
pub fn from_code(code: winit::keyboard::KeyCode) -> Option<Self> {
|
||||
use winit::keyboard::KeyCode;
|
||||
match code {
|
||||
KeyCode::ArrowUp => Some(Self::Up),
|
||||
KeyCode::Space => Some(Self::Space),
|
||||
KeyCode::ControlLeft => Some(Self::LeftControl),
|
||||
KeyCode::ControlRight => Some(Self::RightControl),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
pub struct WinScreen {
|
||||
pub poem: bool,
|
||||
lines: Vec<Text>,
|
||||
centered: HashSet<usize>,
|
||||
total_scroll_length: i32,
|
||||
scroll: f32,
|
||||
speedup_active: bool,
|
||||
speedup_modifiers: HashSet<Key>,
|
||||
unmodified_scroll_speed: f32,
|
||||
scroll_speed: f32,
|
||||
direction: i32,
|
||||
}
|
||||
impl WinScreen {
|
||||
/// `WinScreen.<init>` and `init`: the poem (`texts/end.txt`) when `poem`,
|
||||
/// the credits (`texts/credits.json`), then `texts/postcredits.txt`.
|
||||
pub fn new(poem: bool, root: &Path, player: &str, font: &Font) -> Self {
|
||||
let speed = if poem { 0.5 } else { 0.75 };
|
||||
let mut screen = Self {
|
||||
poem,
|
||||
lines: Vec::new(),
|
||||
centered: HashSet::new(),
|
||||
total_scroll_length: 0,
|
||||
scroll: 0.0,
|
||||
speedup_active: false,
|
||||
speedup_modifiers: HashSet::new(),
|
||||
unmodified_scroll_speed: speed,
|
||||
scroll_speed: speed,
|
||||
direction: 1,
|
||||
};
|
||||
let texts = root.join("assets/minecraft/texts");
|
||||
let read = |name: &str| match std::fs::read_to_string(texts.join(name)) {
|
||||
Ok(s) => Some(s),
|
||||
Err(e) => {
|
||||
log::error!("Couldn't load credits from file minecraft:texts/{name}: {e}");
|
||||
None
|
||||
}
|
||||
};
|
||||
if poem && let Some(s) = read("end.txt") {
|
||||
screen.add_poem_file(&s, player, font);
|
||||
}
|
||||
if let Some(s) = read("credits.json") {
|
||||
match serde_json::from_str(&s) {
|
||||
Ok(json) => screen.add_credits_file(&json),
|
||||
Err(e) => {
|
||||
log::error!("Couldn't load credits from file minecraft:texts/credits.json: {e}")
|
||||
}
|
||||
}
|
||||
}
|
||||
if poem && let Some(s) = read("postcredits.txt") {
|
||||
screen.add_poem_file(&s, player, font);
|
||||
}
|
||||
screen.total_scroll_length = screen.lines.len() as i32 * 12;
|
||||
screen
|
||||
}
|
||||
/// `addPoemFile`: each line with the player's name in, its obfuscated
|
||||
/// spans turned into 3 to 6 scrambled characters, wrapped at 256 pixels
|
||||
/// and followed by an empty line; 8 more empty lines at the end.
|
||||
fn add_poem_file(&mut self, file: &str, player: &str, font: &Font) {
|
||||
let mut random = LegacyRandom::new(8124371);
|
||||
// BufferedReader.readLine: \n, \r or \r\n end a line; no empty last line.
|
||||
let mut lines: Vec<&str> = file
|
||||
.split('\n')
|
||||
.map(|l| l.strip_suffix('\r').unwrap_or(l))
|
||||
.collect();
|
||||
if file.ends_with('\n') {
|
||||
lines.pop();
|
||||
}
|
||||
for line in lines.iter().flat_map(|l| l.split('\r')) {
|
||||
let mut line = line.replace("PLAYERNAME", player);
|
||||
while let Some(pos) = line.find(OBFUSCATE_TOKEN) {
|
||||
let n = random.next_int_bound(4).unwrap_or(0) as usize + 3;
|
||||
line = format!(
|
||||
"{}\u{a7}f\u{a7}k{}{}",
|
||||
&line[..pos],
|
||||
&"XXXXXXXX"[..n],
|
||||
&line[pos + OBFUSCATE_TOKEN.len()..]
|
||||
);
|
||||
}
|
||||
let text = Text::legacy(&line);
|
||||
self.lines
|
||||
.extend(font.split_lines(&text, 256.0).into_iter().map(|(t, _)| t));
|
||||
self.add_empty_line();
|
||||
}
|
||||
for _ in 0..8 {
|
||||
self.add_empty_line();
|
||||
}
|
||||
}
|
||||
/// `addCreditsFile`: each section's heading, then each discipline's name
|
||||
/// and its titles with the names under them.
|
||||
fn add_credits_file(&mut self, json: &serde_json::Value) {
|
||||
let colored = |s: &str, color: u32| Text::styled(s, Style::color(color));
|
||||
let heading = || colored(SECTION_HEADING, text::WHITE);
|
||||
for section in json.as_array().into_iter().flatten() {
|
||||
let name = section["section"].as_str().unwrap_or_default();
|
||||
self.add_credits_line(heading(), true);
|
||||
self.add_credits_line(colored(name, text::YELLOW), true);
|
||||
self.add_credits_line(heading(), true);
|
||||
self.add_empty_line();
|
||||
self.add_empty_line();
|
||||
for discipline in section["disciplines"].as_array().into_iter().flatten() {
|
||||
let name = discipline["discipline"].as_str().unwrap_or_default();
|
||||
if !name.is_empty() {
|
||||
self.add_credits_line(colored(name, text::YELLOW), true);
|
||||
self.add_empty_line();
|
||||
self.add_empty_line();
|
||||
}
|
||||
for title in discipline["titles"].as_array().into_iter().flatten() {
|
||||
let name = title["title"].as_str().unwrap_or_default();
|
||||
self.add_credits_line(colored(name, text::GRAY), false);
|
||||
for name in title["names"].as_array().into_iter().flatten() {
|
||||
let line = format!("{NAME_PREFIX}{}", name.as_str().unwrap_or_default());
|
||||
self.add_credits_line(colored(&line, text::WHITE), false);
|
||||
}
|
||||
self.add_empty_line();
|
||||
self.add_empty_line();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
fn add_empty_line(&mut self) {
|
||||
self.lines.push(Text::empty());
|
||||
}
|
||||
fn add_credits_line(&mut self, line: Text, centered: bool) {
|
||||
if centered {
|
||||
self.centered.insert(self.lines.len());
|
||||
}
|
||||
self.lines.push(line);
|
||||
}
|
||||
/// The lines in order and whether each is centred.
|
||||
pub fn lines(&self) -> impl Iterator<Item = (&Text, bool)> {
|
||||
self.lines
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, l)| (l, self.centered.contains(&i)))
|
||||
}
|
||||
fn calculate_scroll_speed(&self) -> f32 {
|
||||
let speed = if self.speedup_active {
|
||||
self.unmodified_scroll_speed * (5.0 + self.speedup_modifiers.len() as f32 * 15.0)
|
||||
} else {
|
||||
self.unmodified_scroll_speed
|
||||
};
|
||||
speed * self.direction as f32
|
||||
}
|
||||
/// `keyPressed` / `keyReleased` (Escape closes the screen: `onClose`).
|
||||
pub fn key(&mut self, key: Key, pressed: bool) {
|
||||
match (key, pressed) {
|
||||
(Key::Up, _) => self.direction = if pressed { -1 } else { 1 },
|
||||
(Key::Space, _) => self.speedup_active = pressed,
|
||||
(_, true) => {
|
||||
self.speedup_modifiers.insert(key);
|
||||
}
|
||||
(_, false) => {
|
||||
self.speedup_modifiers.remove(&key);
|
||||
}
|
||||
}
|
||||
self.scroll_speed = self.calculate_scroll_speed();
|
||||
}
|
||||
/// `tick`: once the text has rolled past the top, the screen is done
|
||||
/// (`respawn`, or back to the screen that opened it).
|
||||
pub fn finished(&self, height: f32) -> bool {
|
||||
let height = height as i32;
|
||||
self.scroll > (self.total_scroll_length + height + height + 24) as f32
|
||||
}
|
||||
pub fn scroll(&self) -> f32 {
|
||||
self.scroll
|
||||
}
|
||||
/// Stay `scroll` pixels in (until a key changes the speed), for
|
||||
/// screenshots.
|
||||
pub fn hold(&mut self, scroll: f32) {
|
||||
self.scroll = scroll;
|
||||
self.scroll_speed = 0.0;
|
||||
}
|
||||
/// `extractRenderState` after a frame of `delta_ticks` game ticks:
|
||||
/// `extractBackground`, the vignette, then the logo and the lines
|
||||
/// scrolled up. The poem's End portal background samples `end_portal`;
|
||||
/// the credits draw the scrolling menu background (over the blurred
|
||||
/// panorama on the title screen).
|
||||
pub fn draw(
|
||||
&mut self,
|
||||
batch: &mut HudBatch,
|
||||
atlas: &HudAtlas,
|
||||
delta_ticks: f32,
|
||||
end_portal: EndPortalFill,
|
||||
) {
|
||||
let (w, h) = (batch.width, batch.height);
|
||||
if self.poem {
|
||||
let start = batch.vertices.len();
|
||||
batch.fill(atlas, 0.0, 0.0, w, h, u32::MAX);
|
||||
batch.blend_since(start, Blend::EndPortal);
|
||||
batch.end_portal = end_portal;
|
||||
} else {
|
||||
self.menu_background(batch, atlas, w, h);
|
||||
}
|
||||
// extractVignette: credits_vignette.png stretched over the screen.
|
||||
if let Some(r) = atlas.sprites.get("misc/credits_vignette").copied() {
|
||||
let start = batch.vertices.len();
|
||||
batch.rect_uv(0.0, 0.0, w, h, r, u32::MAX);
|
||||
batch.blend_since(start, Blend::Vignette);
|
||||
}
|
||||
self.scroll = (self.scroll + delta_ticks * self.scroll_speed).max(0.0);
|
||||
let logo_x = (w / 2.0).floor() - 128.0;
|
||||
let logo_y = h + 50.0;
|
||||
// Everything below is drawn translated by `y_offs`.
|
||||
let y_offs = -self.scroll;
|
||||
// LogoRenderer(false) at full alpha.
|
||||
let edition_x = (w / 2.0).floor() - 64.0;
|
||||
batch.sprite_region(
|
||||
atlas,
|
||||
"gui/title/minecraft",
|
||||
[logo_x, logo_y + y_offs, 256.0, 44.0],
|
||||
256.0,
|
||||
64.0,
|
||||
);
|
||||
batch.sprite_region(
|
||||
atlas,
|
||||
crate::title_edition::SPRITE,
|
||||
[edition_x, logo_y + 44.0 - 7.0 + y_offs, 128.0, 14.0],
|
||||
128.0,
|
||||
16.0,
|
||||
);
|
||||
let mut y_pos = logo_y + 100.0;
|
||||
let last = self.lines.len().saturating_sub(1);
|
||||
for (i, line) in self.lines.iter().enumerate() {
|
||||
let mut shift = y_offs;
|
||||
if i == last {
|
||||
// The last line stops in the middle of the screen.
|
||||
let diff = y_pos + y_offs - ((h as i32 / 2) as f32 - 6.0);
|
||||
if diff < 0.0 {
|
||||
shift -= diff;
|
||||
}
|
||||
}
|
||||
if y_pos + y_offs + 12.0 + 8.0 > 0.0 && y_pos + y_offs < h {
|
||||
let x = if self.centered.contains(&i) {
|
||||
// GuiGraphicsExtractor.centeredText: x - width / 2.
|
||||
logo_x + 128.0 - (atlas.font.width_text(line) as i32 / 2) as f32
|
||||
} else {
|
||||
logo_x
|
||||
};
|
||||
batch.text_styled(atlas, line, x, y_pos + shift, u32::MAX, true);
|
||||
}
|
||||
y_pos += 12.0;
|
||||
}
|
||||
}
|
||||
/// `extractMenuBackground`: `menu_background.png` tiled at 32 pixels,
|
||||
/// scrolled by half the text's scroll.
|
||||
fn menu_background(&self, batch: &mut HudBatch, atlas: &HudAtlas, w: f32, h: f32) {
|
||||
let v = (self.scroll * 0.5).rem_euclid(32.0);
|
||||
let start = batch.vertices.len();
|
||||
crate::ui::tiled(
|
||||
batch,
|
||||
atlas,
|
||||
"gui/menu_background",
|
||||
0.0,
|
||||
-v,
|
||||
w,
|
||||
h + 32.0,
|
||||
32.0,
|
||||
u32::MAX,
|
||||
);
|
||||
batch.clip_since(start, 0.0, 0.0, w, h);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A resource root with only the credits texts: every glyph is the
|
||||
/// missing one, 6 pixels wide.
|
||||
fn screen(poem: bool, end: &str, credits: &str, post: &str) -> WinScreen {
|
||||
let root = std::env::temp_dir().join(format!(
|
||||
"horizon-win-screen-{}-{poem}-{}",
|
||||
std::process::id(),
|
||||
end.len()
|
||||
));
|
||||
let texts = root.join("assets/minecraft/texts");
|
||||
std::fs::create_dir_all(&texts).unwrap();
|
||||
std::fs::write(texts.join("end.txt"), end).unwrap();
|
||||
std::fs::write(texts.join("credits.json"), credits).unwrap();
|
||||
std::fs::write(texts.join("postcredits.txt"), post).unwrap();
|
||||
let atlas = HudAtlas::fonts(&root).unwrap();
|
||||
let screen = WinScreen::new(poem, &root, "Steve", &atlas.font);
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
screen
|
||||
}
|
||||
const CREDITS: &str = r#"[{"section":"Studio","disciplines":[
|
||||
{"discipline":"","titles":[{"title":"Head","names":["Ada","Bo"]}]},
|
||||
{"discipline":"Art","titles":[{"title":"Artist","names":[]}]}]}]"#;
|
||||
|
||||
#[test]
|
||||
fn the_poem_names_the_player_and_scrambles_its_hidden_names() {
|
||||
let s = screen(
|
||||
true,
|
||||
"\u{a7}3Hello PLAYERNAME.\r\n\n\u{a7}2\u{a7}f\u{a7}k\u{a7}a\u{a7}bkey\n",
|
||||
"[]",
|
||||
"End.",
|
||||
);
|
||||
let lines: Vec<(String, bool)> = s.lines().map(|(t, c)| (t.plain(), c)).collect();
|
||||
// Each line then an empty one; the empty line adds no text line; 8
|
||||
// empty lines after each file.
|
||||
let mut want = vec!["Hello Steve.", ""];
|
||||
want.push("");
|
||||
// LegacyRandom(8124371).nextInt(4) + 3 scrambled characters.
|
||||
let n = LegacyRandom::new(8124371).next_int_bound(4).unwrap() as usize + 3;
|
||||
let hidden = format!("{}key", &"XXXXXXXX"[..n]);
|
||||
want.extend([hidden.as_str(), ""]);
|
||||
want.extend([""; 8]);
|
||||
want.extend(["End.", ""]);
|
||||
want.extend([""; 8]);
|
||||
assert_eq!(
|
||||
lines,
|
||||
want.iter()
|
||||
.map(|l| (l.to_string(), false))
|
||||
.collect::<Vec<_>>()
|
||||
);
|
||||
let hidden_line = s.lines().nth(3).unwrap().0;
|
||||
let styles: Vec<_> = hidden_line.chars().map(|(_, s)| s.obfuscated).collect();
|
||||
assert!(styles[..n].iter().all(|o| *o == Some(true)));
|
||||
assert_eq!(s.total_scroll_length, lines.len() as i32 * 12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn long_poem_lines_wrap_at_256_pixels() {
|
||||
// 6-pixel glyphs: 42 fit a line, and the space at each break goes.
|
||||
let line = vec!["a"; 50].join(" ");
|
||||
let s = screen(true, &line, "[]", "");
|
||||
let lines: Vec<String> = s.lines().take(4).map(|(t, _)| t.plain()).collect();
|
||||
assert_eq!(
|
||||
lines.iter().map(|l| l.chars().count()).collect::<Vec<_>>(),
|
||||
[41, 41, 15, 0]
|
||||
);
|
||||
assert!(lines.iter().all(|l| !l.starts_with(' ')));
|
||||
}
|
||||
#[test]
|
||||
fn credits_centre_the_headings_and_list_the_names() {
|
||||
let s = screen(false, "ignored", CREDITS, "ignored");
|
||||
let lines: Vec<(String, bool)> = s.lines().map(|(t, c)| (t.plain(), c)).collect();
|
||||
let l = |s: &str, c| (s.to_string(), c);
|
||||
let names = |n: &str| l(&format!("{NAME_PREFIX}{n}"), false);
|
||||
assert_eq!(
|
||||
lines,
|
||||
vec![
|
||||
l(SECTION_HEADING, true),
|
||||
l("Studio", true),
|
||||
l(SECTION_HEADING, true),
|
||||
l("", false),
|
||||
l("", false),
|
||||
l("Head", false),
|
||||
names("Ada"),
|
||||
names("Bo"),
|
||||
l("", false),
|
||||
l("", false),
|
||||
l("Art", true),
|
||||
l("", false),
|
||||
l("", false),
|
||||
l("Artist", false),
|
||||
l("", false),
|
||||
l("", false),
|
||||
]
|
||||
);
|
||||
let color = |i: usize| s.lines().nth(i).unwrap().0.chars().next().unwrap().1.color;
|
||||
assert_eq!(
|
||||
[color(0), color(1), color(5), color(6), color(10)],
|
||||
[
|
||||
Some(text::WHITE),
|
||||
Some(text::YELLOW),
|
||||
Some(text::GRAY),
|
||||
Some(text::WHITE),
|
||||
Some(text::YELLOW)
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keys_set_the_scroll_speed_and_direction() {
|
||||
let mut s = screen(false, "", CREDITS, "");
|
||||
assert_eq!(s.scroll_speed, 0.75);
|
||||
s.key(Key::Space, true);
|
||||
assert_eq!(s.scroll_speed, 0.75 * 5.0);
|
||||
s.key(Key::LeftControl, true);
|
||||
s.key(Key::RightControl, true);
|
||||
assert_eq!(s.scroll_speed, 0.75 * 35.0);
|
||||
s.key(Key::Up, true);
|
||||
assert_eq!(s.scroll_speed, -0.75 * 35.0);
|
||||
s.key(Key::Space, false);
|
||||
assert_eq!(s.scroll_speed, -0.75);
|
||||
s.key(Key::Up, false);
|
||||
s.key(Key::LeftControl, false);
|
||||
assert_eq!(s.scroll_speed, 0.75);
|
||||
// The poem rolls at 0.5 pixels a tick.
|
||||
assert_eq!(screen(true, "", CREDITS, "").scroll_speed, 0.5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_screen_ends_once_everything_rolled_past() {
|
||||
let mut s = screen(false, "", CREDITS, "");
|
||||
let atlas = HudAtlas::fonts(Path::new("/nonexistent")).unwrap();
|
||||
let mut batch = HudBatch::new([640, 480]);
|
||||
let h = batch.height;
|
||||
let end = (s.total_scroll_length as f32 + 2.0 * h + 24.0) / 0.75;
|
||||
s.draw(&mut batch, &atlas, end - 1.0, EndPortalFill::default());
|
||||
assert!(!s.finished(h));
|
||||
// Scrolling back never goes above the start.
|
||||
s.key(Key::Up, true);
|
||||
s.draw(&mut batch, &atlas, end * 2.0, EndPortalFill::default());
|
||||
assert_eq!(s.scroll(), 0.0);
|
||||
s.key(Key::Up, false);
|
||||
s.draw(&mut batch, &atlas, end + 1.0, EndPortalFill::default());
|
||||
assert!(s.finished(h));
|
||||
}
|
||||
}
|
||||
@@ -430,9 +430,12 @@ fn guests_see_each_other_and_the_world_entities() {
|
||||
velocity: [0.0; 3],
|
||||
data: 0,
|
||||
meta: Vec::new(),
|
||||
defaults: Vec::new(),
|
||||
equipment: Vec::new(),
|
||||
attributes: Vec::new(),
|
||||
precise: false,
|
||||
needs_sync: false,
|
||||
leash: None,
|
||||
};
|
||||
let first = thread::spawn(move || {
|
||||
let mut bot = connect(port, "Alex", false)?;
|
||||
|
||||
@@ -9,6 +9,7 @@ pub mod carving;
|
||||
pub mod density;
|
||||
pub mod geometry;
|
||||
pub mod jmath;
|
||||
pub mod no_ai;
|
||||
pub mod noise;
|
||||
pub mod paletted;
|
||||
pub mod position;
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
//! The "No AI" switch: one setting that hides and turns off every AI feature of Horizon (the
|
||||
//! companion, agent control through the control socket, `horizon-ctl`, its MCP server and the
|
||||
//! launcher's). The launcher and the game both read and write it here, so they always agree.
|
||||
//!
|
||||
//! It is the `noAi` line of the game's `horizon-extras.txt`, in the game's home (the folder
|
||||
//! with `options.txt`). Missing means off: AI features are available. See docs/agent-control.md.
|
||||
use std::{fs, io, path::Path};
|
||||
|
||||
/// The game's extras file, beside `options.txt`.
|
||||
pub const FILE: &str = "horizon-extras.txt";
|
||||
/// The key No AI is kept under there.
|
||||
pub const KEY: &str = "noAi";
|
||||
/// The one line every AI surface answers with while No AI is on.
|
||||
pub const REFUSAL: &str = "AI features are off: turn off No AI in the launcher's settings";
|
||||
|
||||
/// Whether `text` (an extras file) says No AI.
|
||||
pub fn parse(text: &str) -> bool {
|
||||
text.lines().any(|line| {
|
||||
line.split_once(':')
|
||||
.is_some_and(|(k, v)| k.trim() == KEY && v.trim() == "true")
|
||||
})
|
||||
}
|
||||
|
||||
/// Whether No AI is on for the game whose home is `home`.
|
||||
pub fn is_on(home: &Path) -> bool {
|
||||
fs::read_to_string(home.join(FILE)).is_ok_and(|text| parse(&text))
|
||||
}
|
||||
|
||||
/// `text` with its `noAi` line set to `on`, every other line kept as it was.
|
||||
pub fn with(text: &str, on: bool) -> String {
|
||||
let mut out: String = text
|
||||
.lines()
|
||||
.filter(|line| line.split_once(':').is_none_or(|(k, _)| k.trim() != KEY))
|
||||
.flat_map(|line| [line, "\n"])
|
||||
.collect();
|
||||
out.push_str(&format!("{KEY}:{on}\n"));
|
||||
out
|
||||
}
|
||||
|
||||
/// Turns No AI on or off for the game in `home`, keeping the file's other settings. Turning it
|
||||
/// off where there is no file writes nothing (off is the default).
|
||||
pub fn set(home: &Path, on: bool) -> io::Result<()> {
|
||||
let path = home.join(FILE);
|
||||
let text = match fs::read_to_string(&path) {
|
||||
Ok(text) => text,
|
||||
Err(e) if e.kind() == io::ErrorKind::NotFound => {
|
||||
if !on {
|
||||
return Ok(());
|
||||
}
|
||||
String::new()
|
||||
}
|
||||
Err(e) => return Err(e),
|
||||
};
|
||||
fs::create_dir_all(home)?;
|
||||
fs::write(path, with(&text, on))
|
||||
}
|
||||
|
||||
/// `Err(REFUSAL)` while No AI is on in `home`.
|
||||
pub fn check(home: &Path) -> Result<(), &'static str> {
|
||||
if is_on(home) { Err(REFUSAL) } else { Ok(()) }
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn reads_and_writes_the_extras_line_keeping_the_others() {
|
||||
assert!(!parse(""));
|
||||
assert!(!parse("noAi:false\nzoom:true\n"));
|
||||
assert!(parse("zoom:true\nnoAi: true\n"));
|
||||
assert_eq!(
|
||||
with("zoom:true\nnoAi:false\nfps:true", true),
|
||||
"zoom:true\nfps:true\nnoAi:true\n"
|
||||
);
|
||||
assert_eq!(with("", false), "noAi:false\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_writes_only_when_needed() {
|
||||
let home = std::env::temp_dir().join(format!("horizon-no-ai-{}", std::process::id()));
|
||||
let _ = fs::remove_dir_all(&home);
|
||||
set(&home, false).unwrap();
|
||||
assert!(!home.join(FILE).exists(), "off needs no file");
|
||||
assert!(check(&home).is_ok());
|
||||
set(&home, true).unwrap();
|
||||
assert!(is_on(&home));
|
||||
assert_eq!(check(&home), Err(REFUSAL));
|
||||
fs::write(home.join(FILE), "fps:true\nnoAi:true\n").unwrap();
|
||||
set(&home, false).unwrap();
|
||||
assert_eq!(
|
||||
fs::read_to_string(home.join(FILE)).unwrap(),
|
||||
"fps:true\nnoAi:false\n"
|
||||
);
|
||||
let _ = fs::remove_dir_all(&home);
|
||||
}
|
||||
}
|
||||
@@ -685,6 +685,47 @@ impl PalettedContainer {
|
||||
Palette::Global => (0..1u32 << self.bits.min(31)).any(predicate),
|
||||
}
|
||||
}
|
||||
/// Whether any entry satisfies `predicate`. Unlike [`Self::maybe_has`],
|
||||
/// palette entries no entry uses any more don't count.
|
||||
pub fn has(&self, mut predicate: impl FnMut(u32) -> bool) -> bool {
|
||||
let list = match &self.palette {
|
||||
Palette::Single(value) => return predicate(*value),
|
||||
Palette::List(list) => list,
|
||||
Palette::Global => {
|
||||
return (0..self.strategy.entries()).any(|i| predicate(self.raw(i) as u32));
|
||||
}
|
||||
};
|
||||
let matching: Vec<bool> = list.iter().map(|&v| predicate(v)).collect();
|
||||
let count = matching.iter().filter(|&&m| m).count();
|
||||
if count == 0 || count == list.len() {
|
||||
return count != 0;
|
||||
}
|
||||
let (bits, entries) = (usize::from(self.bits), self.strategy.entries());
|
||||
let per = 64 / bits;
|
||||
// The words whose fields all hold entries; the last may be partial.
|
||||
let full = entries / per;
|
||||
if count <= 4 {
|
||||
// A field-wise zero test of each word XORed with the matching
|
||||
// index in every field: `(v - lo) & !v & hi` is non-zero exactly
|
||||
// when some field of `v` is zero.
|
||||
let lo = (0..per).fold(0u64, |a, f| a | 1 << (f * bits));
|
||||
let hi = lo << (bits - 1);
|
||||
let words = &self.words[..full];
|
||||
let found = matching
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, m)| **m)
|
||||
.any(|(i, _)| {
|
||||
let pattern = lo * i as u64;
|
||||
words.iter().any(|&w| {
|
||||
let v = w ^ pattern;
|
||||
v.wrapping_sub(lo) & !v & hi != 0
|
||||
})
|
||||
});
|
||||
return found || (full * per..entries).any(|i| matching[self.raw(i)]);
|
||||
}
|
||||
(0..entries).any(|i| matching[self.raw(i)])
|
||||
}
|
||||
/// Heap bytes held by the palette and the storage.
|
||||
pub fn heap_bytes(&self) -> usize {
|
||||
self.words.capacity() * 8
|
||||
@@ -782,9 +823,78 @@ mod tests {
|
||||
let wire =
|
||||
PalettedContainer::from_wire(strategy, bits, palette, words.to_vec()).unwrap();
|
||||
assert_eq!(wire, c);
|
||||
for wanted in [0, 7, 14, 35, 7 * 699, 5] {
|
||||
assert_eq!(
|
||||
c.has(|v| v == wanted || v == wanted + 7),
|
||||
plain.iter().any(|&v| v == wanted || v == wanted + 7),
|
||||
"has {wanted} in {distinct} values"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn has_matches_a_scan_at_every_width() {
|
||||
let mut state = 0x9E37_79B9_7F4A_7C15u64;
|
||||
for (strategy, distinct) in [
|
||||
(BLOCKS, 2u32),
|
||||
(BLOCKS, 3),
|
||||
(BLOCKS, 17),
|
||||
(BLOCKS, 33),
|
||||
(BLOCKS, 65),
|
||||
(BLOCKS, 129),
|
||||
(BIOMES, 2),
|
||||
(BIOMES, 3),
|
||||
(BIOMES, 5),
|
||||
] {
|
||||
let entries = strategy.entries();
|
||||
let mut c = PalettedContainer::new(strategy, 0);
|
||||
let mut plain = vec![0u32; entries];
|
||||
for i in 0..entries {
|
||||
let v = (rng(&mut state) % u64::from(distinct)) as u32;
|
||||
c.set(i, v);
|
||||
plain[i] = v;
|
||||
}
|
||||
// Take some values away again (stale entries), the last index too.
|
||||
for gone in [0, 1, distinct - 1] {
|
||||
for i in 0..entries {
|
||||
if plain[i] == gone {
|
||||
c.set(i, distinct);
|
||||
plain[i] = distinct;
|
||||
}
|
||||
}
|
||||
for m in [3, 5, 7] {
|
||||
let hit = |v: u32| v % m == gone % m;
|
||||
assert_eq!(c.has(hit), plain.iter().any(|&v| hit(v)));
|
||||
}
|
||||
for wanted in 0..=distinct {
|
||||
for other in [wanted, wanted + 1, wanted + 2, wanted + 3, wanted + 4] {
|
||||
let hit = |v: u32| v == wanted || v == other;
|
||||
assert_eq!(
|
||||
c.has(hit),
|
||||
plain.iter().any(|&v| hit(v)),
|
||||
"{distinct} values, {wanted}/{other}, without {gone}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn has_ignores_stale_palette_entries() {
|
||||
let mut c = PalettedContainer::new(BLOCKS, 0);
|
||||
assert!(c.has(|v| v == 0) && !c.has(|v| v == 3));
|
||||
c.set(4095, 3);
|
||||
assert!(c.has(|v| v == 3));
|
||||
c.set(4095, 0);
|
||||
assert!(c.maybe_has(|v| v == 3) && !c.has(|v| v == 3));
|
||||
for v in 1..=300 {
|
||||
c.set(v as usize, v);
|
||||
}
|
||||
assert!(c.is_global() && c.has(|v| v == 300));
|
||||
c.set(300, 0);
|
||||
assert!(c.maybe_has(|v| v == 300) && !c.has(|v| v == 300));
|
||||
}
|
||||
#[test]
|
||||
fn uniform_detection_ignores_stale_palette_entries() {
|
||||
let mut c = PalettedContainer::new(BLOCKS, 0);
|
||||
c.set(10, 3);
|
||||
|
||||
@@ -147,6 +147,33 @@ pub struct Content {
|
||||
pub namespaces: Vec<String>,
|
||||
}
|
||||
|
||||
/// Reads a mod's content files ([`Content::reader`]).
|
||||
pub enum FileReader<'a> {
|
||||
Dir(&'a Path),
|
||||
Zip(Box<zip::ZipArchive<std::fs::File>>),
|
||||
}
|
||||
impl FileReader<'_> {
|
||||
/// One content file's bytes.
|
||||
pub fn read(&mut self, name: &str) -> Result<Vec<u8>, String> {
|
||||
check_path(name)?;
|
||||
let reader: Box<dyn Read + '_> = match self {
|
||||
Self::Dir(root) => {
|
||||
Box::new(std::fs::File::open(root.join(name)).map_err(|e| format!("{name}: {e}"))?)
|
||||
}
|
||||
Self::Zip(zip) => Box::new(zip.by_name(name).map_err(|e| format!("{name}: {e}"))?),
|
||||
};
|
||||
let mut out = Vec::new();
|
||||
reader
|
||||
.take(MAX_FILE + 1)
|
||||
.read_to_end(&mut out)
|
||||
.map_err(|e| format!("{name}: {e}"))?;
|
||||
if out.len() as u64 > MAX_FILE {
|
||||
return Err(format!("{name} is over {} MiB", MAX_FILE >> 20));
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
}
|
||||
|
||||
/// Why two content mods cannot be on together: a namespace both own (one's
|
||||
/// id or listed namespace is the other's), which would mix their files.
|
||||
/// `a` and `b` are (mod id, owned namespaces).
|
||||
@@ -222,29 +249,16 @@ impl Content {
|
||||
|
||||
/// One content file's bytes.
|
||||
pub fn file(&self, name: &str) -> Result<Vec<u8>, String> {
|
||||
check_path(name)?;
|
||||
let mut out = Vec::new();
|
||||
match &self.source {
|
||||
Source::Dir(root) => {
|
||||
let file =
|
||||
std::fs::File::open(root.join(name)).map_err(|e| format!("{name}: {e}"))?;
|
||||
file.take(MAX_FILE + 1)
|
||||
.read_to_end(&mut out)
|
||||
.map_err(|e| format!("{name}: {e}"))?;
|
||||
}
|
||||
Source::Zip(path) => {
|
||||
let mut zip = open_zip(path)?;
|
||||
let entry = zip.by_name(name).map_err(|e| format!("{name}: {e}"))?;
|
||||
entry
|
||||
.take(MAX_FILE + 1)
|
||||
.read_to_end(&mut out)
|
||||
.map_err(|e| format!("{name}: {e}"))?;
|
||||
}
|
||||
}
|
||||
if out.len() as u64 > MAX_FILE {
|
||||
return Err(format!("{name} is over {} MiB", MAX_FILE >> 20));
|
||||
}
|
||||
Ok(out)
|
||||
self.reader()?.read(name)
|
||||
}
|
||||
|
||||
/// A reader of its content files. A packaged mod's archive is opened
|
||||
/// once for all of them (opening it reads its whole index).
|
||||
pub fn reader(&self) -> Result<FileReader<'_>, String> {
|
||||
Ok(match &self.source {
|
||||
Source::Dir(root) => FileReader::Dir(root),
|
||||
Source::Zip(path) => FileReader::Zip(Box::new(open_zip(path)?)),
|
||||
})
|
||||
}
|
||||
|
||||
/// Where content may go, and the declarations.
|
||||
|
||||
@@ -671,6 +671,11 @@ packet!(SetCursorItem { item: Option<Stack> => Slot });
|
||||
packet!(SetDefaultSpawnPosition { pos: GlobalPos => GlobalPos, yaw: f32 => Float, pitch: f32 => Float });
|
||||
packet!(SetEntityData { id: i32 => VarInt, values: Vec<(u8, i32, Value)> => EntityData });
|
||||
packet!(SetEntityMotion { id: i32 => VarInt, velocity: [f64; 3] => LpVec3 });
|
||||
packet!(
|
||||
/// ClientboundSetEntityLinkPacket: the entity holding a leashed entity's
|
||||
/// lead, 0 when none (Leashable.setDelayedLeashHolderId).
|
||||
SetEntityLink { source: i32 => Int, dest: i32 => Int }
|
||||
);
|
||||
packet!(
|
||||
/// ClientboundSetPassengersPacket: a vehicle and every entity riding it.
|
||||
SetPassengers { vehicle: i32 => VarInt, passengers: Vec<i32> => List<VarInt> }
|
||||
@@ -1532,6 +1537,7 @@ packet_enum!(Clientbound, crate::ids::play::clientbound {
|
||||
SetDisplayObjective(SetDisplayObjective) = SET_DISPLAY_OBJECTIVE,
|
||||
SetEntityData(SetEntityData) = SET_ENTITY_DATA,
|
||||
SetEntityMotion(SetEntityMotion) = SET_ENTITY_MOTION,
|
||||
SetEntityLink(SetEntityLink) = SET_ENTITY_LINK,
|
||||
SetEquipment(SetEquipment) = SET_EQUIPMENT,
|
||||
SetExperience(SetExperience) = SET_EXPERIENCE,
|
||||
SetHealth(SetHealth) = SET_HEALTH,
|
||||
@@ -2263,6 +2269,7 @@ mod tests {
|
||||
vehicle: 2,
|
||||
passengers: vec![3, 300],
|
||||
}),
|
||||
Clientbound::SetEntityLink(SetEntityLink { source: 2, dest: 0 }),
|
||||
Clientbound::ProjectilePower(ProjectilePower { id: 4, power: 0.1 }),
|
||||
Clientbound::SetEquipment(SetEquipment {
|
||||
id: 2,
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
//! The hardware classes the public output breaks results down by. Coarse on purpose: a class
|
||||
//! holds many machines, and a published cell still needs `MIN_INSTALLS` different installs.
|
||||
//! `website/scripts/benchmarks.mjs` has the same functions, tested on the same fixtures.
|
||||
//!
|
||||
//! The raw values (the exact memory, the driver, the screen) stay in the database for the
|
||||
//! retention period and are never published.
|
||||
use horizon_telemetry::payload::System;
|
||||
use serde_json::Value;
|
||||
|
||||
/// The dimensions, in the order [`of_report`] and [`of_system`] return them.
|
||||
pub const DIMENSIONS: [&str; 5] = ["ram", "vram", "storage", "os", "gpu_kind"];
|
||||
|
||||
pub type Classes = [Option<&'static str>; 5];
|
||||
|
||||
/// Installed memory in GB (a little under the size on the box: some is reserved).
|
||||
pub fn ram(gb: f64) -> Option<&'static str> {
|
||||
Some(match gb {
|
||||
g if g.is_nan() || g <= 0.0 => return None,
|
||||
g if g <= 5.0 => "4 GB or less",
|
||||
g if g <= 10.0 => "8 GB",
|
||||
g if g <= 20.0 => "16 GB",
|
||||
g if g <= 40.0 => "32 GB",
|
||||
_ => "64 GB or more",
|
||||
})
|
||||
}
|
||||
|
||||
/// A GPU's own memory in MiB; an integrated GPU shares the system's.
|
||||
pub fn vram(mb: f64, kind: &str) -> Option<&'static str> {
|
||||
if gpu_kind(kind) == Some("integrated") {
|
||||
return Some("shared");
|
||||
}
|
||||
Some(match mb {
|
||||
m if m.is_nan() || m <= 0.0 => return None,
|
||||
m if m <= 2560.0 => "2 GB or less",
|
||||
m if m <= 4608.0 => "4 GB",
|
||||
m if m <= 6656.0 => "6 GB",
|
||||
m if m <= 8704.0 => "8 GB",
|
||||
m if m <= 12800.0 => "12 GB",
|
||||
m if m <= 16896.0 => "16 GB",
|
||||
_ => "more than 16 GB",
|
||||
})
|
||||
}
|
||||
|
||||
pub fn storage(s: &str) -> Option<&'static str> {
|
||||
["nvme", "ssd", "hdd"].into_iter().find(|k| *k == s)
|
||||
}
|
||||
|
||||
pub fn os(s: &str) -> Option<&'static str> {
|
||||
["linux", "macos", "windows"].into_iter().find(|k| *k == s)
|
||||
}
|
||||
|
||||
/// `integrated` or `discrete`, from either wording (`IntegratedGpu`, as wgpu says it, too).
|
||||
pub fn gpu_kind(s: &str) -> Option<&'static str> {
|
||||
match s {
|
||||
"integrated" | "IntegratedGpu" => Some("integrated"),
|
||||
"discrete" | "DiscreteGpu" => Some("discrete"),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// A benchmark report's classes (`docs/benchmark-report.md`'s `hardware`).
|
||||
pub fn of_report(report: &Value) -> Classes {
|
||||
let hw = &report["hardware"];
|
||||
let kind = hw["gpu"]["kind"].as_str().unwrap_or("");
|
||||
[
|
||||
hw["ram_mb"].as_f64().and_then(|mb| ram(mb / 1024.0)),
|
||||
vram(hw["gpu"]["vram_mb"].as_f64().unwrap_or(0.0), kind),
|
||||
storage(hw["storage"].as_str().unwrap_or("")),
|
||||
os(hw["os"].as_str().unwrap_or("")),
|
||||
gpu_kind(kind),
|
||||
]
|
||||
}
|
||||
|
||||
/// A telemetry item's machine's classes.
|
||||
pub fn of_system(s: &System) -> Classes {
|
||||
[
|
||||
ram(f64::from(s.ram_gb)),
|
||||
vram(f64::from(s.vram_mb), &s.gpu_kind),
|
||||
storage(&s.storage),
|
||||
os(&s.os),
|
||||
gpu_kind(&s.gpu_kind),
|
||||
]
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn classes_are_coarse() {
|
||||
assert_eq!(ram(15.6), Some("16 GB"));
|
||||
assert_eq!(ram(31.0), Some("32 GB"));
|
||||
assert_eq!(ram(64.0), Some("64 GB or more"));
|
||||
assert_eq!(ram(0.0), None);
|
||||
assert_eq!(vram(16376.0, "discrete"), Some("16 GB"));
|
||||
assert_eq!(vram(8192.0, "DiscreteGpu"), Some("8 GB"));
|
||||
assert_eq!(vram(0.0, "integrated"), Some("shared"));
|
||||
assert_eq!(vram(0.0, ""), None);
|
||||
assert_eq!(storage("nvme"), Some("nvme"));
|
||||
assert_eq!(storage("tape"), None);
|
||||
assert_eq!(gpu_kind("virtual"), None);
|
||||
}
|
||||
}
|
||||
@@ -26,6 +26,7 @@ use axum::response::{IntoResponse, Response};
|
||||
use axum::routing::{delete, get, post};
|
||||
use horizon_telemetry::batch;
|
||||
|
||||
pub mod classes;
|
||||
pub mod live;
|
||||
pub mod store;
|
||||
pub mod summary;
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
//! The database: one SQLite file, kept small.
|
||||
//!
|
||||
//! - `systems`: each distinct machine description once. Rows point at it, so a CPU and GPU
|
||||
//! name isn't stored again with every session.
|
||||
//! name isn't stored again with every session. `extra` holds the later hardware fields
|
||||
//! (cores, clocks, GPU maker and kind, VRAM, drive, screen, power, form) as JSON.
|
||||
//! - `perf`: one row per play session, in columns. A frame or tick summary is an 80-byte blob
|
||||
//! ([`pack`]), not JSON.
|
||||
//! - `reports`: crash and bug reports and benchmark uploads, their `data` zstd-compressed.
|
||||
@@ -17,6 +18,7 @@ use horizon_telemetry::Kind;
|
||||
use horizon_telemetry::batch::Batch;
|
||||
use horizon_telemetry::payload::{BUCKET_EDGES_MS, Payload, Summary, System};
|
||||
use rusqlite::{Connection, OptionalExtension, params};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
const BUCKETS: usize = BUCKET_EDGES_MS.len() + 1;
|
||||
|
||||
@@ -27,8 +29,8 @@ CREATE TABLE IF NOT EXISTS systems (
|
||||
id INTEGER PRIMARY KEY,
|
||||
os TEXT NOT NULL, os_version TEXT NOT NULL, arch TEXT NOT NULL, cpu TEXT NOT NULL,
|
||||
threads INTEGER NOT NULL, ram_gb INTEGER NOT NULL,
|
||||
gpu TEXT NOT NULL, driver TEXT NOT NULL, backend TEXT NOT NULL,
|
||||
UNIQUE (os, os_version, arch, cpu, threads, ram_gb, gpu, driver, backend)
|
||||
gpu TEXT NOT NULL, driver TEXT NOT NULL, backend TEXT NOT NULL, extra TEXT NOT NULL DEFAULT '{}',
|
||||
UNIQUE (os, os_version, arch, cpu, threads, ram_gb, gpu, driver, backend, extra)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS perf (
|
||||
install BLOB NOT NULL, day INTEGER NOT NULL, version TEXT NOT NULL, system INTEGER NOT NULL,
|
||||
@@ -126,8 +128,9 @@ pub struct Store {
|
||||
|
||||
impl Store {
|
||||
pub fn open(path: &str) -> rusqlite::Result<Self> {
|
||||
let db = Connection::open(path)?;
|
||||
let mut db = Connection::open(path)?;
|
||||
db.execute_batch(SCHEMA)?;
|
||||
migrate(&mut db)?;
|
||||
Ok(Self { db })
|
||||
}
|
||||
|
||||
@@ -357,14 +360,91 @@ impl Store {
|
||||
})?
|
||||
.collect::<Result<_, _>>()?
|
||||
};
|
||||
let breakdowns = self.breakdowns(from, to)?;
|
||||
Ok(serde_json::json!({
|
||||
"from_day": from, "to_day": to, "min_install_days": k,
|
||||
"bucket_edges_ms": BUCKET_EDGES_MS.map(|e| (f64::from(e) * 10.0).round() / 10.0),
|
||||
"totals": { "sessions": sessions, "hours": (hours * 10.0).round() / 10.0 },
|
||||
"groups": out, "crashes_by_version": crashes,
|
||||
"groups": out, "crashes_by_version": crashes, "breakdowns": breakdowns,
|
||||
}))
|
||||
}
|
||||
|
||||
/// Play sessions in `from..=to` per hardware class (`classes`): for each RAM, VRAM, drive,
|
||||
/// OS and GPU kind class with at least `MIN_INSTALLS` different installs, its installs,
|
||||
/// sessions, hours and mean p50 and p99 frame times. From the raw rows, so only within the
|
||||
/// retention period.
|
||||
fn breakdowns(&self, from: u32, to: u32) -> rusqlite::Result<serde_json::Value> {
|
||||
#[derive(Default)]
|
||||
struct Cell {
|
||||
installs: HashSet<Vec<u8>>,
|
||||
sessions: u64,
|
||||
seconds: u64,
|
||||
p50_sum: f64,
|
||||
p99_sum: f64,
|
||||
}
|
||||
let mut cells: [HashMap<&'static str, Cell>; 5] = Default::default();
|
||||
let mut q = self.db.prepare(
|
||||
"SELECT perf.install, perf.seconds, perf.frames, systems.os, systems.ram_gb, systems.extra
|
||||
FROM perf JOIN systems ON systems.id = perf.system WHERE perf.day BETWEEN ?1 AND ?2",
|
||||
)?;
|
||||
let mut rows = q.query([from, to])?;
|
||||
while let Some(r) = rows.next()? {
|
||||
let Some(frames) = unpack(&r.get::<_, Vec<u8>>(2)?) else {
|
||||
continue;
|
||||
};
|
||||
let extra: Extra = serde_json::from_str(&r.get::<_, String>(5)?).unwrap_or_default();
|
||||
let system = System {
|
||||
os: r.get(3)?,
|
||||
ram_gb: r.get(4)?,
|
||||
gpu_kind: extra.gpu_kind,
|
||||
vram_mb: extra.vram_mb,
|
||||
storage: extra.storage,
|
||||
..System::default()
|
||||
};
|
||||
let install: Vec<u8> = r.get(0)?;
|
||||
let seconds: u64 = r.get(1)?;
|
||||
for (dim, class) in crate::classes::of_system(&system).into_iter().enumerate() {
|
||||
let Some(class) = class else { continue };
|
||||
let c = cells[dim].entry(class).or_default();
|
||||
c.installs.insert(install.clone());
|
||||
c.sessions += 1;
|
||||
c.seconds += seconds;
|
||||
c.p50_sum += f64::from(frames.p50_ms);
|
||||
c.p99_sum += f64::from(frames.p99_ms);
|
||||
}
|
||||
}
|
||||
let mut out = serde_json::Map::new();
|
||||
for (dim, by_class) in crate::classes::DIMENSIONS.iter().zip(cells) {
|
||||
let mut list: Vec<(&str, Cell)> = by_class
|
||||
.into_iter()
|
||||
.filter(|(_, c)| c.installs.len() >= crate::summary::MIN_INSTALLS)
|
||||
.collect();
|
||||
list.sort_by(|a, b| {
|
||||
b.1.installs
|
||||
.len()
|
||||
.cmp(&a.1.installs.len())
|
||||
.then(a.0.cmp(b.0))
|
||||
});
|
||||
if list.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let avg = |sum: f64, n: u64| (sum / n as f64 * 10.0).round() / 10.0;
|
||||
out.insert(
|
||||
dim.to_string(),
|
||||
list.into_iter()
|
||||
.map(|(class, c)| {
|
||||
serde_json::json!({
|
||||
"class": class, "installs": c.installs.len(), "sessions": c.sessions,
|
||||
"hours": (c.seconds as f64 / 360.0).round() / 10.0,
|
||||
"p50_ms": avg(c.p50_sum, c.sessions), "p99_ms": avg(c.p99_sum, c.sessions),
|
||||
})
|
||||
})
|
||||
.collect(),
|
||||
);
|
||||
}
|
||||
Ok(serde_json::Value::Object(out))
|
||||
}
|
||||
|
||||
/// Every benchmark upload received since `from_day`, with its install (to count installs, never shown)
|
||||
/// and release, for `summary`.
|
||||
pub fn benchmark_uploads(
|
||||
@@ -398,15 +478,74 @@ impl Store {
|
||||
}
|
||||
}
|
||||
|
||||
/// The hardware fields after the first nine, as `systems.extra` keeps them.
|
||||
#[derive(Default, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct Extra {
|
||||
pub cores: u16,
|
||||
pub cpu_base_mhz: u32,
|
||||
pub cpu_boost_mhz: u32,
|
||||
pub gpu_vendor: String,
|
||||
pub gpu_kind: String,
|
||||
pub vram_mb: u32,
|
||||
pub storage: String,
|
||||
pub display: Option<horizon_telemetry::payload::Display>,
|
||||
pub power: String,
|
||||
pub form: String,
|
||||
}
|
||||
|
||||
impl Extra {
|
||||
fn of(s: &System) -> Self {
|
||||
Self {
|
||||
cores: s.cores,
|
||||
cpu_base_mhz: s.cpu_base_mhz,
|
||||
cpu_boost_mhz: s.cpu_boost_mhz,
|
||||
gpu_vendor: s.gpu_vendor.clone(),
|
||||
gpu_kind: s.gpu_kind.clone(),
|
||||
vram_mb: s.vram_mb,
|
||||
storage: s.storage.clone(),
|
||||
display: s.display.clone(),
|
||||
power: s.power.clone(),
|
||||
form: s.form.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Databases from before `systems.extra`: the table rebuilt with it (ids kept), once.
|
||||
fn migrate(db: &mut Connection) -> rusqlite::Result<()> {
|
||||
let has_extra = db
|
||||
.prepare("SELECT 1 FROM pragma_table_info('systems') WHERE name = 'extra'")?
|
||||
.exists([])?;
|
||||
if has_extra {
|
||||
return Ok(());
|
||||
}
|
||||
let tx = db.transaction()?;
|
||||
tx.execute_batch(
|
||||
"CREATE TABLE systems_new (
|
||||
id INTEGER PRIMARY KEY,
|
||||
os TEXT NOT NULL, os_version TEXT NOT NULL, arch TEXT NOT NULL, cpu TEXT NOT NULL,
|
||||
threads INTEGER NOT NULL, ram_gb INTEGER NOT NULL,
|
||||
gpu TEXT NOT NULL, driver TEXT NOT NULL, backend TEXT NOT NULL, extra TEXT NOT NULL DEFAULT '{}',
|
||||
UNIQUE (os, os_version, arch, cpu, threads, ram_gb, gpu, driver, backend, extra)
|
||||
);
|
||||
INSERT INTO systems_new (id, os, os_version, arch, cpu, threads, ram_gb, gpu, driver, backend)
|
||||
SELECT id, os, os_version, arch, cpu, threads, ram_gb, gpu, driver, backend FROM systems;
|
||||
DROP TABLE systems;
|
||||
ALTER TABLE systems_new RENAME TO systems;",
|
||||
)?;
|
||||
tx.commit()
|
||||
}
|
||||
|
||||
fn system_id(tx: &rusqlite::Transaction, s: &System) -> rusqlite::Result<i64> {
|
||||
let extra = serde_json::to_string(&Extra::of(s)).unwrap_or_else(|_| "{}".into());
|
||||
tx.execute(
|
||||
"INSERT OR IGNORE INTO systems (os, os_version, arch, cpu, threads, ram_gb, gpu, driver, backend)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
|
||||
params![s.os, s.os_version, s.arch, s.cpu, s.threads, s.ram_gb, s.gpu, s.driver, s.backend],
|
||||
"INSERT OR IGNORE INTO systems (os, os_version, arch, cpu, threads, ram_gb, gpu, driver, backend, extra)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)",
|
||||
params![s.os, s.os_version, s.arch, s.cpu, s.threads, s.ram_gb, s.gpu, s.driver, s.backend, extra],
|
||||
)?;
|
||||
tx.query_row(
|
||||
"SELECT id FROM systems WHERE os = ?1 AND os_version = ?2 AND arch = ?3 AND cpu = ?4
|
||||
AND threads = ?5 AND ram_gb = ?6 AND gpu = ?7 AND driver = ?8 AND backend = ?9",
|
||||
AND threads = ?5 AND ram_gb = ?6 AND gpu = ?7 AND driver = ?8 AND backend = ?9 AND extra = ?10",
|
||||
params![
|
||||
s.os,
|
||||
s.os_version,
|
||||
@@ -416,7 +555,8 @@ fn system_id(tx: &rusqlite::Transaction, s: &System) -> rusqlite::Result<i64> {
|
||||
s.ram_gb,
|
||||
s.gpu,
|
||||
s.driver,
|
||||
s.backend
|
||||
s.backend,
|
||||
extra
|
||||
],
|
||||
|r| r.get(0),
|
||||
)
|
||||
@@ -432,3 +572,65 @@ pub fn inflate(body: &[u8]) -> serde_json::Value {
|
||||
.and_then(|_| serde_json::from_slice(&json).ok())
|
||||
.unwrap_or(serde_json::Value::Null)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A database from before `systems.extra` opens, keeps its machines' ids and takes new ones.
|
||||
#[test]
|
||||
fn systems_from_before_extra_are_kept() {
|
||||
let path = std::env::temp_dir().join(format!(
|
||||
"horizon-telemetry-migrate-{}.sqlite",
|
||||
std::process::id()
|
||||
));
|
||||
let _ = std::fs::remove_file(&path);
|
||||
{
|
||||
let db = Connection::open(&path).unwrap();
|
||||
db.execute_batch(
|
||||
"CREATE TABLE systems (
|
||||
id INTEGER PRIMARY KEY,
|
||||
os TEXT NOT NULL, os_version TEXT NOT NULL, arch TEXT NOT NULL, cpu TEXT NOT NULL,
|
||||
threads INTEGER NOT NULL, ram_gb INTEGER NOT NULL,
|
||||
gpu TEXT NOT NULL, driver TEXT NOT NULL, backend TEXT NOT NULL,
|
||||
UNIQUE (os, os_version, arch, cpu, threads, ram_gb, gpu, driver, backend)
|
||||
);
|
||||
INSERT INTO systems VALUES (7, 'linux', 'Fedora 43', 'x86_64', 'Ryzen', 32, 32, 'RTX 4070', '570', 'Vulkan');",
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
let path_str = path.to_str().unwrap();
|
||||
let mut store = Store::open(path_str).unwrap();
|
||||
let old: (String, String) = store
|
||||
.db
|
||||
.query_row("SELECT gpu, extra FROM systems WHERE id = 7", [], |r| {
|
||||
Ok((r.get(0)?, r.get(1)?))
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(old, ("RTX 4070".to_owned(), "{}".to_owned()));
|
||||
let s = System {
|
||||
os: "linux".into(),
|
||||
gpu: "RTX 4070".into(),
|
||||
vram_mb: 12288,
|
||||
gpu_kind: "discrete".into(),
|
||||
storage: "nvme".into(),
|
||||
..System::default()
|
||||
};
|
||||
let tx = store.db.transaction().unwrap();
|
||||
let id = system_id(&tx, &s).unwrap();
|
||||
assert_eq!(system_id(&tx, &s).unwrap(), id, "the same machine once");
|
||||
let extra: String = tx
|
||||
.query_row("SELECT extra FROM systems WHERE id = ?1", [id], |r| {
|
||||
r.get(0)
|
||||
})
|
||||
.unwrap();
|
||||
tx.commit().unwrap();
|
||||
let back: Extra = serde_json::from_str(&extra).unwrap();
|
||||
assert_eq!((back.vram_mb, back.storage.as_str()), (12288, "nvme"));
|
||||
drop(store);
|
||||
Store::open(path_str).unwrap(); // a second open doesn't rebuild again
|
||||
for suffix in ["", "-wal", "-shm"] {
|
||||
let _ = std::fs::remove_file(format!("{path_str}{suffix}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -69,7 +69,9 @@ pub fn rejection(report: &Value) -> Option<&'static str> {
|
||||
if report.get("kind").and_then(Value::as_str) != Some("game") {
|
||||
return Some("not a game run");
|
||||
}
|
||||
game(&report["app"])?;
|
||||
if game(&report["app"]).is_none() {
|
||||
return Some("not Horizon or plain vanilla");
|
||||
}
|
||||
let c = &report["conditions"];
|
||||
let scenario = c.get("scenario").and_then(Value::as_str).unwrap_or("");
|
||||
if !(scenario == "standard" || scenario == "flight" || scenario.starts_with("perf_bench:")) {
|
||||
@@ -190,6 +192,8 @@ fn newest_first(a: &str, b: &str) -> std::cmp::Ordering {
|
||||
#[derive(Default)]
|
||||
struct Group {
|
||||
installs: HashMap<Vec<u8>, Vec<[Option<f64>; 7]>>,
|
||||
/// Per dimension and class, the installs in it.
|
||||
classes: [BTreeMap<&'static str, BTreeSet<Vec<u8>>>; 5],
|
||||
reports: usize,
|
||||
versions: BTreeSet<String>,
|
||||
}
|
||||
@@ -219,6 +223,14 @@ pub fn groups(uploads: &[Upload], min_installs: usize) -> Vec<Value> {
|
||||
.entry(u.install.clone())
|
||||
.or_default()
|
||||
.push(figures(r));
|
||||
for (dim, class) in crate::classes::of_report(r).into_iter().enumerate() {
|
||||
if let Some(class) = class {
|
||||
g.classes[dim]
|
||||
.entry(class)
|
||||
.or_default()
|
||||
.insert(u.install.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut out: Vec<(usize, Value)> = by
|
||||
.into_iter()
|
||||
@@ -243,11 +255,13 @@ pub fn groups(uploads: &[Upload], min_installs: usize) -> Vec<Value> {
|
||||
versions.sort_by(|a, b| newest_first(a, b));
|
||||
versions.truncate(3);
|
||||
let installs = g.installs.len();
|
||||
let breakdowns = breakdowns(&g.classes, min_installs);
|
||||
(
|
||||
installs,
|
||||
json!({
|
||||
"gpu": gpu, "cpu": cpu, "render_distance": rd, "game": game, "source": "players",
|
||||
"installs": installs, "reports": g.reports, "versions": versions, "medians": medians,
|
||||
"breakdowns": breakdowns,
|
||||
}),
|
||||
)
|
||||
})
|
||||
@@ -257,6 +271,33 @@ pub fn groups(uploads: &[Upload], min_installs: usize) -> Vec<Value> {
|
||||
out.into_iter().map(|(_, v)| v).collect()
|
||||
}
|
||||
|
||||
/// Per dimension, the classes with at least `min_installs` installs, most first:
|
||||
/// `{"ram": [{"class": "32 GB", "installs": 3}], …}`. A dimension with none is left out.
|
||||
pub fn breakdowns(
|
||||
classes: &[BTreeMap<&'static str, BTreeSet<Vec<u8>>>; 5],
|
||||
min_installs: usize,
|
||||
) -> Value {
|
||||
let mut out = serde_json::Map::new();
|
||||
for (dim, by_class) in crate::classes::DIMENSIONS.iter().zip(classes) {
|
||||
let mut cells: Vec<(&str, usize)> = by_class
|
||||
.iter()
|
||||
.map(|(c, set)| (*c, set.len()))
|
||||
.filter(|(_, n)| *n >= min_installs)
|
||||
.collect();
|
||||
cells.sort_by(|a, b| b.1.cmp(&a.1).then(a.0.cmp(b.0)));
|
||||
if !cells.is_empty() {
|
||||
out.insert(
|
||||
dim.to_string(),
|
||||
cells
|
||||
.into_iter()
|
||||
.map(|(class, installs)| json!({"class": class, "installs": installs}))
|
||||
.collect(),
|
||||
);
|
||||
}
|
||||
}
|
||||
Value::Object(out)
|
||||
}
|
||||
|
||||
/// The whole answer; `generated` is an RFC 3339 UTC time.
|
||||
pub fn summary(uploads: &[Upload], min_installs: usize, generated: &str) -> Value {
|
||||
json!({
|
||||
|
||||
@@ -157,6 +157,45 @@ async fn totals_show_only_groups_with_enough_installs() {
|
||||
);
|
||||
}
|
||||
|
||||
/// Sessions split by hardware class; a class shows only with three installs, and never a raw value.
|
||||
#[tokio::test]
|
||||
async fn totals_break_down_by_hardware_class() {
|
||||
let s = server(Config {
|
||||
min_install_days: 1,
|
||||
..generous()
|
||||
});
|
||||
for n in 0..4 {
|
||||
let mut item = perf("NVIDIA GeForce RTX 4080", 12);
|
||||
if let Payload::Perf(p) = &mut item.payload {
|
||||
p.system.ram_gb = if n == 3 { 16 } else { 31 };
|
||||
p.system.vram_mb = 16376;
|
||||
p.system.gpu_kind = "discrete".into();
|
||||
p.system.storage = if n == 3 { "hdd" } else { "nvme" }.into();
|
||||
}
|
||||
assert_eq!(
|
||||
s.post(&install(n), vec![item]).await,
|
||||
StatusCode::NO_CONTENT
|
||||
);
|
||||
}
|
||||
let totals = s.aggregate().await;
|
||||
let b = &totals["breakdowns"];
|
||||
assert_eq!(
|
||||
b["ram"],
|
||||
serde_json::json!([{"class": "32 GB", "installs": 3, "sessions": 3, "hours": 1.5, "p50_ms": b["ram"][0]["p50_ms"], "p99_ms": b["ram"][0]["p99_ms"]}]),
|
||||
"16 GB has one install: {totals}"
|
||||
);
|
||||
assert!(b["ram"][0]["p50_ms"].as_f64().unwrap() > 8.0);
|
||||
assert_eq!(b["vram"][0]["class"], "16 GB");
|
||||
assert_eq!(b["vram"][0]["installs"], 4);
|
||||
assert_eq!(b["storage"].as_array().unwrap().len(), 1);
|
||||
assert_eq!(b["os"][0]["class"], "linux");
|
||||
assert_eq!(b["gpu_kind"][0]["class"], "discrete");
|
||||
let text = totals.to_string();
|
||||
for raw in ["16376", "570.86", "Fedora", "hdd", &install(0)] {
|
||||
assert!(!text.contains(raw), "{raw} in {text}");
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn bad_batches_are_refused() {
|
||||
let s = server(generous());
|
||||
|
||||
Loaded 100 of 163 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user