mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-07 17:21:49 -10:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d9351ec054 | ||
|
|
eb18bb42af |
No files matched your search
Generated
+6
@@ -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
@@ -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]
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
@@ -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.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -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,
|
||||
};
|
||||
@@ -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 |
Generated
+5
@@ -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"
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 '
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
|
||||
Reference in new issue
Block a user