mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-07 11:06:11 -10:00
Compare commits
65
Commits
No files matched your search
@@ -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(())
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -109,6 +109,9 @@ pub struct Extras {
|
||||
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 {
|
||||
@@ -138,6 +141,7 @@ impl Default for Extras {
|
||||
sort: false,
|
||||
sort_key: "key.keyboard.unknown".into(),
|
||||
no_ai: false,
|
||||
agent_control: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -202,6 +206,7 @@ impl Extras {
|
||||
"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),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
@@ -209,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{}:{}\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),
|
||||
@@ -240,6 +245,7 @@ impl Extras {
|
||||
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
|
||||
@@ -457,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>> {
|
||||
@@ -535,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.
|
||||
@@ -548,7 +559,7 @@ impl Item {
|
||||
MouseTweaksWheel => MouseTweaks,
|
||||
SortKey => Sort,
|
||||
Fullbright | DynamicLights | Zoom | Minimap | Fps | Keystrokes | MouseTweaks | Sort
|
||||
| AutoReconnect => {
|
||||
| AutoReconnect | NoAi | AgentControl => {
|
||||
return None;
|
||||
}
|
||||
})
|
||||
@@ -592,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.
|
||||
@@ -622,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,
|
||||
})
|
||||
}
|
||||
@@ -677,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;
|
||||
@@ -160,6 +162,7 @@ mod ui_control;
|
||||
mod vault;
|
||||
mod villagers;
|
||||
mod wandering_trader;
|
||||
mod win;
|
||||
mod wind;
|
||||
mod wither;
|
||||
mod world_loading;
|
||||
@@ -462,6 +465,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 +529,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 +1360,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 +1381,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 +1411,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 +1445,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,
|
||||
@@ -2291,6 +2313,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 +2408,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 +2458,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);
|
||||
@@ -3772,6 +3803,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 +3933,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 {
|
||||
@@ -4458,7 +4493,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 +4709,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 +4793,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 +4839,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 +4889,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 +4947,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 +6534,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 +7532,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;
|
||||
@@ -7753,6 +7822,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<()> {
|
||||
@@ -9444,9 +9514,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;
|
||||
@@ -9771,6 +9839,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 {
|
||||
@@ -10034,12 +10104,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)
|
||||
}
|
||||
@@ -10123,6 +10194,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
|
||||
@@ -10454,6 +10526,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 = (
|
||||
@@ -10718,7 +10794,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()
|
||||
@@ -10776,6 +10854,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)?;
|
||||
@@ -11032,6 +11111,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)
|
||||
}
|
||||
@@ -11165,6 +11247,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());
|
||||
@@ -11592,6 +11675,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(),
|
||||
@@ -11609,6 +11693,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,
|
||||
@@ -11629,6 +11717,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],
|
||||
@@ -11650,6 +11739,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,
|
||||
@@ -11733,6 +11824,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>,
|
||||
@@ -11752,6 +11844,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"),
|
||||
@@ -12444,18 +12537,7 @@ fn start_game<'a>(
|
||||
game.mods.dev = game.options.dev_mods.clone();
|
||||
game.start_mods();
|
||||
}
|
||||
if game.options.control.is_some() && game.settings.extras.no_ai {
|
||||
// No AI turns agent control off: the socket is never opened.
|
||||
log::warn!(
|
||||
"Horizon: control socket not opened: {}",
|
||||
horizon_core::no_ai::REFUSAL
|
||||
);
|
||||
} else 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
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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![
|
||||
|
||||
@@ -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) {
|
||||
@@ -568,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()),
|
||||
};
|
||||
@@ -645,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());
|
||||
@@ -727,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";
|
||||
}
|
||||
@@ -744,6 +775,7 @@ impl Game<'_> {
|
||||
Screen::WorldOptions => "world-options",
|
||||
Screen::Friends => "friends",
|
||||
Screen::CommandBlock => "command-block",
|
||||
Screen::Win => "win",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -854,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()),
|
||||
});
|
||||
|
||||
@@ -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
|
||||
@@ -700,6 +703,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 +742,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 +779,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.
|
||||
|
||||
@@ -1243,7 +1243,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 +1258,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,
|
||||
@@ -309,6 +309,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.
|
||||
@@ -470,7 +471,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);
|
||||
@@ -507,6 +509,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 +1489,32 @@ 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 elsewhere = host.hub.guests[i]
|
||||
.respawn_elsewhere
|
||||
.as_ref()
|
||||
.filter(|_| server)
|
||||
.map(RespawnConfig::from_saved);
|
||||
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 +1553,9 @@ impl Game<'_> {
|
||||
collision: Some(self.collision),
|
||||
};
|
||||
host.hub.respawn(i, spawn, &ctx);
|
||||
if let Some(config) = elsewhere {
|
||||
self.respawn_trip(conn, config);
|
||||
}
|
||||
if away && let Err(e) = self.enter_level(Some(host), back) {
|
||||
log::error!("could not reopen {}: {e}", back.id());
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -351,6 +351,7 @@ impl Game<'_> {
|
||||
r.clear_sections();
|
||||
}
|
||||
self.apply_settings();
|
||||
self.clear_waypoints();
|
||||
self.remote = Some(Remote {
|
||||
session,
|
||||
brand: "unknown".into(),
|
||||
@@ -429,6 +430,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();
|
||||
}
|
||||
@@ -2061,6 +2063,7 @@ impl Game<'_> {
|
||||
self.capture(false);
|
||||
}
|
||||
}
|
||||
C::TrackedWaypoint(p) => self.handle_waypoint(&p),
|
||||
C::SetExperience(p) => {
|
||||
self.experience = Experience {
|
||||
level: p.level,
|
||||
@@ -2114,6 +2117,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 +2891,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()
|
||||
@@ -5023,6 +5031,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;
|
||||
|
||||
@@ -31,6 +31,24 @@ 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 {
|
||||
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] = [
|
||||
@@ -415,6 +433,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 +456,9 @@ impl Game<'_> {
|
||||
}
|
||||
None
|
||||
}
|
||||
/// `ServerPlayer.findRespawnAndUseSpawnBlock` for an anchor config in the
|
||||
/// loaded dimension: the position and yaw, using a charge.
|
||||
/// `ServerPlayer.findRespawnAndUseSpawnBlock` for a config in the loaded
|
||||
/// dimension: the position and yaw, using an anchor's charge. A forced
|
||||
/// point (`spawnpoint`) that is no anchor holds if a player fits in it.
|
||||
pub(super) fn find_respawn_and_use(
|
||||
&mut self,
|
||||
c: RespawnConfig,
|
||||
@@ -440,7 +469,13 @@ impl Game<'_> {
|
||||
&& (c.forced || self.anchor_charges(state) > 0)
|
||||
&& self.anchor_works())
|
||||
{
|
||||
return Ok(None);
|
||||
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
|
||||
|
||||
@@ -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 {
|
||||
@@ -197,6 +218,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 +227,91 @@ 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; else the point is forgotten
|
||||
/// (`block.minecraft.spawn.not_valid`).
|
||||
fn resolve_server_respawn(&mut self, config: RespawnConfig) -> Option<(Vec3, f32)> {
|
||||
match self.find_respawn_and_use(config, true) {
|
||||
Ok(Some((at, yaw))) => {
|
||||
if !config.forced {
|
||||
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,7 +340,23 @@ impl Game<'_> {
|
||||
}
|
||||
self.resolve_arrivals();
|
||||
let yaw = host.hub.guests[i].yaw;
|
||||
let (position, yaw) = if trip.end {
|
||||
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 {
|
||||
match self.end_arrival(trip.target) {
|
||||
Ok((at, turned)) => (at, turned.unwrap_or(yaw)),
|
||||
Err(e) => {
|
||||
@@ -293,6 +417,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(())
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -374,8 +376,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 +383,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 +437,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).
|
||||
@@ -866,6 +869,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] {
|
||||
@@ -932,6 +942,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
|
||||
@@ -1285,7 +1296,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,
|
||||
@@ -1293,6 +1306,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)"),
|
||||
@@ -2173,14 +2194,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,
|
||||
@@ -2215,6 +2235,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),
|
||||
@@ -2772,29 +2794,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);
|
||||
@@ -3093,6 +3093,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));
|
||||
@@ -3111,22 +3113,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);
|
||||
@@ -4129,6 +4162,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
|
||||
@@ -4497,171 +4544,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")
|
||||
@@ -4724,84 +4610,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;
|
||||
@@ -4854,86 +4662,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>) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -40,6 +40,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)?)
|
||||
}
|
||||
@@ -180,7 +183,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]));
|
||||
}
|
||||
@@ -195,6 +198,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}"),
|
||||
@@ -277,7 +288,7 @@ 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));
|
||||
}
|
||||
images.sort_by(|a, b| b.2.cmp(&a.2).then(b.1.cmp(&a.1)));
|
||||
let width = 4096u32;
|
||||
@@ -384,6 +395,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,
|
||||
}
|
||||
@@ -398,6 +411,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)
|
||||
@@ -421,6 +447,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,
|
||||
}
|
||||
}
|
||||
@@ -727,7 +754,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)
|
||||
});
|
||||
@@ -821,6 +854,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)>,
|
||||
@@ -831,6 +878,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,
|
||||
@@ -853,7 +902,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 {
|
||||
@@ -861,7 +910,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,
|
||||
@@ -989,6 +1038,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"),
|
||||
@@ -1003,6 +1077,8 @@ impl HudRenderer {
|
||||
invert_pipeline,
|
||||
additive_pipeline,
|
||||
depth_pipeline,
|
||||
vignette_pipeline,
|
||||
end_portal_pipeline,
|
||||
group,
|
||||
uniform,
|
||||
buffer,
|
||||
@@ -1096,9 +1172,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.
|
||||
@@ -1186,6 +1263,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);
|
||||
}
|
||||
@@ -1198,10 +1277,10 @@ impl HudRenderer {
|
||||
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()));
|
||||
/// 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();
|
||||
@@ -1226,6 +1305,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);
|
||||
@@ -1234,6 +1318,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() {
|
||||
@@ -1418,6 +1517,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);
|
||||
}
|
||||
@@ -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])>,
|
||||
@@ -252,6 +256,7 @@ impl Guest {
|
||||
sleep_timer: 0,
|
||||
sent_bed: None,
|
||||
spawn: None,
|
||||
respawn_elsewhere: None,
|
||||
awaiting_teleport: None,
|
||||
teleport_counter: 0,
|
||||
teleport_tick: 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,
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
@@ -72,6 +72,7 @@ pub fn apply(guest: &mut Guest, level: &Level) {
|
||||
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]);
|
||||
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() {
|
||||
@@ -135,6 +136,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;
|
||||
@@ -180,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).
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}");
|
||||
|
||||
@@ -2274,6 +2274,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).
|
||||
@@ -2465,6 +2467,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;
|
||||
@@ -2686,6 +2689,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![
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -19,9 +19,9 @@
|
||||
//! `ServerLevel.tickChunk`'s random ticks: for each non-empty section,
|
||||
//! `randomTickSpeed` (game rule, default 3) positions are drawn with
|
||||
//! `Level.getBlockRandomPos` and randomly-ticking block and fluid states are
|
||||
//! ticked. The caller decides which sections are randomly ticking (vanilla
|
||||
//! counts randomly-ticking states per section); [`Behaviours::random_tick`]
|
||||
//! ticks a single position.
|
||||
//! ticked. The caller says which sections are randomly ticking when each is
|
||||
//! reached (vanilla counts randomly-ticking states per section);
|
||||
//! [`Behaviours::random_tick`] ticks a single position.
|
||||
//! 3. [`Behaviours::run_block_events`] runs queued block events (pistons),
|
||||
//! then (after entities) [`Behaviours::tick_block_entities`] advances
|
||||
//! moving pistons ([`Ticks::moving_pistons`]).
|
||||
@@ -69,7 +69,7 @@
|
||||
//! level.time += 1;
|
||||
//! behaviours.tick_scheduled(&mut level, &mut ticks);
|
||||
//! for chunk in ticking_chunks {
|
||||
//! behaviours.tick_chunk_random(&mut level, &mut ticks, chunk, randomly_ticking_section_min_ys(chunk));
|
||||
//! behaviours.tick_chunk_random(&mut level, &mut ticks, chunk, |level, from| level.next_ticking_section(chunk, from));
|
||||
//! }
|
||||
//! behaviours.run_block_events(&mut level, &mut ticks);
|
||||
//! /* tick entities */
|
||||
|
||||
@@ -1615,16 +1615,19 @@ impl Behaviours {
|
||||
};
|
||||
ticks.handling_tick = false;
|
||||
}
|
||||
/// Random-tick one chunk (`ServerLevel.tickChunk` block part). `sections`
|
||||
/// yields the minimum block Y of every section the caller considers
|
||||
/// randomly ticking (vanilla: sections containing randomly-ticking states),
|
||||
/// in ascending order. Draws `randomTickSpeed` positions per section.
|
||||
/// Random-tick one chunk (`ServerLevel.tickChunk` block part). Visits the
|
||||
/// sections from the bottom up and draws `randomTickSpeed` positions in
|
||||
/// each randomly ticking one (`LevelChunkSection.isRandomlyTicking`: it
|
||||
/// holds a randomly ticking block or fluid). `next_ticking(level, min_y)`
|
||||
/// is the minimum Y of the lowest such section from `min_y` up, as the
|
||||
/// chunk is now: vanilla asks as it reaches each section, so the ticks of
|
||||
/// the sections below count.
|
||||
pub fn tick_chunk_random<L: LevelAccess + ?Sized>(
|
||||
&self,
|
||||
level: &mut L,
|
||||
ticks: &mut Ticks,
|
||||
chunk: ChunkPos,
|
||||
sections: impl IntoIterator<Item = i32>,
|
||||
mut next_ticking: impl FnMut(&mut L, i32) -> Option<i32>,
|
||||
) {
|
||||
let speed = level.game_rules().random_tick_speed;
|
||||
if speed <= 0 {
|
||||
@@ -1639,11 +1642,13 @@ impl Behaviours {
|
||||
}
|
||||
}
|
||||
}
|
||||
for min_y in sections {
|
||||
let mut from = level.min_y();
|
||||
while let Some(min_y) = next_ticking(level, from).filter(|&y| y < level.max_y()) {
|
||||
for _ in 0..speed {
|
||||
let pos = ticks.block_random_pos(min_x, min_y, min_z, 15);
|
||||
self.sim(level, ticks).random_tick_at(pos);
|
||||
}
|
||||
from = min_y + 16;
|
||||
}
|
||||
}
|
||||
/// Random-tick a single position (block then fluid), as `tickChunk` does.
|
||||
|
||||
+258
-197
@@ -1,237 +1,298 @@
|
||||
# Agent control: driving the launcher and the game
|
||||
|
||||
AI agents and scripts control Horizon through four surfaces:
|
||||
A player's own AI agent (Claude Code, Codex, Cursor, any MCP client) or a script can do
|
||||
everything a player does with the mouse and keyboard, in the launcher and in the game. This page
|
||||
covers developers: the surfaces, the full inventory, and the rules every new command follows.
|
||||
Players read [Control Horizon with your AI agent](../website/docs/agents.md)
|
||||
(playhorizon.dev/docs/agents).
|
||||
|
||||
The command lists are generated, so they can't drift:
|
||||
|
||||
- [launcher-commands.md](launcher-commands.md): every `horizon-launcher` command, its arguments
|
||||
and its MCP tool. It comes from the CLI's clap definition (`cli_tools::markdown`), and the
|
||||
launcher test `the_launcher_reference_is_up_to_date` fails when it is stale or a tool is missing.
|
||||
- [horizon-ctl-commands.md](horizon-ctl-commands.md): every `horizon-ctl` command, its MCP tool
|
||||
and its arguments. It comes from the command table (`horizon-ctl help --markdown`), and the
|
||||
test `the_generated_reference_is_up_to_date` fails when it is stale.
|
||||
|
||||
## Surfaces
|
||||
|
||||
| Surface | Where | Transport |
|
||||
| --- | --- | --- |
|
||||
| Game control socket | `horizon-client --control` (`crates/horizon-client/src/control.rs`, commands in `src/game/control.rs`, `agent.rs` and friends) | text line in, one JSON line out; Unix socket, or localhost TCP + token where there is none |
|
||||
| `horizon-ctl` | `crates/horizon-client/src/bin/horizon-ctl/` | CLI over the socket: one table of commands (`commands.rs`) gives help, argument checks and the MCP tools |
|
||||
| `horizon-ctl mcp` | same | MCP over stdio, a tool per `horizon-ctl` command |
|
||||
| Launcher CLI and MCP | `launcher/src/cli.rs`, `launcher/src/mcp.rs` | `horizon-launcher <cmd> [--json]`; `horizon-launcher mcp` (stdio) |
|
||||
| Game control socket | `horizon-client --control` / `--control-socket PATH`, or Agent Control on in Horizon Settings > AI Agent (`crates/horizon-client/src/control.rs`; commands in `src/game/control.rs`, `agent.rs`, `ui_control.rs`, `saves_control.rs` and friends) | text line in, one JSON line out; Unix socket, or localhost TCP and a token where there is none |
|
||||
| `horizon-ctl` | `crates/horizon-client/src/bin/horizon-ctl/` | CLI over the socket; one table of commands (`commands.rs`) gives the help, the argument checks, the MCP tools and the reference |
|
||||
| `horizon-ctl mcp` | same | MCP over stdio, one tool per command of the table |
|
||||
| Launcher CLI | `launcher/src/cli.rs` | `horizon-launcher <command> [--json]` |
|
||||
| Launcher MCP | `launcher/src/mcp.rs`, `cli_tools.rs`, `game_tools.rs` | `horizon-launcher mcp` (stdio): every CLI command as a tool, the session tools, and the game's tools as `game_*` |
|
||||
| Connecting | `launcher/src/agents.rs`, `agents_page.rs`; `src/game/agent_settings.rs` | `horizon-launcher agent-config`; Settings > AI in the launcher; Horizon Settings > AI Agent in the game |
|
||||
| No AI | `crates/horizon-core/src/no_ai.rs` | `noAi:true` in `<game home>/horizon-extras.txt`, read by every surface |
|
||||
|
||||
This page is the inventory of what each surface can do and the design every new command
|
||||
follows. Quick start for players and scripts: [native-client.md](native-client.md#agent-control-horizon-ctl-and-mcp).
|
||||
## One MCP server for everything
|
||||
|
||||
## What exists today
|
||||
An agent adds one server, `horizon-launcher mcp`. It holds:
|
||||
|
||||
Legend: **yes** complete, **part** partial (see note), **no** missing, **—** not applicable.
|
||||
"Socket" is the game's own command; `horizon-ctl` and its MCP offer the subset in its table
|
||||
(`horizon-ctl help --game` lists everything the game takes, test hooks included).
|
||||
- **The launcher's CLI, generated.** `cli_tools.rs` walks the clap definition. Each command is a
|
||||
tool named after its words (`mods search` is `mods_search`). Its arguments are the command's,
|
||||
in snake_case, with clap's help, choices and defaults. A call runs that command with `--json`
|
||||
as a child process and returns what it printed. A new CLI command is a new tool with no extra
|
||||
code.
|
||||
- **Session tools**, which keep something running between calls:
|
||||
- `play` (the game, under control: `world`, `creative`, `join`, `offscreen`, `arg`);
|
||||
- `server`, `server_command`, `server_log`, `server_stop`;
|
||||
- `account_sign_in`, `account_list`.
|
||||
- **The game's tools.** It runs the selected version's `horizon-ctl mcp` (shipped beside the
|
||||
game) as a child. It lists that server's tools as `game_<name>`, all but `launch`, and passes
|
||||
calls and results through, images included. `play` starts the game with a control socket in
|
||||
the launcher's data folder. While no game answers, `game_*` calls say to call `play`.
|
||||
|
||||
Why one server rather than two: the game's tools stay defined once, in horizon-ctl's table. The
|
||||
agent's client needs one entry and one approval. The version that plays is always the one the
|
||||
launcher selected, so the game's tools always match the game. `horizon-ctl mcp` stays available
|
||||
on its own, for a game started by hand or by a script.
|
||||
|
||||
## Inventory, before and after
|
||||
|
||||
Before is `main` at 2026-10-23, before #1342, #1343, #1344, #1345 and #1353. After is with
|
||||
them. CLI is `horizon-launcher` for the launcher and `horizon-ctl` for the game. MCP is
|
||||
`horizon-launcher mcp` (the game's tools as `game_*`) and `horizon-ctl mcp`. **part** means only
|
||||
through a generic command (`ui` and `click` by widget, or a raw socket line that horizon-ctl
|
||||
passes on without checking it).
|
||||
|
||||
### Launcher
|
||||
|
||||
| Capability | Window | CLI | MCP | Notes |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| Releases, installed versions, select | yes | yes | yes | `versions`, `list_versions`; selecting happens on install only (no `select` command) |
|
||||
| Install / remove a version | yes | yes | yes | |
|
||||
| Prepare resources | yes | yes | yes | |
|
||||
| Start the client | yes | yes (attached) | yes | MCP starts it detached and cannot reach it afterwards |
|
||||
| Dedicated server: start, console, log, stop | yes | yes | yes | |
|
||||
| Accounts (no secrets) | yes | yes | yes | names and UUIDs only; sign-in is a device code the player enters |
|
||||
| Launcher settings (game folder, auto-install, opening animation, desktop entry) | yes | part | no | CLI has `desktop-entry`; the rest only in the window |
|
||||
| Logs, crash report | yes | yes | yes | |
|
||||
| Updates (Horizon and launcher) | yes | yes | yes | MCP only checks |
|
||||
| Patch notes, links | yes | yes | yes | |
|
||||
| Benchmark | — | yes | no | |
|
||||
| Telemetry consent | PR #250 | PR #250 | PR #250 | opt-in, in review |
|
||||
| Friends: join by invite code | yes | yes (`play --join`) | no | |
|
||||
| Profiles, packs, mods | no | no | no | the launcher has none yet |
|
||||
| The running game's controls | — | no | no | an agent needs a second MCP server (`horizon-ctl mcp`) |
|
||||
| Capability | Window | CLI before | MCP before | CLI after | MCP after | Notes |
|
||||
| --- | --- | --- | --- | --- | --- | --- |
|
||||
| Releases and installed versions | Play, version picker | yes | yes | yes | yes | `versions [--installed]` |
|
||||
| Install, remove a version | Play | yes | yes | yes | yes | `remove` needs `confirm` in MCP |
|
||||
| Pick the version that plays | Play | no | no | yes | yes | `settings selected-version TAG` |
|
||||
| Prepare resources | Play | yes | yes | yes | yes | |
|
||||
| Start the game | Play | yes | yes | yes | yes | `play --world --creative --join --arg`; MCP `play` adds `offscreen` |
|
||||
| Join a friend by invite code | Play | yes | no | yes | yes | `play --join CODE` |
|
||||
| Dedicated server here: start, console, log, stop | Servers | yes | yes | yes | yes | `server`; MCP `server`, `server_command`, `server_log`, `server_stop` |
|
||||
| Hosted servers: list, create, set up, run a command, forget | Servers | yes | no | yes | yes | `servers ...`; `servers_forget` needs `confirm` |
|
||||
| Accounts: list, sign in, sign out, select, offline | Settings | yes | part | yes | yes | names and UUIDs only; sign-in is a device code; `account_offline` was missing |
|
||||
| Update channel (Alpha, Stable) | Settings | yes | yes | yes | yes | |
|
||||
| Game folder, server folder, install new versions, opening animation | Settings | no | no | yes | yes | `settings [KEY VALUE]` |
|
||||
| Extra game arguments | Settings | yes | no | yes | yes | `game-args` |
|
||||
| Applications menu entry (Linux) | Settings | yes | no | yes | yes | `desktop-entry` |
|
||||
| Updates: check and install | Settings | yes | part | yes | yes | MCP could only check |
|
||||
| Patch notes, links | Patch notes, footer | yes | yes | yes | yes | |
|
||||
| Game log, crash reports | Settings | yes | yes | yes | yes | `logs [--crash]` |
|
||||
| Data collection: status, allow, deny, show, delete, send, bug, benchmark upload | Settings > Data | yes | part | yes | yes | sending was missing in MCP; `data_delete` needs `confirm` |
|
||||
| Mod browser: search, check, install, update, remove, list (mods, resource packs, shader packs, modpacks) | Mods | yes | no | yes | yes | `mods ...`; `mods_remove` needs `confirm` |
|
||||
| Modpacks: import, port, play, remove, list | Modpacks | yes | no | yes | yes | `modpack ...`; `modpack_remove` needs `confirm` |
|
||||
| Launcher benchmark | (none) | yes | no | yes | yes | |
|
||||
| The game it started | (the game) | (horizon-ctl) | yes | (horizon-ctl) | yes | `game_*` |
|
||||
| Connect an AI agent | Settings > AI | no | (none) | yes | (none) | `agent-config [--client] [--test]`; the agent is already connected, so there is no tool |
|
||||
| No AI | Settings > AI | no | (none) | yes | (none) | `no-ai [on\|off]`; the player's switch, never an agent's |
|
||||
|
||||
### Game
|
||||
|
||||
| Capability | Socket | horizon-ctl / MCP | Notes |
|
||||
| --- | --- | --- | --- |
|
||||
| Session: launch, wait until ready, open/create a world, save, quit, logs | yes | yes | `launch`, `wait-ready`, `open`, `save`, `quit`, `logs` |
|
||||
| Player and world state | yes | yes | `state`, `status` (renderer, streaming, memory) |
|
||||
| Crosshair target | yes | yes | `looking-at` |
|
||||
| Blocks: one, a box, nearest of a kind | yes | yes | `block`, `blocks`, `find-block` |
|
||||
| Entities near the player | yes | yes | `entities [RADIUS] [TYPE]`; no single-entity detail |
|
||||
| Inventory, armor, offhand, carried stack | yes | yes | `inventory` |
|
||||
| Containers and stations: read, click slots | yes | yes | `screen`/`menu`, `click-slot ID`, `container X Y Z` |
|
||||
| Crafting (grid, result, recipe book) | part | part | grid by `click-slot`; the recipe book only by `click-at` |
|
||||
| Movement, look, path-finding | yes | yes | `move`, `goto`, `look`, `look-at`, `turn --over`, `mouse-move` |
|
||||
| Act on the world | yes | yes | `break`, `place`, `interact`, `use-item`, `attack`, `select`, `drop` |
|
||||
| Raw input: keys, mouse buttons | yes | yes | `key`, `mouse`, `release`; `key w down` stays held through `wait` until `key w up`, `release` or `stop` |
|
||||
| Controllers | yes | yes | `pad` button/stick/trigger/status (a virtual pad) |
|
||||
| Chat and commands | yes | yes | `chat`, `command`, `type`, `messages`, `chat-log` |
|
||||
| Screens: which is open | yes | yes | `screen` gives a label (`title`, `options`, `inventory`...) but not the options page or sub-screen |
|
||||
| Screens: widgets | part | part | `screen` lists **labelled buttons** only (label, bounds, enabled); sliders, fields, list rows, tabs and switches are invisible |
|
||||
| Screens: act on a widget | part | part | `click-button LABEL` (buttons only), `click-at X Y`; no slider set, no field focus, no row select by id |
|
||||
| Options | part | no | `options PAGE` opens a page and `render-distance N` sets one value; no read, no set of the other ~120 options |
|
||||
| Packs (resource packs) | no | no | only through the Packs screen by pixel |
|
||||
| F3 debug data | part | part | `status` has the overlay flags and the renderer; not the F3 lines |
|
||||
| Screenshots | yes | yes | PNG of the next frame (works `--offscreen`) |
|
||||
| Recording video | no | no | |
|
||||
| Benchmarks and frame stats | yes | yes | `bench`, `stats`, `fly`, `--benchmark` |
|
||||
| Multiplayer: connect, disconnect, respawn | yes | part | `connect`/`disconnect` are socket-only |
|
||||
| LAN and friends hosting | yes | no | `lan open/close/status`, `friends host/stop/status` socket-only |
|
||||
| Cheats and test hooks | yes | part | `give`, `time`, `setblock`, `summon`, `effect`; `xp`, `enchant`, `potion`, `trade`, `fill`, `particle`, `sculk` socket-only |
|
||||
| Waits | part | part | `wait TICKS`, `wait-ready`; no "until screen X", "until chunks loaded", "until chat says Y" |
|
||||
| Events | no | no | everything is polled |
|
||||
| Errors with reasons | part | part | `{"error": "text"}`; horizon-ctl adds `kind` (`usage`, `connection`) to its own errors only |
|
||||
| Capability | Screen | CLI before | MCP before | CLI after | MCP after | Notes |
|
||||
| --- | --- | --- | --- | --- | --- | --- |
|
||||
| Start, wait until ready, save, quit, log | (all) | yes | yes | yes | yes | `launch`, `wait-ready`, `save`, `quit`, `logs` |
|
||||
| Every widget of every screen, by id; click, set, type | (all) | yes | yes | yes | yes | `ui`, `click ID`, `set ID VALUE`, `type`; `click-at`, `drag-at`, `scroll-at` by pixel |
|
||||
| Title menu, pause menu | Title, Pause | yes | yes | yes | yes | `ui`/`click`, `pause`, `resume` |
|
||||
| Worlds: list | Select World | part | part | yes | yes | `worlds` |
|
||||
| Worlds: create, open | Create New World | yes | yes | yes | yes | `open WORLD [SEED] [creative]` |
|
||||
| Worlds: rename, back up | Edit World | part | part | yes | yes | `world-rename`, `world-backup` |
|
||||
| Worlds: delete | Select World | part | part | yes | yes | `world-delete FOLDER --confirm`; it was possible through `click` without any confirm |
|
||||
| Servers list | Multiplayer | part | part | yes | yes | `servers` (names and addresses; reads servers.dat) |
|
||||
| Join a server or an invite code, leave | Multiplayer | part | no | yes | yes | `connect`, `disconnect` |
|
||||
| Open to LAN | Pause > Open to LAN | part | no | yes | yes | `lan open\|close\|status` |
|
||||
| Play with Friends: host, stop, status | Pause | part | no | yes | yes | `friends host\|stop\|status` |
|
||||
| Vanilla options, every page | Options | yes | part | yes | yes | `options-get`, `options-set` (all of options.txt, typed); `options PAGE` was socket-only |
|
||||
| Horizon Settings: Graphics, Shader Packs, Controller, Extras, Mods, AI Agent | Horizon Settings | part | part | yes | yes | `options horizon\|graphics\|extras\|agent...`, then `ui`/`click`/`set` |
|
||||
| Agent control on or off, status, connection details | Horizon Settings > AI Agent | no | no | yes | yes | new page (#1353) |
|
||||
| Key binds | Controls > Key Binds | yes | yes | yes | yes | `options-set key_key.jump key.keyboard.j` |
|
||||
| Resource packs, shader packs | Options > Resource Packs, Shader Packs | part | part | yes | yes | `options packs`, `options shader-packs`, then `ui` rows and `click`; `options-set resourcePacks` |
|
||||
| Chat and commands | Chat | yes | yes | yes | yes | `chat`, `command`, `messages`, `chat-log` |
|
||||
| Inventory, containers, stations | Inventory, Chest... | yes | part | yes | yes | `inventory`, `screen`, `click-slot`; `container X Y Z` was socket-only |
|
||||
| Crafting, recipe book | Crafting | part | part | yes | yes | the grid by `click-slot`; the recipe book by `ui`/`click` |
|
||||
| Trading, riding, shearing (right-click an entity) | Merchant, Horse | part | no | yes | yes | `interact-entity ID`; `mouse` was CLI-only |
|
||||
| Horse, llama inventory | Horse | no | no | yes | yes | `mount-menu` |
|
||||
| Books, lecterns, signs | Book, Sign | part | part | yes | yes | `book [next\|back\|take\|done]`, `signedit X Y Z` |
|
||||
| Move, look, find a path | (playing) | yes | yes | yes | yes | `move`, `goto`, `look`, `look-at`, `turn`, `jump` |
|
||||
| Mine, place, use, attack, hotbar, drop | (playing) | yes | yes | yes | yes | `break`, `place`, `interact`, `use-item`, `attack`, `select`, `drop` |
|
||||
| Raw keys and mouse buttons | (all) | yes | part | yes | yes | `key`; `mouse`, `release` were CLI-only |
|
||||
| Controllers | (all) | yes | no | yes | yes | `pad` |
|
||||
| Respawn | Death | yes | yes | yes | yes | `respawn` |
|
||||
| Screenshots | (all) | yes | yes | yes | yes | PNG of the next frame, `--offscreen` too |
|
||||
| F3 data | F3 | yes | part | yes | yes | `key f3`; `status` (renderer, chunks, memory) was CLI-only |
|
||||
| Waits and events | (all) | yes | yes | yes | yes | `wait`, `wait-for`, `events` |
|
||||
| Effects, experience, enchantments, potions, fill | (cheats) | part | no | yes | yes | `effect`, `effects`, `xp`, `enchant`, `potion`, `fill` |
|
||||
| Benchmarks and frame stats | (all) | yes | part | yes | yes | `bench`, `stats` were CLI-only; `fly` |
|
||||
|
||||
### After the PRs in [Status](#status)
|
||||
## No AI
|
||||
|
||||
| Capability | Before | After |
|
||||
| --- | --- | --- |
|
||||
| Screens: widgets | labelled buttons only | every widget of every screen (`ui`): id, kind, label, bounds, slider value, tab selected, slots with stacks |
|
||||
| Screens: act on a widget | `click-button LABEL`, `click-at X Y` | `click ID [right\|middle] [shift]`, `set ID VALUE` (sliders), `click` focuses fields |
|
||||
| Options | open a page; `render-distance` only | `options-get` all 193 `options.txt` options typed with ranges/choices; `options-set` validates, applies, saves |
|
||||
| Waits | `wait TICKS`, `wait-ready` | `wait-for screen\|widget\|loaded\|chat\|event` with timeout errors |
|
||||
| Events | none (poll) | `events [--since SEQ]`: screen, chat, death, respawn, item, dimension, world |
|
||||
| Launch headless | `--offscreen` by hand | `launch --offscreen` in horizon-ctl and its MCP; `launch_client {offscreen}` |
|
||||
| The running game from the launcher's MCP | no (a second server) | every game tool as `game_*` through `horizon-launcher mcp` |
|
||||
One switch hides and turns off every AI feature: `noAi:true` in `<game home>/horizon-extras.txt`.
|
||||
The game home is `HORIZON_HOME`, else the data dir's `horizon`. The game reads its extras from
|
||||
its working directory, which is that folder. `horizon_core::no_ai` reads and writes the switch:
|
||||
|
||||
## Design
|
||||
- `is_on(home)` and `check(home)` read it;
|
||||
- `set(home, on)` keeps the file's other lines, and writes no file to turn off a switch that was
|
||||
never on;
|
||||
- `REFUSAL` is the one line every surface prints.
|
||||
|
||||
### One vocabulary, four surfaces
|
||||
The switch is in three places: Settings > AI in the launcher, Horizon Settings in the game
|
||||
(`Item::NoAi`), and `horizon-launcher no-ai on|off`. When it is on:
|
||||
|
||||
| Surface | What happens |
|
||||
| --- | --- |
|
||||
| Game | opens no control socket, even with `--control` (it logs the refusal); Horizon Settings hides AI Agent...; turning it on in a running game closes the socket at once |
|
||||
| `horizon-ctl` | every command refuses: exit status 4, `{"error": REFUSAL, "kind": "denied"}` |
|
||||
| `horizon-ctl mcp` | refuses to start (exit 4, REFUSAL on stderr); a session already running refuses each call |
|
||||
| `horizon-launcher mcp` | refuses to start; a running session answers each `tools/call` with error -32000 and REFUSAL |
|
||||
| Launcher CLI | `mcp` and `agent-config` are hidden from the help and refuse |
|
||||
| Launcher window | Settings > AI shows only the No AI switch, without Connect an AI agent |
|
||||
| Companion (#1068) | uses the same switch (see horizon-parity companion-notes.md) |
|
||||
|
||||
Every other launcher command stays usable: versions, install, remove, prepare, play, server,
|
||||
notes, links, account, update-channel, settings, game-args, logs, update, desktop-entry, data,
|
||||
modpack, mods, servers, no-ai and benchmark. None of them drives the game for an agent. The
|
||||
launcher itself, packagers, the docs and players' scripts need them, and `no-ai` has to stay to
|
||||
turn the switch off.
|
||||
|
||||
## Connecting
|
||||
|
||||
- **Launcher, Settings > AI > Connect an AI agent:**
|
||||
- a client picker (Claude Code, Codex, Cursor, any MCP client) with its config and a Copy button;
|
||||
- for clients with a CLI, the add command, shown and run only on **Add to ...**:
|
||||
`claude mcp add --scope user horizon -- <exe> mcp`, `codex mcp add horizon -- <exe> mcp`;
|
||||
- **Test the connection** (`agents::self_test`) starts `<exe> mcp`, lists its tools and says
|
||||
how many answered.
|
||||
- `horizon-launcher agent-config [--client ID] [--json] [--test]` prints the same thing.
|
||||
- **Game, Horizon Settings > AI Agent:**
|
||||
- **Agent Control** (`agentControl` in horizon-extras.txt, off by default) opens the default
|
||||
endpoint (`$HORIZON_CONTROL`, else `horizon-control.sock` in the runtime directory) in a game
|
||||
the player started. Turning it off closes the endpoint and stops its listener.
|
||||
- The page shows whether a client is connected (`Server::clients`), when the last command came,
|
||||
and the socket's name.
|
||||
- **Copy Connection Details** copies the socket path, a `horizon-ctl --socket ... state` line
|
||||
and an `mcpServers` config for this game's `horizon-ctl mcp`.
|
||||
- **The default:** a game an agent starts (`play` in MCP, `horizon-ctl launch`, `--control`) is
|
||||
under control. A game the player starts from the window is not, until they turn Agent Control
|
||||
on. An open socket lets any program of that user drive the game, so it opens only when an agent
|
||||
asked for the game, or when the player chose it.
|
||||
|
||||
## Design rules for a new command
|
||||
|
||||
### One vocabulary
|
||||
|
||||
- **A command has one name**, kebab-case, the same on the socket, in `horizon-ctl` and in the
|
||||
launcher CLI. Its MCP tool is the name with `_` for `-` (`click-slot` → `click_slot`).
|
||||
The launcher's MCP offers the game's tools under a `game_` prefix (`game_click_slot`) so they
|
||||
never collide with its own (`logs` exists on both sides).
|
||||
- **Arguments** are positional words then `--flags`; the `horizon-ctl` table (`Param`) is the
|
||||
source of truth and generates the MCP `inputSchema`. A new game command is not done until it
|
||||
is in that table.
|
||||
launcher CLI. Its MCP tool is the name with `_` for `-` (`click-slot` is `click_slot`). In the
|
||||
launcher, a subcommand's words are joined (`mods search` is `mods_search`). The launcher's MCP
|
||||
offers every game tool as `game_<name>`, so none collides with its own (`logs` is on both
|
||||
sides). A launcher tool whose name starts with `game_` (`game_args`) is matched first.
|
||||
- **Arguments:** positional words, then `--flags`.
|
||||
- The game: the `horizon-ctl` table (`Param`) is the source of truth. It generates the MCP
|
||||
`inputSchema` and the reference. A game command is not done until it is in that table.
|
||||
- The launcher: the clap definition is the source of truth. Give every argument a help line;
|
||||
it becomes the tool's description.
|
||||
- **Replies are one JSON object.** A query returns its data; an action returns
|
||||
`{"ok": true, ...}` plus what changed (`clicked`, `screen`, `drops`...).
|
||||
- **Errors** are `{"error": "<sentence an agent can act on>", "kind": "<reason>"}`. Kinds:
|
||||
`usage` (bad or missing argument), `unknown` (no such command, widget, option, entity),
|
||||
`state` (the game is not in a state that allows it: no world, wrong screen, disabled
|
||||
widget), `busy` (another action or benchmark runs), `timeout`, `connection` (game not
|
||||
reachable), `denied` (needs consent, below). The message names what to do next
|
||||
("no world open; use: open WORLD").
|
||||
- **Shapes are shared**: a position is `[x, y, z]`; a block is
|
||||
`{"pos", "block", "state", "loaded"}`; an item stack is `{"item", "count", "damage"?,
|
||||
"components"?}`; a slot is a stack plus `"slot": ID` (the slot ids of
|
||||
`click-slot`); bounds are `[x, y, w, h]` in GUI pixels, the coordinates of `click-at`.
|
||||
- **Errors** are `{"error": "<sentence an agent can act on>", "kind": "<reason>"}`. The kinds:
|
||||
- `usage`: a bad or missing argument;
|
||||
- `unknown`: no such command, widget, option or entity;
|
||||
- `state`: the game is not in a state that allows it (no world, wrong screen, disabled widget);
|
||||
- `busy`: another action or a benchmark is running;
|
||||
- `timeout`;
|
||||
- `connection`: the game can't be reached;
|
||||
- `denied`: No AI is on (exit status 4).
|
||||
|
||||
### Structured UI: the widget tree
|
||||
The message names what to do next ("no world open; use: open WORLD").
|
||||
- **Shapes are shared:**
|
||||
- a position is `[x, y, z]`;
|
||||
- a block is `{"pos", "block", "state", "loaded"}`;
|
||||
- an item stack is `{"item", "count", "damage"?, "components"?}`;
|
||||
- a slot is a stack plus `"slot": ID` (the slot ids of `click-slot`);
|
||||
- bounds are `[x, y, w, h]` in GUI pixels, the coordinates of `click-at`.
|
||||
|
||||
`ui` returns every interactive element the last frame drew, on every screen (title menus,
|
||||
pause, options pages, inventories and stations, chat, death, Play with Friends):
|
||||
### Safety
|
||||
|
||||
- **Local only.** The socket is a Unix socket in the user's runtime directory, or a TCP port on
|
||||
127.0.0.1 behind a 128-bit token in a file only the user can read. It is never `0.0.0.0` and
|
||||
never a network listener. Both MCP servers use stdio.
|
||||
- **Confirm what can't be undone.** A command that deletes the player's things requires
|
||||
`confirm` and carries the MCP `destructiveHint`. In the game these are `world-delete`, `fill`
|
||||
and `setblock` (the table's `DESTRUCTIVE`); in the launcher, `remove`, `modpack remove`,
|
||||
`mods remove`, `servers forget`, `account sign-out` and `data delete` (`cli_tools::DESTRUCTIVE`).
|
||||
Without `confirm: true` the call is refused and the message says to ask the player. In the game
|
||||
CLI the flag is `--confirm`. The launcher CLI asks nothing: a person typing `remove` meant it.
|
||||
- **Ask before reaching out.** A command that sends data or reaches other people carries
|
||||
`openWorldHint`, and its description says "only when the player asks". In the game these are
|
||||
`connect`, `lan`, `friends`, `chat` and `command`; in the launcher, `data allow`, `data send`,
|
||||
`data bug`, `data benchmark` and `servers create`. `play`'s `join` argument says the same.
|
||||
Telemetry stays opt-in, and only `data allow` turns it on.
|
||||
- **No secrets.** No surface returns tokens, session files, keychain entries or invite secrets
|
||||
the player didn't ask to share:
|
||||
- accounts are names and UUIDs;
|
||||
- sign-in is a device code the player enters;
|
||||
- `--session-file` is read once and deleted;
|
||||
- `servers` reads names and addresses only.
|
||||
- **Vanilla behaviour untouched.** Control code reads state and synthesizes the input events a
|
||||
player produces; registering widgets for the tree draws nothing. A change to game files runs
|
||||
`tools/parity.py --changed origin/main`.
|
||||
|
||||
### The widget tree
|
||||
|
||||
`ui` returns every interactive element the last frame drew, on every screen:
|
||||
|
||||
```json
|
||||
{"screen": "options", "gui_size": [427, 240], "frame": 3061,
|
||||
"widgets": [
|
||||
{"id": "fov", "kind": "slider", "label": "FOV: 70", "bounds": [40, 50, 150, 20], "enabled": true, "value": 0.5},
|
||||
{"id": "video-settings", "kind": "button", "label": "Video Settings...", "bounds": [...], "enabled": true},
|
||||
{"id": "row-2", "kind": "row", "bounds": [...], "enabled": true},
|
||||
{"id": "done", "kind": "button", "label": "Done", "bounds": [...], "enabled": true}]}
|
||||
```
|
||||
|
||||
- **Ids are stable and readable**: the label up to its `:` as a slug (`FOV: 70` → `fov`,
|
||||
`Video Settings...` → `video-settings`); a repeated slug gets `-2`, `-3` in draw order;
|
||||
unlabelled elements are `<kind>-<n>` in draw order. The id of a slider does not change
|
||||
when its value (and so its label) does. World, server and language rows carry their
|
||||
names; inventory, creative and menu slots are `slot-<ID>` with
|
||||
their stack (an item tab's display slots are `slot-900` up).
|
||||
- **Kinds**: `button`, `slider` (`value` 0..1 as drawn), `field` (text box), `row` (list
|
||||
entry: world, server, pack, language), `tab` (`selected`), `slot`.
|
||||
- **Actions by id**: `click ID [right|middle]` (move the pointer there and click, exactly as a
|
||||
player would; `middle` is the pick block button, which clones a stack in creative), `set ID VALUE` (a slider to a 0..1 position, a click on its track, as vanilla
|
||||
`AbstractSliderButton.onClick` reads the pointer). `type TEXT` keeps typing into the focused
|
||||
field; `click` on a field focuses it. After a click or a set, the next `ui`, `click` or
|
||||
`set` waits for a redrawn frame, so it never reads the screen the click left.
|
||||
- **No new code paths in screens**: the tree is built from what the draw functions already
|
||||
register for the controller's focus navigation (`widgets.rs`), so every screen that a
|
||||
controller can drive is in it, and clicks go through the same window events as the mouse.
|
||||
- **Ids are stable and readable.** An id is the label up to its `:`, as a slug (`FOV: 70` is
|
||||
`fov`, `AI Agent...` is `ai-agent`). A repeated slug gets `-2`, `-3`; unlabelled elements are
|
||||
`<kind>-<n>`. A slider's id doesn't change with its value. Slots are `slot-<ID>` with their
|
||||
stack.
|
||||
- **Kinds:** `button`, `slider` (`value` 0..1), `field`, `row` (a world, server, pack or
|
||||
language), `tab` (`selected`), `slot`.
|
||||
- **Actions:**
|
||||
- `click ID [right|middle] [shift]` clicks where the mouse would.
|
||||
- `set ID VALUE` moves a slider.
|
||||
- `type` types into the focused field.
|
||||
|
||||
### Options
|
||||
After a click, the next `ui`, `click` or `set` waits for a redrawn frame.
|
||||
- **No new code paths in screens.** The tree is built from what the draw functions already
|
||||
register for controller navigation (`widgets.rs`).
|
||||
|
||||
- `options-get [KEY...]`: every option of `options.txt` by its vanilla key, with its value
|
||||
and what it accepts: `{"key": "fov", "value": 70, "type": "int", "min": 30, "max": 110}`,
|
||||
enums with `choices`, key bindings as `key.keyboard.w`. Next: Horizon's own files
|
||||
(`horizon-graphics.txt`, `horizon-controller.txt`, extras) under their own keys.
|
||||
- `options-set KEY VALUE`: validated like vanilla's `OptionInstance.set` (out of range is an
|
||||
error here, not a silent reset), applied at once as the options screen would apply it, and
|
||||
saved. Reply: the new value.
|
||||
- `options [PAGE]` keeps opening a page for screenshots and for `ui`.
|
||||
### Waits and events
|
||||
|
||||
### Inventory, containers, world, entities
|
||||
- `wait-for screen NAME|widget ID|loaded|chat TEXT|event KIND [--timeout S] [--since SEQ]`
|
||||
replies when the condition holds, or with `kind: timeout`.
|
||||
- `events [--since SEQ]` returns what happened since SEQ: screens, chat, deaths, respawns, items,
|
||||
dimensions and worlds (the newest 256). Take `last` before an action, and wait with `--since`,
|
||||
so the action's event can't slip in before the wait.
|
||||
|
||||
The existing commands stay and keep their shapes: `inventory`, `screen`, `click-slot`,
|
||||
`block`, `blocks`, `find-block`, `entities`, `looking-at`. Planned additions: `entity ID`
|
||||
(full detail: equipment, effects, passengers, AI state), `entities` filters by position and
|
||||
radius around any point (`--at X Y Z`), and `blocks --of BLOCK` to keep a box query small.
|
||||
## Tests
|
||||
|
||||
### Waits
|
||||
- **Unit tests:**
|
||||
- horizon-ctl: the table, the schemas, the reference, the No AI refusal, the destructive and
|
||||
network annotations;
|
||||
- the launcher: generated tools, `confirm`, the reference, the agent configs;
|
||||
- `horizon_core::no_ai`;
|
||||
- `options_ui`: the AI Agent page, and that No AI hides it.
|
||||
- **`tools/agent_control_e2e.py`** drives a real game, offscreen on macOS and on a virtual screen
|
||||
on Linux, through the CLI and MCP:
|
||||
- `survival`, `ui`, `options`, `events`, `consume`, `pick`, `suggestions`;
|
||||
- `manage`: worlds, servers, effects, books, LAN, the options pages and `confirm`;
|
||||
- `no-ai`: every surface refuses;
|
||||
- `setting`: Agent Control on opens the endpoint, the AI Agent page, Copy, and off closes it.
|
||||
- **`tools/launcher_mcp_e2e.py`** drives `horizon-launcher mcp`: the generated tools, `play` and
|
||||
the `game_*` tools, `confirm`, `agent-config` and `--test`, and No AI.
|
||||
|
||||
`wait-for CONDITION [--timeout S] [--since SEQ]` replies when the condition holds, or with
|
||||
an error of kind `timeout` saying what screen it ended on (`wait TICKS` waits for ticks):
|
||||
## Later
|
||||
|
||||
- `screen NAME` (the label `state` shows), `widget ID` (a widget is on screen),
|
||||
- `loaded` (a world is open, the player exists and no chunk is pending),
|
||||
- `chat TEXT` (a new chat line containing TEXT), `event KIND` (an event of that kind);
|
||||
both count from `--since SEQ`, else from now: take `last` from `events` before an action
|
||||
so its event can't slip in before the wait starts.
|
||||
These are tracked in FEATURES.md (LATER):
|
||||
|
||||
### Events
|
||||
|
||||
`events [--since SEQ]` returns what happened since SEQ, oldest first, and `last`, the SEQ to
|
||||
pass next time (the game keeps the newest 256):
|
||||
|
||||
```json
|
||||
{"event": "world", "seq": 1, "tick": 0, "state": "opened", "name": "Survival"}
|
||||
{"event": "screen", "seq": 2, "tick": 0, "from": "title", "to": "playing"}
|
||||
{"event": "item", "seq": 3, "tick": 110, "item": "minecraft:oak_log", "delta": 3}
|
||||
{"event": "chat", "seq": 4, "tick": 140, "text": "[Player] hello"}
|
||||
{"event": "death", "seq": 7, "tick": 300}
|
||||
{"event": "respawn", "seq": 9, "tick": 330}
|
||||
{"event": "dimension", "seq": 12, "tick": 900, "from": "minecraft:overworld", "to": "minecraft:the_nether"}
|
||||
```
|
||||
|
||||
Events are found by comparing state once per event loop turn on the game thread, and only
|
||||
under `--control` (no hooks inside game logic), so they cannot change what the game does.
|
||||
Next: `subscribe [KIND...]`, the same events as a stream on one connection, for clients that
|
||||
keep one open (MCP tools reply once, so `events` is what MCP offers).
|
||||
|
||||
### The launcher's MCP proxies the game
|
||||
|
||||
An agent configures one MCP server, `horizon-launcher mcp`. It runs the selected version's
|
||||
`horizon-ctl mcp` (shipped beside the game) as a child and lists every one of its tools as
|
||||
`game_<name>` (all but `launch`), passing calls and results (images included) through, so
|
||||
the game's tools are defined once, in horizon-ctl's table. `launch_client` starts the game
|
||||
with a control socket in the launcher's data folder (`world`, `creative`, `offscreen`
|
||||
optional) and returns once it answers. While no game listens, `game_*` calls fail with
|
||||
`kind: "state"` and say to call `launch_client`. A game the player starts from the window
|
||||
is not under control.
|
||||
|
||||
### Safety
|
||||
|
||||
- **Local only.** The socket is a Unix socket in the user's runtime directory, or a TCP port
|
||||
on 127.0.0.1 behind a 128-bit token in a file only the user can read. Never `0.0.0.0`,
|
||||
never a network listener; the launcher's MCP is stdio.
|
||||
- **No secrets.** No surface returns tokens, session files or keychain entries: accounts are
|
||||
names and UUIDs, `--session-file` is read once and deleted, and `options-get` leaves out
|
||||
nothing secret because `options.txt` holds none (server addresses are in `servers.dat`,
|
||||
which no command reads).
|
||||
- **Consent for anything that sends data.** Commands that reach the network (`connect`,
|
||||
`chat`/`command` on a server, `lan open`, `friends host`, `install_version`,
|
||||
`check_updates`, telemetry) carry the MCP `openWorldHint` annotation and their description
|
||||
says "only when the player asks". Telemetry stays opt-in (PR #250); no agent command turns
|
||||
it on. Destructive ones (`remove_version`, `sign_out`, `fill`, `setblock`) carry
|
||||
`destructiveHint`.
|
||||
- **Vanilla behaviour untouched.** Control code reads state and synthesizes the same input
|
||||
events a player produces; registering widgets for the tree draws nothing. Every PR that
|
||||
touches game files runs `tools/parity.py --changed origin/main`.
|
||||
|
||||
## Status
|
||||
|
||||
| Step | PR |
|
||||
| --- | --- |
|
||||
| This document | #321 |
|
||||
| `ui`, `click ID`, `set ID VALUE`: the widget tree of every screen | #349 |
|
||||
| `options-get` / `options-set` | #350 |
|
||||
| `wait-for`, `events` | #351 |
|
||||
| The launcher's MCP proxies the game (`game_*`), `launch_client` under control | #342 |
|
||||
| Next: `subscribe` (a stream), error `kind` on every game error, MCP annotations (`openWorldHint`, `destructiveHint`) | |
|
||||
| Next: `entity ID`, `entities --at`, packs (`packs list|enable|disable`), F3 lines, Horizon's own option files, launcher settings over CLI/MCP | |
|
||||
|
||||
Each PR adds its tests: unit tests next to the code, and scenarios in
|
||||
`tools/agent_control_e2e.py` (`survival`, `ui`, `options`, `events`; CLI and MCP) and
|
||||
`tools/launcher_mcp_e2e.py`, which drive the real game offscreen on macOS or on a virtual
|
||||
screen on Linux. `tools/lan_e2e.py` runs two games at once, a host that opens its world to LAN
|
||||
and a guest that joins it, and checks from both sides what a guest sees and can do (player
|
||||
list, own model, armour, name tags, chest lids, crafting, fall damage).
|
||||
- Horizon's own option files by key (`horizon-graphics.txt`, controller, extras) next to
|
||||
`options-get` and `options-set`.
|
||||
- Adding and removing saved servers by command.
|
||||
- `subscribe`: events as a stream on one connection.
|
||||
- Launcher window screenshots in this page's PRs.
|
||||
- No AI changed in the launcher while a game is open: the game rewrites its extras when it saves.
|
||||
- A confirmation question in the launcher CLI for destructive commands.
|
||||
@@ -0,0 +1,478 @@
|
||||
# Launcher commands
|
||||
|
||||
<!-- Generated from launcher/src/cli.rs by cli_tools::markdown(); a test fails when this file is out of date. Update it with: HORIZON_WRITE_DOCS=1 cargo test --manifest-path launcher/Cargo.toml the_launcher_reference -->
|
||||
|
||||
Every command of `horizon-launcher`, and the tool `horizon-launcher mcp` offers for it. A tool's arguments are the command's, in snake_case. A tool runs the command with `--json` and returns what it printed. The tools that change the player's things for good need `confirm: true`. The tools that send something or reach other people say "only when the player asks". The game's own tools, `game_*`, are in [horizon-ctl-commands.md](horizon-ctl-commands.md). With No AI on, `mcp` and `agent-config` are hidden and refuse; every other command stays. See [agent-control.md](agent-control.md#no-ai).
|
||||
|
||||
## `horizon-launcher versions`
|
||||
|
||||
List Horizon releases, or the installed versions.
|
||||
|
||||
horizon-launcher versions
|
||||
|
||||
- `--installed`: Only the versions installed here (no network)
|
||||
|
||||
MCP: `versions`.
|
||||
|
||||
## `horizon-launcher install`
|
||||
|
||||
Download and install a release (a tag such as v2026.10.13, or "latest").
|
||||
|
||||
horizon-launcher install <TAG>
|
||||
|
||||
- `TAG`
|
||||
|
||||
MCP: `install`.
|
||||
|
||||
## `horizon-launcher remove`
|
||||
|
||||
Remove an installed version.
|
||||
|
||||
horizon-launcher remove <TAG>
|
||||
|
||||
- `TAG`
|
||||
|
||||
MCP: `remove`, needs `confirm: true`.
|
||||
|
||||
## `horizon-launcher prepare`
|
||||
|
||||
Prepare a version's resources from your own Minecraft install.
|
||||
|
||||
horizon-launcher prepare
|
||||
|
||||
- `--minecraft`: The game folder of the official launcher, Prism Launcher, MultiMC, the Modrinth App, ATLauncher or GDLauncher (default: the launcher's, then every launcher's default)
|
||||
|
||||
MCP: `prepare`.
|
||||
|
||||
## `horizon-launcher play`
|
||||
|
||||
Start the client.
|
||||
|
||||
horizon-launcher play
|
||||
|
||||
- `--world`: Open or create this world (a folder under saves) at once
|
||||
- `--creative`: Creative mode for a new world
|
||||
- `--join`: Join a friend's world with its invite code
|
||||
- `--arg`: An extra argument for the game, passed through as-is (repeatable), for example --server-thread
|
||||
|
||||
MCP: `play`, then the `game_*` tools.
|
||||
|
||||
## `horizon-launcher server`
|
||||
|
||||
Run a dedicated server here; type commands into its console.
|
||||
|
||||
horizon-launcher server
|
||||
|
||||
- `--dir`: The server's folder (default: the one the launcher uses)
|
||||
- `--accept-eula`: Agree to the Minecraft EULA (https://aka.ms/MinecraftEULA)
|
||||
|
||||
MCP: `server`, `server_command`, `server_log`, `server_stop`.
|
||||
|
||||
## `horizon-launcher notes`
|
||||
|
||||
Show a release's patch notes (default: the newest).
|
||||
|
||||
horizon-launcher notes [TAG]
|
||||
|
||||
- `TAG`
|
||||
|
||||
MCP: `notes`.
|
||||
|
||||
## `horizon-launcher links`
|
||||
|
||||
Show the Discord, sponsor and website links.
|
||||
|
||||
horizon-launcher links
|
||||
|
||||
MCP: `links`.
|
||||
|
||||
## `horizon-launcher account list`
|
||||
|
||||
The signed-in accounts and which one plays.
|
||||
|
||||
horizon-launcher account list
|
||||
|
||||
MCP: `account_list`.
|
||||
|
||||
## `horizon-launcher account sign-in`
|
||||
|
||||
Sign in with a Microsoft account in your browser (device code).
|
||||
|
||||
horizon-launcher account sign-in
|
||||
|
||||
MCP: `account_sign_in`.
|
||||
|
||||
## `horizon-launcher account sign-out`
|
||||
|
||||
Sign out: the account's tokens leave the keychain.
|
||||
|
||||
horizon-launcher account sign-out <WHO>
|
||||
|
||||
- `WHO`: Its name or UUID
|
||||
|
||||
MCP: `account_sign_out`, needs `confirm: true`.
|
||||
|
||||
## `horizon-launcher account select`
|
||||
|
||||
Play as this account.
|
||||
|
||||
horizon-launcher account select <WHO>
|
||||
|
||||
- `WHO`
|
||||
|
||||
MCP: `account_select`.
|
||||
|
||||
## `horizon-launcher account offline`
|
||||
|
||||
Play offline, without an account.
|
||||
|
||||
horizon-launcher account offline
|
||||
|
||||
MCP: `account_offline`.
|
||||
|
||||
## `horizon-launcher update-channel`
|
||||
|
||||
Show or set which Horizon releases install by themselves: stable (a tested build about once a day) or alpha (every release as soon as it is built; can break).
|
||||
|
||||
horizon-launcher update-channel [CHANNEL]
|
||||
|
||||
- `CHANNEL` (stable, alpha): Omit to show the current channel
|
||||
|
||||
MCP: `update_channel`.
|
||||
|
||||
## `horizon-launcher settings`
|
||||
|
||||
Show the launcher's settings, or set one (the Settings page): selected-version TAG, minecraft-dir PATH, server-dir PATH ("default" resets a folder), install-new-versions on|off, opening-animation on|off.
|
||||
|
||||
horizon-launcher settings [KEY] [VALUE]
|
||||
|
||||
- `KEY` (selected-version, minecraft-dir, server-dir, install-new-versions, opening-animation)
|
||||
- `VALUE`: The new value (with KEY)
|
||||
|
||||
MCP: `settings`.
|
||||
|
||||
## `horizon-launcher game-args`
|
||||
|
||||
Show or set the extra arguments the game starts with (for example --server-thread); quotes group a word with spaces in it.
|
||||
|
||||
horizon-launcher game-args [ARGS]
|
||||
|
||||
- `ARGS`: Omit to show the current arguments
|
||||
|
||||
MCP: `game_args`.
|
||||
|
||||
## `horizon-launcher logs`
|
||||
|
||||
Print the end of the game's log (logs/latest.log in the game folder), or its newest crash report.
|
||||
|
||||
horizon-launcher logs
|
||||
|
||||
- `--lines`: How many lines of latest.log
|
||||
- `--crash`: The newest crash report instead
|
||||
|
||||
MCP: `logs`.
|
||||
|
||||
## `horizon-launcher update`
|
||||
|
||||
Look for a newer Horizon release and launcher, and install them.
|
||||
|
||||
horizon-launcher update
|
||||
|
||||
- `--check`: Only report what is available
|
||||
|
||||
MCP: `update`.
|
||||
|
||||
## `horizon-launcher desktop-entry`
|
||||
|
||||
Linux: put Horizon in the applications menu (an entry and the icon, under $XDG_DATA_HOME), or take it out with --remove.
|
||||
|
||||
horizon-launcher desktop-entry
|
||||
|
||||
- `--remove`: Remove the entry and the icon, and stop adding them back
|
||||
|
||||
MCP: `desktop_entry`.
|
||||
|
||||
## `horizon-launcher data status`
|
||||
|
||||
What is allowed, the install ID and how many items wait (the default).
|
||||
|
||||
horizon-launcher data status
|
||||
|
||||
MCP: `data_status`.
|
||||
|
||||
## `horizon-launcher data allow`
|
||||
|
||||
Turn kinds on: performance, crashes, bugs, benchmarks, or all.
|
||||
|
||||
horizon-launcher data allow <KINDS>...
|
||||
|
||||
- `KINDS`
|
||||
|
||||
MCP: `data_allow`, only when the player asks.
|
||||
|
||||
## `horizon-launcher data deny`
|
||||
|
||||
Turn kinds off: performance, crashes, bugs, benchmarks, or all.
|
||||
|
||||
horizon-launcher data deny <KINDS>...
|
||||
|
||||
- `KINDS`
|
||||
|
||||
MCP: `data_deny`.
|
||||
|
||||
## `horizon-launcher data show`
|
||||
|
||||
Print exactly what would be sent.
|
||||
|
||||
horizon-launcher data show
|
||||
|
||||
MCP: `data_show`.
|
||||
|
||||
## `horizon-launcher data delete`
|
||||
|
||||
Delete everything sent from here, and what waits, and take a new install ID.
|
||||
|
||||
horizon-launcher data delete
|
||||
|
||||
MCP: `data_delete`, needs `confirm: true`.
|
||||
|
||||
## `horizon-launcher data send`
|
||||
|
||||
Send what is waiting now.
|
||||
|
||||
horizon-launcher data send
|
||||
|
||||
MCP: `data_send`, only when the player asks.
|
||||
|
||||
## `horizon-launcher data bug`
|
||||
|
||||
Send a bug report you write; it is printed first, as it would leave.
|
||||
|
||||
horizon-launcher data bug <TEXT>
|
||||
|
||||
- `TEXT`
|
||||
- `--log`: Add the end of the game's log (scrubbed, without chat)
|
||||
- `--yes`: Send without asking
|
||||
|
||||
MCP: `data_bug`, only when the player asks.
|
||||
|
||||
## `horizon-launcher data benchmark`
|
||||
|
||||
Upload a benchmark report (docs/benchmark-report.md); it is printed first.
|
||||
|
||||
horizon-launcher data benchmark <FILE>
|
||||
|
||||
- `FILE`
|
||||
- `--yes`
|
||||
|
||||
MCP: `data_benchmark`, only when the player asks.
|
||||
|
||||
## `horizon-launcher modpack list`
|
||||
|
||||
The imported packs and their coverage (the default).
|
||||
|
||||
horizon-launcher modpack list
|
||||
|
||||
MCP: `modpack_list`.
|
||||
|
||||
## `horizon-launcher modpack import`
|
||||
|
||||
Import a .mrpack, a CurseForge zip or an instance folder, and write its coverage report.
|
||||
|
||||
horizon-launcher modpack import <PATH>
|
||||
|
||||
- `PATH`
|
||||
|
||||
MCP: `modpack_import`.
|
||||
|
||||
## `horizon-launcher modpack port`
|
||||
|
||||
Get the jars of what this computer can port and carry them over.
|
||||
|
||||
horizon-launcher modpack port <PACK>
|
||||
|
||||
- `PACK`: The pack's folder name, as list shows it
|
||||
- `--from`: A folder with your own jars (an installed instance, a downloads folder)
|
||||
|
||||
MCP: `modpack_port`.
|
||||
|
||||
## `horizon-launcher modpack play`
|
||||
|
||||
Play a ported pack as its own profile.
|
||||
|
||||
horizon-launcher modpack play <PACK>
|
||||
|
||||
- `PACK`
|
||||
|
||||
MCP: `modpack_play`.
|
||||
|
||||
## `horizon-launcher modpack remove`
|
||||
|
||||
Remove an imported pack: its jars, ports and the profile's saves.
|
||||
|
||||
horizon-launcher modpack remove <PACK>
|
||||
|
||||
- `PACK`
|
||||
|
||||
MCP: `modpack_remove`, needs `confirm: true`.
|
||||
|
||||
## `horizon-launcher mods list`
|
||||
|
||||
What the browser installed (the default).
|
||||
|
||||
horizon-launcher mods list
|
||||
|
||||
MCP: `mods_list`.
|
||||
|
||||
## `horizon-launcher mods search`
|
||||
|
||||
Search for packs Horizon loads.
|
||||
|
||||
horizon-launcher mods search [TEXT]...
|
||||
|
||||
- `TEXT`
|
||||
- `--kind` (mod, resource, shader, modpack)
|
||||
- `--everything`: Also what was made for other versions or runs only partly, each marked
|
||||
- `--source` (modrinth, curseforge)
|
||||
|
||||
MCP: `mods_search`.
|
||||
|
||||
## `horizon-launcher mods check`
|
||||
|
||||
Say how well a Java mod runs in Horizon: its jar is downloaded (checksum checked), carried over and read; the answer is kept.
|
||||
|
||||
horizon-launcher mods check <PROJECT>
|
||||
|
||||
- `PROJECT`
|
||||
- `--everything`: Allow a version made for another Minecraft version (1.21 and later)
|
||||
- `--source` (modrinth, curseforge)
|
||||
|
||||
MCP: `mods_check`.
|
||||
|
||||
## `horizon-launcher mods install`
|
||||
|
||||
Install a pack and what it needs, by its id or slug (checksums checked); a Java mod is ported into horizon-mods and turned on; a modpack is imported.
|
||||
|
||||
horizon-launcher mods install <PROJECT>
|
||||
|
||||
- `PROJECT`
|
||||
- `--everything`: Allow a version made for another Minecraft version
|
||||
- `--source` (modrinth, curseforge)
|
||||
|
||||
MCP: `mods_install`.
|
||||
|
||||
## `horizon-launcher mods update`
|
||||
|
||||
Update what the browser installed (or one pack).
|
||||
|
||||
horizon-launcher mods update [PROJECT]
|
||||
|
||||
- `PROJECT`
|
||||
- `--check`: Only say what is newer
|
||||
|
||||
MCP: `mods_update`.
|
||||
|
||||
## `horizon-launcher mods remove`
|
||||
|
||||
Remove a pack the browser installed.
|
||||
|
||||
horizon-launcher mods remove <PROJECT>
|
||||
|
||||
- `PROJECT`
|
||||
|
||||
MCP: `mods_remove`, needs `confirm: true`.
|
||||
|
||||
## `horizon-launcher servers list`
|
||||
|
||||
The servers, their folder and whether they run.
|
||||
|
||||
horizon-launcher servers list
|
||||
|
||||
MCP: `servers_list`.
|
||||
|
||||
## `horizon-launcher servers create`
|
||||
|
||||
Create a server; with no options it is a new world, survival, normal, 10 players, friends only.
|
||||
|
||||
horizon-launcher servers create
|
||||
|
||||
- `--name`
|
||||
- `--seed`: The new world's seed (default: random)
|
||||
- `--copy-world`: Start from a copy of this world folder (one of your saves)
|
||||
- `--mode` (survival, creative, adventure, spectator)
|
||||
- `--difficulty` (peaceful, easy, normal, hard)
|
||||
- `--max-players`
|
||||
- `--open`: Let anyone in (no whitelist)
|
||||
- `--check-accounts`: Check players' Minecraft accounts (they must sign in with Microsoft)
|
||||
- `--player`: Who may join (repeatable); the first becomes an operator
|
||||
- `--accept-eula`: Agree to the Minecraft EULA (https://aka.ms/MinecraftEULA)
|
||||
|
||||
MCP: `servers_create`, only when the player asks.
|
||||
|
||||
## `horizon-launcher servers set-up`
|
||||
|
||||
Copy an installed, prepared version's programs and game files for the servers (again).
|
||||
|
||||
horizon-launcher servers set-up
|
||||
|
||||
MCP: `servers_set_up`.
|
||||
|
||||
## `horizon-launcher servers run`
|
||||
|
||||
Run a horizon-server command on a server, for example: servers run "My server" -- status.
|
||||
|
||||
horizon-launcher servers run <SERVER> -- <ARGS>...
|
||||
|
||||
- `SERVER`: The server's name or folder
|
||||
- `ARGS`
|
||||
|
||||
MCP: `servers_run`.
|
||||
|
||||
## `horizon-launcher servers forget`
|
||||
|
||||
Take a server off the list (its folder stays).
|
||||
|
||||
horizon-launcher servers forget <SERVER>
|
||||
|
||||
- `SERVER`
|
||||
|
||||
MCP: `servers_forget`, needs `confirm: true`.
|
||||
|
||||
## `horizon-launcher mcp`
|
||||
|
||||
Serve the Model Context Protocol on stdin and stdout, for AI agents (refused while No AI is on).
|
||||
|
||||
horizon-launcher mcp
|
||||
|
||||
MCP: none: this command is the MCP server.
|
||||
|
||||
## `horizon-launcher agent-config`
|
||||
|
||||
How to connect an AI agent (Claude Code, Codex, Cursor, any MCP client) to `mcp`: the config to paste and the command that adds it (refused while No AI is on).
|
||||
|
||||
horizon-launcher agent-config
|
||||
|
||||
- `--client` (claude-code, codex, cursor, generic): Only this client
|
||||
- `--test`: Start the MCP server as a client would and say whether it answered
|
||||
|
||||
MCP: none: it prints how to connect a client to `mcp`.
|
||||
|
||||
## `horizon-launcher no-ai`
|
||||
|
||||
Show or set No AI: on hides and turns off every AI feature (the companion, agent control, horizon-ctl and both MCP servers); off, the default, makes them available.
|
||||
|
||||
horizon-launcher no-ai [STATE]
|
||||
|
||||
- `STATE` (on, off): Omit to show the current state
|
||||
|
||||
MCP: none: the player's switch.
|
||||
|
||||
## `horizon-launcher benchmark`
|
||||
|
||||
Measure the launcher: starts (the first is the cold one), page switching, idle CPU and memory, the release list fetch; writes a benchmark report to benchmarks/ in its data folder.
|
||||
|
||||
horizon-launcher benchmark
|
||||
|
||||
- `--repeats`: How many times to start it
|
||||
|
||||
MCP: `benchmark`.
|
||||
@@ -0,0 +1,93 @@
|
||||
# A server's levels: one per dimension
|
||||
|
||||
Vanilla's `MinecraftServer` holds a `ServerLevel` per dimension, and each player is in one of them.
|
||||
Horizon's server does the same. That covers the dedicated server and single player's server thread
|
||||
(`Game::serving`, the default since #1114). So a LAN world's host and each guest can be in different
|
||||
dimensions at the same time.
|
||||
|
||||
## How the single-level `Game` maps onto vanilla's levels
|
||||
|
||||
The game's tick code reads one level: the one held in `Game`'s own fields (the world, its streaming
|
||||
and save, block ticks, entities, block entities and their per-level queues). `game/levels.rs` keeps
|
||||
the other dimensions' levels as `Parked` values and swaps one into place (`enter_level`). The swap is
|
||||
`mem::swap` of each per-level field, so the tick code runs unchanged, one level at a time.
|
||||
|
||||
| Vanilla | Horizon |
|
||||
| --- | --- |
|
||||
| `MinecraftServer.levels` | the level in place plus `Levels::parked` |
|
||||
| `MinecraftServer.tickChildren`, every level | `tick_server`: each level with a player in it, in Overworld, Nether, End order. The first pass reads the network and runs the shared parts (clock, weather, functions, autosave). |
|
||||
| `ServerLevel.players` | `Hub::here`: the guests whose `dimension` is the level in place |
|
||||
| `ServerPlayer.teleport` into another level | `server_travel.rs`: a Respawn packet, then the teleport once the destination has loaded (portals and `execute in … tp`). An End gateway teleports within the End. |
|
||||
| `PlayerList.respawn` | `guest_respawn`: into the respawn point's level, the Overworld unless the player has an anchor or a `spawnpoint` elsewhere (`Guest::respawn_elsewhere`), resolved through its block once that level has loaded |
|
||||
| `ServerLevel.noPlayersEmptyTime`, chunk unloading | `close_idle_levels`: a level other than the Overworld with nobody in it for 300 ticks is saved and closed |
|
||||
| `CommandSourceStack.getLevel` | `with_source` puts the source's level in place for a command (`execute in`, `execute at` a player in another level) |
|
||||
| the levels' shared `LevelData` | not swapped: game time, day time, weather, game rules, scoreboard, maps, entity ids (`next_entity`, unique across levels) |
|
||||
|
||||
The packets of a guest are read in its own level's pass (`Hub::tick_level`), so its actions and
|
||||
commands run with its level in place.
|
||||
|
||||
The window's own game (`--no-server-thread`, `HORIZON_SERVER_THREAD=0`, and the command harness) keeps
|
||||
one level, which follows its player (`enter_dimension`). LAN guests of a world run that way stay in the
|
||||
host's dimension. That mode is the escape hatch now, so it gets no multi-level work.
|
||||
|
||||
## One code path for both
|
||||
|
||||
The dedicated server and the server thread are the same code. The server thread runs a `Game` with
|
||||
`options.dedicated` set and a hub without a host player. The window joins it through memory as one
|
||||
more player. Everything above serves both, and `server_dimension_players` (vanilla clients on Horizon's
|
||||
dedicated server) and `lan_e2e --server-thread` (Horizon clients on the server thread) check it from
|
||||
the two sides.
|
||||
|
||||
## Cost of an extra level
|
||||
|
||||
Measured on the Mac mini (M4 Pro) at render distance 12, on 2026-10-22. The host and a guest each
|
||||
played a fresh world on the server thread. RSS is the host's whole process, window included. Tick time
|
||||
is `/tick query`'s average over 100 ticks.
|
||||
|
||||
| Where the players are | Average tick | P95 | Host RSS |
|
||||
| --- | --- | --- | --- |
|
||||
| both in the Overworld | 1.0 ms | 1.3 ms | 1194 MB |
|
||||
| guest in the Nether (two levels tick) | 2.2 ms | 2.9 ms | 1238 MB |
|
||||
| guest in the End (the Nether still parked) | 0.9 ms | 1.1 ms | 1324 MB |
|
||||
| both back, the Nether and End closed after 300 idle ticks | 0.8 ms | 0.9 ms | 1335 MB |
|
||||
|
||||
- A level costs only while a player is in it: its chunks around that player, their entities, and the
|
||||
tick. With nobody outside the Overworld, `tick_server` runs one pass, as before.
|
||||
- A closed level's memory goes back to the allocator, not always to the system.
|
||||
- Entering a level the first time opens its region files and streaming (`open_level`). That cost
|
||||
comes once, before the player's chunks load.
|
||||
|
||||
## Saving
|
||||
|
||||
`save_game` saves the level in place, then every parked level (`save_parked_levels`). Each level has
|
||||
its own region folder, as in vanilla (`DIM-1`, `DIM1`). A level closed for being idle is saved first.
|
||||
Each player's saved data holds its dimension (`Dimension`). A guest who leaves in
|
||||
the Nether, or whose host saves and quits, joins again in the Nether where it was.
|
||||
|
||||
## What's checked
|
||||
|
||||
`python3 tools/lan_e2e.py --server-thread --check portals --check levels`:
|
||||
|
||||
- `portals`: the guest goes to the Nether through a portal while the host stays. The host then goes
|
||||
to the End while the guest is in the Nether, so three levels run at once, and both come back.
|
||||
- `levels`, with the host and the guest each in a level of its own:
|
||||
- `execute in the_nether run tp` moves the guest alone. From the Nether, `execute in overworld` and
|
||||
`execute at` the host change and test blocks in the host's level.
|
||||
- Mobs and armour stands fall in two levels at once, and every entity id is unique across the
|
||||
levels.
|
||||
- The guest leaves in the Nether and joins again there.
|
||||
- The guest dies in the Nether and respawns in the Overworld. With a `spawnpoint` and then a
|
||||
respawn anchor set in the Nether, it respawns there, and the anchor loses a charge.
|
||||
- The host takes a Nether portal alone.
|
||||
- The guest takes an End portal, an End gateway and then the exit portal alone.
|
||||
- The host saves, quits and opens the world again with the guest in the Nether, and the guest
|
||||
joins again there.
|
||||
|
||||
## Not done yet
|
||||
|
||||
- **End credits for server players.** The first exit from the End should show a player the credits
|
||||
(`EndPortalBlock.entityInside`, `seenCredits`). The client's WinScreen is #338.
|
||||
- **Entities through portals between levels.** Mobs, items and minecarts that enter a portal on a
|
||||
server, and portal tickets (`TeleportTransition.PLACE_PORTAL_TICKET`), are not handled.
|
||||
- **Unlimited selectors.** `@e` without a distance or volume reads only the source's level. Vanilla's
|
||||
reads every level.
|
||||
+1
-1
@@ -90,7 +90,7 @@ not tick cost, so it is in scope here (see section 9).
|
||||
|---|---|
|
||||
| `online-mode=true` | **Works** (ServerLoginPacketListenerImpl): a 1024-bit RSA key pair at start ("Generating keypair"), the encryption request, the shared secret and challenge, AES/CFB8, and the session service's `hasJoined` check (with the address when `prevent-proxy-connections` is on), with vanilla's log lines and messages. `test_online_mode_parity.py` runs the same logins against vanilla's server and Horizon's with a mock session service. |
|
||||
| Secure chat | **Works** (`SignedMessageChain`, `LastSeenMessagesValidator`, `MessageSignatureCache`): the chat session update and its profile key, checked against the services' `playerCertificateKeys`; signed chat, `say`, `me` and `msg`, relayed with their signatures and last-seen lists; `enforce-secure-profile` (with `online-mode`) refuses unsigned chat; and vanilla's log lines (`[Not Secure]`, the decoder's warnings, the kicks). Offline servers and players without a chat session chat unsigned, as on vanilla. `test_secure_chat_parity.py` runs the same signed and unsigned players against vanilla's server and Horizon's, with test keys and a mock services server. |
|
||||
| Nether and End | **Players can't change dimension.** The engine keeps one dimension loaded (`Game::enter_dimension` moves only the local single-player player), and the hub serves one `dimension` to everyone. |
|
||||
| Nether and End | **Works**: the server holds a level per dimension, and each player is in its own (portals, `execute in … tp`, respawn). `test_server_dimension_players_parity.py` checks it against vanilla. How it works, its cost and what's left: [server-levels.md](server-levels.md). |
|
||||
| Refused logins | Logged as vanilla does: `Disconnecting <name> (/address): <reason>`, then `<name> (/address) lost connection: <reason>`, and the session errors (`Username '<name>' tried to join with an invalid session`, `Couldn't verify username because servers are unavailable`). |
|
||||
| Gameplay for remote players | Checked against vanilla's server with a vanilla-codec client (#484, `tools/test_dedicated_server_parity.py`): a right click with an item on a block uses it once, a dead player's commands run, players on a team without friendly fire don't hurt each other, and a player's projectiles are theirs (their ender pearl takes them). The recipe book, status effects and block sync while digging (#500) work. Left: a player's projectiles don't hit other players (no bow PvP between guests), and a mob a guest kills counts in the host's statistics and advancements, not the guest's (`mob_died` runs as the host). |
|
||||
| Defaults that surprise owners | `white-list=true` is vanilla 26.3's default, so a fresh server refuses everyone until they are added. That matches vanilla, so the first-run flow (section 3) asks about the whitelist up front. |
|
||||
|
||||
@@ -232,6 +232,111 @@ impl Tool {
|
||||
}
|
||||
}
|
||||
|
||||
/// The tools mcp.rs runs itself for a CLI command, and what an agent uses instead of the
|
||||
/// player-only ones.
|
||||
#[cfg(test)]
|
||||
fn session_tool(words: &str) -> &'static str {
|
||||
match words {
|
||||
"play" => "`play`, then the `game_*` tools",
|
||||
"server" => "`server`, `server_command`, `server_log`, `server_stop`",
|
||||
"account sign-in" => "`account_sign_in`",
|
||||
"account list" => "`account_list`",
|
||||
"mcp" => "none: this command is the MCP server",
|
||||
"agent-config" => "none: it prints how to connect a client to `mcp`",
|
||||
_ => "none: the player's switch",
|
||||
}
|
||||
}
|
||||
|
||||
/// docs/launcher-commands.md: every CLI command, its arguments and its MCP tool, from the CLI's
|
||||
/// definition. A test keeps the file equal to this.
|
||||
#[cfg(test)]
|
||||
fn markdown() -> String {
|
||||
let mut out = String::from(
|
||||
"# Launcher commands\n\n<!-- Generated from launcher/src/cli.rs by cli_tools::markdown(); a test fails when this file is out of date. Update it with: HORIZON_WRITE_DOCS=1 cargo test --manifest-path launcher/Cargo.toml the_launcher_reference -->\n\n\
|
||||
Every command of `horizon-launcher`, and the tool `horizon-launcher mcp` offers for it. A tool's \
|
||||
arguments are the command's, in snake_case. A tool runs the command with `--json` and returns what it printed. \
|
||||
The tools that change the player's things for good need `confirm: true`. \
|
||||
The tools that send something or reach other people say \"only when the player asks\". \
|
||||
The game's own tools, `game_*`, are in [horizon-ctl-commands.md](horizon-ctl-commands.md). \
|
||||
With No AI on, `mcp` and `agent-config` are hidden and refuse; every other command stays. See [agent-control.md](agent-control.md#no-ai).\n\n",
|
||||
);
|
||||
let mut rows = Vec::new();
|
||||
collect(&crate::cli::command(), &mut Vec::new(), &mut rows);
|
||||
let generated: Vec<String> = all().iter().map(|t| t.words()).collect();
|
||||
for (words, cmd) in rows {
|
||||
let usage = cmd
|
||||
.clone()
|
||||
.bin_name(format!("horizon-launcher {words}"))
|
||||
.render_usage()
|
||||
.to_string();
|
||||
let usage = usage
|
||||
.trim_start_matches("Usage: ")
|
||||
.replace(" [OPTIONS]", "");
|
||||
out += &format!("## `horizon-launcher {words}`\n\n");
|
||||
if let Some(about) = cmd.get_about() {
|
||||
out += &format!("{about}.\n\n");
|
||||
}
|
||||
out += &format!(" {usage}\n\n");
|
||||
let args: Vec<&clap::Arg> = cmd.get_arguments().filter(|a| shown(a)).collect();
|
||||
for arg in &args {
|
||||
let name = match arg.get_long() {
|
||||
Some(long) => format!("--{long}"),
|
||||
None => arg.get_id().as_str().to_uppercase(),
|
||||
};
|
||||
let help = arg.get_help().map(ToString::to_string).unwrap_or_default();
|
||||
let choices: Vec<String> = arg
|
||||
.get_possible_values()
|
||||
.iter()
|
||||
.map(|v| v.get_name().to_owned())
|
||||
.collect();
|
||||
let choices = if choices.is_empty() || matches!(arg.get_action(), ArgAction::SetTrue) {
|
||||
String::new()
|
||||
} else {
|
||||
format!(" ({})", choices.join(", "))
|
||||
};
|
||||
let help = if help.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!(": {help}")
|
||||
};
|
||||
out += &format!("- `{name}`{choices}{help}\n");
|
||||
}
|
||||
if !args.is_empty() {
|
||||
out += "\n";
|
||||
}
|
||||
let mcp = if generated.contains(&words) {
|
||||
let path: Vec<String> = words.split(' ').map(str::to_owned).collect();
|
||||
let mut text = format!("`{}`", tool_name(&path));
|
||||
if DESTRUCTIVE.contains(&words.as_str()) {
|
||||
text += ", needs `confirm: true`";
|
||||
} else if OPEN_WORLD.contains(&words.as_str()) {
|
||||
text += ", only when the player asks";
|
||||
}
|
||||
text
|
||||
} else {
|
||||
session_tool(&words).to_owned()
|
||||
};
|
||||
out += &format!("MCP: {mcp}.\n\n");
|
||||
}
|
||||
out.truncate(out.trim_end().len());
|
||||
out.push('\n');
|
||||
out
|
||||
}
|
||||
|
||||
/// Every command a player can type, groups included when they run on their own.
|
||||
#[cfg(test)]
|
||||
fn collect(cmd: &Command, path: &mut Vec<String>, out: &mut Vec<(String, Command)>) {
|
||||
for sub in cmd.get_subcommands().filter(|c| c.get_name() != "help") {
|
||||
path.push(sub.get_name().to_owned());
|
||||
if sub.has_subcommands() {
|
||||
collect(sub, path, out);
|
||||
} else {
|
||||
out.push((path.join(" "), sub.clone()));
|
||||
}
|
||||
path.pop();
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether an argument is part of a tool (not --help, --version or the global --json).
|
||||
fn shown(arg: &clap::Arg) -> bool {
|
||||
!arg.is_hide_set() && !matches!(arg.get_id().as_str(), "help" | "version" | "json")
|
||||
@@ -315,6 +420,28 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_launcher_reference_is_up_to_date() {
|
||||
let path =
|
||||
std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../docs/launcher-commands.md");
|
||||
let want = markdown();
|
||||
if std::env::var_os("HORIZON_WRITE_DOCS").is_some() {
|
||||
std::fs::write(&path, &want).unwrap();
|
||||
}
|
||||
let have = std::fs::read_to_string(&path).unwrap_or_default();
|
||||
assert!(
|
||||
have == want,
|
||||
"docs/launcher-commands.md is out of date: HORIZON_WRITE_DOCS=1 cargo test --manifest-path launcher/Cargo.toml the_launcher_reference"
|
||||
);
|
||||
for tool in all() {
|
||||
assert!(
|
||||
have.contains(&format!("`{}`", tool.name)),
|
||||
"{} undocumented",
|
||||
tool.name
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn destructive_tools_need_confirm() {
|
||||
let remove = tool("remove");
|
||||
|
||||
+102
@@ -0,0 +1,102 @@
|
||||
package dev.horizonlab.parity;
|
||||
|
||||
import java.io.*;
|
||||
import java.lang.reflect.Field;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.*;
|
||||
import net.minecraft.core.BlockPos;
|
||||
import net.minecraft.util.RandomSource;
|
||||
import net.minecraft.world.level.Level;
|
||||
import net.minecraft.world.level.block.Block;
|
||||
import net.minecraft.world.level.block.state.BlockState;
|
||||
import net.minecraft.world.level.chunk.LevelChunkSection;
|
||||
import net.minecraft.world.level.chunk.PalettedContainerFactory;
|
||||
|
||||
/**
|
||||
* Original oracle for which sections of a chunk random-tick and where: the
|
||||
* real LevelChunkSection block counts (setBlockState, and recalcBlockCounts
|
||||
* for a section built from a filled container, as a loaded one is) and
|
||||
* isRandomlyTicking, with the block part of ServerLevel.tickChunk's section
|
||||
* loop around them (the precipitation draws included, on a level random
|
||||
* seeded 0) and the real Level.getBlockRandomPos drawing the positions.
|
||||
*
|
||||
* Each case line is "randValue speed op...", ops being "s:section:index:state"
|
||||
* (setBlockState, index y << 8 | z << 4 | x), "f:section:a:b:m" (a section
|
||||
* loaded from a container holding b where index % m == m - 1, a elsewhere) and
|
||||
* "t" (one tick). Prints, per tick, every drawn position with whether its
|
||||
* block and its fluid tick randomly, then the final randValue.
|
||||
*/
|
||||
public final class VanillaRandomTickSectionsOracle {
|
||||
static final int SECTIONS = 24;
|
||||
static final int MIN_Y = -64;
|
||||
|
||||
public static void main(String[] args) throws Exception {
|
||||
ParityData.init();
|
||||
var factory = PalettedContainerFactory.create(ParityData.access);
|
||||
Level level = ParityData.level;
|
||||
Field randValue = Level.class.getDeclaredField("randValue");
|
||||
randValue.setAccessible(true);
|
||||
try (var out = new PrintWriter(Files.newBufferedWriter(Path.of(args[1]), StandardCharsets.UTF_8));
|
||||
var lines = Files.lines(Path.of(args[0]))) {
|
||||
for (var line : (Iterable<String>) lines::iterator) {
|
||||
if (line.startsWith("#")) continue;
|
||||
String[] words = line.trim().split(" ");
|
||||
randValue.setInt(level, Integer.parseInt(words[0]));
|
||||
int speed = Integer.parseInt(words[1]);
|
||||
RandomSource random = RandomSource.create(0);
|
||||
LevelChunkSection[] sections = new LevelChunkSection[SECTIONS];
|
||||
for (int i = 0; i < SECTIONS; i++) sections[i] = new LevelChunkSection(factory);
|
||||
StringBuilder result = new StringBuilder();
|
||||
for (int w = 2; w < words.length; w++) {
|
||||
String[] op = words[w].split(":");
|
||||
switch (op[0]) {
|
||||
case "s" -> {
|
||||
int index = Integer.parseInt(op[2]);
|
||||
sections[Integer.parseInt(op[1])].setBlockState(index & 15, index >> 8, index >> 4 & 15,
|
||||
state(op[3]));
|
||||
}
|
||||
case "f" -> {
|
||||
BlockState a = state(op[2]), b = state(op[3]);
|
||||
int m = Integer.parseInt(op[4]);
|
||||
var states = factory.createForBlockStates();
|
||||
for (int index = 0; index < 4096; index++) {
|
||||
states.getAndSetUnchecked(index & 15, index >> 8, index >> 4 & 15,
|
||||
index % m == m - 1 ? b : a);
|
||||
}
|
||||
sections[Integer.parseInt(op[1])] = new LevelChunkSection(states, factory.createForBiomes());
|
||||
}
|
||||
case "t" -> {
|
||||
result.append('|');
|
||||
// ServerLevel.tickChunk, for a chunk at 0, 0. Its
|
||||
// precipitation positions use randValue too.
|
||||
for (int i = 0; i < speed; i++) {
|
||||
if (random.nextInt(48) == 0) level.getBlockRandomPos(0, 0, 0, 15);
|
||||
}
|
||||
if (speed <= 0) continue;
|
||||
for (int s = 0; s < SECTIONS; s++) {
|
||||
LevelChunkSection section = sections[s];
|
||||
if (!section.isRandomlyTicking()) continue;
|
||||
int minY = MIN_Y + s * 16;
|
||||
for (int i = 0; i < speed; i++) {
|
||||
BlockPos pos = level.getBlockRandomPos(0, minY, 0, 15);
|
||||
BlockState state = section.getBlockState(pos.getX(), pos.getY() - minY, pos.getZ());
|
||||
result.append(' ').append(pos.getX()).append(',').append(pos.getY()).append(',')
|
||||
.append(pos.getZ()).append(',')
|
||||
.append(state.isRandomlyTicking() ? 'B' : '-')
|
||||
.append(state.getFluidState().isRandomlyTicking() ? 'F' : '-');
|
||||
}
|
||||
}
|
||||
}
|
||||
default -> throw new IllegalArgumentException(words[w]);
|
||||
}
|
||||
}
|
||||
out.println(result.append(" | ").append(randValue.getInt(level)));
|
||||
}
|
||||
}
|
||||
System.exit(0);
|
||||
}
|
||||
|
||||
static BlockState state(String id) {
|
||||
return Block.BLOCK_STATE_REGISTRY.byId(Integer.parseInt(id));
|
||||
}
|
||||
}
|
||||
@@ -684,9 +684,73 @@ def no_ai_check(mode, ctl, resources):
|
||||
shutil.rmtree(work, ignore_errors=True)
|
||||
|
||||
|
||||
def setting_check(mode, ctl, out, resources):
|
||||
"""Agent Control turned on in Horizon Settings > AI Agent: the game the player starts (no
|
||||
--control) opens the default endpoint, the page shows the agent connected, Copy Connection
|
||||
Details copies the socket, and turning the switch off closes the endpoint at once."""
|
||||
work, env = scratch(mode, resources)
|
||||
socket = work / 'c.sock'
|
||||
env['HORIZON_CONTROL'] = str(socket) # the default endpoint, this home's
|
||||
(work / 'horizon-extras.txt').write_text('agentControl:true\n')
|
||||
args = [str(ctl.with_name('horizon-client'))]
|
||||
if sys.platform == 'darwin':
|
||||
args.append('--offscreen')
|
||||
game = subprocess.Popen(args, cwd=work, env=env, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||
d = None
|
||||
try:
|
||||
deadline = time.time() + 300
|
||||
while not socket.exists():
|
||||
assert game.poll() is None, f'the game exited ({game.returncode})'
|
||||
assert time.time() < deadline, 'Agent Control on, but no control socket'
|
||||
time.sleep(0.5)
|
||||
d = (Cli if mode == 'cli' else Mcp)(ctl, socket, work, env)
|
||||
assert d('state')['screen'] == 'title' # the player's own game, at its title screen
|
||||
d('options', page='horizon')
|
||||
tree = widgets(d)
|
||||
assert {'ai-agent', 'no-ai'} <= set(tree), sorted(tree)
|
||||
d('screenshot', path=str(out / f'{mode}-horizon-settings.png'))
|
||||
d('options', page='agent')
|
||||
tree = widgets(d)
|
||||
switch = next(w for w in tree.values() if w.get('label', '').startswith('Agent Control'))
|
||||
assert switch['label'] == 'Agent Control: On', switch
|
||||
d('screenshot', path=str(out / f'{mode}-ai-agent.png'))
|
||||
copy = next(w for w in tree.values() if w.get('label') == 'Copy Connection Details')
|
||||
d('click', id=copy['id'])
|
||||
# Off: the endpoint goes away and the next command can't reach the game.
|
||||
try:
|
||||
d('click', id=switch['id'])
|
||||
except Failed:
|
||||
pass # the reply may be lost with the socket
|
||||
deadline = time.time() + 30
|
||||
while socket.exists():
|
||||
assert time.time() < deadline, 'Agent Control off, but the socket stayed'
|
||||
time.sleep(0.5)
|
||||
assert 'agentControl:false' in (work / 'horizon-extras.txt').read_text()
|
||||
assert game.poll() is None, 'the game exited when Agent Control went off'
|
||||
print(f'[{mode}] PASS: setting')
|
||||
return True
|
||||
except (Failed, AssertionError, StopIteration, json.JSONDecodeError, subprocess.TimeoutExpired) as e:
|
||||
print(f'[{mode}] FAIL: setting: {e!r}')
|
||||
return False
|
||||
finally:
|
||||
if d is not None:
|
||||
try:
|
||||
d.close()
|
||||
except Exception:
|
||||
pass
|
||||
game.terminate()
|
||||
try:
|
||||
game.wait(30)
|
||||
except subprocess.TimeoutExpired:
|
||||
game.kill()
|
||||
shutil.rmtree(work, ignore_errors=True)
|
||||
|
||||
|
||||
def run(mode, ctl, out, resources, which='survival'):
|
||||
if which == 'no-ai':
|
||||
return no_ai_check(mode, ctl, resources)
|
||||
if which == 'setting':
|
||||
return setting_check(mode, ctl, out, resources)
|
||||
work, env = scratch(mode, resources)
|
||||
socket = work / 'c.sock'
|
||||
driver = (Cli if mode == 'cli' else Mcp)(ctl, socket, work, env)
|
||||
@@ -715,7 +779,7 @@ def run(mode, ctl, out, resources, which='survival'):
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||
parser.add_argument('--mode', choices=['cli', 'mcp', 'both'], default='both')
|
||||
parser.add_argument('--scenario', choices=[*SCENARIOS, 'no-ai', 'all'], default='all')
|
||||
parser.add_argument('--scenario', choices=[*SCENARIOS, 'no-ai', 'setting', 'all'], default='all')
|
||||
parser.add_argument('--bin-dir', help='prebuilt horizon-client and horizon-ctl (default: build with the fast profile)')
|
||||
parser.add_argument('--out', default=str(ROOT / '.local/agent-control-e2e'), help='screenshots go here')
|
||||
args = parser.parse_args()
|
||||
@@ -733,7 +797,7 @@ def main():
|
||||
out = Path(args.out)
|
||||
out.mkdir(parents=True, exist_ok=True)
|
||||
modes = ['cli', 'mcp'] if args.mode == 'both' else [args.mode]
|
||||
scenarios = [*SCENARIOS, 'no-ai'] if args.scenario == 'all' else [args.scenario]
|
||||
scenarios = [*SCENARIOS, 'no-ai', 'setting'] if args.scenario == 'all' else [args.scenario]
|
||||
results = {f'{m}/{s}': run(m, bins / 'horizon-ctl', out, resources, s) for m in modes for s in scenarios}
|
||||
print(json.dumps(results))
|
||||
sys.exit(0 if all(results.values()) else 1)
|
||||
|
||||
+299
-4
@@ -67,6 +67,13 @@ address. The script then checks, from both sides, what a guest's game used to ge
|
||||
- portals: (--server-thread) the server holds a level per dimension: the guest goes to the Nether
|
||||
through a portal while the host stays, then the host goes to the End while the guest is in
|
||||
the Nether, and both come back.
|
||||
- levels: (--server-thread) the host and the guest each in a level of their own: `execute in`
|
||||
teleports into another level, and `execute in` and `at` change and test blocks there;
|
||||
mobs fall in two levels at once, with ids unique across them; the guest rejoins in the
|
||||
Nether, dies there and respawns in the Overworld, then in the Nether at a `spawnpoint`
|
||||
and at a respawn anchor set there; the host takes a Nether portal alone;
|
||||
the guest takes an End portal, an End gateway and the exit portal alone; and the host
|
||||
saves, quits and reopens with the guest in the Nether.
|
||||
- commands: the guest's chat completes commands and their arguments over the server's command
|
||||
tree (Tab), and runs what it completed;
|
||||
- recipes: the guest's recipe book lists the planks recipe the logs unlock, and clicking it
|
||||
@@ -172,7 +179,7 @@ ROOT = Path(__file__).resolve().parents[1]
|
||||
GUEST_NAME = 'Guest'
|
||||
# The host plays single player on its server thread (--server-thread).
|
||||
SERVER_THREAD = False
|
||||
CHECKS = ['tab', 'self', 'armour', 'nametags', 'walls', 'chest', 'crafting', 'sign', 'damage', 'mobs', 'hazards', 'pvp', 'arrows', 'kills', 'hurt', 'respawn', 'containers', 'feedback', 'titles', 'chat', 'commands', 'recipes', 'machines', 'brewing', 'loom', 'stonecutter', 'grindstone', 'anvil', 'smithing', 'enchanting', 'beacon', 'crafter', 'lectern', 'cartography', 'trials', 'clicks', 'sleep', 'predict', 'push', 'eat', 'eating', 'xp', 'objects', 'projectiles', 'worn', 'blast', 'barter', 'deflect', 'fireball', 'light', 'time', 'swings', 'cracks', 'plates', 'effects', 'portals', 'distance', 'growth', 'flight', 'ladder', 'boats', 'minecarts', 'frames', 'lightning', 'riding', 'mounts']
|
||||
CHECKS = ['tab', 'self', 'armour', 'nametags', 'walls', 'chest', 'crafting', 'sign', 'damage', 'mobs', 'hazards', 'pvp', 'arrows', 'kills', 'hurt', 'respawn', 'containers', 'feedback', 'titles', 'chat', 'commands', 'recipes', 'machines', 'brewing', 'loom', 'stonecutter', 'grindstone', 'anvil', 'smithing', 'enchanting', 'beacon', 'crafter', 'lectern', 'cartography', 'trials', 'clicks', 'sleep', 'predict', 'push', 'eat', 'eating', 'xp', 'objects', 'projectiles', 'worn', 'blast', 'barter', 'deflect', 'fireball', 'light', 'time', 'swings', 'cracks', 'plates', 'effects', 'portals', 'distance', 'growth', 'flight', 'ladder', 'boats', 'minecarts', 'frames', 'lightning', 'riding', 'mounts', 'levels']
|
||||
|
||||
|
||||
class Game:
|
||||
@@ -509,12 +516,17 @@ def portals(host, guest, out, x, y, z, hx, hy, hz):
|
||||
guest('command', command=f'tp @s {x + 0.5} {y} {z + 3.5}')
|
||||
# players_nether_portal_default_delay: 80 ticks in survival.
|
||||
there = wait_dimension(guest, 'minecraft:the_nether', 600)
|
||||
guest.wait(40)
|
||||
# On a server the guest is held where it entered until the chunks around the destination are
|
||||
# lit, then placed in its portal: wait for that before reading where it arrived.
|
||||
for _ in range(30):
|
||||
guest.wait(20)
|
||||
arrived = [int(v // 1) for v in pos_of(guest)]
|
||||
if guest.raw('block', *arrived).get('block') == 'minecraft:nether_portal':
|
||||
break
|
||||
guest.shot(out, 'portals-nether')
|
||||
host_dim = host('state').get('dimension')
|
||||
assert there.get('dimension') == 'minecraft:the_nether', f'the guest is in {there.get("dimension")}'
|
||||
assert host_dim == 'minecraft:overworld', f'the host went to {host_dim} with the guest'
|
||||
arrived = [int(v // 1) for v in pos_of(guest)]
|
||||
below = guest.raw('block', arrived[0], arrived[1] - 1, arrived[2])
|
||||
assert below.get('loaded') and below.get('block') != 'minecraft:air', f'under the guest in the Nether: {below}'
|
||||
# The host takes an End portal while the guest is in the Nether: three levels at once.
|
||||
@@ -542,6 +554,288 @@ def portals(host, guest, out, x, y, z, hx, hy, hz):
|
||||
return {'guest_nether': arrived, 'host_end': [ex, ey, ez], 'back': pos_of(guest)}
|
||||
|
||||
|
||||
NETHER, END, OVERWORLD = 'minecraft:the_nether', 'minecraft:the_end', 'minecraft:overworld'
|
||||
|
||||
|
||||
def check_levels(host, guest, out):
|
||||
"""(--server-thread) Each player in its own level (MinecraftServer.levels), the host and the guest
|
||||
apart: the steps run in order, each reported, and the check fails if any did."""
|
||||
if not SERVER_THREAD:
|
||||
return 'skipped (needs --server-thread)'
|
||||
start = {g.name: pos_of(g) for g in (host, guest)}
|
||||
steps, failed = {}, []
|
||||
guest('command', command='gamemode creative')
|
||||
try:
|
||||
for name, step in LEVEL_STEPS:
|
||||
try:
|
||||
steps[name] = step(host, guest, out)
|
||||
except (Failed, AssertionError, StopIteration, KeyError) as e:
|
||||
steps[name], failed = repr(e), failed + [name]
|
||||
for g in (host, guest):
|
||||
back_to_game(g)
|
||||
print(f'[lan] levels {name}: {"FAIL" if name in failed else "PASS"} {steps[name]}')
|
||||
finally:
|
||||
for g in (host, guest):
|
||||
back_to_game(g)
|
||||
if g('state').get('screen') == 'dead':
|
||||
g.raw('respawn')
|
||||
g.wait(20)
|
||||
x, y, z = start[g.name]
|
||||
g('command', command=f'execute in {OVERWORLD} run tp @s {x:.2f} {y:.2f} {z:.2f}')
|
||||
guest('command', command='gamemode survival')
|
||||
guest.wait(40)
|
||||
assert not failed, f'failed: {failed}; {steps}'
|
||||
return steps
|
||||
|
||||
|
||||
def level_floor(game, x, y, z):
|
||||
"""An obsidian floor under x y z with air above it, in the level `game` is in (fill needs the
|
||||
chunks loaded: ServerLevel.hasChunksAt)."""
|
||||
game('command', command=f'fill {x - 4} {y - 1} {z - 4} {x + 4} {y - 1} {z + 4} minecraft:obsidian')
|
||||
game('command', command=f'fill {x - 4} {y} {z - 4} {x + 4} {y + 4} {z + 4} minecraft:air')
|
||||
|
||||
|
||||
def into_level(game, dimension, x, y, z):
|
||||
"""`execute in DIMENSION run tp` to x y z, onto a floor built once the level has loaded around it."""
|
||||
game('command', command=f'execute in {dimension} run tp @s {x + 0.5} {y} {z + 0.5}')
|
||||
state = wait_dimension(game, dimension, 400)
|
||||
game.wait(20)
|
||||
level_floor(game, x, y, z)
|
||||
game('command', command=f'tp @s {x + 0.5} {y} {z + 0.5}')
|
||||
game.wait(20)
|
||||
return state
|
||||
|
||||
|
||||
def dims(host, guest):
|
||||
return host('state').get('dimension'), guest('state').get('dimension')
|
||||
|
||||
|
||||
def level_execute(host, guest, out):
|
||||
"""`execute in the_nether run tp` takes the guest alone. From the Nether, the guest's commands
|
||||
reach the host's level (CommandSourceStack.getLevel): `execute in overworld` and `execute at`
|
||||
the host put a block beside the host, and `execute if block` reads it."""
|
||||
x, z = (int(v // 1) for v in pos_of(guest)[::2])
|
||||
hx, hy, hz = (int(v // 1) for v in pos_of(host))
|
||||
there = into_level(guest, NETHER, x, 100, z)
|
||||
below = guest.raw('block', x, 99, z)
|
||||
gold = f'{hx} {hy} {hz + 2} minecraft:gold_block'
|
||||
try:
|
||||
guest('command', command=f'execute in {OVERWORLD} run setblock {gold}')
|
||||
guest('command', command=f'execute at {host.player} run setblock ~ ~ ~-2 minecraft:diamond_block')
|
||||
guest('command', command=f'execute in {OVERWORLD} if block {gold} in {NETHER} '
|
||||
f'run setblock {x} 99 {z} minecraft:gold_block')
|
||||
host.wait(10)
|
||||
placed = [host.raw('block', hx, hy, hz + 2), host.raw('block', hx, hy, hz - 2)]
|
||||
found = guest.raw('block', x, 99, z)
|
||||
finally:
|
||||
host('command', command=f'fill {hx} {hy} {hz - 2} {hx} {hy} {hz + 2} minecraft:air')
|
||||
assert dims(host, guest) == (OVERWORLD, NETHER), f'host, guest in {dims(host, guest)}'
|
||||
assert there.get('dimension') == NETHER and below.get('block') == 'minecraft:obsidian', f'{there} on {below}'
|
||||
assert [p.get('block') for p in placed] == ['minecraft:gold_block', 'minecraft:diamond_block'], \
|
||||
f'beside the host: {placed}'
|
||||
assert found.get('block') == 'minecraft:gold_block', f'execute in overworld if block: {found}'
|
||||
return {'guest': pos_of(guest)}
|
||||
|
||||
|
||||
def level_ticking(host, guest, out):
|
||||
"""Mobs and objects fall in both levels at once (each level with a player ticks); every entity
|
||||
has its own id across the levels."""
|
||||
gx, gy, gz = pos_of(guest)
|
||||
hx, hy, hz = pos_of(host)
|
||||
# A floor of its own beside the host (earlier checks may have broken the open one there).
|
||||
bx, by, bz = int(hx // 1), int(hy // 1), int(hz // 1)
|
||||
host('command', command=f'fill {bx - 4} {by - 1} {bz - 4} {bx + 4} {by - 1} {bz - 2} minecraft:stone')
|
||||
host('command', command=f'fill {bx - 4} {by} {bz - 4} {bx + 4} {by + 4} {bz - 2} minecraft:air')
|
||||
guest('command', command=f'summon minecraft:pig {gx + 2.5:.2f} {gy + 3:.2f} {gz:.2f}')
|
||||
guest('command', command=f'summon minecraft:armor_stand {gx - 2.5:.2f} {gy + 3:.2f} {gz:.2f}')
|
||||
host('command', command=f'summon minecraft:pig {hx + 2.5:.2f} {hy + 3:.2f} {hz - 3:.2f}')
|
||||
host('command', command=f'summon minecraft:armor_stand {hx - 2.5:.2f} {hy + 3:.2f} {hz - 3:.2f}')
|
||||
try:
|
||||
guest.wait(60)
|
||||
seen = {}
|
||||
for name, g, floor in (('guest', guest, gy), ('host', host, hy)):
|
||||
mobs = [m for m in g.raw('mobs', 8)['near'] if m['kind'] == 'minecraft:pig']
|
||||
stands = [o for o in g('status')['objects'] if o['kind'] == 'minecraft:armor_stand']
|
||||
seen[name] = {'pigs': [(m['id'], round(m['pos'][1], 2)) for m in mobs],
|
||||
'stands': [(o['id'], round(o['position'][1], 2)) for o in stands], 'floor': floor}
|
||||
finally:
|
||||
guest('command', command='kill @e[type=!minecraft:player,distance=..8]')
|
||||
host('command', command='kill @e[type=!minecraft:player,distance=..8]')
|
||||
for name, s in seen.items():
|
||||
heights = [y for _, y in s['pigs'] + s['stands']]
|
||||
assert len(s['pigs']) == 1 and len(s['stands']) == 1, f'{name} sees {s}'
|
||||
assert all(abs(y - s['floor']) < 0.1 for y in heights), f"{name}'s did not fall to the floor: {seen}"
|
||||
ids = [i for s in seen.values() for i, _ in s['pigs'] + s['stands']]
|
||||
assert len(set(ids)) == len(ids), f'ids shared across the levels: {seen}'
|
||||
return seen
|
||||
|
||||
|
||||
def level_rejoin(host, guest, out):
|
||||
"""The guest leaves in the Nether and joins again there (its player data keeps its level)."""
|
||||
before = pos_of(guest)
|
||||
port = host.raw('lan', 'status')['port']
|
||||
guest.raw('disconnect')
|
||||
time.sleep(2)
|
||||
guest.raw('connect', f'127.0.0.1:{port}', GUEST_NAME)
|
||||
guest.raw('wait-for', 'loaded', '--timeout', 300)
|
||||
guest.wait(20)
|
||||
after = pos_of(guest)
|
||||
assert dims(host, guest) == (OVERWORLD, NETHER), f'host, guest in {dims(host, guest)}'
|
||||
assert all(abs(a - b) < 1 for a, b in zip(before, after)), f'left at {before}, back at {after}'
|
||||
return {'at': after}
|
||||
|
||||
|
||||
def level_death(host, guest, out):
|
||||
"""The guest dies in the Nether and respawns in the Overworld; the host stays where it is."""
|
||||
guest('command', command='kill @s')
|
||||
guest.wait(20)
|
||||
guest.raw('respawn')
|
||||
back = wait_dimension(guest, OVERWORLD, 400)
|
||||
guest.wait(20)
|
||||
assert back.get('dimension') == OVERWORLD, f'the guest respawned in {back.get("dimension")}'
|
||||
assert dims(host, guest) == (OVERWORLD, OVERWORLD), f'host, guest in {dims(host, guest)}'
|
||||
return {'respawned': back['player']['position']}
|
||||
|
||||
|
||||
def level_respawn_points(host, guest, out):
|
||||
"""Respawn points outside the Overworld (ServerPlayer.findRespawnAndUseSpawnBlock): a point
|
||||
`spawnpoint` sets in the Nether (forced), then a charged respawn anchor the guest uses there,
|
||||
each bring the guest back in the Nether; the anchor loses a charge."""
|
||||
home = pos_of(guest)
|
||||
x, z = (int(v // 1) for v in home[::2])
|
||||
into_level(guest, NETHER, x, 100, z)
|
||||
results = {}
|
||||
try:
|
||||
guest('command', command=f'spawnpoint @s {x} 100 {z}')
|
||||
results['spawnpoint'] = die_and_respawn(guest)
|
||||
guest('command', command=f'setblock {x + 2} 100 {z} minecraft:respawn_anchor[charges=2]')
|
||||
guest.wait(10)
|
||||
guest.raw('interact', x + 2, 100, z)
|
||||
guest.wait(10)
|
||||
results['anchor'] = die_and_respawn(guest)
|
||||
results['charges'] = guest.raw('block', x + 2, 100, z).get('state')
|
||||
finally:
|
||||
sx, sy, sz = (int(v // 1) for v in pos_of(host))
|
||||
guest('command', command=f'execute in {OVERWORLD} run spawnpoint @s {sx} {sy} {sz}')
|
||||
guest('command', command=f'execute in {NETHER} run setblock {x + 2} 100 {z} minecraft:air')
|
||||
guest('command', command=f'execute in {OVERWORLD} run tp @s {home[0]:.2f} {home[1]:.2f} {home[2]:.2f}')
|
||||
wait_dimension(guest, OVERWORLD, 400)
|
||||
point, anchor = results['spawnpoint'], results['anchor']
|
||||
assert point[0] == NETHER and abs(point[1][0] - x - 0.5) < 0.6 and abs(point[1][2] - z - 0.5) < 0.6, \
|
||||
f'the spawnpoint brought the guest to {point}'
|
||||
assert anchor[0] == NETHER and abs(anchor[1][0] - x - 2.5) < 2 and abs(anchor[1][2] - z - 0.5) < 2, \
|
||||
f'the anchor brought the guest to {anchor}'
|
||||
assert 'charges=1' in (results['charges'] or ''), f'the anchor after the respawn: {results["charges"]}'
|
||||
return results
|
||||
|
||||
|
||||
def die_and_respawn(guest):
|
||||
"""Kill the guest and respawn it: the dimension and position it comes back at, once settled."""
|
||||
guest('command', command='kill @s')
|
||||
guest.wait(20)
|
||||
guest.raw('respawn')
|
||||
guest.wait(80)
|
||||
state = guest('state')
|
||||
return state.get('dimension'), state['player']['position']
|
||||
|
||||
|
||||
def level_host_nether(host, guest, out):
|
||||
"""The host goes through a Nether portal while the guest stays in the Overworld."""
|
||||
hx, hy, hz = (int(v // 1) for v in pos_of(host))
|
||||
nether_portal(host, hx, hy, hz - 3)
|
||||
try:
|
||||
host('command', command=f'tp @s {hx + 0.5} {hy} {hz - 2.5}')
|
||||
there = wait_dimension(host, NETHER, 600)
|
||||
host.wait(20)
|
||||
result = dims(host, guest)
|
||||
assert there.get('dimension') == NETHER, f'the host is in {there.get("dimension")}'
|
||||
assert result == (NETHER, OVERWORLD), f'host, guest in {result}'
|
||||
return {'host': there['player']['position']}
|
||||
finally:
|
||||
host('command', command=f'execute in {OVERWORLD} run fill {hx - 1} {hy} {hz - 3} {hx + 2} {hy + 3} {hz - 3} '
|
||||
'minecraft:air')
|
||||
host('command', command=f'execute in {OVERWORLD} run tp @s {hx + 0.5} {hy} {hz + 0.5}')
|
||||
wait_dimension(host, OVERWORLD, 400)
|
||||
|
||||
|
||||
def level_guest_end(host, guest, out):
|
||||
"""The guest steps into an End portal alone and lands on the obsidian platform, an End gateway
|
||||
takes it across the End, and an exit portal takes it back to the Overworld."""
|
||||
x, y, z = (int(v // 1) for v in pos_of(guest))
|
||||
guest('command', command=f'setblock {x + 2} {y} {z} minecraft:end_portal')
|
||||
try:
|
||||
guest('command', command=f'tp @s {x + 2.5} {y} {z + 0.5}')
|
||||
wait_dimension(guest, END, 600)
|
||||
# The arrival waits for the platform's chunks; its portal cooldown (10 ticks) runs out after,
|
||||
# and a player who stands in a portal while it runs stays (Entity.setAsInsidePortal).
|
||||
guest.wait(60)
|
||||
landed = pos_of(guest)
|
||||
assert dims(host, guest) == (OVERWORLD, END), f'host, guest in {dims(host, guest)}'
|
||||
assert [int(v // 1) for v in landed] == [100, 49, 0], f'the guest landed at {landed}'
|
||||
finally:
|
||||
guest('command', command=f'execute in {OVERWORLD} run setblock {x + 2} {y} {z} minecraft:air')
|
||||
# A gateway far out with an exact exit at the End spawn, as end_gateway_return's
|
||||
# (TheEndGatewayBlockEntity.getPortalPosition), takes the guest back there within the End.
|
||||
guest('command', command='tp @s 700.5 80 0.5')
|
||||
guest.wait(60)
|
||||
level_floor(guest, 700, 80, 0)
|
||||
guest('command', command='tp @s 700.5 80 0.5')
|
||||
guest.wait(20)
|
||||
guest('command', command='setblock 702 80 0 minecraft:end_gateway{exit_portal:[I;100,50,0],exact_teleport:1b}')
|
||||
guest('command', command='tp @s 702.5 80 0.5')
|
||||
gateway = None
|
||||
for _ in range(20):
|
||||
guest.wait(10)
|
||||
gateway = pos_of(guest)
|
||||
if abs(gateway[0] - 702.5) > 50:
|
||||
break
|
||||
assert dims(host, guest) == (OVERWORLD, END), f'host, guest in {dims(host, guest)}'
|
||||
assert abs(gateway[0] - 100.5) < 1 and abs(gateway[2] - 0.5) < 1 and 49 <= gateway[1] <= 50, \
|
||||
f'the gateway left the guest at {gateway}'
|
||||
guest.wait(40)
|
||||
ex, ey, ez = (int(v // 1) for v in pos_of(guest))
|
||||
guest('command', command=f'setblock {ex + 2} {ey} {ez} minecraft:end_portal')
|
||||
guest('command', command=f'tp @s {ex + 2.5} {ey} {ez + 0.5}')
|
||||
home = wait_dimension(guest, OVERWORLD, 600)
|
||||
guest.wait(20)
|
||||
assert dims(host, guest) == (OVERWORLD, OVERWORLD), f'host, guest in {dims(host, guest)}'
|
||||
return {'landed': landed, 'gateway': gateway, 'home': home['player']['position']}
|
||||
|
||||
|
||||
def level_reload(host, guest, out):
|
||||
"""The host saves and quits with the guest in the Nether, opens the world again and opens it to the
|
||||
LAN; the guest joins again where it was, in the Nether."""
|
||||
x, z = (int(v // 1) for v in pos_of(guest)[::2])
|
||||
into_level(guest, NETHER, x, 100, z)
|
||||
before = pos_of(guest)
|
||||
host.raw('disconnect')
|
||||
# The world saves on its server thread before the title screen shows.
|
||||
for attempt in range(60):
|
||||
time.sleep(1)
|
||||
try:
|
||||
host.raw('open', 'Lan')
|
||||
break
|
||||
except Failed:
|
||||
if attempt == 59:
|
||||
raise
|
||||
host('wait_ready', seconds=400)
|
||||
port = host.raw('lan', 'open', 0, 'commands')['port']
|
||||
back_to_game(guest)
|
||||
guest.raw('connect', f'127.0.0.1:{port}', GUEST_NAME)
|
||||
guest.raw('wait-for', 'loaded', '--timeout', 300)
|
||||
guest.wait(20)
|
||||
after = pos_of(guest)
|
||||
assert dims(host, guest) == (OVERWORLD, NETHER), f'host, guest in {dims(host, guest)}'
|
||||
assert all(abs(a - b) < 1 for a, b in zip(before, after)), f'left at {before}, back at {after}'
|
||||
return {'at': after}
|
||||
|
||||
|
||||
LEVEL_STEPS = [('execute', level_execute), ('ticking', level_ticking), ('rejoin', level_rejoin),
|
||||
('death', level_death), ('respawn_points', level_respawn_points),
|
||||
('host_nether', level_host_nether),
|
||||
('guest_end', level_guest_end), ('reload', level_reload)]
|
||||
|
||||
|
||||
def check_damage(host, guest, out):
|
||||
guest('command', command='gamemode survival')
|
||||
guest.wait(10)
|
||||
@@ -3075,7 +3369,8 @@ CHECK_FNS = {'tab': check_tab, 'self': check_self, 'armour': check_armour, 'name
|
||||
'xp': check_xp, 'objects': check_objects, 'projectiles': check_projectiles, 'blast': check_blast,
|
||||
'light': check_light, 'plates': check_plates,
|
||||
'swings': check_swings, 'cracks': check_cracks,
|
||||
'time': check_time, 'growth': check_growth, 'flight': check_flight, 'fireball': check_fireball, 'deflect': check_deflect, 'barter': check_barter, 'worn': check_worn, 'kills': check_kills, 'arrows': check_arrows, 'predict': check_predict, 'ladder': check_ladder, 'boats': check_boats, 'minecarts': check_minecarts, 'frames': check_frames, 'lightning': check_lightning, 'riding': check_riding, 'mounts': check_mounts}
|
||||
'time': check_time, 'growth': check_growth, 'flight': check_flight, 'fireball': check_fireball, 'deflect': check_deflect, 'barter': check_barter, 'worn': check_worn, 'kills': check_kills, 'arrows': check_arrows, 'predict': check_predict, 'ladder': check_ladder, 'boats': check_boats, 'minecarts': check_minecarts, 'frames': check_frames, 'lightning': check_lightning, 'riding': check_riding, 'mounts': check_mounts,
|
||||
'levels': check_levels}
|
||||
|
||||
|
||||
def run(ctl, out, resources, checks):
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Compare which sections of a chunk random-tick, and the positions drawn in them, with the pinned client.
|
||||
|
||||
Vanilla random-ticks a section while its count of randomly ticking blocks and fluids is above zero
|
||||
(LevelChunkSection.isRandomlyTicking, counted by setBlockState and recalcBlockCounts), whatever its palette still lists.
|
||||
Each case loads sections (every block state fills one, alone, so each state's own answer is compared; others mix a
|
||||
ticking and a non-ticking state), ticks, sets and removes randomly ticking blocks (leaving them in the palette), and
|
||||
ticks again. Vanilla runs ServerLevel.tickChunk's section loop on real sections with the real getBlockRandomPos
|
||||
(VanillaRandomTickSectionsOracle); Horizon runs Behaviours::tick_chunk_random with the game's RandomTickSections
|
||||
(examples/random_tick_sections_probe). Both print every drawn position, whether its block and its fluid tick
|
||||
randomly, and the final randValue.
|
||||
"""
|
||||
import itertools
|
||||
import json
|
||||
import random
|
||||
|
||||
try:
|
||||
from . import lab, oracle_harness as harness
|
||||
except ImportError:
|
||||
import lab, oracle_harness as harness
|
||||
|
||||
COUNT = 3000
|
||||
SECTIONS = 24
|
||||
|
||||
|
||||
def make_cases(build):
|
||||
resources = lab.LOCAL / 'horizon-client' / '26.3'
|
||||
states = json.loads((resources / 'gameplay-facts.json').read_text())['states']
|
||||
ticking = [s['id'] for s in states if s['random_ticks']]
|
||||
still = [s['id'] for s in states if not s['random_ticks']]
|
||||
sweep = itertools.cycle(range(len(states)))
|
||||
rng = random.Random(0x5449434B)
|
||||
lines = [f'#resources {resources}']
|
||||
for _ in range(COUNT):
|
||||
ops = []
|
||||
loaded = rng.sample(range(SECTIONS), 14)
|
||||
for section in loaded[:12]:
|
||||
state = next(sweep)
|
||||
ops.append(f'f:{section}:{state}:{state}:1')
|
||||
mixed = loaded[12:]
|
||||
for section in mixed:
|
||||
ops.append(f'f:{section}:{rng.choice(still)}:{rng.choice(ticking)}:{rng.choice([1, 2, 7, 64, 4096])}')
|
||||
ops.append('t')
|
||||
placed = []
|
||||
for _ in range(rng.randrange(1, 6)):
|
||||
section, index = rng.randrange(SECTIONS), rng.randrange(4096)
|
||||
ops.append(f's:{section}:{index}:{rng.choice(ticking)}')
|
||||
placed.append((section, index))
|
||||
ops.append('t')
|
||||
# Take them away again, and the lone ticking block of the mixed sections:
|
||||
# their palettes keep the states.
|
||||
for section, index in placed + [(section, 4095) for section in mixed]:
|
||||
ops.append(f's:{section}:{index}:{rng.choice([0, 0, rng.choice(still)])}')
|
||||
ops.append('t')
|
||||
ops.append('t')
|
||||
lines.append(f'{rng.randrange(-2**31, 2**31)} {rng.choice([0, 1, 2, 3, 3, 3, 4])} {" ".join(ops)}')
|
||||
path = build / 'cases.txt'
|
||||
path.write_text('\n'.join(lines) + '\n')
|
||||
return path, COUNT
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
harness.differential(
|
||||
'random-tick-sections-parity', 'VanillaRandomTickSectionsOracle', 'random_tick_sections_probe', make_cases,
|
||||
'Which sections of a chunk random-tick (block and fluid counts over loads, sets and removals, not the palette) '
|
||||
'and the positions tickChunk draws in them, with each position\'s block and fluid random-tick flags. The '
|
||||
'blocks\' random-tick behaviour itself is test_random_tick_parity\'s.',
|
||||
sources=[lab.ROOT / 'crates/horizon-client/src/random_tick_sections.rs',
|
||||
lab.ROOT / 'crates/horizon-client/src/chunk.rs',
|
||||
lab.ROOT / 'crates/horizon-core/src/paletted.rs',
|
||||
lab.ROOT / 'crates/horizon-world/src/level/tick.rs',
|
||||
lab.ROOT / 'crates/horizon-world/src/level/ticks.rs'],
|
||||
expected_lines=COUNT)
|
||||
@@ -0,0 +1,126 @@
|
||||
# Control Horizon with your AI agent
|
||||
|
||||
Your own AI agent can drive the launcher and the game: Claude Code, Codex, Cursor or any client that speaks MCP (the Model Context Protocol). It runs on your computer and uses the same commands you can type in a terminal. It can install a version, start the game, play it, change settings and manage your worlds. Before it deletes anything, it asks you. If you don't want any of this, one **No AI** switch hides and turns off every AI feature.
|
||||
|
||||
## What an agent can do
|
||||
|
||||
Everything you can do with the mouse and keyboard, through commands instead of clicks:
|
||||
|
||||
- **In the launcher:**
|
||||
- install, remove and prepare versions;
|
||||
- start the game, alone or with a world, or a server;
|
||||
- your settings, the update channel and the extra game arguments;
|
||||
- the mod browser (mods, resource packs, shader packs, modpacks), imported modpacks and hosted servers;
|
||||
- your accounts (names only), logs and crash reports, patch notes, the data collection settings and the benchmark.
|
||||
- **In the game:**
|
||||
- read what is on screen (every button, slider, field and slot, by name), click and type;
|
||||
- move, look, mine, place, attack, use items and pick from the hotbar;
|
||||
- the inventory, chests and every other container, crafting, trading, books and signs;
|
||||
- every options page, including Horizon Settings, and any option by its key;
|
||||
- worlds (create, open, rename, back up, delete), servers, LAN and Play with Friends;
|
||||
- chat and commands, screenshots, F3 data, waiting for an event and the game's log.
|
||||
|
||||
The full lists are in the repository: [launcher commands](https://github.com/ludovic111/horizon/blob/main/docs/launcher-commands.md) and [game commands](https://github.com/ludovic111/horizon/blob/main/docs/horizon-ctl-commands.md).
|
||||
|
||||
## Connect it from the launcher
|
||||
|
||||
The easiest way. Open the launcher's **Settings** and scroll to **AI**:
|
||||
|
||||
1. Under **Connect an AI agent**, pick your client: **Claude Code**, **Codex**, **Cursor** or **Any MCP client**.
|
||||
2. For Claude Code and Codex, **Add to Claude Code** (or **Add to Codex**) runs that client's own command to add Horizon. The command is shown above the button, and runs only when you click it. For the others, **Copy** the config and paste it into the file named above it.
|
||||
3. **Test the connection** starts Horizon's MCP server the way your client will and says how many tools it offers.
|
||||
4. Restart your agent if it was open, then ask it: "Say hello from Horizon".
|
||||
|
||||
One server, called `horizon`, covers both the launcher and the game. Ask your agent to start the game and it starts it under its control. The game's tools are the ones whose names begin with `game_`.
|
||||
|
||||
## Copy-paste config
|
||||
|
||||
The launcher prints these with the exact path of your copy: `horizon-launcher agent-config`. The paths below are the usual ones:
|
||||
|
||||
- **macOS:** `/Applications/Horizon.app/Contents/MacOS/horizon-launcher`
|
||||
- **Windows:** where you extracted the zip, for example `C:\Horizon\horizon-launcher.exe`
|
||||
- **Linux:** where you put the AppImage or binary
|
||||
|
||||
**Claude Code**, in a terminal:
|
||||
|
||||
```sh
|
||||
claude mcp add --scope user horizon -- /Applications/Horizon.app/Contents/MacOS/horizon-launcher mcp
|
||||
```
|
||||
|
||||
**Codex**, in a terminal:
|
||||
|
||||
```sh
|
||||
codex mcp add horizon -- /Applications/Horizon.app/Contents/MacOS/horizon-launcher mcp
|
||||
```
|
||||
|
||||
Or in `~/.codex/config.toml`:
|
||||
|
||||
```toml
|
||||
[mcp_servers.horizon]
|
||||
command = "/Applications/Horizon.app/Contents/MacOS/horizon-launcher"
|
||||
args = ["mcp"]
|
||||
```
|
||||
|
||||
**Cursor** (`~/.cursor/mcp.json`), **Claude Desktop**, and **any other MCP client** that takes the usual JSON:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"horizon": {
|
||||
"command": "/Applications/Horizon.app/Contents/MacOS/horizon-launcher",
|
||||
"args": ["mcp"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The server talks over its standard input and output, so it needs no port and is never on the network.
|
||||
|
||||
## A game you started yourself
|
||||
|
||||
A game your agent starts is under its control. A game you start with **Play** is not, unless you allow it. In the game, open **Options**, then **Horizon Settings**, then **AI Agent...**:
|
||||
|
||||
- **Agent Control: On** lets agents on this computer reach the game you started. It is off by default.
|
||||
- The page shows whether an agent is connected and when it last sent a command.
|
||||
- **Copy Connection Details** copies where the game listens, a command to try, and an MCP config for `horizon-ctl`, the game's own command line, which comes with the game.
|
||||
|
||||
Turning **Agent Control** off disconnects agents at once.
|
||||
|
||||
## The command line
|
||||
|
||||
The same commands, for scripts and terminals. `--json` prints JSON.
|
||||
|
||||
- See the versions: `horizon-launcher versions`
|
||||
- Install the newest: `horizon-launcher install latest`
|
||||
- Play a world: `horizon-launcher play --world "My world"`
|
||||
- Change a setting: `horizon-launcher settings install-new-versions on`
|
||||
- Find a shader pack: `horizon-launcher mods search --kind shader sildurs`
|
||||
- Read the game's log: `horizon-launcher logs`
|
||||
- Turn No AI on: `horizon-launcher no-ai on`
|
||||
- Start a game agents control: `horizon-ctl launch --world Test`
|
||||
- See the game's state: `horizon-ctl state`
|
||||
- See what is on screen: `horizon-ctl ui`
|
||||
- Click a button by name: `horizon-ctl click singleplayer`
|
||||
- Walk somewhere: `horizon-ctl goto 10 64 -3`
|
||||
- Take a screenshot: `horizon-ctl screenshot shot.png`
|
||||
|
||||
`horizon-ctl help` lists every game command. `horizon-launcher help` lists the launcher's.
|
||||
|
||||
## Safety
|
||||
|
||||
- **Your computer only.** The launcher's server talks only to the agent that started it. The game listens on a local socket that only your user account can open; where there is none (Windows), it uses a localhost port and a secret token in a file only you can read. Nothing listens on your network.
|
||||
- **It asks before deleting.** Removing a version, a modpack, a mod or a server, deleting a world or your collected data, and signing out need `confirm: true` from the agent. A call without it is refused and tells the agent to ask you first.
|
||||
- **It asks before reaching out.** The tools that send something or reach other people say "only when the player asks". That covers joining a server, chatting on one, opening to LAN, hosting for friends, creating a server and sending data or a bug report. Data collection stays off until you allow it yourself.
|
||||
- **No secrets.** No command returns your account tokens, session files or invite secrets. Accounts appear as names. Signing in stays a code that you enter in your own browser.
|
||||
- **Your agent, your rules.** Clients such as Claude Code ask you before each tool they haven't been allowed to use. Allow only what you are happy for them to do.
|
||||
|
||||
## No AI: turn it all off
|
||||
|
||||
In the launcher's **Settings**, under **AI**, turn on **No AI**. You can also use **No AI** in the game's **Horizon Settings**, or type `horizon-launcher no-ai on`. Then:
|
||||
|
||||
- the **Connect an AI agent** section and the game's **AI Agent** page are hidden;
|
||||
- the game opens no control socket, even when it is started with one;
|
||||
- `horizon-ctl`, `horizon-ctl mcp` and `horizon-launcher mcp` refuse with "AI features are off: turn off No AI in the launcher's settings";
|
||||
- the launcher's own commands (install, play, server, update and the rest) keep working, so scripts and packagers don't break.
|
||||
|
||||
Turn it off the same way to bring everything back.
|
||||
@@ -2,6 +2,6 @@
|
||||
{ "group": "Start here", "pages": ["index", "install"] },
|
||||
{ "group": "Playing", "pages": ["settings", "multiplayer", "troubleshooting"] },
|
||||
{ "group": "Hosting", "pages": ["hosting"] },
|
||||
{ "group": "Going further", "pages": ["modpacks", "mods", "performance"] },
|
||||
{ "group": "Going further", "pages": ["modpacks", "mods", "performance", "agents"] },
|
||||
{ "group": "Project", "pages": ["contributing", "faq"] }
|
||||
]
|
||||
Reference in new issue
Block a user