Compare commits

..
2 Commits
Author SHA1 Message Date
Ludovic MarieandClaude Opus 5.5 d9351ec054 Menus: Horizon-style options screen, pause polish, screenshots
Options open as the launcher's Settings page: a sidebar with the sliding
selection (Video, Audio, Controls, Accessibility, Language, Chat, Interface)
and rows with a switch, slider or value button per option
(options_horizon.rs; new sections or rows slot into SECTIONS). Key binds,
the language list and resource packs open vanilla's pages. Interface holds
Menu style (Horizon / Classic). The pause menu hides the in-game HUD over the
blurred world. Scripted and frame-limited runs default to Classic, so the
harnesses see vanilla's screens.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-13 15:33:15 +02:00
Ludovic MarieandClaude Opus 5.5 eb18bb42af Menus: Horizon style for the title, worlds, create, multiplayer and pause screens
One theme for the launcher and the game: crates/horizon-theme holds the
palette, type scale, spacing, radii, control sizes, motion and the bundled
fonts; launcher/src/theme.rs and the game's ui_theme both read it. ui_theme
ports the launcher's components (button with hover layer and press state,
sidebar list with the sliding selection, segmented control, switch, rows)
and draws Geist and Instrument Serif through the HUD atlas.

The title is laid out like the launcher (sidebar, page header, rows) over a
live backdrop: Horizon generates an ice-spikes landscape for a fixed seed,
orbits a camera over it with falling snow, then blurs and cold-grades the
frame (post.rs). The launcher's opening (wordmark, then the interface) plays
once per session and a click or key skips it. Menu style Horizon / Classic
lives in horizon.txt, never options.txt; Classic keeps vanilla's drawing.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-13 15:19:14 +02:00
57 changed files with 3796 additions and 2360 deletions

No files matched your search

Generated
+6
View File
@@ -681,12 +681,14 @@ dependencies = [
name = "horizon-client"
version = "0.1.0"
dependencies = [
"ab_glyph",
"bytemuck",
"cpal",
"glam",
"horizon-assets",
"horizon-core",
"horizon-protocol",
"horizon-theme",
"horizon-world",
"miniz_oxide 0.8.9",
"png",
@@ -713,6 +715,10 @@ dependencies = [
"serde_json",
]
[[package]]
name = "horizon-theme"
version = "0.1.0"
[[package]]
name = "horizon-world"
version = "0.1.0"
+1 -1
View File
@@ -1,6 +1,6 @@
[workspace]
exclude = ["launcher"]
members = ["crates/horizon-core", "crates/horizon-world", "crates/voxel-kernels", "crates/horizon-client", "crates/horizon-assets", "crates/horizon-protocol"]
members = ["crates/horizon-core", "crates/horizon-world", "crates/voxel-kernels", "crates/horizon-client", "crates/horizon-assets", "crates/horizon-protocol", "crates/horizon-theme"]
resolver = "2"
[workspace.package]
+2
View File
@@ -22,6 +22,8 @@ serde_json = { version = "1.0.151", features = ["raw_value"] }
cpal = "0.16"
symphonia = { version = "0.5", default-features = false, features = ["ogg", "vorbis"] }
miniz_oxide = "0.8"
ab_glyph = "0.2.32"
horizon-theme = { path = "../horizon-theme" }
[features]
# Records the call sites of mob random draws (`mob_ai_probe` traces).
+1 -10
View File
@@ -5,7 +5,7 @@ use horizon_client::{
Result,
camera::{
AvatarBob, BobState, Camera, CameraEntity, CameraFluid, CameraType, FovPlayer, HurtBob,
LivingCamera, UseItem, bob_matrix, field_of_view_modifier, perspective,
LivingCamera, UseItem, bob_matrix, field_of_view_modifier,
},
};
use horizon_core::{
@@ -258,15 +258,6 @@ fn camera(c: &Value, out: &mut impl Write) -> Result<()> {
fb(fov),
fb(hud)
)?;
let (w, h, far) = (fl(&op[4]), fl(&op[5]), fl(&op[6]));
let matrix =
|m: [f32; 16]| m.iter().map(|&x| format!(" {}", fb(x))).collect::<String>();
writeln!(
out,
"projection{}{}",
matrix(perspective(fov, w, h, far, 0.05, true)),
matrix(perspective(hud, w, h, far, 0.05, true))
)?;
}
other => panic!("{other}"),
}
@@ -1,461 +0,0 @@
//! Probe for test_first_person_parity.py: drives horizon_client::first_person
//! and attack_strength over the harness's scripted clicks, targets and use
//! results, printing what VanillaFirstPersonOracle prints.
use horizon_client::{
Result,
attack_strength::{AttackStrength, HeldStack},
first_person::{
Clicks, Hand, HandHeights, HandWorld, Hit, SwingAnimation, SwingState, UseResult,
swing_duration,
},
};
use serde_json::Value;
use std::{
collections::HashMap,
fs::File,
io::{BufRead, BufReader, BufWriter, Write},
};
fn fb(v: f32) -> String {
format!("{:08x}", v.to_bits())
}
fn fl(v: &Value) -> f32 {
f32::from_bits(u32::from_str_radix(v.as_str().unwrap(), 16).unwrap())
}
fn dl(v: &Value) -> f64 {
f64::from_bits(u64::from_str_radix(v.as_str().unwrap(), 16).unwrap())
}
/// An `ItemStack` object: the hands hold indices into `Probe::stacks`, so
/// shrinking keeps its identity as vanilla's in-place `shrink` does.
#[derive(Clone)]
struct Stack {
item: String,
count: i32,
}
impl Stack {
fn empty(&self) -> bool {
self.item == "minecraft:air" || self.count <= 0
}
/// `getItem` and `getCount` of an empty stack are air and 0.
fn effective(&self) -> (&str, i32) {
if self.empty() {
("minecraft:air", 0)
} else {
(&self.item, self.count)
}
}
}
struct Probe<'a> {
case: &'a Value,
step: &'a Value,
creative: bool,
stacks: Vec<Stack>,
/// The stacks in the main and off hand.
hands: [usize; 2],
blocks: HashMap<[i32; 3], String>,
swing: SwingState,
events: Vec<String>,
heights: HandHeights,
}
fn hand_name(hand: Hand) -> &'static str {
match hand {
Hand::Main => "main",
Hand::Off => "off",
}
}
impl Probe<'_> {
fn stack(&self, hand: Hand) -> &Stack {
&self.stacks[self.hands[hand as usize]]
}
fn new_stack(&mut self, v: &Value) -> usize {
// ItemStack.EMPTY is index 0.
if v.is_null() {
return 0;
}
self.stacks.push(Stack {
item: v[0].as_str().unwrap().into(),
count: v[1].as_i64().unwrap() as i32,
});
self.stacks.len() - 1
}
fn table<'b>(&'b self, name: &str) -> &'b Value {
&self.case["rust"][name]
}
fn attack_speed(&self) -> f64 {
let item = self.stack(Hand::Main).effective().0;
self.table("attack_speed").get(item).map_or(4.0, dl)
}
fn scripted(&mut self, kind: &str, hand: Hand) -> UseResult {
let name = self.step["uses"][kind].as_str().unwrap().to_string();
self.events
.push(format!("use {kind} {} {name}", hand_name(hand)));
let result = match name.as_str() {
"success" => UseResult::Success { swing: true },
"server" | "consume" => UseResult::Success { swing: false },
"fail" => UseResult::Fail,
_ => UseResult::Pass,
};
if matches!(result, UseResult::Success { .. }) && self.step["shrink"].as_bool().unwrap() {
let i = self.hands[hand as usize];
if i != 0 {
self.stacks[i].count -= 1;
}
}
result
}
}
impl HandWorld for Probe<'_> {
fn hit(&mut self) -> Hit {
let h = &self.step["hit"];
match h[0].as_str().unwrap() {
"block" => Hit::Block {
pos: [0, 1, 2].map(|i| h[i + 1].as_i64().unwrap() as i32),
face: h[4].as_u64().unwrap() as u8,
},
"entity" => Hit::Entity,
_ => Hit::Miss,
}
}
fn is_air(&mut self, pos: [i32; 3]) -> bool {
self.blocks.get(&pos).is_none_or(|b| b == "minecraft:air")
}
fn creative(&self) -> bool {
self.creative
}
fn spectator(&self) -> bool {
false
}
fn instabuild(&self) -> bool {
self.creative
}
fn hands_busy(&self) -> bool {
self.step["busy"].as_bool().unwrap()
}
fn using_item(&self) -> bool {
self.step["using"].as_bool().unwrap()
}
fn item_enabled(&mut self, _: Hand) -> bool {
true
}
fn held_empty(&mut self, hand: Hand) -> bool {
self.stack(hand).empty()
}
fn held_count(&mut self, hand: Hand) -> i32 {
self.stack(hand).effective().1
}
fn infinite_materials(&self) -> bool {
self.creative
}
fn cannot_attack(&mut self) -> bool {
false
}
fn piercing_weapon(&mut self) -> bool {
false
}
fn attack_animation(&mut self) -> SwingAnimation {
SwingAnimation::DEFAULT
}
fn interact_animation(&mut self, _: Hand) -> SwingAnimation {
SwingAnimation::DEFAULT
}
fn piercing_attack(&mut self, _: SwingAnimation) {
unreachable!()
}
fn attack_entity(&mut self) -> bool {
self.events.push("attack".into());
true
}
fn block_action_restricted(&mut self, _: [i32; 3]) -> bool {
false
}
fn within_border(&mut self, _: [i32; 3]) -> bool {
true
}
fn attack_block(&mut self, _: [i32; 3]) {}
/// `BlockBehaviour.getDestroyProgress` with `Player.getDestroySpeed` for
/// a player on the ground, out of water, with no mining efficiency.
fn destroy_progress(&mut self, pos: [i32; 3]) -> f32 {
let block = self.blocks.get(&pos).cloned().unwrap_or_default();
let b = &self.table("blocks")[block.as_str()];
let hardness = fl(&b[0]);
if hardness == -1.0 {
return 0.0;
}
let item = self.stack(Hand::Main).effective().0.to_string();
let tool = &self.table("tools")[format!("{item} {block}").as_str()];
let (mut speed, correct) = if tool.is_null() {
(1.0f32, false)
} else {
(fl(&tool[0]), tool[1].as_bool().unwrap())
};
if let Some(amp) = self.case["haste"].as_i64() {
speed *= 1.0 + (amp as i32 + 1) as f32 * 0.2;
}
if let Some(amp) = self.case["fatigue"].as_i64() {
speed *= horizon_core::jmath::pow(0.3, f64::from(amp as i32 + 1)) as f32;
}
let harvest = !b[1].as_bool().unwrap() || correct;
speed / hardness / if harvest { 30.0 } else { 100.0 }
}
fn destroy_block(&mut self, pos: [i32; 3]) -> bool {
let item = self.stack(Hand::Main).effective().0;
// Item.canDestroyBlock: swords' tools cannot destroy in creative.
let sword = item.ends_with("_sword");
let ok = !(sword && self.creative) && !self.is_air(pos);
if ok {
self.blocks.insert(pos, "minecraft:air".into());
}
self.events
.push(format!("destroy {} {} {} {ok}", pos[0], pos[1], pos[2]));
ok
}
/// The stack object itself: `destroyingItem` is a reference.
fn main_hand_key(&mut self) -> String {
self.hands[0].to_string()
}
fn same_destroying_item(&mut self, key: &str) -> bool {
// ItemStack.EMPTY (index 0) before any break.
let then = &self.stacks[key.parse::<usize>().unwrap_or(0)];
then.effective().0 == self.stack(Hand::Main).effective().0
}
fn destroy_stage(&mut self, pos: [i32; 3], stage: Option<i32>) {
self.events.push(format!(
"stage {} {} {} {}",
pos[0],
pos[1],
pos[2],
stage.unwrap_or(-1)
));
}
fn swing(&mut self, hand: Hand, animation: SwingAnimation) {
let duration = swing_duration(
animation,
self.case["haste"].as_i64().map(|a| a as i32),
self.case["fatigue"].as_i64().map(|a| a as i32),
);
let started = self.swing.start_if_able(hand, animation, duration);
self.events.push(format!(
"swing {} {} {started}",
hand_name(hand),
animation.duration
));
}
fn reset_attack_strength(&mut self) {
unreachable!("ResetWorld resets the tickers")
}
fn entity_within_border(&mut self) -> bool {
true
}
fn use_on_entity(&mut self, hand: Hand) -> Option<UseResult> {
self.step["hit"][1]
.as_bool()
.unwrap()
.then(|| self.scripted("entity", hand))
}
fn use_on_block(&mut self, hand: Hand) -> UseResult {
self.scripted("block", hand)
}
fn use_item(&mut self, hand: Hand) -> UseResult {
self.scripted("item", hand)
}
fn item_used(&mut self, hand: Hand) {
self.events.push(format!("used {}", hand_name(hand)));
self.heights.item_used(hand);
}
fn release_using(&mut self) {
self.events.push("release".into());
}
}
fn held(stack: &Stack) -> Option<HeldStack> {
(!stack.empty()).then(|| HeldStack {
item: stack.item.clone(),
rest: stack.count.to_string(),
})
}
fn run(case: &Value, out: &mut impl Write) -> Result<()> {
let empty = Value::Null;
let mut probe = Probe {
case,
step: &empty,
creative: case["creative"].as_bool().unwrap(),
stacks: vec![Stack {
item: "minecraft:air".into(),
count: 0,
}],
hands: [0, 0],
blocks: case["blocks"]
.as_array()
.unwrap()
.iter()
.map(|b| {
(
[0, 1, 2].map(|i| b[i].as_i64().unwrap() as i32),
b[3].as_str().unwrap().to_string(),
)
})
.collect(),
swing: SwingState::default(),
events: Vec::new(),
heights: HandHeights::default(),
};
probe.hands[0] = probe.new_stack(&case["main"]);
probe.hands[1] = probe.new_stack(&case["off"]);
writeln!(out, "attack_speed {:x}", probe.attack_speed().to_bits())?;
let mut clicks = Clicks::default();
let mut strength = AttackStrength::default();
// FirstPersonHandsAndItems' visible items start as ItemStack.EMPTY.
let mut visible = [0usize, 0usize];
for (s, step) in case["steps"].as_array().unwrap().iter().enumerate() {
probe.step = step;
probe.events.clear();
if !step["set"].is_null() {
let i = probe.new_stack(&step["set"]["stack"]);
let hand = if step["set"]["hand"] == "main" { 0 } else { 1 };
probe.hands[hand] = i;
}
clicks.tick(
&mut ResetWorld {
probe: &mut probe,
strength: &mut strength,
},
step["ac"].as_u64().unwrap() as u32,
step["uc"].as_u64().unwrap() as u32,
step["ad"].as_bool().unwrap(),
step["ud"].as_bool().unwrap(),
);
probe.swing.tick();
let main = probe.stack(Hand::Main).clone();
strength.tick(held(&main));
// FirstPersonHandsAndItems.tick: shouldInstantlyReplaceVisibleItem
// (ItemStack.matchesIgnoringComponents; every item plays the swap
// animation), then the heights.
let mut changed = [false; 2];
for h in 0..2 {
let (a, b) = (visible[h], probe.hands[h]);
let same = a == b || probe.stacks[a].effective() == probe.stacks[b].effective();
if same {
visible[h] = b;
}
changed[h] = visible[h] != b;
}
let speed = probe.attack_speed();
let replace = probe.heights.tick(
probe.step["busy"].as_bool().unwrap(),
strength.swap_scale(1.0, speed),
changed,
);
for h in 0..2 {
if replace[h] {
visible[h] = probe.hands[h];
}
}
for e in &probe.events {
writeln!(out, "{e}")?;
}
let sw = &probe.swing;
let current = sw.current.map_or("- 0".to_string(), |c| {
format!("{} {}", hand_name(c.hand), c.duration_ticks)
});
let h = &probe.heights;
let d = &clicks.destroying;
writeln!(
out,
"tick {s} {current} {} {} {} {} {} {} {} {} {} {} {} {} {} {} {} {}",
sw.ticks,
fb(sw.animation),
fb(sw.old_animation),
fb(sw.animation(0.5)),
strength.ticker,
strength.swap_ticker,
fb(strength.swap_scale(1.0, speed)),
fb(h.main),
fb(h.old_main),
fb(h.off),
fb(h.old_off),
clicks.miss_time,
clicks.right_click_delay,
d.delay,
d.active,
fb(d.progress)
)?;
}
Ok(())
}
/// The probe with the attack-strength tickers it resets.
struct ResetWorld<'p, 'c> {
probe: &'p mut Probe<'c>,
strength: &'p mut AttackStrength,
}
macro_rules! forward {
($($name:ident($($arg:ident: $t:ty),*) -> $r:ty;)*) => {
$(fn $name(&mut self, $($arg: $t),*) -> $r { self.probe.$name($($arg),*) })*
};
}
impl HandWorld for ResetWorld<'_, '_> {
forward! {
hit() -> Hit;
is_air(pos: [i32; 3]) -> bool;
item_enabled(hand: Hand) -> bool;
held_empty(hand: Hand) -> bool;
held_count(hand: Hand) -> i32;
cannot_attack() -> bool;
piercing_weapon() -> bool;
attack_animation() -> SwingAnimation;
interact_animation(hand: Hand) -> SwingAnimation;
piercing_attack(a: SwingAnimation) -> ();
attack_entity() -> bool;
block_action_restricted(pos: [i32; 3]) -> bool;
within_border(pos: [i32; 3]) -> bool;
attack_block(pos: [i32; 3]) -> ();
destroy_progress(pos: [i32; 3]) -> f32;
destroy_block(pos: [i32; 3]) -> bool;
main_hand_key() -> String;
same_destroying_item(key: &str) -> bool;
destroy_stage(pos: [i32; 3], stage: Option<i32>) -> ();
swing(hand: Hand, a: SwingAnimation) -> ();
entity_within_border() -> bool;
use_on_entity(hand: Hand) -> Option<UseResult>;
use_on_block(hand: Hand) -> UseResult;
use_item(hand: Hand) -> UseResult;
item_used(hand: Hand) -> ();
release_using() -> ();
}
fn creative(&self) -> bool {
self.probe.creative()
}
fn spectator(&self) -> bool {
false
}
fn instabuild(&self) -> bool {
self.probe.instabuild()
}
fn hands_busy(&self) -> bool {
self.probe.hands_busy()
}
fn using_item(&self) -> bool {
self.probe.using_item()
}
fn infinite_materials(&self) -> bool {
self.probe.infinite_materials()
}
fn reset_attack_strength(&mut self) {
self.strength.reset();
}
}
fn main() -> Result<()> {
let args: Vec<String> = std::env::args().collect();
let input = BufReader::new(File::open(&args[1])?);
let mut out = BufWriter::new(File::create(&args[2])?);
for (n, line) in input.lines().enumerate() {
let c: Value = serde_json::from_str(&line?)?;
writeln!(out, "== {n}")?;
run(&c, &mut out)?;
}
out.flush()?;
Ok(())
}
-38
View File
@@ -583,44 +583,6 @@ impl Camera {
modify_fov_based_on_death_or_fluid(partial, 70.0, effect_scale, dead, fluid)
}
}
/// `Projection.getMatrix` for a perspective set up by `setupPerspective`:
/// JOML's `Matrix4f.setPerspective(fov * DEG_TO_RAD, width / height, near,
/// far, zZeroToOne)`, column-major. Vanilla passes its planes reversed
/// (`near` is `zFar`); the hand pass here keeps them in order.
pub fn perspective(
fov_degrees: f32,
width: f32,
height: f32,
near: f32,
far: f32,
zero_to_one: bool,
) -> [f32; 16] {
let h = horizon_core::jmath::tan(f64::from(fov_degrees * DEG_TO_RAD * 0.5)) as f32;
let aspect = width / height;
let mut m = [0.0f32; 16];
m[0] = 1.0 / (h * aspect);
m[5] = 1.0 / h;
let e = 1.0e-6f32;
if far > 0.0 && far.is_infinite() {
m[10] = e - 1.0;
m[14] = (e - if zero_to_one { 1.0 } else { 2.0 }) * near;
} else if near > 0.0 && near.is_infinite() {
m[10] = (if zero_to_one { 0.0 } else { 1.0 }) - e;
m[14] = ((if zero_to_one { 1.0 } else { 2.0 }) - e) * far;
} else {
m[10] = (if zero_to_one { far } else { far + near }) / (near - far);
m[14] = (if zero_to_one { far } else { far + far }) * near / (near - far);
}
m[11] = -1.0;
m
}
/// `GameRenderer.render3dHud`: the first-person hand and held items are
/// drawn with their own projection at `hudFov` (70 degrees, changed only by
/// death and fluids), never the FOV option or the sprint, speed and bow
/// modifier.
pub fn hand_projection(hud_fov: f32, width: f32, height: f32, far: f32) -> [f32; 16] {
perspective(hud_fov, width, height, 0.05, far, true)
}
/// `Camera.modifyFovBasedOnDeathOrFluid`. `dead` is the camera entity's
/// `deathTime` while it is a living entity that is dead or dying.
pub fn modify_fov_based_on_death_or_fluid(
+6 -1
View File
@@ -365,7 +365,12 @@ impl PlayerEffects {
f *= 1.0 + (a + 1) as f32 * 0.2;
}
if let Some(a) = self.amplifier("mining_fatigue") {
f *= horizon_core::jmath::pow(0.3, f64::from(a + 1)) as f32;
f *= match a {
0 => 0.3,
1 => 0.09,
2 => 0.0027,
_ => 8.1e-4,
};
}
f
}
-601
View File
@@ -1,601 +0,0 @@
//! The local player's hand: the arm swing (`LivingEntity.SwingState`,
//! `getModifiedSwingDuration`), the first-person equip heights
//! (`FirstPersonHandsAndItems.tick` and `itemUsed`), and the click handling
//! that starts swings: `Minecraft.startAttack`, `continueAttack`,
//! `startUseItem` and `handleKeybinds`' attack and use part, with
//! `MultiPlayerGameMode`'s block breaking (`startDestroyBlock`,
//! `continueDestroyBlock`, `stopDestroyBlock`).
//!
//! What those read and do in the world is behind [`HandWorld`].
/// `InteractionHand`.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Hand {
Main,
Off,
}
impl Hand {
pub const BOTH: [Hand; 2] = [Hand::Main, Hand::Off];
}
/// `SwingAnimation`: its type (`SwingAnimationType` ordinal) and duration.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct SwingAnimation {
pub kind: u8,
pub duration: i32,
}
impl SwingAnimation {
/// `SwingAnimation.DEFAULT`: WHACK for 6 ticks.
pub const DEFAULT: SwingAnimation = SwingAnimation {
kind: 1,
duration: 6,
};
}
/// `LivingEntity.SwingDescription`.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct SwingDescription {
pub hand: Hand,
pub animation: SwingAnimation,
pub duration_ticks: i32,
}
/// `LivingEntity.SwingState`.
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub struct SwingState {
pub current: Option<SwingDescription>,
pub ticks: i32,
pub old_animation: f32,
pub animation: f32,
}
impl SwingState {
/// `startIfAble`: a swing restarts unless the current one is in its
/// first half (past its first tick).
pub fn start_if_able(&mut self, hand: Hand, animation: SwingAnimation, duration: i32) -> bool {
if let Some(c) = self.current
&& self.ticks <= c.duration_ticks / 2
&& self.ticks > 0
{
return false;
}
let swing = SwingDescription {
hand,
animation,
duration_ticks: duration,
};
if self.current != Some(swing) {
self.old_animation = 0.0;
self.animation = 0.0;
}
self.current = Some(swing);
self.ticks = 0;
true
}
/// `tick` (from `LivingEntity.baseTick`).
pub fn tick(&mut self) {
self.old_animation = self.animation;
if let Some(c) = self.current {
if c.duration_ticks > 0 {
self.animation = (self.ticks as f32 / c.duration_ticks as f32).min(1.0);
}
let ticks = self.ticks;
self.ticks += 1;
if ticks > c.duration_ticks {
self.current = None;
self.animation = 0.0;
}
} else {
self.animation = 0.0;
}
}
/// `getAnimation(partialTicks)`: a restarted swing keeps moving forward.
pub fn animation(&self, partial: f32) -> f32 {
if self.animation >= self.old_animation {
crate::camera::lerp(partial, self.old_animation, self.animation)
} else {
crate::camera::lerp(partial, self.old_animation, self.animation + 1.0)
}
}
pub fn swinging(&self) -> bool {
self.current.is_some()
}
/// The hand of the current swing (`state.attackHand`), main by default.
pub fn attack_hand(&self) -> Hand {
self.current.map_or(Hand::Main, |c| c.hand)
}
}
/// `LivingEntity.getModifiedSwingDuration`: `dig_speed` is
/// `MobEffectUtil.getDigSpeedAmplification` while haste or conduit power is
/// active, `fatigue` the mining fatigue amplifier.
pub fn swing_duration(
animation: SwingAnimation,
dig_speed: Option<i32>,
fatigue: Option<i32>,
) -> i32 {
let duration = animation.duration;
if let Some(amplification) = dig_speed {
duration - (1 + amplification)
} else if let Some(amplifier) = fatigue {
duration + (1 + amplifier) * 2
} else {
duration
}
}
/// `FirstPersonHandsAndItems`' equip heights.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct HandHeights {
pub main: f32,
pub old_main: f32,
pub off: f32,
pub old_off: f32,
}
impl Default for HandHeights {
fn default() -> Self {
HandHeights {
main: 0.0,
old_main: 0.0,
off: 0.0,
old_off: 0.0,
}
}
}
/// What `FirstPersonHandsAndItems.tick` decides about a hand's visible item.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct HandChange {
/// The visible item is not the hand's item after the instant replacement
/// (`mainHandItem != nextMainHand`): the hand lowers.
pub changed: bool,
/// The visible item becomes the hand's item this tick.
pub replace: bool,
}
impl HandHeights {
/// `FirstPersonHandsAndItems.tick`'s heights. `changed` says per hand
/// whether the visible item differs from the held one once
/// `shouldInstantlyReplaceVisibleItem` has run; `swap_scale` is
/// `getItemSwapScale(1.0F)`. Returns whether each hand's visible item
/// is replaced at the end.
pub fn tick(&mut self, hands_busy: bool, swap_scale: f32, changed: [bool; 2]) -> [bool; 2] {
self.old_main = self.main;
self.old_off = self.off;
if hands_busy {
self.main = (self.main - 0.4).clamp(0.0, 1.0);
self.off = (self.off - 0.4).clamp(0.0, 1.0);
} else {
let attack = swap_scale;
let main_target = if changed[0] {
0.0
} else {
attack * attack * attack
};
let off_target = if changed[1] { 0.0 } else { 1.0 };
self.main += (main_target - self.main).clamp(-0.4, 0.4);
self.off += (off_target - self.off).clamp(-0.4, 0.4);
}
[self.main < 0.1, self.off < 0.1]
}
/// `itemUsed`: the hand drops to the bottom and rises again.
pub fn item_used(&mut self, hand: Hand) {
match hand {
Hand::Main => self.main = 0.0,
Hand::Off => self.off = 0.0,
}
}
/// `Mth.lerp(partialTicks, oMainHandHeight, mainHandHeight)` and the
/// off hand's.
pub fn interpolated(&self, partial: f32) -> [f32; 2] {
[
crate::camera::lerp(partial, self.old_main, self.main),
crate::camera::lerp(partial, self.old_off, self.off),
]
}
}
/// `HitResult` at the crosshair (`Minecraft.hitResult`).
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum Hit {
Miss,
Block { pos: [i32; 3], face: u8 },
Entity,
}
/// `InteractionResult` as `startUseItem` reads it.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum UseResult {
/// `Success` with its `SwingSource`: whether the client swings.
Success {
swing: bool,
},
Pass,
Fail,
}
/// What the click handling reads and does in the world.
pub trait HandWorld {
fn hit(&mut self) -> Hit;
fn is_air(&mut self, pos: [i32; 3]) -> bool;
/// `MultiPlayerGameMode.localPlayerMode`.
fn creative(&self) -> bool;
fn spectator(&self) -> bool;
/// `Abilities.instabuild`.
fn instabuild(&self) -> bool;
fn hands_busy(&self) -> bool;
fn using_item(&self) -> bool;
/// `ItemStack.isItemEnabled` for the item in a hand.
fn item_enabled(&mut self, hand: Hand) -> bool;
fn held_empty(&mut self, hand: Hand) -> bool;
fn held_count(&mut self, hand: Hand) -> i32;
/// `Player.hasInfiniteMaterials`.
fn infinite_materials(&self) -> bool;
/// `Player.cannotAttackWithItem(heldItem, 0)`.
fn cannot_attack(&mut self) -> bool;
/// The main-hand item is a `PIERCING_WEAPON`.
fn piercing_weapon(&mut self) -> bool;
fn attack_animation(&mut self) -> SwingAnimation;
fn interact_animation(&mut self, hand: Hand) -> SwingAnimation;
/// `MultiPlayerGameMode.piercingAttack`.
fn piercing_attack(&mut self, animation: SwingAnimation);
/// `Player.attack` on the targeted entity when the item's attack range
/// allows it; false when out of range.
fn attack_entity(&mut self) -> bool;
/// `Player.blockActionRestricted` for the game mode.
fn block_action_restricted(&mut self, pos: [i32; 3]) -> bool;
/// `WorldBorder.isWithinBounds`.
fn within_border(&mut self, pos: [i32; 3]) -> bool;
/// `BlockState.attack` on the first hit of a break.
fn attack_block(&mut self, pos: [i32; 3]);
/// `BlockState.getDestroyProgress` for the player.
fn destroy_progress(&mut self, pos: [i32; 3]) -> f32;
/// `MultiPlayerGameMode.destroyBlock`.
fn destroy_block(&mut self, pos: [i32; 3]) -> bool;
/// A key for the main-hand stack, kept as `destroyingItem`.
fn main_hand_key(&mut self) -> String;
/// `ItemStack.isSameItemSameComponents(selected, destroyingItem)` for
/// the stack `main_hand_key` gave when the break started. Vanilla keeps
/// a reference to that stack, so it compares what it holds now.
fn same_destroying_item(&mut self, key: &str) -> bool {
self.main_hand_key() == key
}
/// Breaking progress shown (`destroyBlockProgress`), or cleared with `None`.
fn destroy_stage(&mut self, pos: [i32; 3], stage: Option<i32>);
/// `player.swing(hand, animation, false)`.
fn swing(&mut self, hand: Hand, animation: SwingAnimation);
/// `Player.resetAttackStrengthTicker`.
fn reset_attack_strength(&mut self);
/// The targeted entity's block position is inside the world border.
fn entity_within_border(&mut self) -> bool;
/// The use attempts of `startUseItem`: on the targeted entity (`None`
/// when it is out of interaction range), the targeted block, and the
/// item alone.
fn use_on_entity(&mut self, hand: Hand) -> Option<UseResult>;
fn use_on_block(&mut self, hand: Hand) -> UseResult;
fn use_item(&mut self, hand: Hand) -> UseResult;
/// `LocalPlayer.itemUsed`.
fn item_used(&mut self, hand: Hand);
/// `MultiPlayerGameMode.releaseUsingItem`.
fn release_using(&mut self);
}
/// `MultiPlayerGameMode`'s block breaking.
#[derive(Clone, Debug, PartialEq)]
pub struct Destroying {
pub delay: i32,
pub active: bool,
pub pos: [i32; 3],
pub face: u8,
pub item: String,
pub progress: f32,
pub ticks: f32,
}
impl Default for Destroying {
/// `destroyBlockPos` starts at (-1, -1, -1) and `destroyingItem` empty
/// (an empty key).
fn default() -> Self {
Destroying {
delay: 0,
active: false,
pos: [-1; 3],
face: 0,
item: String::new(),
progress: 0.0,
ticks: 0.0,
}
}
}
impl Destroying {
/// `getDestroyStage`.
pub fn stage(&self) -> i32 {
if self.progress > 0.0 {
(self.progress * 10.0) as i32
} else {
-1
}
}
fn same_target(&self, w: &mut impl HandWorld, pos: [i32; 3]) -> bool {
pos == self.pos && w.same_destroying_item(&self.item)
}
/// `startDestroyBlock`.
pub fn start(&mut self, w: &mut impl HandWorld, pos: [i32; 3], face: u8) -> bool {
if w.block_action_restricted(pos) || !w.within_border(pos) {
return false;
}
if w.instabuild() {
w.destroy_block(pos);
self.delay = 5;
} else if !self.active || !self.same_target(w, pos) {
let not_air = !w.is_air(pos);
if not_air && self.progress == 0.0 {
w.attack_block(pos);
}
if not_air && w.destroy_progress(pos) >= 1.0 {
w.destroy_block(pos);
} else {
self.active = true;
self.pos = pos;
self.face = face;
self.item = w.main_hand_key();
self.progress = 0.0;
self.ticks = 0.0;
w.destroy_stage(pos, Some(self.stage()));
}
}
true
}
/// `stopDestroyBlock`.
pub fn stop(&mut self, w: &mut impl HandWorld) {
if self.active {
self.active = false;
self.progress = 0.0;
w.destroy_stage(self.pos, None);
w.reset_attack_strength();
}
}
/// `continueDestroyBlock`: true while the hand keeps swinging.
pub fn continue_destroy(&mut self, w: &mut impl HandWorld, pos: [i32; 3], face: u8) -> bool {
if self.delay > 0 {
self.delay -= 1;
return true;
}
if w.instabuild() && w.within_border(pos) {
self.delay = 5;
w.destroy_block(pos);
return true;
}
if !self.same_target(w, pos) {
return self.start(w, pos, face);
}
if w.is_air(pos) {
self.active = false;
return false;
}
self.progress += w.destroy_progress(pos);
self.ticks += 1.0;
if self.progress >= 1.0 {
self.active = false;
w.destroy_block(pos);
self.progress = 0.0;
self.ticks = 0.0;
self.delay = 5;
} else if self.face != face {
self.face = face;
}
w.destroy_stage(self.pos, Some(self.stage()));
true
}
}
/// `Minecraft`'s attack and use handling.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct Clicks {
pub miss_time: i32,
pub right_click_delay: i32,
pub destroying: Destroying,
}
impl Clicks {
/// `Minecraft.startAttack`: whether the click ended the attack (broke
/// the block outright).
pub fn start_attack(&mut self, w: &mut impl HandWorld) -> bool {
if self.miss_time > 0 || w.hands_busy() {
return false;
}
if w.spectator() {
return true;
}
if !w.item_enabled(Hand::Main) || w.cannot_attack() {
return false;
}
let animation = w.attack_animation();
if w.piercing_weapon() {
w.piercing_attack(animation);
return true;
}
let mut end = false;
let mut miss = false;
match w.hit() {
// MultiPlayerGameMode.attack.
Hit::Entity => {
if w.attack_entity() {
w.reset_attack_strength();
if w.instabuild() {
self.destroying.delay = 5;
}
}
}
Hit::Block { pos, face } if !w.is_air(pos) => {
self.destroying.start(w, pos, face);
end = w.is_air(pos);
}
Hit::Block { .. } | Hit::Miss => miss = true,
}
if miss {
if !w.creative() {
self.miss_time = 10;
}
w.reset_attack_strength();
}
w.swing(Hand::Main, animation);
end
}
/// `Minecraft.continueAttack`.
pub fn continue_attack(&mut self, w: &mut impl HandWorld, down: bool) {
if !down {
self.miss_time = 0;
}
if self.miss_time > 0 || w.using_item() || w.piercing_weapon() {
return;
}
let animation = w.attack_animation();
match w.hit() {
Hit::Block { pos, face } if down => {
if !w.is_air(pos) && self.destroying.continue_destroy(w, pos, face) {
w.swing(Hand::Main, animation);
}
}
_ => self.destroying.stop(w),
}
}
/// `Minecraft.startUseItem`.
pub fn start_use_item(&mut self, w: &mut impl HandWorld) {
if self.destroying.active {
return;
}
self.right_click_delay = 4;
if w.hands_busy() {
return;
}
for hand in Hand::BOTH {
if !w.item_enabled(hand) {
return;
}
let animation = w.interact_animation(hand);
match w.hit() {
Hit::Entity => {
if !w.entity_within_border() {
return;
}
if let Some(UseResult::Success { swing }) = w.use_on_entity(hand) {
if swing {
w.swing(hand, animation);
}
return;
}
}
Hit::Block { .. } => {
let count = w.held_count(hand);
match w.use_on_block(hand) {
UseResult::Success { swing } => {
if swing {
w.swing(hand, animation);
if !w.held_empty(hand)
&& (w.held_count(hand) != count || w.infinite_materials())
{
w.item_used(hand);
}
}
return;
}
UseResult::Fail => return,
UseResult::Pass => {}
}
}
Hit::Miss => {}
}
if !w.held_empty(hand)
&& let UseResult::Success { swing } = w.use_item(hand)
{
if swing {
w.swing(hand, animation);
}
w.item_used(hand);
return;
}
}
}
/// The attack and use part of `Minecraft.tick`: `rightClickDelay`, the
/// clicks `handleKeybinds` consumes, held use and attack, then
/// `missTime`. `attack_down` is `keyAttack.isDown()` with no screen and
/// the mouse grabbed.
pub fn tick(
&mut self,
w: &mut impl HandWorld,
attack_clicks: u32,
use_clicks: u32,
attack_down: bool,
use_down: bool,
) {
if self.right_click_delay > 0 {
self.right_click_delay -= 1;
}
let mut instant = false;
if w.using_item() {
if !use_down {
w.release_using();
}
} else {
for _ in 0..attack_clicks {
instant |= self.start_attack(w);
}
for _ in 0..use_clicks {
self.start_use_item(w);
}
}
if use_down && self.right_click_delay == 0 && !w.using_item() {
self.start_use_item(w);
}
self.continue_attack(w, !instant && attack_down);
if self.miss_time > 0 {
self.miss_time -= 1;
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_swing_lasts_its_duration_and_restarts_after_half() {
let mut s = SwingState::default();
assert!(s.start_if_able(Hand::Main, SwingAnimation::DEFAULT, 6));
let mut seen = Vec::new();
for _ in 0..9 {
s.tick();
seen.push(s.animation);
}
assert_eq!(
seen,
[
0.0,
1.0 / 6.0,
2.0 / 6.0,
0.5,
4.0 / 6.0,
5.0 / 6.0,
1.0,
0.0,
0.0
]
);
assert!(!s.swinging());
s.start_if_able(Hand::Main, SwingAnimation::DEFAULT, 6);
s.tick();
s.tick();
assert!(!s.start_if_able(Hand::Main, SwingAnimation::DEFAULT, 6));
s.tick();
s.tick();
assert!(s.start_if_able(Hand::Main, SwingAnimation::DEFAULT, 6));
s.tick();
// Restarting the same swing wraps forward rather than snapping back.
assert_eq!(
s.animation(0.5),
(s.old_animation + s.animation + 1.0) / 2.0
);
}
#[test]
fn haste_shortens_and_fatigue_lengthens_swings() {
let d = SwingAnimation::DEFAULT;
assert_eq!(swing_duration(d, None, None), 6);
assert_eq!(swing_duration(d, Some(1), Some(0)), 4);
assert_eq!(swing_duration(d, None, Some(2)), 12);
}
}
+987
View File
@@ -0,0 +1,987 @@
//! The front-end screens in Horizon's menu style (`ui_theme`), laid out like
//! the launcher: a 200-wide sidebar with the sliding selection (Home,
//! Singleplayer, Multiplayer, Options), content pages with a page header,
//! settings rows and 1px dividers, and the launcher's opening (the wordmark,
//! then the interface). They drive the same state as the classic layouts in
//! `title` and `multiplayer`; only drawing and hit testing differ. The pause
//! menu's Horizon layout is `pause`, below.
use crate::{
hud::{HudAtlas, HudBatch},
multiplayer::{Outcome, Ping, Screen},
title::{Action, Mode, Title, list_worlds},
ui_theme::{
self as t, BODY, CONTROL, CONTROL_LARGE, Face, Kind, SIDEBAR, SMALL, Ui, color, id,
},
};
use std::time::Instant;
const NAV: u64 = id("title.nav", 0);
const QUIT: u64 = id("title.quit", 0);
const PLAY: u64 = id("home.play", 0);
const MULTIPLAYER: u64 = id("home.multiplayer", 0);
const RESUME: u64 = id("home.resume", 0);
const BACK: u64 = id("front.back", 0);
const PLAY_WORLD: u64 = id("worlds.play", 0);
const CREATE: u64 = id("worlds.create", 0);
const WORLD: u64 = id("worlds.row", 0);
const NAME: u64 = id("create.name", 0);
const SEED: u64 = id("create.seed", 0);
const MODE: u64 = id("create.mode", 0);
const MAKE: u64 = id("create.make", 0);
const SERVER: u64 = id("mp.row", 0);
const JOIN: u64 = id("mp.join", 0);
const DIRECT: u64 = id("mp.direct", 0);
const ADD: u64 = id("mp.add", 0);
const EDIT: u64 = id("mp.edit", 0);
const DELETE: u64 = id("mp.delete", 0);
const REFRESH: u64 = id("mp.refresh", 0);
const FIELD_A: u64 = id("mp.field", 0);
const FIELD_B: u64 = id("mp.field", 1);
const DONE: u64 = id("mp.done", 0);
/// The launcher's opening (`launcher/src/main.rs`): the wordmark holds over
/// the backdrop, then the interface comes in. A click or a key skips ahead.
#[derive(Clone, Copy, Debug)]
pub struct Opening {
started: Instant,
reveal: Option<Instant>,
}
impl Opening {
pub fn new() -> Self {
Self {
started: Instant::now(),
reveal: None,
}
}
/// The reveal's progress, 0 to 1; `ready` says the backdrop is showing.
fn phase(&mut self, ready: bool) -> f32 {
let held = self.started.elapsed();
if self.reveal.is_none()
&& ((held >= horizon_theme::OPENING_HOLD && ready)
|| held >= horizon_theme::OPENING_MAX)
{
self.reveal = Some(Instant::now());
}
self.reveal.map_or(0.0, |at| {
at.elapsed().as_secs_f32() / horizon_theme::OPENING_REVEAL.as_secs_f32()
})
}
pub fn skip(&mut self) {
self.reveal.get_or_insert_with(Instant::now);
}
}
impl Default for Opening {
fn default() -> Self {
Self::new()
}
}
/// Which sidebar item a mode belongs to.
fn page(mode: &Mode) -> usize {
match mode {
Mode::Main => 0,
Mode::Select { .. } | Mode::Create { .. } => 1,
Mode::Multiplayer(_) => 2,
}
}
const PAGES: [&str; 4] = ["Home", "Singleplayer", "Multiplayer", "Options"];
impl Title {
/// Horizon's layout for every title mode. `backdrop` is how much of the
/// live backdrop is visible (0 shows the plain cold background).
pub fn draw_horizon(&self, ui: &mut Ui, backdrop: f32) {
let reveal = self.opening_phase(backdrop >= 1.0);
if backdrop < 1.0 {
ui.cold_background(1.0 - backdrop);
}
let shade = t::ease_out_cubic(reveal);
let veil = if matches!(self.mode, Mode::Main) {
0.42
} else {
0.66
};
ui.atmosphere(veil * shade, shade);
let side = t::ease_out_cubic((reveal - 0.2) / 0.7);
let main = t::ease_out_cubic((reveal - 0.35) / 0.65);
// The sidebar slides in from the left.
ui.opacity = side;
ui.offset = (-16.0 * (1.0 - side), 0.0);
if side > 0.0 {
self.sidebar(ui);
}
// The content rises; each new page fades in and rises 8 over MOVE.
let entered = self.entered();
let p = t::ease_out_quint(entered.elapsed().as_secs_f32() / t::move_());
ui.opacity = main * p;
ui.offset = (0.0, 16.0 * (1.0 - main) + 8.0 * (1.0 - p));
if main > 0.0 {
let x = SIDEBAR + 48.0;
let w = (ui.w - x - 48.0).min(760.0);
match &self.mode {
Mode::Main => self.home(ui, x, w),
Mode::Select { worlds, selected } => worlds_page(ui, x, w, worlds, *selected),
Mode::Create {
name,
seed,
creative,
focus,
} => {
let caret = (self.started.elapsed().as_millis() / 500).is_multiple_of(2);
create_page(ui, x, w, name, seed, *creative, *focus, caret);
}
Mode::Multiplayer(screen) => {
let caret = (self.started.elapsed().as_millis() / 500).is_multiple_of(2);
multiplayer_page(ui, x, w, screen, caret);
}
}
}
ui.opacity = 1.0;
ui.offset = (0.0, 0.0);
if reveal < 1.0 {
// The wordmark rises and fades as the interface comes in.
let word = 1.0 - t::ease_out_cubic(reveal / 0.6);
ui.opacity = word;
let tw = ui.measure(Face::Display, t::WORDMARK, "Horizon");
let line = (t::WORDMARK * 1.2).round();
ui.text_line(
Face::Display,
t::WORDMARK,
line,
((ui.w - tw) / 2.0).round(),
(ui.h - line) / 2.0 - 28.0 * (1.0 - word),
color::WHITE,
"Horizon",
);
ui.opacity = 1.0;
}
}
fn opening_phase(&self, ready: bool) -> f32 {
let mut opening = self.opening.get();
let phase = opening.as_mut().map_or(1.0, |o| o.phase(ready));
self.opening.set(opening.filter(|_| phase < 1.0));
phase.min(1.0)
}
/// A click or key during the opening jumps to the reveal; true if it did.
pub fn skip_opening(&self) -> bool {
let mut opening = self.opening.get();
let skipped = opening.as_mut().is_some_and(|o| {
let holding = o.reveal.is_none();
o.skip();
holding
});
self.opening.set(opening);
skipped
}
fn sidebar(&self, ui: &mut Ui) {
let h = ui.h;
ui.fill(0.0, 0.0, SIDEBAR, h, color::with_alpha(color::SIDEBAR, 0.8));
ui.vline(SIDEBAR - 1.0, 0.0, h, color::BORDER);
ui.text_line(
Face::Display,
22.0,
30.0,
22.0,
16.0,
color::TEXT,
"Horizon",
);
ui.sidebar_list(NAV, 12.0, 66.0, SIDEBAR - 24.0, &PAGES, page(&self.mode));
let qy = h - 12.0 - 18.0 - 12.0 - CONTROL;
let hover = ui.interact(QUIT, 12.0, qy, SIDEBAR - 24.0, CONTROL, true);
ui.rounded(
12.0,
qy,
SIDEBAR - 24.0,
CONTROL,
t::RADIUS,
color::with_alpha(color::HOVER, 0.7 * hover),
);
ui.text_line(
Face::Regular,
BODY,
CONTROL,
22.0,
qy,
color::mix(color::MUTED, color::TEXT, hover),
"Quit game",
);
ui.text(
Face::Regular,
SMALL,
22.0,
h - 12.0 - 18.0,
color::FAINT,
"Horizon 26.3",
);
}
/// Home: the launcher's Play page over the backdrop.
fn home(&self, ui: &mut Ui, x: f32, w: f32) {
let mut y = 36.0 + 28.0;
ui.text(
Face::Medium,
SMALL,
x,
y,
color::FAINT,
"Minecraft 26.3 · Java Edition, rewritten in Rust",
);
y += 22.0;
ui.text_line(Face::Display, t::HERO, 58.0, x, y, color::TEXT, "Horizon");
y += 58.0 + 12.0;
ui.text(
Face::Regular,
t::LARGE,
x,
y,
color::MUTED,
"The world you know, rebuilt from the ground up.",
);
y += 22.0 + 24.0;
let pw = ui.button_width("Singleplayer", true).max(120.0);
ui.button(
PLAY,
[x, y, pw, CONTROL_LARGE],
"Singleplayer",
Kind::Primary,
true,
);
let mw = ui.button_width("Multiplayer", true);
ui.button(
MULTIPLAYER,
[x + pw + 8.0, y, mw, CONTROL_LARGE],
"Multiplayer",
Kind::Secondary,
true,
);
y += CONTROL_LARGE + 24.0;
ui.divider(x, y, w);
let worlds = self.recent_worlds();
let last = worlds.first();
let value = last.map_or("None yet".to_owned(), |l| {
let secs = l
.modified
.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |d| d.as_secs());
format!("{} · {}", l.name, crate::title::date_label(secs))
});
let rh = ui.row(x, y, "Last played", &value, w - t::ROW_LABEL - 120.0);
if last.is_some() {
let bw = ui.button_width("Resume", false);
ui.button(
RESUME,
[x + w - bw, y + (rh - CONTROL) / 2.0, bw, CONTROL],
"Resume",
Kind::Secondary,
true,
);
}
y += rh;
ui.divider(x, y, w);
let count = match worlds.len() {
1 => "1 world".to_owned(),
n => format!("{n} worlds"),
};
y += ui.row(x, y, "Saves", &count, w - t::ROW_LABEL - 16.0);
ui.divider(x, y, w);
let note = "Not affiliated with Mojang or Microsoft";
ui.text(
Face::Regular,
SMALL,
x,
ui.h - 12.0 - 18.0,
color::FAINT,
note,
);
}
/// A click in Horizon's layout (`x`, `y` in GUI pixels).
pub fn click_horizon(
&mut self,
atlas: &HudAtlas,
batch: &mut HudBatch,
x: f32,
y: f32,
) -> Action {
if self.skip_opening() {
return Action::None;
}
let hit = {
let mut ui = Ui::dry(atlas, batch, (x, y));
self.draw_horizon(&mut ui, 1.0);
ui.hit((x, y))
};
let Some(hit) = hit else {
if let Mode::Create { focus, .. } = &mut self.mode {
*focus = 2;
}
return Action::None;
};
let saves = self.saves.clone();
let select = |saves: &std::path::Path| Mode::Select {
worlds: list_worlds(saves),
selected: None,
};
match hit {
QUIT => return Action::Quit,
h if h == NAV => {
self.mode = Mode::Main;
return Action::None;
}
h if h == NAV + 1 => {
if !matches!(self.mode, Mode::Select { .. }) {
self.mode = select(&saves);
}
return Action::None;
}
h if h == NAV + 2 => {
if !matches!(self.mode, Mode::Multiplayer(_)) {
self.mode = Mode::Multiplayer(Screen::list(&saves));
}
return Action::None;
}
h if h == NAV + 3 => return Action::Options,
_ => {}
}
match &mut self.mode {
Mode::Main => match hit {
PLAY => self.mode = select(&saves),
MULTIPLAYER => self.mode = Mode::Multiplayer(Screen::list(&saves)),
RESUME => {
if let Some(w) = list_worlds(&saves).into_iter().next() {
return Action::Play { folder: w.folder };
}
}
_ => {}
},
Mode::Select { worlds, selected } => match hit {
PLAY_WORLD => {
if let Some(w) = selected.and_then(|i| worlds.get(i)) {
return Action::Play {
folder: w.folder.clone(),
};
}
}
CREATE => {
self.mode = Mode::Create {
name: "New World".into(),
seed: String::new(),
creative: false,
focus: 0,
}
}
h if (WORLD..WORLD + 10_000).contains(&h) => {
let i = (h - WORLD) as usize;
if *selected == Some(i) {
return Action::Play {
folder: worlds[i].folder.clone(),
};
}
*selected = Some(i);
}
_ => {}
},
Mode::Create {
creative, focus, ..
} => match hit {
BACK => self.mode = select(&saves),
NAME => *focus = 0,
SEED => *focus = 1,
h if h == MODE || h == MODE + 1 => *creative = h == MODE + 1,
MAKE => return self.create_action(),
_ => {}
},
Mode::Multiplayer(screen) => {
let id = match hit {
JOIN => Some(40),
DIRECT => Some(41),
ADD => Some(42),
EDIT => Some(43),
DELETE => Some(44),
REFRESH => Some(45),
BACK => Some(match screen {
Screen::List { .. } => 46,
Screen::Edit { .. } => 51,
Screen::Direct { .. } => 53,
Screen::Disconnected { .. } => 54,
}),
DONE => Some(match screen {
Screen::Direct { .. } => 52,
_ => 50,
}),
FIELD_A | FIELD_B => {
if let Screen::Edit { focus, .. } = screen {
*focus = u8::from(hit == FIELD_B);
}
None
}
h if (SERVER..SERVER + 10_000).contains(&h) => {
let i = (h - SERVER) as usize;
let again =
matches!(screen, Screen::List { selected, .. } if *selected == Some(i));
if again {
if let Some(address) = screen.address_of(i) {
return Action::Connect { address };
}
} else if let Screen::List { selected, .. } = screen {
*selected = Some(i);
}
None
}
_ => None,
};
if let Some(id) = id {
match screen.click(Some(id), 0.0, 0.0, (0.0, 0.0), &saves) {
Outcome::Back => self.mode = Mode::Main,
Outcome::Connect(address) => return Action::Connect { address },
Outcome::None => {}
}
}
}
}
Action::None
}
}
/// The page header plus a row of actions under it; returns the y below.
fn header(ui: &mut Ui, x: f32, title: &str, description: &str) -> f32 {
let y = 36.0;
y + ui.page_header(x, y, title, description) + 24.0
}
/// Actions laid out right to left from `right`; returns the left edge.
fn actions_right(ui: &mut Ui, right: f32, y: f32, items: &[(u64, &str, Kind, bool)]) -> f32 {
let mut rx = right;
for &(bid, label, kind, on) in items {
let large = kind == Kind::Primary;
let (bw, bh) = if large {
(ui.button_width(label, true).max(120.0), CONTROL_LARGE)
} else {
(ui.button_width(label, false), CONTROL)
};
rx -= bw;
ui.button(
bid,
[rx, y + (CONTROL_LARGE - bh) / 2.0, bw, bh],
label,
kind,
on,
);
rx -= 8.0;
}
rx
}
/// A selectable list row: the launcher's row look with a hover layer and
/// the raised selection with its 1px border. Returns the row height.
#[allow(clippy::too_many_arguments)]
fn list_row(
ui: &mut Ui,
rid: u64,
x: f32,
y: f32,
w: f32,
selected: bool,
title: &str,
meta: &str,
right: &str,
dot: Option<u32>,
) -> f32 {
let h = 60.0;
let hover = ui.interact(rid, x - 12.0, y + 2.0, w + 24.0, h - 4.0, true);
let sel = ui.tween(rid ^ 0x5e1ec7, if selected { 1.0 } else { 0.0 }, t::quick());
ui.rounded(
x - 12.0,
y + 4.0,
w + 24.0,
h - 8.0,
t::RADIUS,
color::with_alpha(color::HOVER, 0.7 * hover),
);
ui.rounded(
x - 12.0,
y + 4.0,
w + 24.0,
h - 8.0,
t::RADIUS,
color::with_alpha(color::RAISED, sel),
);
ui.border(
x - 12.0,
y + 4.0,
w + 24.0,
h - 8.0,
t::RADIUS,
color::with_alpha(color::BORDER, sel),
);
let mut tx = x;
if let Some(dot) = dot {
ui.rounded(x, y + 17.0, 6.0, 6.0, 3.0, dot);
tx += 16.0;
}
let title = ui.fit(Face::Medium, BODY, title, w - 200.0);
ui.text(Face::Medium, BODY, tx, y + 10.0, color::TEXT, &title);
let meta = ui.fit(Face::Regular, SMALL, meta, w - 200.0);
ui.text(Face::Regular, SMALL, tx, y + 31.0, color::MUTED, &meta);
ui.text_right(Face::Regular, SMALL, x + w, y + 21.0, color::FAINT, right);
ui.divider(x, y + h, w);
h
}
/// An empty list: a serif line and a hint.
fn empty(ui: &mut Ui, x: f32, y: f32, title: &str, hint: &str) {
ui.text_line(Face::Display, 22.0, 30.0, x, y + 32.0, color::TEXT, title);
ui.text(Face::Regular, BODY, x, y + 66.0, color::MUTED, hint);
}
fn worlds_page(
ui: &mut Ui,
x: f32,
w: f32,
worlds: &[crate::title::WorldEntry],
selected: Option<usize>,
) {
let count = match worlds.len() {
0 => "No worlds yet. They live in the saves folder.".to_owned(),
1 => "1 world in the saves folder.".to_owned(),
n => format!("{n} worlds in the saves folder."),
};
let mut y = header(ui, x, "Singleplayer", &count);
actions_right(
ui,
x + w,
y - 24.0 - 58.0,
&[
(PLAY_WORLD, "Play", Kind::Primary, selected.is_some()),
(CREATE, "Create world", Kind::Secondary, true),
],
);
ui.divider(x, y, w);
if worlds.is_empty() {
empty(ui, x, y, "A blank map.", "Create a world to begin.");
return;
}
let bottom = ui.h - 36.0;
for (i, world) in worlds.iter().enumerate() {
if y + 60.0 > bottom {
break;
}
let secs = world
.modified
.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |d| d.as_secs());
let meta = format!("{} · {}", crate::title::date_label(secs), world.folder);
let mode = if world.creative {
"Creative"
} else {
"Survival"
};
y += list_row(
ui,
WORLD + i as u64,
x,
y,
w,
selected == Some(i),
&world.name,
&meta,
mode,
None,
);
}
}
#[allow(clippy::too_many_arguments)]
fn create_page(
ui: &mut Ui,
x: f32,
w: f32,
name: &str,
seed: &str,
creative: bool,
focus: u8,
caret: bool,
) {
let mut y = header(
ui,
x,
"Create world",
"Generated by Horizon from the seed, as vanilla would.",
);
actions_right(
ui,
x + w,
y - 24.0 - 58.0,
&[
(MAKE, "Create", Kind::Primary, true),
(BACK, "Cancel", Kind::Ghost, true),
],
);
ui.divider(x, y, w);
let fx = x + t::ROW_LABEL + 16.0;
let fw = (w - t::ROW_LABEL - 16.0).min(360.0);
for (i, label) in ["World name", "Seed", "Game mode"].iter().enumerate() {
let rh = 12.0 + CONTROL + 12.0;
ui.text_line(Face::Regular, BODY, rh, x, y, color::MUTED, label);
let r = [fx, y + 12.0, fw, CONTROL];
match i {
0 => ui.field(NAME, r, name, "New World", focus == 0, caret),
1 => ui.field(SEED, r, seed, "Random", focus == 1, caret),
_ => {
ui.segmented(
MODE,
fx,
y + 12.0,
&["Survival", "Creative"],
usize::from(creative),
);
}
}
y += rh;
ui.divider(x, y, w);
}
}
fn multiplayer_page(ui: &mut Ui, x: f32, w: f32, screen: &Screen, caret: bool) {
match screen {
Screen::List {
servers,
selected,
pings,
lan,
} => {
let games = lan.games();
let sub = match servers.len() {
0 => "No saved servers. Worlds opened to LAN show up here too.".to_owned(),
1 => "1 saved server, and worlds opened to LAN.".to_owned(),
n => format!("{n} saved servers, and worlds opened to LAN."),
};
let mut y = header(ui, x, "Multiplayer", &sub);
let has = selected.is_some();
let saved = selected.is_some_and(|i| i < servers.len());
let mut items = vec![
(JOIN, "Join", Kind::Primary, has),
(ADD, "Add server", Kind::Secondary, true),
(DIRECT, "Direct connect", Kind::Secondary, true),
(REFRESH, "Refresh", Kind::Ghost, true),
];
if saved {
items.push((EDIT, "Edit", Kind::Ghost, true));
items.push((DELETE, "Delete", Kind::Danger, true));
}
actions_right(ui, x + w, y - 24.0 - 58.0, &items);
ui.divider(x, y, w);
let pings = pings.lock().map(|p| p.clone()).unwrap_or_default();
let mut rows: Vec<(String, String, String, u32)> = servers
.iter()
.map(|s| {
let (line, right, dot) = match pings.get(&s.ip) {
Some(Ping::Ok {
motd, players, ms, ..
}) => (
motd.lines().next().unwrap_or("").trim().to_owned(),
format!("{players} · {ms} ms"),
color::ACCENT,
),
Some(Ping::Failed(_)) => (
"Can't reach the server".to_owned(),
String::new(),
color::DANGER,
),
_ => ("Pinging…".to_owned(), String::new(), color::FAINT),
};
(s.name.clone(), format!("{line} · {}", s.ip), right, dot)
})
.collect();
rows.extend(games.iter().map(|g| {
(
"LAN world".to_owned(),
format!("{} · {}", g.motd, g.address),
"On your network".to_owned(),
color::ACCENT,
)
}));
if rows.is_empty() {
empty(
ui,
x,
y,
"Nobody here yet.",
"Add a server, connect directly, or open a world to LAN.",
);
return;
}
let bottom = ui.h - 36.0;
for (i, (name, meta, right, dot)) in rows.iter().enumerate() {
if y + 60.0 > bottom {
break;
}
y += list_row(
ui,
SERVER + i as u64,
x,
y,
w,
*selected == Some(i),
name,
meta,
right,
Some(*dot),
);
}
}
Screen::Edit {
index,
name,
address,
focus,
} => {
let title = if index.is_some() {
"Edit server"
} else {
"Add server"
};
let mut y = header(
ui,
x,
title,
"Saved to servers.dat, like vanilla's server list.",
);
let ok = horizon_protocol::servers::parse_address(address).is_some();
actions_right(
ui,
x + w,
y - 24.0 - 58.0,
&[
(DONE, "Save", Kind::Primary, ok),
(BACK, "Cancel", Kind::Ghost, true),
],
);
ui.divider(x, y, w);
let fx = x + t::ROW_LABEL + 16.0;
let fw = (w - t::ROW_LABEL - 16.0).min(360.0);
for (i, (label, value, hint)) in [
("Name", name, "Minecraft Server"),
("Address", address, "play.example.net"),
]
.iter()
.enumerate()
{
let rh = 12.0 + CONTROL + 12.0;
ui.text_line(Face::Regular, BODY, rh, x, y, color::MUTED, label);
let fid = if i == 0 { FIELD_A } else { FIELD_B };
ui.field(
fid,
[fx, y + 12.0, fw, CONTROL],
value,
hint,
usize::from(*focus) == i,
caret,
);
y += rh;
ui.divider(x, y, w);
}
}
Screen::Direct { address } => {
let y = header(
ui,
x,
"Direct connect",
"Join a server once without saving it.",
);
let ok = horizon_protocol::servers::parse_address(address).is_some();
actions_right(
ui,
x + w,
y - 24.0 - 58.0,
&[
(DONE, "Join", Kind::Primary, ok),
(BACK, "Cancel", Kind::Ghost, true),
],
);
ui.divider(x, y, w);
let rh = 12.0 + CONTROL + 12.0;
ui.text_line(Face::Regular, BODY, rh, x, y, color::MUTED, "Address");
let fx = x + t::ROW_LABEL + 16.0;
ui.field(
FIELD_A,
[fx, y + 12.0, (w - t::ROW_LABEL - 16.0).min(360.0), CONTROL],
address,
"host:port",
true,
caret,
);
ui.divider(x, y + rh, w);
}
Screen::Disconnected { reason } => {
let mut y = header(
ui,
x,
"Connection lost",
"The server closed the connection.",
);
ui.divider(x, y, w);
y += 16.0;
// The launcher's error note: a danger dot beside the message.
let lines = wrap(ui, reason, w - 40.0);
let box_h = 24.0 + lines.len() as f32 * 19.0;
ui.rounded(x, y, w, box_h, t::RADIUS, color::RAISED);
ui.border(x, y, w, box_h, t::RADIUS, color::BORDER);
ui.rounded(x + 12.0, y + 18.0, 6.0, 6.0, 3.0, color::DANGER);
for (i, line) in lines.iter().enumerate() {
ui.text_line(
Face::Regular,
BODY,
19.0,
x + 28.0,
y + 12.0 + i as f32 * 19.0,
color::TEXT,
line,
);
}
y += box_h + 16.0;
let bw = ui.button_width("Back to servers", false);
ui.button(
BACK,
[x, y, bw, CONTROL],
"Back to servers",
Kind::Secondary,
true,
);
}
}
}
/// Greedy word wrap in dp.
pub fn wrap(ui: &Ui, text: &str, width: f32) -> Vec<String> {
let mut lines = Vec::new();
for para in text.lines() {
let mut line = String::new();
for word in para.split_whitespace() {
let next = if line.is_empty() {
word.to_owned()
} else {
format!("{line} {word}")
};
if !line.is_empty() && ui.measure(Face::Regular, BODY, &next) > width {
lines.push(std::mem::replace(&mut line, word.to_owned()));
} else {
line = next;
}
}
lines.push(line);
}
lines
}
/// The pause menu in Horizon's style: the launcher's sidebar over the
/// blurred world, with the game's actions as a page. Ids match
/// `game/screens.rs`'s PauseScreen actions: 0 back to game, 1 options,
/// 2 save and quit, 3 statistics, 4 advancements, 9 world options.
pub mod pause {
use super::*;
fn act(a: u8) -> u64 {
id("pause", u64::from(a))
}
pub fn draw(ui: &mut Ui, world: &str, quit_label: &str, opened: Instant) {
let (w, h) = (ui.w, ui.h);
let p = t::ease_out_quint(opened.elapsed().as_secs_f32() / t::move_());
ui.opacity = p;
ui.atmosphere(0.35, 1.0);
ui.offset = (-16.0 * (1.0 - p), 0.0);
ui.fill(0.0, 0.0, SIDEBAR, h, color::with_alpha(color::SIDEBAR, 0.8));
ui.vline(SIDEBAR - 1.0, 0.0, h, color::BORDER);
ui.text_line(
Face::Display,
22.0,
30.0,
22.0,
16.0,
color::TEXT,
"Horizon",
);
ui.sidebar_list(
id("pause.nav", 0),
12.0,
66.0,
SIDEBAR - 24.0,
&["Game", "Advancements", "Statistics", "Options"],
0,
);
ui.text(
Face::Regular,
SMALL,
22.0,
h - 12.0 - 18.0,
color::FAINT,
"Horizon 26.3",
);
ui.offset = (0.0, 8.0 * (1.0 - p));
let x = SIDEBAR + 48.0;
let cw = (w - x - 48.0).min(760.0);
let mut y = 36.0;
ui.text(Face::Medium, SMALL, x, y, color::FAINT, "Game paused");
y += 22.0;
let title = ui.fit(Face::Display, t::HERO, world, cw);
ui.text_line(Face::Display, t::HERO, 58.0, x, y, color::TEXT, &title);
y += 58.0 + 24.0;
let bw = ui.button_width("Back to game", true).max(120.0);
ui.button(
act(0),
[x, y, bw, CONTROL_LARGE],
"Back to game",
Kind::Primary,
true,
);
let lw = ui.button_width("Open to LAN", true);
ui.button(
act(9),
[x + bw + 8.0, y, lw, CONTROL_LARGE],
"Open to LAN",
Kind::Secondary,
true,
);
y += CONTROL_LARGE + 24.0;
ui.divider(x, y, cw);
let rh = ui.row(
x,
y,
"Leave",
"Saves the world, then returns to the title.",
cw - t::ROW_LABEL - 220.0,
);
let qw = ui.button_width(quit_label, false);
ui.button(
act(2),
[x + cw - qw, y + (rh - CONTROL) / 2.0, qw, CONTROL],
quit_label,
Kind::Danger,
true,
);
y += rh;
ui.divider(x, y, cw);
ui.opacity = 1.0;
ui.offset = (0.0, 0.0);
}
/// The PauseScreen action under a click, if any.
pub fn click(
atlas: &HudAtlas,
batch: &mut HudBatch,
x: f32,
y: f32,
world: &str,
quit_label: &str,
) -> Option<u8> {
let mut ui = Ui::dry(atlas, batch, (x, y));
draw(
&mut ui,
world,
quit_label,
Instant::now() - std::time::Duration::from_secs(5),
);
let hit = ui.hit((x, y))?;
let nav = id("pause.nav", 0);
match hit {
h if h == nav => Some(0),
h if h == nav + 1 => Some(4),
h if h == nav + 2 => Some(3),
h if h == nav + 3 => Some(1),
h => [0u8, 1, 2, 3, 4, 9].into_iter().find(|&a| act(a) == h),
}
}
}
+140 -47
View File
@@ -4,6 +4,7 @@ mod advancements;
mod advancements_screen;
mod allays;
mod armor_stands;
mod backdrop;
mod banners;
mod beacon;
mod block_entity_anims;
@@ -39,7 +40,6 @@ mod game_mode;
mod ghasts;
mod golems;
mod growth;
mod hand;
mod item_draw;
mod jukebox;
mod lan;
@@ -686,21 +686,19 @@ struct Game<'a> {
orbs: Vec<ExperienceOrb>,
xp_delay: u32,
last_level_up: u64,
/// The arm swing (`LivingEntity.SwingState`) and how many swings started.
swing: horizon_client::first_person::SwingState,
swings_started: u32,
/// The first-person equip heights and the items the hands show (with
/// the slot each came from).
heights: horizon_client::first_person::HandHeights,
visible_hands: [(usize, Option<ItemStack>); 2],
/// `Minecraft`'s attack handling, attack clicks since the last tick and
/// the attack strength tickers the hand reads.
clicks: horizon_client::first_person::Clicks,
attack_clicks: u32,
hand_strength: horizon_client::attack_strength::AttackStrength,
/// Arm swing: ticks into the 6-tick swing (None when idle), previous/current progress.
swing: Option<u32>,
swing_anim: (f32, f32),
/// Main-hand equip height (previous, current) and the item it shows.
hand_height: (f32, f32),
hand_item: Option<ItemStack>,
settings: Settings,
/// Where the options screen returns: true = title, false = pause menu.
options_from_title: bool,
/// The live title backdrop (game/backdrop.rs).
backdrop: backdrop::Backdrop,
/// When the pause menu opened (Horizon style's entrance).
pause_since: Option<Instant>,
dragging: bool,
/// `ClientAvatarState`: walked distance and view bob.
avatar: horizon_client::camera::AvatarBob,
@@ -2796,7 +2794,7 @@ impl Game<'_> {
// ticks; ServerLevel.tick then runs the scheduled and random block
// ticks before any entity, so what they drop or spawn (dispensed
// items, popped crops) moves on the tick it appears, as in vanilla.
self.tick_local_hand()?;
self.tick_mining()?;
self.postprocess_chunks()?;
if running {
self.tick_blocks()?;
@@ -2848,7 +2846,7 @@ impl Game<'_> {
self.tick_particles();
}
if self.right && self.use_delay == 0 && self.screen == Screen::Playing {
self.use_with_hand()?;
self.use_item()?;
self.use_delay = 4;
}
Ok(())
@@ -2976,7 +2974,15 @@ impl Game<'_> {
return batch;
}
if let Some(title) = &self.title {
title.draw(&self.res.atlas, &mut batch, self.cursor);
if horizon_client::ui_theme::horizon() {
let fade = self.backdrop_fade();
let mut ui =
horizon_client::ui_theme::Ui::new(&self.res.atlas, &mut batch, self.cursor);
title.draw_horizon(&mut ui, fade);
ui.finish();
} else {
title.draw(&self.res.atlas, &mut batch, self.cursor);
}
return batch;
}
let atlas_ptr: *const HudAtlas = &self.res.atlas;
@@ -3001,7 +3007,8 @@ impl Game<'_> {
// the sleep overlay, debug screen and open screens still draw.
let spectator = self.spectator();
let (wi, hi) = (w as i32, h as i32);
if !self.ui.hide_gui {
// Horizon's pause menu sits over the blurred world alone.
if !self.ui.hide_gui && !self.horizon_pause() {
self.draw_spyglass(&mut batch);
// Hud.extractCrosshair / extractItemHotbar / extractPlayerHealth /
// extractVehicleHealth and the experience bar, laid out by hud_layout.
@@ -6381,6 +6388,38 @@ impl Game<'_> {
}
self.damage_held(1);
}
/// LivingEntity.swing: restart the 6-tick arm swing.
fn swing_arm(&mut self) {
if self.swing.is_none_or(|t| t >= 3) {
self.swing = Some(0);
}
}
/// LivingEntity.updateSwingTime and ItemInHandRenderer.tick (equip height).
fn tick_hand(&mut self) {
self.swing_anim.0 = self.swing_anim.1;
if let Some(t) = self.swing {
let t = t + 1;
if t >= 6 {
self.swing = None;
self.swing_anim.1 = 0.0;
} else {
self.swing = Some(t);
self.swing_anim.1 = t as f32 / 6.0;
}
}
self.hand_height.0 = self.hand_height.1;
let held = self.inventory[self.selected].clone();
let same = match (&held, &self.hand_item) {
(Some(a), Some(b)) => a.item == b.item,
(None, None) => true,
_ => false,
};
let target = if same { 1.0 } else { 0.0 };
self.hand_height.1 += (target - self.hand_height.1).clamp(-0.4, 0.4);
if self.hand_height.1 < 0.1 {
self.hand_item = held;
}
}
/// FirstPersonHandsAndItemsRenderer: the right arm or held item in view
/// space, converted to camera-relative world space.
fn hand_geometry(&mut self, alpha: f64) -> Vec<Vertex> {
@@ -6398,15 +6437,14 @@ impl Game<'_> {
return out;
}
let a = alpha as f32;
// FirstPersonHandsAndItemsRenderer: the swing of the hand that
// swings, and its inverse arm height (the swap scale is 1 for every
// item but spears).
let attack = if self.swing.attack_hand() == horizon_client::first_person::Hand::Main {
self.swing.animation(a)
} else {
let attack = self.swing_anim.0 + (self.swing_anim.1 - self.swing_anim.0) * a;
let attack = if self.swing.is_none() && self.swing_anim.1 == 0.0 {
0.0
} else {
attack
};
let inverse_height = 1.0 - self.heights.interpolated(a)[0];
let height = self.hand_height.0 + (self.hand_height.1 - self.hand_height.0) * a;
let inverse_height = 1.0 - height;
let (yaw, pitch) = (self.yaw.to_radians(), self.pitch.to_radians());
let forward = glam::Vec3::new(
-yaw.sin() * pitch.cos(),
@@ -6427,8 +6465,7 @@ impl Game<'_> {
if self.held_map_geometry(to_world, attack, inverse_height, packed, &mut out) {
return out;
}
let [main_item, _] = self.visible_hand_items();
match main_item {
match self.hand_item.clone() {
None => {
let Some(arm) = self.res.player_arm.clone() else {
return out;
@@ -7127,7 +7164,13 @@ impl Game<'_> {
return Ok(false);
};
let alpha = self.clock.fraction();
let (camera, underwater, view_yaw, view_pitch) = match self.camera_view(alpha as f32) {
let live = self.backdrop_live();
let view = if live {
self.backdrop_view()
} else {
self.camera_view(alpha as f32)
};
let (camera, underwater, view_yaw, view_pitch) = match view {
Some((c, yaw, pitch)) => {
let s =
self.world
@@ -7213,7 +7256,7 @@ impl Game<'_> {
entities.extend(dragon);
}
}
let celestial = if title {
let celestial = if title && !live {
self.panorama_geometry()
} else if self.dim == Dim::Overworld
&& self.potions.effects.get("darkness").is_none()
@@ -7239,12 +7282,14 @@ impl Game<'_> {
if !title {
self.end_geometry(camera, &mut entities);
}
if !title && self.dim == Dim::Overworld {
if (!title || live) && self.dim == Dim::Overworld {
self.cloud_geometry(&sky, camera, alpha, &mut entities);
self.weather_geometry(camera, alpha, &mut entities);
}
// PanoramaRenderer: the title cube spins at 0.1 degrees per tick, pitched 10 degrees.
let (yaw, pitch) = if title {
let (yaw, pitch) = if live {
(view_yaw, view_pitch)
} else if title {
(-(self.started.elapsed().as_secs_f32() * 2.0) % 360.0, 10.0)
} else {
(view_yaw, view_pitch)
@@ -7264,7 +7309,9 @@ impl Game<'_> {
camera,
yaw,
pitch,
fov_degrees: if title {
fov_degrees: if live {
72.0
} else if title {
85.0
} else {
self.fov_degrees(alpha as f32, camera.to_array())
@@ -7277,7 +7324,7 @@ impl Game<'_> {
sky,
distance: (self.streaming.radius * 16) as f32,
underwater,
title,
title: title && !live,
skybox: self.dim == Dim::Overworld,
end_sky: self.dim == Dim::End,
environment_fog: (self.dim == Dim::Nether).then_some((sky.fog_start, sky.fog_end)),
@@ -7296,7 +7343,6 @@ impl Game<'_> {
additive: &additive,
celestial: &celestial,
hand: &hand,
hand_fov_degrees: self.hud_fov_degrees(alpha as f32, camera.to_array()),
screen: &screen,
border,
game_time: self.world.game_time as f32,
@@ -7312,6 +7358,13 @@ impl Game<'_> {
darkness: self.darkness_lighting(alpha as f32),
night_vision: self.night_vision_intensity(),
block_flicker: self.res.fog.state.block_light_flicker,
post: if live {
Some(horizon_client::post::PostGrade::FROST)
} else if self.horizon_pause() {
Some(horizon_client::post::PostGrade::PAUSE)
} else {
None
},
};
self.renderer.as_mut().ok_or("renderer")?.render(&frame)
}
@@ -7469,20 +7522,22 @@ impl Game<'_> {
}
match button {
MouseButton::Left => {
// KeyMapping.click: Minecraft.tick starts the attack.
if pressed {
self.attack_clicks += 1;
if pressed && self.attack() {
return Ok(());
}
self.toggles
.button(2, pressed, false, self.settings.toggle_attack);
self.left = self.toggles.is_down(2);
if pressed {
self.destroy_delay = 0;
}
}
MouseButton::Right => {
self.toggles
.button(3, pressed, false, self.settings.toggle_use);
self.right = self.toggles.is_down(3);
if pressed {
self.use_with_hand()?;
self.use_item()?;
self.use_delay = 4;
}
}
@@ -7603,6 +7658,15 @@ impl ApplicationHandler for Game<'_> {
) {
self.last_input = Instant::now();
}
if let WindowEvent::MouseInput {
state,
button: MouseButton::Left,
..
} = &event
{
// The press state of Horizon-style buttons.
horizon_client::ui_theme::set_pressed(*state == ElementState::Pressed);
}
let result: Result<()> = (|| {
match event {
WindowEvent::CloseRequested => {
@@ -7728,9 +7792,14 @@ impl ApplicationHandler for Game<'_> {
.as_ref()
.map(|r| [r.config.width, r.config.height])
.unwrap_or([1, 1]);
let b = HudBatch::new(size);
let mut b = HudBatch::new(size);
let atlas = &self.res.atlas;
let action = self.title.as_mut().map_or(Action::None, |t| {
t.click(self.cursor.0, self.cursor.1, (b.width, b.height))
if horizon_client::ui_theme::horizon() {
t.click_horizon(atlas, &mut b, self.cursor.0, self.cursor.1)
} else {
t.click(self.cursor.0, self.cursor.1, (b.width, b.height))
}
});
match action {
Action::Quit => event_loop.exit(),
@@ -7896,6 +7965,7 @@ impl ApplicationHandler for Game<'_> {
WindowEvent::KeyboardInput { event, .. } if self.title.is_some() => {
if event.state == ElementState::Pressed
&& let Some(title) = self.title.as_mut()
&& !title.skip_opening()
{
match event.physical_key {
PhysicalKey::Code(KeyCode::Backspace) => title.backspace(),
@@ -7977,6 +8047,11 @@ impl ApplicationHandler for Game<'_> {
self.pitch = (self.pitch
+ (turn(KeyCode::ArrowDown) - turn(KeyCode::ArrowUp)) * 90.0 * secs)
.clamp(-90.0, 90.0);
self.update_backdrop()?;
horizon_client::ui_theme::set_backdrop(self.backdrop_fade());
if self.screen != Screen::Paused {
self.pause_since = None;
}
if self.title.is_none() {
self.update_world();
self.spawn_player()?;
@@ -7989,6 +8064,8 @@ impl ApplicationHandler for Game<'_> {
self.tick()?;
self.tick_ambience();
self.tick_fov();
} else if self.title.is_some() {
self.tick_backdrop();
}
self.tick_music();
self.tick_sound_engine();
@@ -8222,6 +8299,14 @@ pub fn run() -> Result<()> {
let now = Instant::now();
let game_root = options.resources.clone();
let mut settings = Settings::load(&std::env::current_dir()?.join("options.txt"));
horizon_client::ui_theme::load_preferences(&std::env::current_dir()?);
// Scripted and frame-limited runs (the parity harnesses) see vanilla's
// screens unless HORIZON_MENU_STYLE asks otherwise.
if (options.frames.is_some() || options.control.is_some())
&& std::env::var_os("HORIZON_MENU_STYLE").is_none()
{
horizon_client::ui_theme::set_style(horizon_client::ui_theme::MenuStyle::Classic);
}
if let Some(d) = options.distance_override {
settings.render_distance = d;
}
@@ -8333,15 +8418,14 @@ pub fn run() -> Result<()> {
orbs: Vec::new(),
xp_delay: 0,
last_level_up: 0,
swing: Default::default(),
swings_started: 0,
heights: Default::default(),
visible_hands: [(0, None), (0, None)],
clicks: Default::default(),
attack_clicks: 0,
hand_strength: Default::default(),
swing: None,
swing_anim: (0.0, 0.0),
hand_height: (1.0, 1.0),
hand_item: None,
settings,
options_from_title: false,
backdrop: Default::default(),
pause_since: None,
dragging: false,
avatar: Default::default(),
flash: 0,
@@ -8472,7 +8556,16 @@ pub fn run() -> Result<()> {
let (seed, creative) = (game.options.seed, game.options.creative);
game.open_world(&folder, None, Some(seed), creative)?;
}
None => game.title = Some(Title::new(std::env::current_dir()?.join("saves"))),
None => {
let title = Title::new(std::env::current_dir()?.join("saves"));
// The launcher's opening, once per session and only in Horizon's style.
if horizon_client::ui_theme::horizon() && game.options.frames.is_none() {
title
.opening
.set(Some(horizon_client::front::Opening::new()));
}
game.title = Some(title);
}
}
if let (Some(script), Some(output)) = (
std::env::var_os("HORIZON_COMMAND_SCRIPT"),
@@ -183,7 +183,7 @@ impl Game<'_> {
true
}
/// The armor stand under the crosshair, its distance and the hit point.
pub(super) fn armor_stand_hit(&self) -> Option<(usize, f64, [f64; 3])> {
fn armor_stand_hit(&self) -> Option<(usize, f64, [f64; 3])> {
let eye = self.eye_position()?;
let dir = view_vector(self.yaw, self.pitch);
let reach: f64 = if self.creative { 5.0 } else { 3.0 };
+245
View File
@@ -0,0 +1,245 @@
//! The live title backdrop (Menu style: Horizon): a frozen landscape that
//! Horizon generates and renders itself behind the title screens, seen
//! through a slow orbiting camera, falling snow, an overcast sky and the cold
//! blur and grade of `gfx::PostGrade`. Nothing is bundled: the chunks come
//! from the vanilla generator for a fixed seed and are cached in
//! `saves/.horizon-title/backdrop` (no level.dat, so no world list entry).
//! The ice-spikes spot is found once with `/locate biome`'s search and kept
//! in `spot.txt` there.
use super::Game;
use horizon_client::{
Result,
save::{Persistent, SaveDir},
streaming::Streaming,
vanillagen::VanillaTerrain,
world::{Weather, World},
};
use horizon_core::biome::{BiomeZoom, obfuscate_seed};
use std::{
sync::{Arc, Mutex},
time::Instant,
};
/// The world the backdrop is cut from (HORIZON_BACKDROP_SEED overrides it).
const SEED: i64 = 2_026_031_337;
/// The biome the camera looks for.
const BIOME: &str = "minecraft:ice_spikes";
/// Late morning: the snow reads clearly; the grade (`PostGrade::FROST`) makes it cold.
const DAY_TIME: i64 = 4_000;
#[derive(Default)]
pub(super) struct Backdrop {
pub active: bool,
/// The search for the spot, then the spot (block x, z).
spot: Arc<Mutex<Option<Option<(i32, i32)>>>>,
centred: bool,
/// Camera position once the ground under it has loaded.
pub camera: Option<glam::DVec3>,
pub ready: Option<Instant>,
pub started: Option<Instant>,
}
fn seed() -> i64 {
std::env::var("HORIZON_BACKDROP_SEED")
.ok()
.and_then(|s| s.parse().ok())
.unwrap_or(SEED)
}
impl Game<'_> {
/// How visible the live backdrop is (0 until its first chunks are drawn).
pub(super) fn backdrop_fade(&self) -> f32 {
let Some(at) = self.backdrop.ready else {
return 0.0;
};
let x = (at.elapsed().as_secs_f32() / 1.6).clamp(0.0, 1.0);
x * x * (3.0 - 2.0 * x)
}
/// Whether the frame should draw the backdrop world instead of the panorama.
pub(super) fn backdrop_live(&self) -> bool {
self.title.is_some() && self.backdrop.active && self.backdrop.camera.is_some()
}
/// The orbiting camera: position, yaw and pitch.
pub(super) fn backdrop_view(&self) -> Option<(glam::DVec3, f32, f32)> {
let c = self.backdrop.camera?;
let t = self
.backdrop
.started
.map_or(0.0, |s| s.elapsed().as_secs_f32());
// A full turn in about six minutes.
let yaw = (200.0 + t * 1.0) % 360.0;
let pitch = 6.0 + (t * 0.05).sin() * 2.0;
Some((c, yaw, pitch))
}
/// Called every frame while the title is up.
pub(super) fn update_backdrop(&mut self) -> Result<()> {
let wanted = self.title.is_some()
&& horizon_client::ui_theme::horizon()
&& self.renderer.is_some()
&& self.options.frames.is_none();
if !wanted {
if self.backdrop.active && self.title.is_some() {
self.stop_backdrop();
}
if self.title.is_none() && self.backdrop.active {
// open_world / connect_server replaced the world and streaming.
self.backdrop = Backdrop::default();
}
return Ok(());
}
if !self.backdrop.active {
self.start_backdrop()?;
}
let found = *self.backdrop.spot.lock().unwrap_or_else(|e| e.into_inner());
let Some(Some((x, z))) = found else {
return Ok(());
};
if !self.backdrop.centred {
self.backdrop.centred = true;
self.streaming.center = (x >> 4, z >> 4);
self.streaming.paused = false;
}
self.update_world();
if self.backdrop.camera.is_none()
&& (-2..=2)
.all(|dz| (-2..=2).all(|dx| self.streaming.is_lit((x >> 4) + dx, (z >> 4) + dz)))
{
// Above the tallest block nearby, so no spike pokes through the lens.
let mut top = 64;
for dz in (-12..=12).step_by(3) {
for dx in (-12..=12).step_by(3) {
if let Some(y) = self.surface_y(x + dx, z + dz) {
top = top.max(y);
}
}
}
let ground = self.surface_y(x, z).unwrap_or(top);
let y = (ground + 22).max(top + 4);
self.backdrop.camera = Some(glam::DVec3::new(
f64::from(x) + 0.5,
f64::from(y),
f64::from(z) + 0.5,
));
}
if self.backdrop.camera.is_some()
&& self.backdrop.ready.is_none()
&& self
.renderer
.as_ref()
.is_some_and(|r| r.section_count() > 40)
{
self.backdrop.ready = Some(Instant::now());
}
Ok(())
}
/// The world tick of the backdrop: time passes for clouds and snow only.
pub(super) fn tick_backdrop(&mut self) {
if self.backdrop.active {
self.world.game_time += 1;
// The snow's fall follows the client tick counter.
self.ticks += 1;
}
}
fn surface_y(&self, x: i32, z: i32) -> Option<i32> {
let air = self.res.facts.air;
(-60..320).rev().find(|&y| self.world.block(x, y, z) != air)
}
fn start_backdrop(&mut self) -> Result<()> {
let seed = seed();
let root = std::env::current_dir()?
.join("saves")
.join(".horizon-title")
.join("backdrop");
std::fs::create_dir_all(&root)?;
let save = Arc::new(SaveDir::new(root.join(format!("{seed}"))));
let res = &*self.res;
let inner = Arc::new(
VanillaTerrain::new(
&self.options.resources.join("worldgen"),
res.assets.registry,
&res.biomes,
seed,
)
.map_err(|e| format!("title backdrop: {e}"))?,
);
let mut streaming = Streaming::new(
Arc::new(Persistent {
inner: inner.clone(),
save: save.clone(),
registry: res.assets.clone(),
biomes: res.biomes.clone(),
}),
res.context.clone(),
res.threads.min(6),
);
streaming.radius = self.settings.render_distance.clamp(4, 10);
streaming.biome_blend = self.settings.biome_blend;
streaming.smooth_lighting = self.settings.smooth_lighting;
streaming.cutout_leaves = self.settings.cutout_leaves;
streaming.biome_zoom = BiomeZoom::new(obfuscate_seed(seed));
streaming.paused = true;
self.streaming = streaming;
self.save = save;
let mut world = World::new(self.res.facts.air, seed);
world.day_time = DAY_TIME;
world.game_time = 6000;
// Light snow: the weather renders as snow in frozen biomes.
world.weather = Weather {
clear_time: 0,
rain_time: i32::MAX / 2,
thunder_time: i32::MAX / 2,
raining: true,
thundering: false,
rain_level: (0.35, 0.35),
thunder_level: (0.0, 0.0),
};
self.world = world;
self.dim = super::Dim::Overworld;
if let Some(r) = self.renderer.as_mut() {
r.clear_sections();
}
self.backdrop = Backdrop {
active: true,
started: Some(Instant::now()),
..Default::default()
};
// Find (or recall) the spot off the main thread.
let spot = self.backdrop.spot.clone();
std::fs::create_dir_all(root.join(format!("{seed}")))?;
let cache = root.join(format!("{seed}")).join("spot.txt");
std::thread::Builder::new()
.name("horizon-backdrop".into())
.spawn(move || {
let known = std::fs::read_to_string(&cache).ok().and_then(|t| {
let mut it = t.split_whitespace().map(|v| v.parse::<i32>().ok());
Some((it.next()??, it.next()??))
});
let found = known.or_else(|| {
let allowed = |name: &str| name == BIOME;
let search = horizon_world::worldgen::generator::BiomeSearch {
allowed: &allowed,
origin: [0, 64, 0],
radius: 6400,
horizontal: 32,
vertical: 64,
min_y: -64,
max_y: 320,
};
let found = inner.find_closest_biome(&search);
eprintln!("Horizon: title backdrop: {BIOME} at {found:?}");
let (_, p) = found?;
let _ = std::fs::write(&cache, format!("{} {}\n", p[0], p[2]));
Some((p[0], p[2]))
});
*spot.lock().unwrap_or_else(|e| e.into_inner()) =
Some(Some(found.unwrap_or((0, 0))));
})?;
Ok(())
}
fn stop_backdrop(&mut self) {
self.streaming.paused = true;
self.world = World::new(self.res.facts.air, 0);
if let Some(r) = self.renderer.as_mut() {
r.clear_sections();
}
self.backdrop = Backdrop::default();
}
}
+1 -1
View File
@@ -597,7 +597,7 @@ impl Game<'_> {
self.swing_arm();
true
}
pub(super) fn boat_hit(&self) -> Option<(usize, f64)> {
fn boat_hit(&self) -> Option<(usize, f64)> {
let eye = self.eye_position()?;
let dir = view_vector(self.yaw, self.pitch);
let reach: f64 = if self.creative { 5.0 } else { 3.0 };
+5 -9
View File
@@ -722,9 +722,10 @@ impl Game<'_> {
.play("minecraft:entity.ender_dragon.hurt", Some(at), 5.0, pitch);
}
/// Player melee on the dragon's parts or a crystal under the crosshair.
/// The dragon part under the crosshair within reach: its id and distance.
pub(super) fn dragon_hit(&self) -> Option<(u32, f64)> {
let eye = self.eye_position()?;
pub(super) fn attack_dragon(&mut self) -> bool {
let Some(eye) = self.eye_position() else {
return false;
};
let dir = super::view_vector(self.yaw, self.pitch);
let reach: f64 = if self.creative { 5.0 } else { 3.0 };
let mut best: Option<(u32, f64)> = None;
@@ -749,12 +750,7 @@ impl Game<'_> {
best = Some((id, t0));
}
}
best
}
pub(super) fn attack_dragon(&mut self) -> bool {
let Some((id, d)) = self.dragon_hit() else {
return false;
};
let Some((id, d)) = best else { return false };
if !self.before_block(d) || self.mob_hit().is_some_and(|m| m.1 < d) {
return false;
}
-9
View File
@@ -86,15 +86,6 @@ impl Game<'_> {
false,
)
}
/// `Camera.calculateHudFov`: the first-person hand's field of view.
pub(super) fn hud_fov_degrees(&self, partial: f32, camera: [f64; 3]) -> f32 {
self.view.calculate_hud_fov(
partial,
self.fov_effect_scale(),
self.death_time(),
self.camera_fluid(camera),
)
}
/// The pose `GameRenderer.renderLevel` multiplies into the projection:
/// `bobHurt`, then `bobView` with the View Bobbing option.
pub(super) fn bob_matrix(&self, partial: f32) -> glam::Mat4 {
-372
View File
@@ -1,372 +0,0 @@
//! The local player's hand: clicks and held attack through
//! `horizon_client::first_person` (`Minecraft.startAttack` /
//! `continueAttack`, `MultiPlayerGameMode` block breaking), the arm swing
//! (`LivingEntity.SwingState`) and the first-person equip heights
//! (`FirstPersonHandsAndItems`) with the item swap ticker they read.
use super::{Game, Mining, Screen};
use horizon_client::{
Result,
attack_strength::HeldStack,
first_person::{Hand, HandWorld, Hit, SwingAnimation, UseResult, swing_duration},
items::ItemStack,
};
/// The off hand's place in `visible_hands` (the main hand uses its hotbar slot).
const OFFHAND: usize = usize::MAX;
impl Game<'_> {
/// `LivingEntity.swing` with `getModifiedSwingDuration`.
pub(super) fn swing_hand(&mut self, hand: Hand, animation: SwingAnimation) {
let effects = &self.potions.effects;
let dig_speed = effects
.amplifier("haste")
.max(effects.amplifier("conduit_power"));
let fatigue = effects.amplifier("mining_fatigue");
let duration = swing_duration(animation, dig_speed, fatigue);
if self.swing.start_if_able(hand, animation, duration) {
self.swings_started += 1;
}
}
/// `swing(InteractionHand.MAIN_HAND)` with the default animation.
pub(super) fn swing_arm(&mut self) {
self.swing_hand(Hand::Main, SwingAnimation::DEFAULT);
}
/// `Player.resetAttackStrengthTicker`.
pub(super) fn reset_attack_strength(&mut self) {
self.attack_ticks = 0;
self.hand_strength.reset();
}
/// The attack and use part of `Minecraft.tick` for the local player:
/// attack clicks since the last tick and the held attack button.
pub(super) fn tick_local_hand(&mut self) -> Result<()> {
let attack_clicks = std::mem::take(&mut self.attack_clicks);
if self.screen == Screen::Playing && self.title.is_none() {
let mut clicks = std::mem::take(&mut self.clicks);
// keyAttack.isDown(); playing implies the grabbed mouse here
// (scripts under --control hold buttons without grabbing it).
let down = self.left;
let ticks = self.clicks.destroying.ticks;
let mut world = GameHands {
game: self,
error: None,
};
clicks.tick(&mut world, attack_clicks, 0, down, false);
let error = world.error.take();
self.clicks = clicks;
if let Some(e) = error {
return Err(e);
}
// A tick of breaking: the hit sound every fourth tick and the
// cracking particles.
let d = &self.clicks.destroying;
if d.active && d.ticks > ticks {
let (pos, first) = (d.pos, (d.ticks as u32 - 1).is_multiple_of(4));
let state = self.world.block(pos[0], pos[1], pos[2]);
if first {
let sounds = self.res.facts.get(state).sounds.clone();
self.audio.play(
&sounds.hit,
Some(pos.map(|v| f64::from(v) + 0.5)),
(sounds.volume + 1.0) / 8.0,
sounds.pitch * 0.5,
);
}
if let Some(hit) = self.pick() {
self.crack_particle(pos, hit.face, state);
}
}
} else {
// Minecraft.tick: a screen keeps the attack from starting.
self.clicks.miss_time = 10000;
}
let d = &self.clicks.destroying;
self.mining = d.active.then_some(Mining {
pos: d.pos,
progress: d.progress,
ticks: d.ticks as u32,
});
Ok(())
}
/// `BlockBehaviour.getDestroyProgress` with `Player.getDestroySpeed`.
pub(super) fn destroy_progress(&self, pos: [i32; 3]) -> f32 {
let state = self.world.block(pos[0], pos[1], pos[2]);
let facts = self.res.facts.get(state);
if facts.hardness == -1.0 {
return 0.0;
}
let held = self.held();
let mut speed = self.res.items.mining_speed(held, &facts.name);
speed = self.modify_destroy_speed(speed);
if let Some(eye) = self.eye_position()
&& self
.res
.facts
.get(self.world.block(
eye[0].floor() as i32,
eye[1].floor() as i32,
eye[2].floor() as i32,
))
.fluid
== horizon_client::blocks::Fluid::Water
{
speed *= self.submerged_mining_speed();
}
if self.player.as_ref().is_some_and(|p| !p.ground.on_ground) {
speed /= 5.0;
}
let harvest = !facts.requires_tool || self.res.items.correct_for_drops(held, &facts.name);
speed / facts.hardness / if harvest { 30.0 } else { 100.0 }
}
/// Whether an attack would hit an entity rather than the block behind
/// it: the crosshair's `EntityHitResult`.
fn entity_targeted(&self) -> bool {
let before = |d: Option<f64>| d.is_some_and(|d| self.before_block(d));
before(self.painting_hit().map(|h| h.1))
|| before(self.frame_hit().map(|h| h.1))
|| before(self.armor_stand_hit().map(|h| h.1))
|| before(self.boat_hit().map(|h| h.1))
|| before(self.cart_hit().map(|h| h.1))
|| before(self.dragon_hit().map(|h| h.1))
|| before(self.mob_hit().map(|h| h.1))
}
/// `LocalPlayer.isHandsBusy`: paddling a boat.
fn hands_busy(&self) -> bool {
self.riding
.is_some_and(|id| self.boats.iter().any(|b| b.id == id))
&& [
winit::keyboard::KeyCode::KeyW,
winit::keyboard::KeyCode::KeyS,
winit::keyboard::KeyCode::KeyA,
winit::keyboard::KeyCode::KeyD,
]
.iter()
.any(|k| self.keys.contains(k))
}
/// `MultiPlayerGameMode.startUseItem`'s bookkeeping around the game's
/// use: nothing starts while a block is being broken or the hands are
/// busy, and `LocalPlayer.itemUsed` drops the hand after a use that
/// swung and spent (or, with infinite materials, kept) the item, or that
/// started using it.
pub(super) fn use_with_hand(&mut self) -> Result<()> {
if self.clicks.destroying.active || self.hands_busy() {
return Ok(());
}
let before = self.held().cloned();
let swings = self.swings_started;
let using = self.using.is_some();
self.use_item()?;
let swung = self.swings_started != swings;
let after = self.held().cloned();
let spent = match (&before, &after) {
(Some(a), Some(b)) => a.count != b.count || a.item != b.item || self.creative,
_ => false,
};
if (swung && spent) || (!using && self.using.is_some() && after.is_some()) {
self.heights.item_used(Hand::Main);
}
Ok(())
}
/// `LocalPlayer.tick`: the swing (`LivingEntity.baseTick`), the attack
/// strength and item swap tickers (`Player.tick`), then
/// `FirstPersonHandsAndItems.tick`.
pub(super) fn tick_hand(&mut self) {
self.swing.tick();
let main = self.inventory[self.selected].clone();
self.hand_strength.tick(main.as_ref().map(|s| HeldStack {
item: s.item.to_string(),
rest: format!("{} {} {:?}", s.count, s.damage, s.extra),
}));
let next = [(self.selected, main), (OFFHAND, self.offhand.clone())];
// shouldInstantlyReplaceVisibleItem: the same stack, or an equal one
// (matchesIgnoringComponents; every item plays the swap animation).
// A stack that stays in its slot with the same item is the same
// stack, whatever its count (vanilla shrinks and grows it in place).
let mut changed = [false; 2];
for h in 0..2 {
let same = match (&self.visible_hands[h], &next[h]) {
((_, None), (_, None)) => true,
((slot, Some(a)), (next_slot, Some(b))) => {
a.item == b.item
&& (slot == next_slot || (a.count == b.count && a.extra == b.extra))
}
_ => false,
};
if same {
self.visible_hands[h] = next[h].clone();
}
changed[h] = !same;
}
let speed = self.main_hand_attack_speed();
let replace = self.heights.tick(
self.hands_busy(),
self.hand_strength.swap_scale(1.0, speed),
changed,
);
for h in 0..2 {
if replace[h] {
self.visible_hands[h] = next[h].clone();
}
}
}
/// `Attributes.ATTACK_SPEED` with the main-hand item's modifier.
fn main_hand_attack_speed(&self) -> f64 {
horizon_client::attack_strength::BASE_ATTACK_SPEED
+ self
.held()
.and_then(|h| self.res.items.get(&h.item))
.map_or(0.0, |i| f64::from(i.attack_speed))
}
/// The visible main- and off-hand items.
pub(super) fn visible_hand_items(&self) -> [Option<ItemStack>; 2] {
[
self.visible_hands[0].1.clone(),
self.visible_hands[1].1.clone(),
]
}
}
/// The game as `first_person`'s world: the crosshair, breaking and attacking.
struct GameHands<'g, 'a> {
game: &'g mut Game<'a>,
error: Option<Box<dyn std::error::Error>>,
}
impl GameHands<'_, '_> {
fn keep(&mut self, r: Result<()>) {
if let Err(e) = r
&& self.error.is_none()
{
self.error = Some(e);
}
}
}
impl HandWorld for GameHands<'_, '_> {
fn hit(&mut self) -> Hit {
if self.game.entity_targeted() {
return Hit::Entity;
}
match self.game.pick() {
Some(h) => Hit::Block {
pos: h.pos,
face: match h.face {
[0, -1, 0] => 0,
[0, 1, 0] => 1,
[0, 0, -1] => 2,
[0, 0, 1] => 3,
[-1, 0, 0] => 4,
_ => 5,
},
},
None => Hit::Miss,
}
}
fn is_air(&mut self, pos: [i32; 3]) -> bool {
let state = self.game.world.block(pos[0], pos[1], pos[2]);
self.game.res.facts.get(state).name.ends_with("air")
}
fn creative(&self) -> bool {
self.game.creative
}
fn spectator(&self) -> bool {
self.game.spectator()
}
fn instabuild(&self) -> bool {
self.game.creative
}
fn hands_busy(&self) -> bool {
self.game.hands_busy()
}
fn using_item(&self) -> bool {
self.game.using.is_some()
}
fn item_enabled(&mut self, _: Hand) -> bool {
true
}
fn held_empty(&mut self, hand: Hand) -> bool {
match hand {
Hand::Main => self.game.held().is_none(),
Hand::Off => self.game.offhand.is_none(),
}
}
fn held_count(&mut self, hand: Hand) -> i32 {
let stack = match hand {
Hand::Main => self.game.held(),
Hand::Off => self.game.offhand.as_ref(),
};
stack.map_or(0, |s| s.count as i32)
}
fn infinite_materials(&self) -> bool {
self.game.creative
}
fn cannot_attack(&mut self) -> bool {
false
}
fn piercing_weapon(&mut self) -> bool {
false
}
fn attack_animation(&mut self) -> SwingAnimation {
SwingAnimation::DEFAULT
}
fn interact_animation(&mut self, _: Hand) -> SwingAnimation {
SwingAnimation::DEFAULT
}
fn piercing_attack(&mut self, _: SwingAnimation) {}
fn attack_entity(&mut self) -> bool {
self.game.attack()
}
fn block_action_restricted(&mut self, _: [i32; 3]) -> bool {
!self.game.may_build()
}
fn within_border(&mut self, _: [i32; 3]) -> bool {
true
}
fn attack_block(&mut self, _: [i32; 3]) {}
fn destroy_progress(&mut self, pos: [i32; 3]) -> f32 {
self.game.destroy_progress(pos)
}
fn destroy_block(&mut self, pos: [i32; 3]) -> bool {
if self.is_air(pos) {
return false;
}
// Item.canDestroyBlock: swords, tridents and maces cannot break
// blocks in creative.
let held = self.game.held().map(|s| s.item.to_string());
if self.game.creative
&& held.is_some_and(|i| {
i.ends_with("_sword") || i == "minecraft:trident" || i == "minecraft:mace"
})
{
return false;
}
let r = self.game.break_block(pos);
self.keep(r);
true
}
fn main_hand_key(&mut self) -> String {
self.game
.held()
.map_or(String::new(), |s| format!("{} {:?}", s.item, s.extra))
}
fn destroy_stage(&mut self, _: [i32; 3], _: Option<i32>) {}
fn swing(&mut self, hand: Hand, animation: SwingAnimation) {
self.game.swing_hand(hand, animation);
}
fn reset_attack_strength(&mut self) {
self.game.reset_attack_strength();
}
fn entity_within_border(&mut self) -> bool {
true
}
fn use_on_entity(&mut self, _: Hand) -> Option<UseResult> {
None
}
fn use_on_block(&mut self, _: Hand) -> UseResult {
UseResult::Pass
}
fn use_item(&mut self, _: Hand) -> UseResult {
UseResult::Pass
}
fn item_used(&mut self, hand: Hand) {
self.game.heights.item_used(hand);
}
fn release_using(&mut self) {}
}
@@ -29,7 +29,8 @@ pub(super) struct GuestPlay {
using: Option<(usize, u32)>,
use_delay: u32,
attack_ticks: u32,
swing: horizon_client::first_person::SwingState,
swing: Option<u32>,
swing_anim: (f32, f32),
/// The block being dug and the face the client aimed at.
digging: Option<([i32; 3], [i32; 3])>,
last_progress: i32,
@@ -130,7 +131,8 @@ impl Game<'_> {
let using = std::mem::replace(&mut self.using, play.using.take());
let use_delay = std::mem::replace(&mut self.use_delay, play.use_delay);
let attack_ticks = std::mem::replace(&mut self.attack_ticks, play.attack_ticks);
let swing = std::mem::replace(&mut self.swing, play.swing);
let swing = std::mem::replace(&mut self.swing, play.swing.take());
let swing_anim = std::mem::replace(&mut self.swing_anim, play.swing_anim);
let (left, right) = (self.left, self.right);
self.left = play.digging.is_some();
self.right = play.using.is_some() || self.using.is_some();
@@ -151,6 +153,7 @@ impl Game<'_> {
play.use_delay = std::mem::replace(&mut self.use_delay, use_delay);
play.attack_ticks = std::mem::replace(&mut self.attack_ticks, attack_ticks);
play.swing = std::mem::replace(&mut self.swing, swing);
play.swing_anim = std::mem::replace(&mut self.swing_anim, swing_anim);
self.container = container;
self.screen = screen;
if sneak {
@@ -686,7 +689,7 @@ impl Game<'_> {
.mining
.as_ref()
.map_or(-1, |m| (m.progress.clamp(0.0, 1.0) * 10.0) as i32 - 1);
let swinging = play.swing.swinging();
let swinging = play.swing.is_some();
if progress != play.last_progress && progress >= 0 {
play.last_progress = progress;
host.hub.broadcast_near(
@@ -776,7 +779,7 @@ impl Game<'_> {
};
let (id, at) = (g.entity_id, g.position);
let play = host.play.get_mut(&conn).expect("play");
let swinging = play.swing.swinging() && play.swing.ticks == 0;
let swinging = play.swing == Some(0);
if swinging && !play.swung {
host.hub.swing(id, 0, at, Some(conn));
}
+1 -1
View File
@@ -890,7 +890,7 @@ impl Game<'_> {
self.swing_arm();
true
}
pub(super) fn cart_hit(&self) -> Option<(usize, f64)> {
fn cart_hit(&self) -> Option<(usize, f64)> {
let eye = self.eye_position()?;
let dir = view_vector(self.yaw, self.pitch);
let reach: f64 = if self.creative { 5.0 } else { 3.0 };
@@ -297,8 +297,8 @@ impl Game<'_> {
}
let crouching = p.pose == Pose::Crouching;
let (position, flying) = (p.position, p.flying || p.fall_flying);
let attack = (self.swing.old_animation, self.swing.animation);
let swinging = self.swing.swinging();
let attack = self.swing_anim;
let swinging = self.swing.is_some();
let (yaw, pitch) = (self.yaw, self.pitch);
let main = self.inventory[self.selected]
.as_ref()
+46 -4
View File
@@ -52,7 +52,32 @@ fn death_buttons(w: f32, h: f32) -> [(f32, f32, &'static str, u8); 2] {
]
}
impl Game<'_> {
/// Whether the pause menu is drawn in Horizon's style (over a blurred world).
pub(super) fn horizon_pause(&self) -> bool {
self.screen == Screen::Paused && self.title.is_none() && horizon_client::ui_theme::horizon()
}
fn pause_labels(&self) -> (String, &'static str) {
let world = if self.remote.is_some() {
"Multiplayer".to_owned()
} else {
self.level.name.clone()
};
let quit = if self.remote.is_some() {
"Disconnect"
} else {
"Save and quit"
};
(world, quit)
}
pub(super) fn draw_pause_menu(&mut self, batch: &mut HudBatch) {
if horizon_client::ui_theme::horizon() {
let opened = *self.pause_since.get_or_insert_with(std::time::Instant::now);
let (world, quit) = self.pause_labels();
let mut ui = horizon_client::ui_theme::Ui::new(&self.res.atlas, batch, self.cursor);
horizon_client::front::pause::draw(&mut ui, &world, quit, opened);
ui.finish();
return;
}
let atlas = &self.res.atlas;
let (w, h) = (batch.width, batch.height);
ui::menu_background(batch, atlas, 0.0, 0.0, w, h, true);
@@ -102,10 +127,27 @@ impl Game<'_> {
let b = HudBatch::new(size);
(b.width, b.height)
};
let hit = pause_buttons(w, h)
.into_iter()
.find(|b| b.5 && ui::hovered(self.cursor, b.0, b.1, b.2, 20.0))
.map(|b| b.4);
let hit = if horizon_client::ui_theme::horizon() {
let size = self
.renderer
.as_ref()
.map_or([1, 1], |r| [r.config.width, r.config.height]);
let mut b = HudBatch::new(size);
let (world, quit) = self.pause_labels();
horizon_client::front::pause::click(
&self.res.atlas,
&mut b,
self.cursor.0,
self.cursor.1,
&world,
quit,
)
} else {
pause_buttons(w, h)
.into_iter()
.find(|b| b.5 && ui::hovered(self.cursor, b.0, b.1, b.2, 20.0))
.map(|b| b.4)
};
match hit {
Some(0) => {
self.screen = Screen::Playing;
+23 -55
View File
@@ -78,9 +78,6 @@ pub struct Frame<'a> {
pub celestial: &'a [Vertex],
/// First-person hand and held item, drawn after clearing depth.
pub hand: &'a [Vertex],
/// `CameraRenderState.hudFov`: the hand's own field of view
/// (`GameRenderer.render3dHud`).
pub hand_fov_degrees: f32,
/// Full-screen camera effects drawn over the world and hand, under the HUD.
pub screen: &'a [crate::screen_effects::ScreenQuad],
/// The world border wall, when near enough to be drawn.
@@ -99,6 +96,8 @@ pub struct Frame<'a> {
pub night_vision: f32,
/// Torch flicker added to the block light factor of 1.4.
pub block_flicker: f32,
/// Blur and grade the frame under a Horizon-style menu (`post`).
pub post: Option<crate::post::PostGrade>,
}
/// One frame of the world border wall (`WorldBorderRenderer.render`).
#[derive(Clone, Debug)]
@@ -142,9 +141,6 @@ pub struct Renderer {
screen_buffer: wgpu::Buffer,
screen_capacity: u64,
entity_group: wgpu::BindGroup,
/// The entity resources with the hand's projection (`hand_globals`).
hand_group: wgpu::BindGroup,
hand_globals: wgpu::Buffer,
entity_buffer: wgpu::Buffer,
entity_capacity: u64,
group: wgpu::BindGroup,
@@ -168,6 +164,8 @@ pub struct Renderer {
reachable_from: Option<SectionKey>,
visibility_dirty: bool,
pub hud: HudRenderer,
/// The menus' backdrop blur, made on first use.
post: Option<crate::post::Post>,
pub info: wgpu::AdapterInfo,
pub suspended: bool,
pub drawn_sections: usize,
@@ -476,12 +474,6 @@ impl Renderer {
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
let hand_globals = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("First-person hand globals"),
size: std::mem::size_of::<Globals>() as u64,
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
let (mips, mip_levels) = mip_chain(sprite_pixels, sprite_size, layers);
let sprite_texture = device.create_texture_with_data(
&queue,
@@ -677,32 +669,6 @@ impl Renderer {
},
],
});
let hand_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("First-person hand resources"),
layout: &entity_bindings,
entries: &[
wgpu::BindGroupEntry {
binding: 0,
resource: hand_globals.as_entire_binding(),
},
wgpu::BindGroupEntry {
binding: 1,
resource: wgpu::BindingResource::TextureView(&atlas_view),
},
wgpu::BindGroupEntry {
binding: 2,
resource: wgpu::BindingResource::Sampler(&atlas_sampler),
},
wgpu::BindGroupEntry {
binding: 3,
resource: wgpu::BindingResource::TextureView(&lightmap_view),
},
wgpu::BindGroupEntry {
binding: 4,
resource: wgpu::BindingResource::Sampler(&lightmap_sampler),
},
],
});
let entity_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("Entity layout"),
bind_group_layouts: &[Some(&entity_bindings)],
@@ -1103,8 +1069,6 @@ impl Renderer {
screen_buffer,
screen_capacity,
entity_group,
hand_group,
hand_globals,
entity_buffer,
entity_capacity,
group,
@@ -1126,6 +1090,7 @@ impl Renderer {
reachable_from: None,
visibility_dirty: true,
hud,
post: None,
info,
suspended: size.width == 0 || size.height == 0,
drawn_sections: 0,
@@ -1532,20 +1497,6 @@ impl Renderer {
};
self.queue
.write_buffer(&self.globals, 0, bytemuck::bytes_of(&globals));
// GameRenderer.render3dHud: the hand's projection at hudFov, with the
// bob in its pose rather than the projection (same product).
let hand_projection = Mat4::from_cols_array(&crate::camera::hand_projection(
frame.hand_fov_degrees,
self.config.width as f32,
self.config.height.max(1) as f32,
(frame.distance * 4.0).max(1024.0),
));
let hand_globals = Globals {
view_proj: (hand_projection * frame.bob * view).to_cols_array_2d(),
..globals
};
self.queue
.write_buffer(&self.hand_globals, 0, bytemuck::bytes_of(&hand_globals));
// Frustum culling and camera-relative origins.
let planes = frustum(view_proj);
let camera = frame.camera;
@@ -1857,7 +1808,7 @@ impl Renderer {
..Default::default()
});
pass.set_pipeline(&self.entity_pipeline);
pass.set_bind_group(0, &self.hand_group, &[]);
pass.set_bind_group(0, &self.entity_group, &[]);
pass.set_index_buffer(self.indices.slice(..), wgpu::IndexFormat::Uint32);
pass.set_vertex_buffer(0, self.entity_buffer.slice(..));
pass.draw_indexed(
@@ -1907,6 +1858,23 @@ impl Renderer {
pass.draw_indexed(0..6, (i * 4) as i32, 0..1);
}
}
if let Some(grade) = &frame.post
&& self.config.usage.contains(wgpu::TextureUsages::COPY_SRC)
{
let format = self.config.format;
let device = &self.device;
self.post
.get_or_insert_with(|| crate::post::Post::new(device, format))
.apply(
&self.device,
&self.queue,
&mut encoder,
&surface_frame.texture,
&view_target,
[self.config.width, self.config.height],
grade,
);
}
self.hud.draw(
&self.device,
&self.queue,
+7
View File
@@ -204,6 +204,13 @@ impl HudAtlas {
for i in 0..SKIN_SLOTS {
images.push((format!("horizon/skin{i}"), 64, 64, vec![0; 64 * 64 * 4]));
}
// Horizon-style menu text and corner masks (ui_theme), rasterized at run time.
images.push((
"horizon/ui-text".into(),
2048,
1024,
vec![0; 2048 * 1024 * 4],
));
images.sort_by(|a, b| b.2.cmp(&a.2).then(b.1.cmp(&a.1)));
let width = 4096u32;
let mut placements = Vec::with_capacity(images.len());
+4 -1
View File
@@ -41,10 +41,10 @@ pub mod entities;
pub mod entity_nbt;
pub mod environment;
pub mod fastutil_order;
pub mod first_person;
pub mod flatgen;
pub mod fog;
pub mod font;
pub mod front;
pub mod gamerules;
pub mod gfx;
pub mod gossip;
@@ -80,12 +80,14 @@ pub mod nbt_path;
pub mod net;
pub mod options_file;
pub mod options_fixes;
pub mod options_horizon;
pub mod options_ui;
pub mod pack_screen;
pub mod packs;
pub mod particles;
pub mod patrol;
pub mod pose;
pub mod post;
pub mod projectiles;
pub mod raid;
pub mod random_sequences;
@@ -117,6 +119,7 @@ pub mod testgen;
pub mod text;
pub mod title;
pub mod ui;
pub mod ui_theme;
pub mod vanillagen;
pub mod window;
pub mod world;
+7
View File
@@ -108,6 +108,13 @@ fn entry_address(servers: &[ServerEntry], lan: &Scanner, index: usize) -> Option
}
impl Screen {
/// The address of list row `row` (saved servers, then LAN games).
pub fn address_of(&self, row: usize) -> Option<String> {
match self {
Self::List { servers, lan, .. } => entry_address(servers, lan, row),
_ => None,
}
}
pub fn list(saves: &Path) -> Self {
let servers = servers::load(&servers_file(saves));
let pings: Pings = Arc::default();
@@ -0,0 +1,518 @@
//! The options screen in Horizon's menu style, laid out like the launcher's
//! Settings page: a sidebar with the sliding selection (one item per
//! section), then the section's page header and settings rows (label column,
//! value, control on the right) between 1px dividers.
//!
//! Adding settings (for example the Simple/Advanced graphics menu): add rows
//! to a section in [`SECTIONS`], or a new section. A row is an [`HRow`]: an
//! existing option (`Opt`, drawn as a switch, a slider or a value button from
//! its kind), a header, or a button opening one of vanilla's pages. New kinds
//! of control belong in `ui_theme` so the launcher-matching look stays in one
//! place. Everything else (sliders dragging, scrolling, Done) is handled here.
use crate::{
hud::{HudAtlas, HudBatch},
options_ui::{Event, Kind, Opt, OptionsUi, Page, apply_preset},
settings::{SOUND_SOURCES, Settings},
ui_theme::{
self as t, CONTROL, Face, Kind as Button, MenuStyle, ROW_LABEL, SIDEBAR, SMALL, Ui, color,
id,
},
};
/// One row of a section.
#[derive(Clone, Copy, Debug)]
pub enum HRow {
Header(&'static str),
Opt(Opt),
/// A label, a value and a button opening one of vanilla's pages.
Page(&'static str, &'static str, Page),
/// Menu style: Horizon / Classic.
Style,
}
/// A sidebar section: name, description, rows.
pub struct Section {
pub name: &'static str,
pub blurb: &'static str,
pub rows: fn(&Settings) -> Vec<HRow>,
}
pub const SECTIONS: &[Section] = &[
Section {
name: "Video",
blurb: "Display, quality and how the world is drawn.",
rows: |_| {
let mut r = vec![HRow::Header("Display")];
r.extend(
[
Opt::Fullscreen,
Opt::Vsync,
Opt::MaxFps,
Opt::InactivityFps,
Opt::GuiScale,
Opt::Gamma,
]
.map(HRow::Opt),
);
r.push(HRow::Header("Quality"));
r.extend(
[
Opt::Preset,
Opt::RenderDistance,
Opt::SimulationDistance,
Opt::SmoothLighting,
Opt::Clouds,
Opt::CloudRange,
Opt::Particles,
Opt::Mipmaps,
Opt::EntityShadows,
Opt::EntityDistance,
Opt::BiomeBlend,
Opt::CutoutLeaves,
Opt::ImprovedTransparency,
Opt::WeatherRadius,
Opt::ChunkBuilder,
]
.map(HRow::Opt),
);
r.push(HRow::Header("Preferences"));
r.extend(
[
Opt::Fov,
Opt::ViewBobbing,
Opt::Autosave,
Opt::Vignette,
Opt::AttackIndicator,
Opt::ChunkFade,
]
.map(HRow::Opt),
);
r
},
},
Section {
name: "Audio",
blurb: "Volume for every kind of sound, and subtitles.",
rows: |_| {
let mut r: Vec<HRow> = (0..SOUND_SOURCES.len())
.map(|i| HRow::Opt(Opt::Volume(i)))
.collect();
r.push(HRow::Opt(Opt::ShowSubtitles));
r
},
},
Section {
name: "Controls",
blurb: "Keys, the mouse and how movement keys behave.",
rows: |_| {
let mut r = vec![HRow::Page(
"Key binds",
"Every action and its key",
Page::KeyBinds,
)];
r.push(HRow::Header("Mouse"));
r.extend(
[
Opt::Sensitivity,
Opt::WheelSensitivity,
Opt::InvertY,
Opt::DiscreteScroll,
]
.map(HRow::Opt),
);
r.push(HRow::Header("Movement"));
r.extend([Opt::ToggleCrouch, Opt::ToggleSprint, Opt::AutoJump].map(HRow::Opt));
r
},
},
Section {
name: "Accessibility",
blurb: "Contrast, motion, text backgrounds and notifications.",
rows: |_| {
[
Opt::HighContrast,
Opt::MenuBlur,
Opt::TextBackgroundOpacity,
Opt::NotificationDisplayTime,
Opt::ShowSubtitles,
Opt::ViewBobbing,
Opt::ChatLineSpacing,
]
.map(HRow::Opt)
.to_vec()
},
},
Section {
name: "Language",
blurb: "The game's language and its font.",
rows: |_| {
vec![
HRow::Page("Language", "", Page::Language),
HRow::Opt(Opt::ForceUnicode),
HRow::Opt(Opt::JapaneseGlyphs),
]
},
},
Section {
name: "Chat",
blurb: "What chat shows and how it looks.",
rows: |_| {
[
Opt::ChatVisibility,
Opt::ChatColors,
Opt::ChatLinks,
Opt::ChatOpacity,
Opt::ChatScale,
Opt::ChatWidth,
Opt::ChatHeightFocused,
Opt::ChatHeightUnfocused,
Opt::AutoSuggestions,
Opt::ReducedDebugInfo,
]
.map(HRow::Opt)
.to_vec()
},
},
Section {
name: "Interface",
blurb: "Menus and resource packs.",
rows: |_| {
vec![
HRow::Style,
HRow::Page("Resource packs", "Textures, sounds and fonts", Page::Packs),
]
},
},
];
const NAV: u64 = id("options.nav", 0);
const DONE: u64 = id("options.done", 0);
const ROW: u64 = id("options.row", 0);
const STYLE: u64 = id("options.style", 0);
const ROW_H: f32 = 12.0 + CONTROL + 12.0;
const HEADER_H: f32 = 40.0;
/// Whether an option is an on/off switch, and its value.
fn switch_value(opt: Opt, s: &Settings) -> Option<bool> {
Some(match opt {
Opt::InvertY => s.invert_y,
Opt::DiscreteScroll => s.discrete_scroll,
Opt::ViewBobbing => s.view_bobbing,
Opt::Vsync => s.vsync,
Opt::Fullscreen => s.fullscreen,
Opt::SmoothLighting => s.smooth_lighting,
Opt::EntityShadows => s.entity_shadows,
Opt::Vignette => s.vignette,
Opt::Autosave => s.autosave_indicator,
Opt::CutoutLeaves => s.cutout_leaves,
Opt::ImprovedTransparency => s.improved_transparency,
Opt::AutoJump => s.auto_jump,
Opt::ChatColors => s.chat_colors,
Opt::ChatLinks => s.chat_links,
Opt::AutoSuggestions => s.auto_suggestions,
Opt::ReducedDebugInfo => s.reduced_debug_info,
Opt::HighContrast => s.high_contrast,
Opt::ShowSubtitles => s.show_subtitles,
Opt::ForceUnicode => s.force_unicode,
Opt::JapaneseGlyphs => s.japanese_glyphs,
_ => return None,
})
}
/// The option's caption and value text, from its vanilla label
/// ("Caption: Value") and its caption key.
fn caption_value(opt: Opt, s: &Settings) -> (String, String) {
let label = opt.label(s).plain();
let caption = crate::text::Text::translate(opt.caption(), &[]).plain();
if let Some(rest) = label.strip_prefix(&caption) {
return (caption, rest.trim_start_matches([':', ' ']).to_owned());
}
match label.split_once(": ") {
Some((c, v)) => (c.to_owned(), v.to_owned()),
None => (caption, label),
}
}
/// What a click hit.
enum Target {
Nav(usize),
Done,
Row(usize),
Slider(Opt, f32),
Style(usize),
}
impl OptionsUi {
/// Lay out (and draw, on a real frame) the Horizon options shell.
/// Returns the sliders' tracks (option, x, width) for dragging.
fn horizon_layout(&mut self, s: &Settings, ui: &mut Ui, backdrop: f32) -> Vec<(Opt, f32, f32)> {
let (w, h) = (ui.w, ui.h);
if self.in_world {
ui.atmosphere(0.45, 1.0);
} else {
if backdrop < 1.0 {
ui.cold_background(1.0 - backdrop);
}
ui.atmosphere(0.66, 1.0);
}
let section = self.section.min(SECTIONS.len() - 1);
// The sidebar.
ui.fill(0.0, 0.0, SIDEBAR, h, color::with_alpha(color::SIDEBAR, 0.8));
ui.vline(SIDEBAR - 1.0, 0.0, h, color::BORDER);
ui.text_line(
Face::Display,
22.0,
30.0,
22.0,
16.0,
color::TEXT,
"Options",
);
let names: Vec<&str> = SECTIONS.iter().map(|s| s.name).collect();
ui.sidebar_list(NAV, 12.0, 66.0, SIDEBAR - 24.0, &names, section);
let dy = h - 12.0 - CONTROL;
ui.button(
DONE,
[12.0, dy, SIDEBAR - 24.0, CONTROL],
"Done",
Button::Secondary,
true,
);
// The page fades in and rises 8 when the section changes.
let p = ui.tween(id("options.page", section as u64), 1.0, t::move_());
if self.section_shown != Some(section) {
self.section_shown = Some(section);
ui.restart(id("options.page", section as u64), 0.0, 1.0, t::move_());
}
let x = SIDEBAR + 48.0;
let cw = (w - x - 48.0).min(760.0);
ui.opacity = p;
ui.offset = (0.0, 8.0 * (1.0 - p));
let sec = &SECTIONS[section];
let mut y = 36.0;
y += ui.page_header(x, y, sec.name, sec.blurb) + 24.0;
let top = y;
let rows = (sec.rows)(s);
let content: f32 = rows
.iter()
.map(|r| {
if matches!(r, HRow::Header(_)) {
HEADER_H
} else {
ROW_H
}
})
.sum();
let bottom = h - 24.0;
let max = (content - (bottom - top)).max(0.0);
self.hscroll = self.hscroll.clamp(0.0, max);
y -= self.hscroll;
let vstart = ui.batch.vertices.len();
let hstart = ui.hits.len();
let mut sliders = Vec::new();
ui.divider(x, y, cw);
let control_x = x + cw;
for (i, row) in rows.iter().enumerate() {
match *row {
HRow::Header(label) => {
ui.text(Face::Medium, SMALL, x, y + 18.0, color::FAINT, label);
y += HEADER_H;
ui.divider(x, y, cw);
continue;
}
HRow::Opt(opt) => {
let (caption, value) = caption_value(opt, s);
let cy = y + ROW_H / 2.0;
if let Some(on) = switch_value(opt, s) {
ui.row(x, y, &caption, "", 0.0);
ui.switch(ROW + i as u64, control_x, cy, on);
} else if let Kind::Slider(v) = opt.kind(s) {
let sw = 220.0_f32.min(cw - ROW_LABEL - 200.0).max(80.0);
ui.row(x, y, &caption, &value, cw - ROW_LABEL - 16.0 - sw - 24.0);
let sx = control_x - sw - 8.0;
ui.slider(ROW + i as u64, sx, cy, sw, v);
sliders.push((opt, sx, sw));
} else {
ui.row(x, y, &caption, "", 0.0);
let bw = ui.button_width(&value, false).max(96.0);
ui.button(
ROW + i as u64,
[control_x - bw, y + 12.0, bw, CONTROL],
&value,
Button::Secondary,
true,
);
}
}
HRow::Page(label, value, _) => {
let value = if value.is_empty() {
self.languages
.iter()
.find(|l| l.code == s.language)
.map_or_else(|| s.language.clone(), |l| l.label())
} else {
value.to_owned()
};
ui.row(x, y, label, &value, cw - ROW_LABEL - 140.0);
let bw = ui.button_width("Change…", false);
let open = if label == "Language" {
"Change…"
} else {
"Open…"
};
let bw = bw.max(ui.button_width(open, false));
ui.button(
ROW + i as u64,
[control_x - bw, y + 12.0, bw, CONTROL],
open,
Button::Secondary,
true,
);
}
HRow::Style => {
ui.row(x, y, "Menu style", "", 0.0);
let labels = ["Horizon", "Classic"];
let probe = ui.segmented(STYLE, -10_000.0, -10_000.0, &labels, 0);
let selected = usize::from(t::style() == MenuStyle::Classic);
ui.segmented(STYLE, control_x - probe, y + 12.0 - 3.0, &labels, selected);
}
}
y += ROW_H;
ui.divider(x, y, cw);
}
// Clip the rows to the page below the header, like the launcher's scroll view.
let (ox, oy) = ui.offset;
let scale = ui.batch.scale;
let k = ui.density / scale;
let (y0, y1) = ((top - 1.0 + oy) * k, (bottom + oy) * k);
ui.batch.clip_since(vstart, 0.0, y0, ui.batch.width, y1);
let (cy0, cy1) = (top - 1.0 + oy, bottom + oy);
let kept: Vec<_> = ui
.hits
.drain(hstart..)
.filter(|h| h.rect[1] >= cy0 && h.rect[3] <= cy1)
.collect();
ui.hits.extend(kept);
if max > 0.0 {
// A thin scroll indicator.
let track = bottom - top;
let bar = (track * track / (track + max)).max(24.0);
let by = top + (track - bar) * (self.hscroll / max);
ui.rounded(
x + cw + 16.0,
by,
3.0,
bar,
1.5,
color::with_alpha(color::MUTED, 0.35),
);
}
let _ = ox;
ui.opacity = 1.0;
ui.offset = (0.0, 0.0);
sliders
}
pub(crate) fn draw_horizon(
&mut self,
s: &Settings,
batch: &mut HudBatch,
atlas: &HudAtlas,
cursor: (f32, f32),
) {
let mut ui = Ui::new(atlas, batch, cursor);
let backdrop = t::backdrop();
self.horizon_layout(s, &mut ui, backdrop);
ui.finish();
}
fn horizon_target(&mut self, s: &Settings, atlas: &HudAtlas, x: f32, y: f32) -> Option<Target> {
let mut batch = t::scratch_batch();
let mut ui = Ui::dry(atlas, &mut batch, (x, y));
let sliders = self.horizon_layout(s, &mut ui, 1.0);
let hit = ui.hit((x, y))?;
let section = self.section.min(SECTIONS.len() - 1);
if (NAV..NAV + SECTIONS.len() as u64).contains(&hit) {
return Some(Target::Nav((hit - NAV) as usize));
}
if hit == DONE {
return Some(Target::Done);
}
if hit == STYLE || hit == STYLE + 1 {
return Some(Target::Style((hit - STYLE) as usize));
}
let i = hit.checked_sub(ROW)? as usize;
let rows = (SECTIONS[section].rows)(s);
if let Some(HRow::Opt(opt)) = rows.get(i)
&& let Some(&(_, sx, sw)) = sliders.iter().find(|(o, ..)| o == opt)
{
let v = t::slider_value(&ui, sx, sw, x);
return Some(Target::Slider(*opt, v));
}
(i < rows.len()).then_some(Target::Row(i))
}
/// A click (or a drag with the button held) on the Horizon shell.
pub(crate) fn click_horizon(
&mut self,
s: &mut Settings,
atlas: &HudAtlas,
x: f32,
y: f32,
drag: bool,
right: bool,
) -> Vec<Event> {
let mut events = Vec::new();
if drag {
if let Some(opt) = self.dragging {
let mut batch = t::scratch_batch();
let mut ui = Ui::dry(atlas, &mut batch, (x, y));
let sliders = self.horizon_layout(s, &mut ui, 1.0);
if let Some(&(_, sx, sw)) = sliders.iter().find(|(o, ..)| *o == opt) {
opt.set_slider(s, t::slider_value(&ui, sx, sw, x));
if opt == Opt::Preset {
apply_preset(s);
}
events.push(Event::Changed);
}
}
return events;
}
match self.horizon_target(s, atlas, x, y) {
Some(Target::Nav(i)) => {
self.section = i;
self.hscroll = 0.0;
}
Some(Target::Done) => events.extend(self.back(s)),
Some(Target::Style(i)) => {
t::set_style(if i == 1 {
MenuStyle::Classic
} else {
MenuStyle::Horizon
});
if let Ok(dir) = std::env::current_dir() {
t::save_preferences(&dir);
}
}
Some(Target::Slider(opt, v)) => {
self.dragging = Some(opt);
opt.set_slider(s, v);
if opt == Opt::Preset {
apply_preset(s);
}
events.push(Event::Changed);
}
Some(Target::Row(i)) => {
let section = self.section.min(SECTIONS.len() - 1);
match (SECTIONS[section].rows)(s).get(i) {
Some(HRow::Opt(opt)) => {
opt.cycle(s, right);
if *opt == Opt::Preset {
apply_preset(s);
}
events.push(Event::Changed);
}
Some(HRow::Page(_, _, page)) => self.open_page(*page),
_ => {}
}
}
None => {}
}
events
}
pub(crate) fn scroll_horizon(&mut self, amount: f32) {
self.hscroll -= amount * 40.0;
}
}
+27 -4
View File
@@ -92,13 +92,13 @@ fn chat_px(caption: &str, px: f32) -> Text {
&[tr(caption), Text::literal((px as i32).to_string())],
)
}
enum Kind {
pub(crate) enum Kind {
/// Slider position 0..1.
Slider(f32),
Cycle,
}
impl Opt {
fn caption(self) -> &'static str {
pub(crate) fn caption(self) -> &'static str {
match self {
Opt::Fov => "options.fov",
Opt::RenderDistance => "options.renderDistance",
@@ -155,7 +155,7 @@ impl Opt {
Opt::JapaneseGlyphs => "options.japaneseGlyphVariants",
}
}
fn kind(self, s: &Settings) -> Kind {
pub(crate) fn kind(self, s: &Settings) -> Kind {
let unit = |v: f32, lo: f32, hi: f32| Kind::Slider(((v - lo) / (hi - lo)).clamp(0.0, 1.0));
match self {
Opt::Fov => unit(s.fov, 30.0, 110.0),
@@ -590,7 +590,12 @@ pub enum Event {
pub struct OptionsUi {
pub stack: Vec<Page>,
scroll: f32,
dragging: Option<Opt>,
pub(crate) dragging: Option<Opt>,
/// Horizon style: the selected sidebar section, the one last drawn and
/// the rows' scroll in dp (`options_horizon`).
pub section: usize,
pub(crate) section_shown: Option<usize>,
pub(crate) hscroll: f32,
/// Key Binds: the mapping waiting for a key.
pub selected_key: Option<&'static str>,
pub languages: Vec<LanguageInfo>,
@@ -615,6 +620,9 @@ impl Default for OptionsUi {
stack: vec![Page::Main],
scroll: 0.0,
dragging: None,
section: 0,
section_shown: None,
hscroll: 0.0,
selected_key: None,
languages: Vec::new(),
language_choice: None,
@@ -693,6 +701,10 @@ impl OptionsUi {
self.in_world = in_world;
self.selected_key = None;
}
/// Open one of vanilla's pages over the current one.
pub(crate) fn open_page(&mut self, page: Page) {
self.push(page);
}
fn push(&mut self, page: Page) {
self.stack.push(page);
self.scroll = 0.0;
@@ -1175,6 +1187,10 @@ impl OptionsUi {
cursor: (f32, f32),
) {
let (w, h) = (batch.width, batch.height);
if crate::ui_theme::horizon() && self.page() == Page::Main {
self.draw_horizon(s, batch, atlas, cursor);
return;
}
if self.page() == Page::Packs {
ui::menu_background(batch, atlas, 0.0, 0.0, w, h, self.in_world);
let blink = self.pack_focus.elapsed().as_millis() as u64;
@@ -1324,6 +1340,9 @@ impl OptionsUi {
drag: bool,
right: bool,
) -> Vec<Event> {
if crate::ui_theme::horizon() && self.page() == Page::Main {
return self.click_horizon(s, atlas, x, y, drag, right);
}
if self.page() == Page::Packs {
let mut events = Vec::new();
if drag || right {
@@ -1427,6 +1446,10 @@ impl OptionsUi {
}
/// The mouse wheel at the cursor.
pub fn scroll_at(&mut self, cursor: (f32, f32), amount: f32) {
if crate::ui_theme::horizon() && self.page() == Page::Main {
self.scroll_horizon(amount);
return;
}
match &mut self.pack_screen {
Some(p) if self.stack.last() == Some(&Page::Packs) => {
p.scroll(cursor.0 as f64, cursor.1 as f64, amount as f64)
+273
View File
@@ -0,0 +1,273 @@
//! The menus' backdrop treatment (`ui_theme`'s Horizon style): the finished
//! world frame is blurred (dual Kawase: three halvings down, back up) and
//! colour graded before the HUD is drawn over it. Needs a swapchain that can
//! be copied from; without one the frame is left as it is.
/// How the frame under a Horizon-style menu is treated.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct PostGrade {
/// Colour multiplier and lift (`c * tint + lift`).
pub tint: [f32; 3],
pub lift: [f32; 3],
/// Toward grey, 0..1.
pub desaturate: f32,
/// Darkening toward the corners, 0..1.
pub vignette: f32,
}
impl PostGrade {
/// The title backdrop: icy, slightly lifted shadows.
pub const FROST: Self = Self {
tint: [0.62, 0.78, 1.0],
lift: [0.0, 0.01, 0.035],
desaturate: 0.45,
vignette: 0.5,
};
/// Behind the pause menu: the world stays recognisable.
pub const PAUSE: Self = Self {
tint: [0.8, 0.86, 0.98],
lift: [0.0, 0.004, 0.012],
desaturate: 0.25,
vignette: 0.3,
};
}
#[repr(C)]
#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
struct Uniform {
texel: [f32; 4],
tint: [f32; 4],
lift: [f32; 4],
params: [f32; 4],
}
const LEVELS: usize = 3;
pub struct Post {
layout: wgpu::BindGroupLayout,
down: wgpu::RenderPipeline,
up: wgpu::RenderPipeline,
finish: wgpu::RenderPipeline,
sampler: wgpu::Sampler,
/// The copied frame, then the halvings.
levels: Vec<(wgpu::Texture, wgpu::TextureView, [u32; 2])>,
uniforms: Vec<wgpu::Buffer>,
size: [u32; 2],
format: wgpu::TextureFormat,
}
impl Post {
pub fn new(device: &wgpu::Device, format: wgpu::TextureFormat) -> Self {
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("Menu backdrop"),
source: wgpu::ShaderSource::Wgsl(include_str!("post.wgsl").into()),
});
let layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("Menu backdrop bindings"),
entries: &[
wgpu::BindGroupLayoutEntry {
binding: 0,
visibility: wgpu::ShaderStages::FRAGMENT,
ty: wgpu::BindingType::Texture {
sample_type: wgpu::TextureSampleType::Float { filterable: true },
view_dimension: wgpu::TextureViewDimension::D2,
multisampled: false,
},
count: None,
},
wgpu::BindGroupLayoutEntry {
binding: 1,
visibility: wgpu::ShaderStages::FRAGMENT,
ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
count: None,
},
wgpu::BindGroupLayoutEntry {
binding: 2,
visibility: wgpu::ShaderStages::FRAGMENT,
ty: wgpu::BindingType::Buffer {
ty: wgpu::BufferBindingType::Uniform,
has_dynamic_offset: false,
min_binding_size: None,
},
count: None,
},
],
});
let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("Menu backdrop"),
bind_group_layouts: &[Some(&layout)],
..Default::default()
});
let pipeline = |entry: &str| {
device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some("Menu backdrop pass"),
layout: Some(&pipeline_layout),
vertex: wgpu::VertexState {
module: &shader,
entry_point: Some("vs_main"),
buffers: &[],
compilation_options: Default::default(),
},
fragment: Some(wgpu::FragmentState {
module: &shader,
entry_point: Some(entry),
targets: &[Some(wgpu::ColorTargetState {
format,
blend: None,
write_mask: wgpu::ColorWrites::ALL,
})],
compilation_options: Default::default(),
}),
primitive: Default::default(),
depth_stencil: None,
multisample: Default::default(),
multiview_mask: None,
cache: None,
})
};
let sampler = device.create_sampler(&wgpu::SamplerDescriptor {
label: Some("Menu backdrop"),
mag_filter: wgpu::FilterMode::Linear,
min_filter: wgpu::FilterMode::Linear,
address_mode_u: wgpu::AddressMode::ClampToEdge,
address_mode_v: wgpu::AddressMode::ClampToEdge,
..Default::default()
});
let uniforms = (0..LEVELS * 2 + 1)
.map(|_| {
device.create_buffer(&wgpu::BufferDescriptor {
label: Some("Menu backdrop pass"),
size: std::mem::size_of::<Uniform>() as u64,
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
})
})
.collect();
Self {
down: pipeline("fs_down"),
up: pipeline("fs_up"),
finish: pipeline("fs_final"),
layout,
sampler,
levels: Vec::new(),
uniforms,
size: [0, 0],
format,
}
}
fn ensure(&mut self, device: &wgpu::Device, size: [u32; 2]) {
if self.size == size && !self.levels.is_empty() {
return;
}
self.size = size;
self.levels = (0..=LEVELS)
.map(|i| {
let s = [(size[0] >> i).max(1), (size[1] >> i).max(1)];
let texture = device.create_texture(&wgpu::TextureDescriptor {
label: Some("Menu backdrop level"),
size: wgpu::Extent3d {
width: s[0],
height: s[1],
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: self.format,
usage: wgpu::TextureUsages::TEXTURE_BINDING
| wgpu::TextureUsages::RENDER_ATTACHMENT
| wgpu::TextureUsages::COPY_DST,
view_formats: &[],
});
let view = texture.create_view(&Default::default());
(texture, view, s)
})
.collect();
}
fn pass(
&self,
device: &wgpu::Device,
queue: &wgpu::Queue,
encoder: &mut wgpu::CommandEncoder,
pipeline: &wgpu::RenderPipeline,
(from, uniform): (usize, usize),
target: &wgpu::TextureView,
data: Uniform,
) {
let (_, view, s) = &self.levels[from];
let mut data = data;
data.texel = [0.5 / s[0] as f32, 0.5 / s[1] as f32, 0.0, 0.0];
queue.write_buffer(&self.uniforms[uniform], 0, bytemuck::bytes_of(&data));
let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("Menu backdrop pass"),
layout: &self.layout,
entries: &[
wgpu::BindGroupEntry {
binding: 0,
resource: wgpu::BindingResource::TextureView(view),
},
wgpu::BindGroupEntry {
binding: 1,
resource: wgpu::BindingResource::Sampler(&self.sampler),
},
wgpu::BindGroupEntry {
binding: 2,
resource: self.uniforms[uniform].as_entire_binding(),
},
],
});
let attachments = [Some(wgpu::RenderPassColorAttachment {
view: target,
resolve_target: None,
depth_slice: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Clear(wgpu::Color::BLACK),
store: wgpu::StoreOp::Store,
},
})];
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("Menu backdrop pass"),
color_attachments: &attachments,
..Default::default()
});
pass.set_pipeline(pipeline);
pass.set_bind_group(0, &group, &[]);
pass.draw(0..3, 0..1);
}
/// Blur and grade `frame` (the swapchain texture) into `target`.
#[allow(clippy::too_many_arguments)]
pub fn apply(
&mut self,
device: &wgpu::Device,
queue: &wgpu::Queue,
encoder: &mut wgpu::CommandEncoder,
frame: &wgpu::Texture,
target: &wgpu::TextureView,
size: [u32; 2],
grade: &PostGrade,
) {
self.ensure(device, size);
encoder.copy_texture_to_texture(
frame.as_image_copy(),
self.levels[0].0.as_image_copy(),
wgpu::Extent3d {
width: size[0],
height: size[1],
depth_or_array_layers: 1,
},
);
let data = Uniform {
texel: [0.0; 4],
tint: [grade.tint[0], grade.tint[1], grade.tint[2], 1.0],
lift: [grade.lift[0], grade.lift[1], grade.lift[2], 0.0],
params: [grade.desaturate, grade.vignette, 0.0, 0.0],
};
let mut u = 0;
for i in 1..=LEVELS {
let target = &self.levels[i].1;
self.pass(device, queue, encoder, &self.down, (i - 1, u), target, data);
u += 1;
}
for i in (1..LEVELS).rev() {
let target = &self.levels[i].1;
self.pass(device, queue, encoder, &self.up, (i + 1, u), target, data);
u += 1;
}
self.pass(device, queue, encoder, &self.finish, (1, u), target, data);
}
}
+51
View File
@@ -0,0 +1,51 @@
// Menu backdrop: dual-Kawase blur (down and up passes) and a cold colour grade.
struct Post {
// Half a source texel (u, v), unused, unused.
texel: vec4<f32>,
// Multiplier and lift of the grade.
tint: vec4<f32>,
lift: vec4<f32>,
// Desaturation, vignette, unused, unused.
params: vec4<f32>,
}
@group(0) @binding(0) var src: texture_2d<f32>;
@group(0) @binding(1) var samp: sampler;
@group(0) @binding(2) var<uniform> post: Post;
struct Out {
@builtin(position) clip: vec4<f32>,
@location(0) uv: vec2<f32>,
}
@vertex fn vs_main(@builtin(vertex_index) i: u32) -> Out {
let p = vec2<f32>(f32((i << 1u) & 2u), f32(i & 2u));
var out: Out;
out.clip = vec4(p * 2.0 - 1.0, 0.0, 1.0);
out.uv = vec2(p.x, 1.0 - p.y);
return out;
}
fn s(uv: vec2<f32>) -> vec3<f32> {
return textureSampleLevel(src, samp, uv, 0.0).rgb;
}
@fragment fn fs_down(in: Out) -> @location(0) vec4<f32> {
let h = post.texel.xy;
let c = s(in.uv) * 4.0 + s(in.uv - h) + s(in.uv + h) + s(in.uv + vec2(h.x, -h.y)) + s(in.uv - vec2(h.x, -h.y));
return vec4(c / 8.0, 1.0);
}
fn up(uv: vec2<f32>) -> vec3<f32> {
let h = post.texel.xy;
var c = s(uv + vec2(-h.x * 2.0, 0.0)) + s(uv + vec2(h.x * 2.0, 0.0));
c += s(uv + vec2(0.0, -h.y * 2.0)) + s(uv + vec2(0.0, h.y * 2.0));
c += (s(uv + vec2(-h.x, h.y)) + s(uv + vec2(h.x, h.y)) + s(uv + vec2(-h.x, -h.y)) + s(uv + vec2(h.x, -h.y))) * 2.0;
return c / 12.0;
}
@fragment fn fs_up(in: Out) -> @location(0) vec4<f32> {
return vec4(up(in.uv), 1.0);
}
@fragment fn fs_final(in: Out) -> @location(0) vec4<f32> {
var c = up(in.uv);
let l = dot(c, vec3(0.2126, 0.7152, 0.0722));
c = mix(c, vec3(l), post.params.x);
c = c * post.tint.rgb + post.lift.rgb;
let d = length((in.uv - 0.5) * vec2(1.25, 1.0));
c *= 1.0 - post.params.y * smoothstep(0.35, 0.95, d);
return vec4(clamp(c, vec3(0.0), vec3(1.0)), 1.0);
}
+83 -40
View File
@@ -50,6 +50,12 @@ pub struct Title {
pub mode: Mode,
pub saves: PathBuf,
pub started: std::time::Instant,
/// The mode last drawn and when it appeared (Horizon's screen fades).
shown: std::cell::Cell<(std::mem::Discriminant<Mode>, std::time::Instant)>,
/// The launcher-style opening (Horizon style, first title of the session).
pub opening: std::cell::Cell<Option<crate::front::Opening>>,
/// The saves listed for Home, re-read every couple of seconds.
recent: std::cell::RefCell<Option<(std::time::Instant, Vec<WorldEntry>)>>,
}
struct Button {
x: f32,
@@ -96,10 +102,78 @@ pub fn list_worlds(saves: &Path) -> Vec<WorldEntry> {
}
impl Title {
pub fn new(saves: PathBuf) -> Self {
let mode = Mode::Main;
let now = std::time::Instant::now();
Self {
mode: Mode::Main,
shown: std::cell::Cell::new((std::mem::discriminant(&mode), now)),
mode,
saves,
started: std::time::Instant::now(),
started: now,
opening: std::cell::Cell::new(None),
recent: std::cell::RefCell::new(None),
}
}
/// The worlds, newest first, cached for two seconds (drawn every frame).
pub fn recent_worlds(&self) -> Vec<WorldEntry> {
let mut recent = self.recent.borrow_mut();
match &*recent {
Some((at, worlds)) if at.elapsed().as_secs() < 2 => worlds.clone(),
_ => {
let worlds = list_worlds(&self.saves);
*recent = Some((std::time::Instant::now(), worlds.clone()));
worlds
}
}
}
/// When the current mode first appeared.
pub fn entered(&self) -> std::time::Instant {
let now = std::mem::discriminant(&self.mode);
let (mode, at) = self.shown.get();
if mode == now {
return at;
}
let at = std::time::Instant::now();
self.shown.set((now, at));
at
}
/// Create New World: the folder name and options (FileUtil.findAvailableName).
pub fn create_action(&self) -> Action {
let Mode::Create {
name,
seed,
creative,
..
} = &self.mode
else {
return Action::None;
};
let base: String = name
.chars()
.map(|c| {
if c.is_alphanumeric() || c == ' ' || c == '-' || c == '_' {
c
} else {
'_'
}
})
.collect();
let base = if base.trim().is_empty() {
"World".to_owned()
} else {
base.trim().to_owned()
};
// FileUtil.findAvailableName: append (n) until unused.
let mut folder = base.clone();
let mut n = 1;
while self.saves.join(&folder).exists() {
folder = format!("{base} ({n})");
n += 1;
}
Action::Create {
folder,
name: name.clone(),
seed: parse_seed(seed),
creative: *creative,
}
}
fn buttons(&self, w: f32, h: f32) -> Vec<Button> {
@@ -446,44 +520,7 @@ impl Title {
*creative = !*creative;
Action::None
}
(
Mode::Create {
name,
seed,
creative,
..
},
Some(21),
) => {
let base: String = name
.chars()
.map(|c| {
if c.is_alphanumeric() || c == ' ' || c == '-' || c == '_' {
c
} else {
'_'
}
})
.collect();
let base = if base.trim().is_empty() {
"World".to_owned()
} else {
base.trim().to_owned()
};
// FileUtil.findAvailableName: append (n) until unused.
let mut folder = base.clone();
let mut n = 1;
while self.saves.join(&folder).exists() {
folder = format!("{base} ({n})");
n += 1;
}
Action::Create {
folder,
name: name.clone(),
seed: parse_seed(seed),
creative: *creative,
}
}
(Mode::Create { .. }, Some(21)) => self.create_action(),
(Mode::Create { .. }, Some(22)) => {
self.mode = Mode::Select {
worlds: list_worlds(&self.saves),
@@ -510,6 +547,7 @@ impl Title {
if let Mode::Create {
name, seed, focus, ..
} = &mut self.mode
&& *focus < 2
{
let field = if *focus == 0 { name } else { seed };
for c in text.chars().filter(|c| !c.is_control()) {
@@ -526,6 +564,7 @@ impl Title {
if let Mode::Create {
name, seed, focus, ..
} = &mut self.mode
&& *focus < 2
{
let field = if *focus == 0 { name } else { seed };
field.pop();
@@ -554,6 +593,10 @@ impl Title {
};
}
}
/// `yyyy/MM/dd HH:mm` (UTC) for a modification time in Unix seconds.
pub fn date_label(secs: u64) -> String {
date_string(secs)
}
fn date_string(secs: u64) -> String {
// Civil date from Unix time (days-from-civil inverse), UTC.
let days = (secs / 86400) as i64;
File diff suppressed because it is too large. Load diff
+8
View File
@@ -0,0 +1,8 @@
[package]
name = "horizon-theme"
version = "0.1.0"
edition = "2021"
license = "MIT"
description = "Horizon's design tokens and fonts, shared by the launcher and the game's menus"
[dependencies]
File renamed without changes.
+120
View File
@@ -0,0 +1,120 @@
//! Horizon's design tokens, shared by the launcher (`launcher/src/theme.rs`)
//! and the game's menus (`horizon-client`'s `ui_theme`), so the two read as
//! one product: change a value here and both follow.
//!
//! One neutral palette in two appearances and one accent. Spacing follows
//! 4/8/12/16/24/32. Type: Geist for the interface, Instrument Serif for
//! titles, Geist Mono for consoles and commands, all bundled here under the
//! SIL Open Font License (`fonts/OFL-*.txt`). Five sizes: 12, 13, 15, 28 and 56.
//! Colours are `0xRRGGBB`; sizes are in logical pixels.
use std::time::Duration;
pub const FONT: &str = "Geist";
pub const DISPLAY: &str = "Instrument Serif";
pub const MONO: &str = "Geist Mono";
/// The bundled font files.
pub mod fonts {
pub const GEIST_REGULAR: &[u8] = include_bytes!("../fonts/Geist-Regular.ttf");
pub const GEIST_MEDIUM: &[u8] = include_bytes!("../fonts/Geist-Medium.ttf");
pub const GEIST_SEMIBOLD: &[u8] = include_bytes!("../fonts/Geist-SemiBold.ttf");
pub const GEIST_MONO: &[u8] = include_bytes!("../fonts/GeistMono-Regular.ttf");
pub const INSTRUMENT_SERIF: &[u8] = include_bytes!("../fonts/InstrumentSerif-Regular.ttf");
}
pub const SMALL: f32 = 12.;
pub const BODY: f32 = 13.;
pub const LARGE: f32 = 15.;
pub const TITLE: f32 = 28.;
pub const HERO: f32 = 56.;
/// The opening's wordmark.
pub const WORDMARK: f32 = 96.;
/// The spacing scale.
pub const SPACE: [f32; 6] = [4., 8., 12., 16., 24., 32.];
/// Corner radii: buttons, fields, nav items and rows; segments inside a
/// segmented control; the segmented control's frame; menus and cards.
pub const RADIUS: f32 = 6.;
pub const RADIUS_SEGMENT: f32 = 5.;
pub const RADIUS_GROUP: f32 = 7.;
pub const RADIUS_CARD: f32 = 8.;
/// Dividers and borders are one pixel.
pub const HAIRLINE: f32 = 1.;
/// Control heights: buttons and nav items, segments, the large Play button.
pub const CONTROL: f32 = 30.;
pub const SEGMENT: f32 = 26.;
pub const CONTROL_LARGE: f32 = 40.;
/// A pressed primary button's opacity (secondary and ghost: `PRESSED_SOFT`).
pub const PRESSED: f32 = 0.82;
pub const PRESSED_SOFT: f32 = 0.8;
/// Disabled controls' opacity.
pub const DISABLED: f32 = 0.4;
/// The hover layer of a primary button: white at this opacity.
pub const PRIMARY_HOVER: f32 = 0.10;
/// Sidebar width and the label column of settings rows.
pub const SIDEBAR: f32 = 200.;
pub const ROW_LABEL: f32 = 150.;
/// Motion: short and eased out, never bouncy.
pub const QUICK: Duration = Duration::from_millis(160);
pub const MOVE: Duration = Duration::from_millis(240);
/// The opening: the wordmark holds, then the interface appears.
pub const OPENING_HOLD: Duration = Duration::from_millis(1400);
pub const OPENING_MAX: Duration = Duration::from_millis(2500);
pub const OPENING_REVEAL: Duration = Duration::from_millis(800);
/// GPUI's `ease_out_quint`, used for hovers, the sliding selection and page changes.
pub fn ease_out_quint(t: f32) -> f32 {
1. - (1. - t.clamp(0., 1.)).powi(5)
}
pub fn ease_out_cubic(t: f32) -> f32 {
1. - (1. - t.clamp(0., 1.)).powi(3)
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Palette {
pub bg: u32,
pub sidebar: u32,
pub raised: u32,
pub hover: u32,
pub border: u32,
pub text: u32,
pub muted: u32,
pub faint: u32,
pub accent: u32,
pub on_accent: u32,
pub danger: u32,
pub dark: bool,
}
pub const DARK: Palette = Palette {
bg: 0x111113,
sidebar: 0x0c0c0d,
raised: 0x1a1a1d,
hover: 0x202024,
border: 0x26262a,
text: 0xe8e8ea,
muted: 0x9a9aa1,
faint: 0x626269,
accent: 0x7c5cc4,
on_accent: 0xffffff,
danger: 0xd9705f,
dark: true,
};
pub const LIGHT: Palette = Palette {
bg: 0xfcfcfb,
sidebar: 0xf4f4f2,
raised: 0xf2f2f0,
hover: 0xebebe8,
border: 0xe3e3df,
text: 0x19191b,
muted: 0x63636a,
faint: 0x9a9a9f,
accent: 0x6a4bb3,
on_accent: 0xffffff,
danger: 0xb84a3a,
dark: false,
};
+1
View File
@@ -7,6 +7,7 @@ To play, use [the launcher](../launcher/README.md). These pages are for develope
- [Build from source](../README.md#build-from-source): your own copy of the game, resource preparation, `cargo run`.
- [Native client](native-client.md): options, controls, the dedicated server, `horizon-ctl` scripting, packaging, architecture.
- [Launcher](../launcher/README.md): installs, updates and starts builds; its CLI and MCP server; releasing it.
- Menus: the title, world list, create world, multiplayer, options and pause screens share the launcher's design tokens and fonts (`crates/horizon-theme`), drawn by `ui_theme` over a live frozen backdrop (`game/backdrop.rs`, blurred and graded by `post.rs`). Options → Interface → Menu style switches to vanilla's screens (Classic, kept in `horizon.txt`; `HORIZON_MENU_STYLE=horizon|classic` overrides it, and scripted runs default to Classic). [Title](media/menu-title.png) ([webm](media/menu-title.webm)), [worlds](media/menu-worlds.png), [options](media/menu-options.png), [pause](media/menu-pause.png).
## Parity
Binary file not shown.

Before

Width:  |  Height:  |  Size: 170 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 110 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 325 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 474 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 377 KiB

Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 351 KiB

+5
View File
@@ -2571,6 +2571,7 @@ dependencies = [
"dirs 5.0.1",
"flate2",
"gpui",
"horizon-theme",
"image",
"pulldown-cmark",
"self-replace",
@@ -2581,6 +2582,10 @@ dependencies = [
"zip",
]
[[package]]
name = "horizon-theme"
version = "0.1.0"
[[package]]
name = "http"
version = "1.5.0"
+1
View File
@@ -20,6 +20,7 @@ dirs = "5"
self-replace = "1"
image = { version = "0.25", default-features = false, features = ["jpeg"] }
clap = { version = "4", features = ["derive"] }
horizon-theme = { path = "../crates/horizon-theme" }
[profile.release]
strip = true
+1 -1
View File
@@ -87,7 +87,7 @@ workflow builds all three platforms on every push to the `launcher` branch.
cargo test --lib # tag separation, version order, update selection, notes as text
Geist, Geist Mono and Instrument Serif (SIL Open Font License, `assets/fonts/OFL-*.txt`) are
Geist, Geist Mono and Instrument Serif (SIL Open Font License, `crates/horizon-theme/fonts/OFL-*.txt`, shared with the game's menus) are
bundled so the type is the same on every platform.
Background image (`assets/background.jpg`): backgrounds.supply (free preview), licence to confirm before release.
+28 -28
View File
@@ -53,9 +53,9 @@ enum SelfUpdate {
/// The opening: the wordmark over the image holds at least `OPENING_HOLD` (until the release
/// list is in, at most `OPENING_MAX`), then the launcher appears over `OPENING_REVEAL`.
const OPENING_HOLD: Duration = Duration::from_millis(1400);
const OPENING_MAX: Duration = Duration::from_millis(2500);
const OPENING_REVEAL: Duration = Duration::from_millis(800);
const OPENING_HOLD: Duration = horizon_theme::OPENING_HOLD;
const OPENING_MAX: Duration = horizon_theme::OPENING_MAX;
const OPENING_REVEAL: Duration = horizon_theme::OPENING_REVEAL;
/// The phase of the opening at one moment: how far the reveal has gone, 0 to 1.
struct Opening {
@@ -78,7 +78,7 @@ fn background_image() -> Arc<RenderImage> {
}
fn ease_out_cubic(x: f32) -> f32 {
1. - (1. - x.clamp(0., 1.)).powi(3)
horizon_theme::ease_out_cubic(x)
}
/// How often the launcher looks for new releases while it is open.
@@ -560,7 +560,7 @@ fn button(id: impl Into<SharedString>, label: impl Into<SharedString>, kind: Kin
let id: SharedString = id.into();
let t = *t;
let hover = match kind {
Kind::Primary => gpui::white().opacity(0.10),
Kind::Primary => gpui::white().opacity(theme::PRIMARY_HOVER),
_ => t.hover,
};
let base = div()
@@ -570,19 +570,19 @@ fn button(id: impl Into<SharedString>, label: impl Into<SharedString>, kind: Kin
.flex_none()
.items_center()
.justify_center()
.h(px(30.))
.h(px(theme::CONTROL))
.px(px(12.))
.rounded(px(6.))
.rounded(px(theme::RADIUS))
.text_size(px(theme::BODY))
.font_weight(FontWeight::MEDIUM)
.cursor_pointer()
.child(hover_layer(&id, hover, 6.))
.child(hover_layer(&id, hover, theme::RADIUS))
.child(div().relative().child(label.into()));
let base = track_hover(base, id);
match kind {
Kind::Primary => base.bg(t.accent).text_color(t.on_accent).active(|s| s.opacity(0.82)),
Kind::Secondary => base.border_1().border_color(t.border).bg(t.raised).text_color(t.text).active(|s| s.opacity(0.8)),
Kind::Ghost => base.text_color(t.muted).hover(move |s| s.text_color(t.text)).active(|s| s.opacity(0.8)),
Kind::Primary => base.bg(t.accent).text_color(t.on_accent).active(|s| s.opacity(theme::PRESSED)),
Kind::Secondary => base.border_1().border_color(t.border).bg(t.raised).text_color(t.text).active(|s| s.opacity(theme::PRESSED_SOFT)),
Kind::Ghost => base.text_color(t.muted).hover(move |s| s.text_color(t.text)).active(|s| s.opacity(theme::PRESSED_SOFT)),
}
}
@@ -607,7 +607,7 @@ fn switch(id: &'static str, on: bool, t: &Theme) -> Stateful<Div> {
}
fn disabled(b: Stateful<Div>) -> Stateful<Div> {
b.opacity(0.4).cursor_default()
b.opacity(theme::DISABLED).cursor_default()
}
fn page_header(title: &str, description: &str, t: &Theme) -> Div {
@@ -637,7 +637,7 @@ fn row(label: &str, value: impl IntoElement, action: Option<Stateful<Div>>, t: &
.items_center()
.gap(px(16.))
.py(px(12.))
.child(div().w(px(150.)).flex_none().text_size(px(13.)).text_color(t.muted).child(label.to_string()))
.child(div().w(px(theme::ROW_LABEL)).flex_none().text_size(px(13.)).text_color(t.muted).child(label.to_string()))
.child(div().flex_1().min_w_0().text_size(px(13.)).text_color(t.text).child(value))
.children(action)
}
@@ -687,7 +687,7 @@ fn megabytes(bytes: u64) -> String {
impl Launcher {
fn sidebar(&self, cx: &mut Context<Self>) -> Div {
let t = self.theme;
const STEP: f32 = 32.;
const STEP: f32 = theme::CONTROL + 2.;
let mut nav = div().relative().flex().flex_col().gap(px(2.));
// The selection is one pill that slides to the chosen item.
let (from, to) = (self.previous_page.index() as f32 * STEP, self.page.index() as f32 * STEP);
@@ -696,8 +696,8 @@ impl Launcher {
.absolute()
.left_0()
.w_full()
.h(px(30.))
.rounded(px(6.))
.h(px(theme::CONTROL))
.rounded(px(theme::RADIUS))
.bg(t.raised)
.border_1()
.border_color(t.border)
@@ -715,12 +715,12 @@ impl Launcher {
.relative()
.flex()
.items_center()
.h(px(30.))
.h(px(theme::CONTROL))
.px(px(10.))
.rounded(px(6.))
.rounded(px(theme::RADIUS))
.text_size(px(theme::BODY))
.cursor_pointer()
.when(!active, |d| d.child(hover_layer(&id, t.hover.opacity(0.7), 6.)))
.when(!active, |d| d.child(hover_layer(&id, t.hover.opacity(0.7), theme::RADIUS)))
.child(
div()
.relative()
@@ -756,7 +756,7 @@ impl Launcher {
.flex()
.flex_col()
.flex_none()
.w(px(200.))
.w(px(theme::SIDEBAR))
.h_full()
.bg(t.sidebar.opacity(0.8))
.border_r_1()
@@ -785,9 +785,9 @@ impl Launcher {
.id(label)
.flex()
.items_center()
.h(px(26.))
.h(px(theme::SEGMENT))
.px(px(14.))
.rounded(px(5.))
.rounded(px(theme::RADIUS_SEGMENT))
.text_size(px(13.))
.cursor_pointer()
.when(active, |d| {
@@ -804,7 +804,7 @@ impl Launcher {
.flex()
.p(px(2.))
.gap(px(2.))
.rounded(px(7.))
.rounded(px(theme::RADIUS_GROUP))
.bg(t.raised)
.border_1()
.border_color(t.border)
@@ -1769,11 +1769,11 @@ fn main() {
Application::new().run(|cx: &mut App| {
cx.text_system()
.add_fonts(vec![
Cow::Borrowed(include_bytes!("../assets/fonts/Geist-Regular.ttf").as_slice()),
Cow::Borrowed(include_bytes!("../assets/fonts/Geist-Medium.ttf").as_slice()),
Cow::Borrowed(include_bytes!("../assets/fonts/Geist-SemiBold.ttf").as_slice()),
Cow::Borrowed(include_bytes!("../assets/fonts/GeistMono-Regular.ttf").as_slice()),
Cow::Borrowed(include_bytes!("../assets/fonts/InstrumentSerif-Regular.ttf").as_slice()),
Cow::Borrowed(horizon_theme::fonts::GEIST_REGULAR),
Cow::Borrowed(horizon_theme::fonts::GEIST_MEDIUM),
Cow::Borrowed(horizon_theme::fonts::GEIST_SEMIBOLD),
Cow::Borrowed(horizon_theme::fonts::GEIST_MONO),
Cow::Borrowed(horizon_theme::fonts::INSTRUMENT_SERIF),
])
.expect("bundled fonts");
let bounds = Bounds::centered(None, size(px(1040.), px(700.)), cx);
+37 -39
View File
@@ -1,22 +1,29 @@
//! One neutral palette in two appearances and one accent. Spacing follows 4/8/12/16/24/32.
//! Type: Geist for the interface, Instrument Serif for titles, Geist Mono for consoles and
//! commands, all bundled (SIL Open Font License). Five sizes: 12, 13, 15, 28 and 56.
//! The launcher's view of Horizon's design tokens (`crates/horizon-theme`), which the game's
//! menus share: one neutral palette in two appearances and one accent, spacing 4/8/12/16/24/32,
//! Geist / Instrument Serif / Geist Mono (bundled, SIL Open Font License), five sizes.
use gpui::{rgb, Hsla, WindowAppearance};
use horizon_theme as tokens;
use std::time::Duration;
pub const FONT: &str = "Geist";
pub const DISPLAY: &str = "Instrument Serif";
pub const MONO: &str = "Geist Mono";
pub const FONT: &str = tokens::FONT;
pub const DISPLAY: &str = tokens::DISPLAY;
pub const MONO: &str = tokens::MONO;
pub const SMALL: f32 = 12.;
pub const BODY: f32 = 13.;
pub const LARGE: f32 = 15.;
pub const TITLE: f32 = 28.;
pub const HERO: f32 = 56.;
pub const SMALL: f32 = tokens::SMALL;
pub const BODY: f32 = tokens::BODY;
pub const LARGE: f32 = tokens::LARGE;
pub const TITLE: f32 = tokens::TITLE;
pub const HERO: f32 = tokens::HERO;
/// Shapes and controls, shared with the game's menus.
pub use tokens::{
CONTROL, CONTROL_LARGE, DISABLED, PRESSED, PRESSED_SOFT, PRIMARY_HOVER, RADIUS, RADIUS_CARD, RADIUS_GROUP,
RADIUS_SEGMENT, ROW_LABEL, SEGMENT, SIDEBAR,
};
/// Motion: short and eased out, never bouncy.
pub const QUICK: Duration = Duration::from_millis(160);
pub const MOVE: Duration = Duration::from_millis(240);
pub const QUICK: Duration = tokens::QUICK;
pub const MOVE: Duration = tokens::MOVE;
#[derive(Clone, Copy)]
pub struct Theme {
@@ -47,36 +54,27 @@ impl Theme {
}
pub fn dark() -> Self {
Self {
bg: c(0x111113),
sidebar: c(0x0c0c0d),
raised: c(0x1a1a1d),
hover: c(0x202024),
border: c(0x26262a),
text: c(0xe8e8ea),
muted: c(0x9a9aa1),
faint: c(0x626269),
accent: c(0x7c5cc4),
on_accent: c(0xffffff),
danger: c(0xd9705f),
dark: true,
}
Self::from(tokens::DARK)
}
pub fn light() -> Self {
Self::from(tokens::LIGHT)
}
fn from(p: tokens::Palette) -> Self {
Self {
bg: c(0xfcfcfb),
sidebar: c(0xf4f4f2),
raised: c(0xf2f2f0),
hover: c(0xebebe8),
border: c(0xe3e3df),
text: c(0x19191b),
muted: c(0x63636a),
faint: c(0x9a9a9f),
accent: c(0x6a4bb3),
on_accent: c(0xffffff),
danger: c(0xb84a3a),
dark: false,
bg: c(p.bg),
sidebar: c(p.sidebar),
raised: c(p.raised),
hover: c(p.hover),
border: c(p.border),
text: c(p.text),
muted: c(p.muted),
faint: c(p.faint),
accent: c(p.accent),
on_accent: c(p.on_accent),
danger: c(p.danger),
dark: p.dark,
}
}
}
@@ -299,11 +299,6 @@ public final class VanillaCameraOracle {
+ " " + fb(up.x()) + " " + fb(up.y()) + " " + fb(up.z())
+ " " + fb(left.x()) + " " + fb(left.y()) + " " + fb(left.z())
+ " " + camera.isDetached() + " " + fb(fv) + " " + fb(hv));
// Camera.setupPerspective and GameRenderer.render3dHud's hudProjection:
// Projection.getMatrix's setPerspective (planes reversed, zero-to-one depth).
float w = f(op.get(4)), h = f(op.get(5)), far = f(op.get(6));
out.println("projection" + matrix(new Matrix4f().setPerspective(fv * (float) (Math.PI / 180.0), w / h, far, 0.05F, true))
+ matrix(new Matrix4f().setPerspective(hv * (float) (Math.PI / 180.0), w / h, far, 0.05F, true)));
}
default -> throw new IllegalArgumentException(op.toString());
}
@@ -393,16 +388,6 @@ public final class VanillaCameraOracle {
out.println(sb);
}
static String matrix(Matrix4f m) {
var sb = new StringBuilder();
for (int col = 0; col < 4; col++) {
for (int row = 0; row < 4; row++) {
sb.append(' ').append(fb(m.get(col, row)));
}
}
return sb.toString();
}
static void avatar(JsonObject c, PrintWriter out) throws Exception {
StubPlayer p = make(StubPlayer.class);
var state = new ClientAvatarState();
@@ -1,385 +0,0 @@
package dev.horizonlab.parity;
import com.google.gson.*;
import java.io.*;
import java.lang.reflect.*;
import java.nio.charset.StandardCharsets;
import java.nio.file.*;
import java.util.*;
import net.minecraft.client.Minecraft;
import net.minecraft.client.multiplayer.ClientLevel;
import net.minecraft.client.multiplayer.ClientPacketListener;
import net.minecraft.client.multiplayer.MultiPlayerGameMode;
import net.minecraft.client.multiplayer.prediction.BlockStatePredictionHandler;
import net.minecraft.client.player.FirstPersonHandsAndItems;
import net.minecraft.client.player.LocalPlayer;
import net.minecraft.client.renderer.item.ItemModelResolver;
import net.minecraft.client.tutorial.Tutorial;
import net.minecraft.core.*;
import net.minecraft.core.registries.BuiltInRegistries;
import net.minecraft.network.protocol.Packet;
import net.minecraft.resources.Identifier;
import net.minecraft.tags.TagKey;
import net.minecraft.world.InteractionHand;
import net.minecraft.world.InteractionResult;
import net.minecraft.world.effect.*;
import net.minecraft.world.entity.*;
import net.minecraft.world.entity.ai.attributes.*;
import net.minecraft.world.entity.player.*;
import net.minecraft.world.flag.FeatureFlagSet;
import net.minecraft.world.flag.FeatureFlags;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.item.component.SwingAnimation;
import net.minecraft.world.level.GameType;
import net.minecraft.world.level.Level;
import net.minecraft.world.level.block.Blocks;
import net.minecraft.world.level.block.state.BlockState;
import net.minecraft.world.level.border.WorldBorder;
import net.minecraft.world.level.gameevent.GameEvent;
import net.minecraft.world.level.material.*;
import net.minecraft.world.phys.*;
/**
* Drives the real Minecraft.startAttack, continueAttack and startUseItem,
* MultiPlayerGameMode's startDestroyBlock, continueDestroyBlock,
* stopDestroyBlock, destroyBlock and attack, LivingEntity.swing with its
* SwingState and getModifiedSwingDuration, Player's attack strength and item
* swap tickers and getItemSwapScale, and FirstPersonHandsAndItems.tick and
* itemUsed, tick by tick over scripted clicks and crosshair targets. The
* player is a LocalPlayer shell with a real Inventory and attributes; the
* level, connection and item model resolver are shells answering what these
* read, and the results of using items on blocks, entities and the air are
* scripted. The attack and use part of Minecraft.handleKeybinds and the
* ticker lines of Player.tick are mirrored here in vanilla's order.
*/
public final class VanillaFirstPersonOracle {
static Object unsafe;
static Method allocate;
@SuppressWarnings("unchecked")
static <T> T make(Class<T> type) throws Exception {
return (T) allocate.invoke(unsafe, type);
}
static Field field(Class<?> type, String name) throws Exception {
for (Class<?> c = type; c != null; c = c.getSuperclass()) {
try {
Field f = c.getDeclaredField(name);
f.setAccessible(true);
return f;
} catch (NoSuchFieldException e) {
// try the superclass
}
}
throw new NoSuchFieldException(type + "." + name);
}
static Method method(Class<?> type, String name, Class<?>... args) throws Exception {
Method m = type.getDeclaredMethod(name, args);
m.setAccessible(true);
return m;
}
static String fb(float v) {
return String.format("%08x", Float.floatToRawIntBits(v));
}
static List<String> events = new ArrayList<>();
static Map<BlockPos, BlockState> blocks = new HashMap<>();
static JsonObject step;
static String hand(InteractionHand h) {
return h == InteractionHand.MAIN_HAND ? "main" : "off";
}
static InteractionResult result(String name) {
return switch (name) {
case "success" -> InteractionResult.SUCCESS;
case "server" -> InteractionResult.SUCCESS_SERVER;
case "consume" -> InteractionResult.CONSUME;
case "fail" -> InteractionResult.FAIL;
default -> InteractionResult.PASS;
};
}
static final class Level0 extends ClientLevel {
Level0() {
super(null, null, null, null, 0, 0, null, false, 0L, 0);
}
@Override public BlockState getBlockState(BlockPos pos) { return blocks.getOrDefault(pos, Blocks.AIR.defaultBlockState()); }
@Override public FluidState getFluidState(BlockPos pos) { return Fluids.EMPTY.defaultFluidState(); }
@Override public boolean setBlock(BlockPos pos, BlockState state, int flags, int limit) {
blocks.put(pos.immutable(), state);
return true;
}
@Override public void levelEvent(Entity source, int type, BlockPos pos, int data) { }
@Override public void gameEvent(Holder<GameEvent> event, Vec3 pos, GameEvent.Context context) { }
@Override public void destroyBlockProgress(int id, BlockPos pos, int progress) {
events.add("stage " + pos.getX() + " " + pos.getY() + " " + pos.getZ() + " " + progress);
}
@Override public void addBreakingBlockEffects(BlockPos pos, Direction direction, boolean sound) { }
@Override public FeatureFlagSet enabledFeatures() { return FeatureFlags.REGISTRY.allFlags(); }
}
static final class Connection0 extends ClientPacketListener {
Connection0() {
super(null, null, null);
}
@Override public void send(Packet<?> packet) { }
}
static final class Resolver extends ItemModelResolver {
Resolver() {
super(null);
}
// No vanilla item model turns hand_animation_on_swap off, and only
// spears (not scripted here) scale the swap animation.
@Override public boolean shouldPlaySwapAnimation(ItemStack stack) { return true; }
@Override public float swapAnimationScale(ItemStack stack) { return 1.0F; }
}
static final class Player0 extends LocalPlayer {
AttributeMap attributes;
boolean inRange;
Player0() {
super(null, null, null, null, null, Input.EMPTY, false, null, null);
}
@Override public AttributeMap getAttributes() { return attributes; }
@Override public boolean isEyeInFluid(TagKey<Fluid> type) { return false; }
@Override public boolean onGround() { return true; }
@Override public boolean isWithinEntityInteractionRange(Entity entity, double buffer) { return inRange; }
@Override public void attack(Entity entity) { events.add("attack"); }
@Override public boolean swing(InteractionHand hand, SwingAnimation animation, boolean send) {
boolean started = super.swing(hand, animation, send);
events.add("swing " + hand(hand) + " " + animation.duration() + " " + started);
return started;
}
@Override public void itemUsed(InteractionHand hand) {
events.add("used " + hand(hand));
super.itemUsed(hand);
}
}
static final class GameMode0 extends MultiPlayerGameMode {
GameMode0(Minecraft minecraft, ClientPacketListener connection) {
super(minecraft, connection);
}
@Override public boolean destroyBlock(BlockPos pos) {
boolean changed = super.destroyBlock(pos);
events.add("destroy " + pos.getX() + " " + pos.getY() + " " + pos.getZ() + " " + changed);
return changed;
}
@Override public void releaseUsingItem(Player player) { events.add("release"); }
InteractionResult scripted(String kind, Player player, InteractionHand hand) {
String name = step.getAsJsonObject("uses").get(kind).getAsString();
events.add("use " + kind + " " + hand(hand) + " " + name);
InteractionResult r = result(name);
if (r instanceof InteractionResult.Success && step.get("shrink").getAsBoolean()) {
player.getItemInHand(hand).shrink(1);
}
return r;
}
@Override public InteractionResult useItemOn(LocalPlayer player, InteractionHand hand, BlockHitResult hit) { return scripted("block", player, hand); }
@Override public InteractionResult useItem(Player player, InteractionHand hand) { return scripted("item", player, hand); }
@Override public InteractionResult interact(Player player, Entity entity, EntityHitResult hit, InteractionHand hand) {
return scripted("entity", player, hand);
}
}
static ItemStack stack(JsonElement e) {
if (e.isJsonNull()) {
return ItemStack.EMPTY;
}
var a = e.getAsJsonArray();
return new ItemStack(BuiltInRegistries.ITEM.getValue(Identifier.parse(a.get(0).getAsString())), a.get(1).getAsInt());
}
/** LivingEntity.collectEquipmentChanges for the main hand: the item's attribute modifiers. */
static AttributeMap attributes(ItemStack main) {
var map = new AttributeMap(Player.createAttributes().build());
main.forEachModifier(EquipmentSlot.MAINHAND, (attribute, modifier) -> {
var instance = map.getInstance(attribute);
if (instance != null) {
instance.addTransientModifier(modifier);
}
});
return map;
}
public static void main(String[] args) throws Exception {
// The vanilla data pack: item components (tools, attribute modifiers) and block tags.
ParityData.init();
var u = Class.forName("sun.misc.Unsafe");
unsafe = field(u, "theUnsafe").get(null);
allocate = u.getMethod("allocateInstance", Class.class);
Method startAttack = method(Minecraft.class, "startAttack");
Method continueAttack = method(Minecraft.class, "continueAttack", boolean.class);
Method startUseItem = method(Minecraft.class, "startUseItem");
Field missTime = field(Minecraft.class, "missTime");
Field rightClickDelay = field(Minecraft.class, "rightClickDelay");
Field swingState = field(LivingEntity.class, "swingState");
Field attackTicker = field(LivingEntity.class, "attackStrengthTicker");
Field swapTicker = field(LivingEntity.class, "itemSwapTicker");
Field lastItem = field(Player.class, "lastItemInMainHand");
Field hands = field(LocalPlayer.class, "firstPersonHandsAndItems");
Field handsBusy = field(LocalPlayer.class, "handsBusy");
Field using = field(LocalPlayer.class, "startedUsingItem");
Field destroyDelay = field(MultiPlayerGameMode.class, "destroyDelay");
Field destroying = field(MultiPlayerGameMode.class, "isDestroying");
Field progress = field(MultiPlayerGameMode.class, "destroyProgress");
Field mainHeight = field(FirstPersonHandsAndItems.class, "mainHandHeight");
Field oMainHeight = field(FirstPersonHandsAndItems.class, "oMainHandHeight");
Field offHeight = field(FirstPersonHandsAndItems.class, "offHandHeight");
Field oOffHeight = field(FirstPersonHandsAndItems.class, "oOffHandHeight");
Field ticks = field(LivingEntity.SwingState.class, "ticks");
Field animation = field(LivingEntity.SwingState.class, "animation");
Field oldAnimation = field(LivingEntity.SwingState.class, "oldAnimation");
try (var out = new PrintWriter(Files.newBufferedWriter(Path.of(args[1]), StandardCharsets.UTF_8));
var lines = Files.lines(Path.of(args[0]))) {
int n = 0;
for (var line : (Iterable<String>) lines::iterator) {
var c = JsonParser.parseString(line).getAsJsonObject();
out.println("== " + n++);
blocks.clear();
for (var e : c.getAsJsonArray("blocks")) {
var b = e.getAsJsonArray();
blocks.put(new BlockPos(b.get(0).getAsInt(), b.get(1).getAsInt(), b.get(2).getAsInt()),
BuiltInRegistries.BLOCK.getValue(Identifier.parse(b.get(3).getAsString())).defaultBlockState());
}
Minecraft mc = make(Minecraft.class);
Level0 level = make(Level0.class);
field(ClientLevel.class, "worldBorder").set(level, new WorldBorder());
field(ClientLevel.class, "blockStatePredictionHandler").set(level, new BlockStatePredictionHandler());
Connection0 connection = make(Connection0.class);
Player0 player = make(Player0.class);
field(LocalPlayer.class, "minecraft").set(player, mc);
field(LocalPlayer.class, "connection").set(player, connection);
field(Entity.class, "level").set(player, level);
field(Entity.class, "id").setInt(player, 1);
var equipment = new PlayerEquipment(player);
field(LivingEntity.class, "equipment").set(player, equipment);
field(Player.class, "inventory").set(player, new Inventory(player, equipment));
var abilities = new Abilities();
boolean creative = c.get("creative").getAsBoolean();
abilities.instabuild = creative;
field(Player.class, "abilities").set(player, abilities);
swingState.set(player, new LivingEntity.SwingState());
lastItem.set(player, ItemStack.EMPTY);
hands.set(player, new FirstPersonHandsAndItems());
var effects = new HashMap<Holder<MobEffect>, MobEffectInstance>();
if (!c.get("haste").isJsonNull()) {
effects.put(MobEffects.HASTE, new MobEffectInstance(MobEffects.HASTE, 100000, c.get("haste").getAsInt()));
}
if (!c.get("fatigue").isJsonNull()) {
effects.put(MobEffects.MINING_FATIGUE, new MobEffectInstance(MobEffects.MINING_FATIGUE, 100000, c.get("fatigue").getAsInt()));
}
field(LivingEntity.class, "activeEffects").set(player, effects);
player.getInventory().setItem(0, stack(c.get("main")));
equipment.set(EquipmentSlot.OFFHAND, stack(c.get("off")));
player.attributes = attributes(player.getMainHandItem());
field(Minecraft.class, "player").set(mc, player);
field(Minecraft.class, "level").set(mc, level);
field(Minecraft.class, "tutorial").set(mc, make(Tutorial.class));
field(Minecraft.class, "itemModelResolver").set(mc, make(Resolver.class));
var gameMode = new GameMode0(mc, connection);
field(MultiPlayerGameMode.class, "localPlayerMode").set(gameMode, creative ? GameType.CREATIVE : GameType.SURVIVAL);
field(Minecraft.class, "gameMode").set(mc, gameMode);
out.println("attack_speed " + Long.toHexString(Double.doubleToRawLongBits(player.getAttributeValue(Attributes.ATTACK_SPEED))));
var entity = make(net.minecraft.world.entity.item.ItemEntity.class);
field(Entity.class, "blockPosition").set(entity, BlockPos.ZERO);
field(Entity.class, "id").setInt(entity, 2);
int s = 0;
for (var e : c.getAsJsonArray("steps")) {
step = e.getAsJsonObject();
events.clear();
if (!step.get("set").isJsonNull()) {
var set = step.getAsJsonObject("set");
if (set.get("hand").getAsString().equals("main")) {
player.getInventory().setItem(0, stack(set.get("stack")));
} else {
equipment.set(EquipmentSlot.OFFHAND, stack(set.get("stack")));
}
}
var hit = step.getAsJsonArray("hit");
HitResult hitResult = switch (hit.get(0).getAsString()) {
case "block" -> new BlockHitResult(Vec3.ZERO, Direction.from3DDataValue(hit.get(4).getAsInt()),
new BlockPos(hit.get(1).getAsInt(), hit.get(2).getAsInt(), hit.get(3).getAsInt()), false);
case "entity" -> {
player.inRange = hit.get(1).getAsBoolean();
yield new EntityHitResult(entity, Vec3.ZERO);
}
default -> BlockHitResult.miss(Vec3.ZERO, Direction.UP, BlockPos.ZERO);
};
field(Minecraft.class, "hitResult").set(mc, hitResult);
handsBusy.setBoolean(player, step.get("busy").getAsBoolean());
using.setBoolean(player, step.get("using").getAsBoolean());
boolean attackDown = step.get("ad").getAsBoolean();
boolean useDown = step.get("ud").getAsBoolean();
// Minecraft.tick: rightClickDelay, then handleKeybinds' attack and use part.
if (rightClickDelay.getInt(mc) > 0) {
rightClickDelay.setInt(mc, rightClickDelay.getInt(mc) - 1);
}
boolean instant = false;
if (player.isUsingItem()) {
if (!useDown) {
gameMode.releaseUsingItem(player);
}
} else {
for (int i = 0; i < step.get("ac").getAsInt(); i++) {
instant |= (boolean) startAttack.invoke(mc);
}
for (int i = 0; i < step.get("uc").getAsInt(); i++) {
startUseItem.invoke(mc);
}
}
if (useDown && rightClickDelay.getInt(mc) == 0 && !player.isUsingItem()) {
startUseItem.invoke(mc);
}
continueAttack.invoke(mc, !instant && attackDown);
if (missTime.getInt(mc) > 0) {
missTime.setInt(mc, missTime.getInt(mc) - 1);
}
// LocalPlayer.tick: LivingEntity.baseTick's swing, the equipment's
// attribute modifiers, Player.tick's tickers, then
// FirstPersonHandsAndItems.tick.
var swing = (LivingEntity.SwingState) swingState.get(player);
swing.tick();
// LivingEntity.tick's detectEquipmentUpdates: the main hand's modifiers.
player.attributes = attributes(player.getMainHandItem());
attackTicker.setInt(player, attackTicker.getInt(player) + 1);
swapTicker.setInt(player, swapTicker.getInt(player) + 1);
ItemStack main = player.getMainHandItem();
if (!ItemStack.matches((ItemStack) lastItem.get(player), main)) {
if (!ItemStack.isSameItem((ItemStack) lastItem.get(player), main)) {
player.resetAttackStrengthTicker();
}
lastItem.set(player, main.copy());
}
var fp = (FirstPersonHandsAndItems) hands.get(player);
fp.tick(player);
for (var ev : events) {
out.println(ev);
}
var current = player.getCurrentSwing();
out.println("tick " + s++ + " " + (current == null ? "- 0" : hand(current.hand()) + " " + current.durationTicks())
+ " " + ticks.getInt(swing) + " " + fb(animation.getFloat(swing)) + " " + fb(oldAnimation.getFloat(swing))
+ " " + fb(player.getSwingAnimation(0.5F))
+ " " + attackTicker.getInt(player) + " " + swapTicker.getInt(player) + " " + fb(player.getItemSwapScale(1.0F))
+ " " + fb(mainHeight.getFloat(fp)) + " " + fb(oMainHeight.getFloat(fp))
+ " " + fb(offHeight.getFloat(fp)) + " " + fb(oOffHeight.getFloat(fp))
+ " " + missTime.getInt(mc) + " " + rightClickDelay.getInt(mc) + " " + destroyDelay.getInt(gameMode)
+ " " + destroying.getBoolean(gameMode) + " " + fb(progress.getFloat(gameMode)));
}
}
}
System.exit(0);
}
}
+5 -30
View File
@@ -4,10 +4,8 @@ field-of-view easing), alignWithEntity (view rotation quaternion and basis,
interpolated eye, third person back and front, camera distance and scale
attributes with mounts, getMaxZoom clipping against visual shapes, sleeping),
calculateFov/calculateHudFov (option, modifier, death and fluid effects,
panoramic mode) with the world projection and the first-person hand's
(GameRenderer.render3dHud at hudFov, whatever the FOV option),
AbstractClientPlayer.getFieldOfViewModifier (flight, speed attribute against
walking speed, bow draw, spyglass), the walk and bob
panoramic mode), AbstractClientPlayer.getFieldOfViewModifier (flight, speed
attribute against walking speed, bow draw, spyglass), the walk and bob
history (ClientAvatarState, updateBob) and GameRenderer.bobHurt/bobView,
driven tick by tick and frame by frame with identical scripted states."""
import json
@@ -93,12 +91,6 @@ def entity_state(rng, center):
return s
def window(rng):
"""Window size and Camera.depthFar for the world and hand projections."""
w, h = rng.choice([(1600, 1000), (854, 480), (1920, 1080), (800, 1200), (1, 1), (rng.randint(1, 4000), rng.randint(1, 3000))])
return [fbits(w), fbits(h), fbits(rng.choice([768.0, 128.0, 2048.0, 4096.0, rng.uniform(32, 4096)]))]
def camera_cases(rng):
for n in range(2500):
center = rng.choice([(0, 64, 0), (-10, -60, 7), (1000, 120, -3000), (0, 0, 0)])
@@ -113,27 +105,12 @@ def camera_cases(rng):
ops.append(['tick'])
elif kind == 'frame':
pt = rng.choice([0.0, 1.0, 0.5, 0.25, 0.999, rng.random()])
ops.append(['frame', fbits(pt), rng.choice([0, 0, 0, 1, 2, 3]), rng.random() < 0.05] + window(rng))
ops.append(['frame', fbits(pt), rng.choice([0, 0, 0, 1, 2, 3]), rng.random() < 0.05])
else:
ops.append(['type', rng.randrange(3)])
yield dict(t='camera', fov=rng.choice([70, 30, 110, 90, rng.randint(30, 110)]),
effect=dbits(rng.choice([1.0, 0.0, 0.5, rng.random()])), type=rng.choice([0, 1, 1, 2]),
cells=world(rng, center), ops=ops)
# The hand's projection at FOV 30, 70 and 110 while walking, sprinting, under
# speed, drawing a bow, dying and in fluids: GameRenderer.render3dHud uses
# hudFov, never the option or the modifier.
for fov in (30, 70, 110):
for speed in (f32(0.1), f32(0.1) * 1.3, 0.2, 0.05):
for use in (None, ['bow', 10]):
for dead in (None, 5):
for fluid in (0, 1, 2):
s = dict(kind='player', pos=[dbits(0.5), dbits(64.0), dbits(0.5)], prev=[dbits(0.5), dbits(64.0), dbits(0.5)],
xr=fbits(0.0), yr=fbits(0.0), xro=fbits(0.0), yro=fbits(0.0), eye=fbits(1.62), flying=False,
ws=fbits(0.1), speed=dbits(speed), use=use, scale_attr=dbits(1.0), dist=dbits(4.0),
sleeping=None, dead=dead, mount=None)
ops = [['entity', s]] + [['tick']] * 8 + [['frame', fbits(pt), fluid, False, fbits(1600.0), fbits(1000.0), fbits(768.0)]
for pt in (0.0, 0.5, 1.0)]
yield dict(t='camera', fov=fov, effect=dbits(1.0), type=0, cells={}, ops=ops)
# A walled-in third-person camera stepping through every distance bound.
for dist in [0.0, 0.5, 1.0, 2.0, 4.0, 8.0, 32.0]:
for wall in range(0, 6):
@@ -147,7 +124,7 @@ def camera_cases(rng):
xr=fbits(15.0), yr=fbits(yaw), xro=fbits(15.0), yro=fbits(yaw), eye=fbits(1.62), flying=False,
ws=fbits(0.1), speed=dbits(f32(0.1)), use=None, scale_attr=dbits(1.0), dist=dbits(dist),
sleeping=None, dead=None, mount=None)
ops = [['entity', s]] + [['tick']] * 6 + [['frame', fbits(pt), 0, False] + window(rng) for pt in (0.0, 0.5, 1.0)]
ops = [['entity', s]] + [['tick']] * 6 + [['frame', fbits(pt), 0, False] for pt in (0.0, 0.5, 1.0)]
yield dict(t='camera', fov=70, effect=dbits(1.0), type=1 + (wall % 2), cells=cells, ops=ops)
@@ -207,9 +184,7 @@ def main():
'interpolated eye with eye-height easing, first person, third person back and mirrored front, scale and '
'camera_distance attributes including a living mount, getMaxZoom over the actual Level.clip with '
'ClipContext.Block.VISUAL against controlled visual shapes, sleeping bed orientation), calculateFov and '
'calculateHudFov (option, eased modifier, death, water/lava with the effect scale, panoramic), the world projection '
'(Camera.setupPerspective) and the hand projection GameRenderer.render3dHud sets up at hudFov, as JOML setPerspective '
'matrices, at FOV options 30, 70 and 110 among others, '
'calculateHudFov (option, eased modifier, death, water/lava with the effect scale, panoramic), '
'AbstractClientPlayer.getFieldOfViewModifier with real Abilities, attribute maps and item stacks, '
'ClientAvatarState walk/bob history with AbstractClientPlayer.updateBob, and GameRenderer.bobHurt/bobView '
'as the full JOML pose matrix with its property flags. The camera entity is a shell with controlled '
-175
View File
@@ -1,175 +0,0 @@
#!/usr/bin/env python3
"""Compare the local player's hand with the pinned client, tick by tick over
scripted clicks: Minecraft.startAttack, continueAttack and startUseItem with
handleKeybinds' attack and use part (missTime, rightClickDelay, held
buttons), MultiPlayerGameMode's block breaking (survival progress, creative
instant breaking and its destroy delay, swords that cannot break in
creative, stopping), LivingEntity.swing with SwingState and haste or mining
fatigue durations, Player's attack strength and item swap tickers, and
FirstPersonHandsAndItems' equip heights (swap animation, itemUsed after
placing or using an item, busy hands)."""
import json
import random
import struct
try:
from . import lab, oracle_harness as harness
except ImportError:
import lab, oracle_harness as harness
def fbits(v):
return struct.pack('>f', v).hex()
def dbits(v):
return struct.pack('>d', v).hex()
def f32(v):
return struct.unpack('>f', struct.pack('>f', v))[0]
# What the probe needs about the scripted blocks and items, checked against
# the client through the progress and attack speed both print:
# Block.destroyTime and requiresCorrectToolForDrops,
BLOCKS = {'minecraft:dirt': (0.5, False), 'minecraft:stone': (1.5, True), 'minecraft:glass': (0.3, False),
'minecraft:bedrock': (-1.0, False), 'minecraft:short_grass': (0.0, False), 'minecraft:oak_planks': (2.0, False)}
# the Tool component's speed and correct-for-drops per item and block (1.0
# and incorrect otherwise),
TOOLS = {'minecraft:diamond_pickaxe minecraft:stone': (8.0, True), 'minecraft:diamond_shovel minecraft:dirt': (8.0, True),
'minecraft:diamond_pickaxe minecraft:glass': (1.0, False)}
# and ATTACK_SPEED with the main-hand item's modifier (4.0 without one).
SPEEDS = {'minecraft:diamond_sword': 4.0 + f32(-2.4), 'minecraft:diamond_pickaxe': 4.0 + f32(-2.8),
'minecraft:diamond_shovel': 4.0 + f32(-3.0)}
ITEMS = [None, ['minecraft:diamond_sword', 1], ['minecraft:diamond_pickaxe', 1], ['minecraft:diamond_shovel', 1],
['minecraft:dirt', 64], ['minecraft:dirt', 2], ['minecraft:bread', 3], ['minecraft:bow', 1], ['minecraft:shield', 1]]
TARGETS = [(0, 64, 0), (1, 64, 0), (0, 65, 2)]
def step(hit=('miss',), ac=0, uc=0, ad=False, ud=False, busy=False, using=False, uses=None, shrink=False, set=None):
return dict(hit=list(hit), ac=ac, uc=uc, ad=ad, ud=ud, busy=busy, using=using,
uses=uses or dict(entity='pass', block='pass', item='pass'), shrink=shrink, set=set)
def case(steps, creative=False, main=None, off=None, blocks=None, haste=None, fatigue=None):
return dict(creative=creative, main=main, off=off, haste=haste, fatigue=fatigue,
blocks=[[*p, b] for p, b in (blocks or {}).items()], steps=steps,
rust=dict(blocks={k: [fbits(v[0]), v[1]] for k, v in BLOCKS.items()},
tools={k: [fbits(v[0]), v[1]] for k, v in TOOLS.items()},
attack_speed={k: dbits(v) for k, v in SPEEDS.items()}))
def block_hit(pos, face=1):
return ('block', *pos, face)
def scenarios():
stone = {TARGETS[0]: 'minecraft:stone', TARGETS[1]: 'minecraft:dirt', TARGETS[2]: 'minecraft:glass'}
for creative in (False, True):
for main in ITEMS:
# Hold attack on blocks until they break, then on the air behind.
for target, block in [(TARGETS[1], 'minecraft:dirt'), (TARGETS[0], 'minecraft:stone'), (TARGETS[0], 'minecraft:bedrock'),
(TARGETS[2], 'minecraft:short_grass'), (TARGETS[2], 'minecraft:glass')]:
steps = [step(block_hit(target), ac=1, ad=True)] + [step(block_hit(target), ad=True)] * 40 + [step()] * 8
yield case(steps, creative, main, blocks={target: block})
# Sweep across three blocks while holding, as when mining a tunnel.
steps = [step(block_hit(TARGETS[0]), ac=1, ad=True)]
for i in range(60):
steps.append(step(block_hit(TARGETS[(i // 7) % 3]), ad=True))
yield case(steps + [step()] * 6, creative, main, blocks=stone)
# Clicking the air, quickly and slowly; and clicking an entity.
for gap in (0, 1, 2, 3, 5, 9, 10, 11, 15):
steps = []
for _ in range(5):
steps += [step(ac=1)] + [step()] * gap
yield case(steps + [step()] * 8, creative, main)
steps = []
for _ in range(5):
steps += [step(('entity', True), ac=1)] + [step(('entity', True))] * gap
yield case(steps + [step(('entity', True), ac=1, ad=True)] + [step(('entity', True), ad=True)] * 8, creative, main)
# Placing and using: held right button on a block and in the air.
for result in ('success', 'server', 'consume', 'pass', 'fail'):
for shrink in (False, True):
uses = dict(entity=result, block=result, item=result)
steps = [step(block_hit(TARGETS[0]), uc=1, ud=True, uses=uses, shrink=shrink)]
steps += [step(block_hit(TARGETS[0]), ud=True, uses=uses, shrink=shrink)] * 14 + [step(uses=uses)] * 8
yield case(steps, creative, main, off=['minecraft:shield', 1], blocks=stone)
steps = [step(uc=1, ud=True, uses=uses, shrink=shrink)] + [step(ud=True, uses=uses, shrink=shrink)] * 10
yield case(steps + [step(uses=uses)] * 8, creative, main, blocks=stone)
steps = [step(('entity', inr), uc=1, ud=True, uses=uses, shrink=shrink) for inr in (True, False, True)]
yield case(steps + [step(('entity', True), uses=uses)] * 8, creative, main)
# Eating or drawing a bow: using the item, then releasing.
uses = dict(entity='pass', block='pass', item='consume')
steps = [step(uc=1, ud=True, uses=uses)] + [step(ud=True, using=True, ac=k % 2, ad=True, uses=uses) for k in range(12)]
steps += [step(using=True, uses=uses), step(uses=uses)] + [step()] * 8
yield case(steps, creative, main, off=['minecraft:bread', 3])
# Changing the held item: other items, the same item with another count,
# emptying the hand, while the attack recharges or the hands are busy.
for a in ITEMS:
for b in ITEMS:
for busy in (False, True):
steps = [step()] * 25 + [step(set=dict(hand='main', stack=b), busy=busy)] + [step(busy=busy)] * 25
steps += [step(set=dict(hand='off', stack=a))] + [step()] * 12 + [step(ac=1)] + [step()] * 30
yield case(steps, False, a)
# Haste and mining fatigue change the swing's length.
for haste in (None, 0, 1, 2, 4):
for fatigue in (None, 0, 1, 2):
steps = [step(block_hit(TARGETS[1]), ac=1, ad=True)] + [step(block_hit(TARGETS[1]), ad=True)] * 60 + [step(ac=1)] + [step()] * 20
yield case(steps, False, ['minecraft:diamond_shovel', 1], blocks={TARGETS[1]: 'minecraft:dirt'}, haste=haste, fatigue=fatigue)
yield case(steps, True, None, blocks={TARGETS[1]: 'minecraft:dirt'}, haste=haste, fatigue=fatigue)
def random_cases(rng):
results = ['success', 'server', 'consume', 'pass', 'fail']
for _ in range(3000):
blocks = {p: rng.choice(list(BLOCKS) + ['minecraft:air']) for p in TARGETS}
steps = []
for _ in range(rng.randrange(5, 80)):
hit = rng.choice([('miss',), ('entity', rng.random() < 0.7)] + [block_hit(p, rng.randrange(6)) for p in TARGETS] * 2)
setting = None
if rng.random() < 0.04:
setting = dict(hand=rng.choice(['main', 'off']), stack=rng.choice(ITEMS))
steps.append(step(hit, ac=rng.choice([0] * 6 + [1, 1, 2]), uc=rng.choice([0] * 8 + [1, 2]), ad=rng.random() < 0.5,
ud=rng.random() < 0.3, busy=rng.random() < 0.05, using=rng.random() < 0.08,
uses=dict(entity=rng.choice(results), block=rng.choice(results), item=rng.choice(results)),
shrink=rng.random() < 0.3, set=setting))
yield case(steps, rng.random() < 0.4, rng.choice(ITEMS), rng.choice(ITEMS), blocks,
rng.choice([None] * 6 + [0, 1, 3]), rng.choice([None] * 6 + [0, 2]))
def make_cases(build):
rng = random.Random(0x48414e44)
path = build / 'cases.jsonl'
cases = build / 'cases'
cases.mkdir(exist_ok=True)
count = 0
with path.open('w') as out:
for generator in (scenarios(), random_cases(rng)):
for c in generator:
text = json.dumps(c, separators=(',', ':'))
out.write(text + '\n')
if count < 20000:
(cases / str(count)).write_text(text)
count += 1
return path, count
def main():
scope = ('Real Minecraft.startAttack, continueAttack and startUseItem (with the attack and use part of handleKeybinds and '
'missTime mirrored in order), MultiPlayerGameMode.startDestroyBlock, continueDestroyBlock, stopDestroyBlock, '
'destroyBlock and attack, LivingEntity.swing with SwingState and getModifiedSwingDuration under haste and mining '
'fatigue, Player.getItemSwapScale and resetAttackStrengthTicker with the ticker lines of Player.tick, and '
'FirstPersonHandsAndItems.tick and itemUsed, on a LocalPlayer shell with a real Inventory, effects and attribute '
'modifiers, over scripted clicks, held buttons, crosshair targets, item changes and use results. Level effects '
'(particles, game events), packets and the outcome of using items on blocks, entities and the air are scripted '
'or absorbed; every vanilla item plays the swap animation unscaled except spears, which are not scripted.')
harness.differential(
'rust-first-person-parity', 'VanillaFirstPersonOracle', 'first_person_probe', make_cases, scope,
sources=[lab.ROOT / 'crates/horizon-client/src/first_person.rs', lab.ROOT / 'crates/horizon-client/src/attack_strength.rs',
lab.ROOT / 'crates/horizon-client/src/camera.rs',
lab.ROOT / 'mods/horizon/src/test/java/dev/horizonlab/parity/ParityData.java'])
if __name__ == '__main__':
main()
-25
View File
@@ -334,31 +334,6 @@ def sprint_corpus():
for gap in range(12):
seq=[0,1,0]+[0]*gap+[1]*12+[0]*3+[65]*20+[1]*12+[0]
yield dict(position=vec((12,4,0)),ops=[op('controlled_tick',k) for k in seq])
# Sprint-jumping: Player.jumpFromGround's forward impulse while sprinting,
# with sprint held (65) or started by double-tapping forward, and jump
# pressed with it, tapped, held through landings (bunny hops) or held
# before sprinting; at several headings, with too little food to sprint,
# under a two-block ceiling and up onto a slab.
runs={
'held':[65]*12+[81]+[65]*14+[81]*2+[65]*14+[0]*10,
'together':[0,81]+[81]*30+[0]*10,
'hop':[65]*8+[81]*40+[0]*10,
'jump_first':[16]*3+[17]*3+[81]*30+[0]*10,
'double_tap':[1,0,1]+[1]*12+[17]+[1]*14+[17]*25+[0]*10,
'release_sprint':[65]*10+[81]*6+[17]*20+[1]*10+[0]*5,
}
for name,seq in runs.items():
for yaw in [0,45,90,180,300]:
for food in [20,6]:
for scene in ['open','ceiling','slab']:
ops=[op('food',food=food,saturation=single(5),exhaustion=single(0),timer=0),op('yaw',single(yaw))]
blocks=[]
if scene=='ceiling':blocks=[([8,6,8+i],'stone',{}) for i in range(14)]
elif scene=='slab':blocks=[([8,4,12],'stone_slab',{'type':'bottom'}),([8,4,13],'stone',{})]
ops += [op('block',name='minecraft:'+b,position=pos,properties=props) for pos,b,props in blocks]
ops += [op('controlled_tick',k) for k in seq]
ops += [op('block',name='minecraft:air',position=pos) for pos,_,_ in blocks]
yield dict(position=vec((8.5,4,8.5)),ops=ops)
def main():
parser=argparse.ArgumentParser(description=__doc__);parser.add_argument('--sprint-input',action='store_true');args=parser.parse_args()