mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-09 02:29:50 -10:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9bc1425177 | ||
|
|
1a9452d408 | ||
|
|
1a10d4efe8 | ||
|
|
59fd98dea4 |
No files matched your search
@@ -27,7 +27,12 @@ jobs:
|
||||
- os: windows-latest
|
||||
asset: horizon-launcher-windows-x64.zip
|
||||
binary: horizon-launcher.exe
|
||||
# The C runtime linked in: without it the launcher asks for VCRUNTIME140.dll,
|
||||
# which only the Visual C++ redistributable installs.
|
||||
rustflags: -C target-feature=+crt-static
|
||||
runs-on: ${{ matrix.os }}
|
||||
env:
|
||||
RUSTFLAGS: ${{ matrix.rustflags }}
|
||||
defaults:
|
||||
run:
|
||||
working-directory: launcher
|
||||
@@ -48,6 +53,9 @@ jobs:
|
||||
run: grep -qx "version = \"${GITHUB_REF_NAME#launcher-v}\"" Cargo.toml
|
||||
- run: cargo test --release --locked --lib
|
||||
- run: cargo build --release --locked
|
||||
- name: Only system libraries
|
||||
if: runner.os == 'Windows'
|
||||
run: python ../tools/check_imports.py target/release/${{ matrix.binary }}
|
||||
- name: Package
|
||||
run: |
|
||||
mkdir -p dist
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
name: release builds
|
||||
# Started by hand: gh workflow run release.yml --ref <branch>
|
||||
# Builds the Windows x64 client and uploads the playable folder as an artifact.
|
||||
# Linux and macOS builds are made on the development machines.
|
||||
# Builds the Windows x64 and Linux x86_64 clients and uploads the playable folders as artifacts.
|
||||
# macOS builds are made on the development machines. Linux is built here, on an older Ubuntu,
|
||||
# so the binary uses the standard loader and runs on any recent distribution (and on NixOS
|
||||
# through nix-ld): a build made on NixOS asks for a /nix/store loader no other system has.
|
||||
on:
|
||||
workflow_dispatch:
|
||||
permissions:
|
||||
@@ -18,6 +20,10 @@ jobs:
|
||||
env:
|
||||
RUSTFLAGS: -C target-feature=+crt-static
|
||||
run: cargo +stable build --release --locked -p horizon-client --bin horizon-client
|
||||
- name: Only system DLLs
|
||||
# Vulkan and the rest are loaded at run time; a linked non-system DLL is a
|
||||
# "DLL not found" dialog on some players' machines.
|
||||
run: python tools/check_imports.py target/release/horizon-client.exe
|
||||
- name: Package
|
||||
shell: bash
|
||||
run: packaging/package.sh target/release/horizon-client.exe dist/horizon-windows-x64
|
||||
@@ -25,3 +31,21 @@ jobs:
|
||||
with:
|
||||
name: horizon-windows-x64
|
||||
path: dist/horizon-windows-x64
|
||||
linux:
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Install Rust toolchain
|
||||
run: rustup toolchain install stable --profile minimal
|
||||
- name: ALSA headers
|
||||
run: sudo apt-get update && sudo apt-get install -y --no-install-recommends libasound2-dev
|
||||
- name: Build the client
|
||||
run: cargo +stable build --release --locked -p horizon-client --bin horizon-client
|
||||
- name: Standard loader and libraries only
|
||||
run: python3 tools/check_imports.py target/release/horizon-client
|
||||
- name: Package
|
||||
run: packaging/package.sh target/release/horizon-client dist/horizon-linux-x86_64
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: horizon-linux-x86_64
|
||||
path: dist/horizon-linux-x86_64
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -61,10 +61,78 @@ pub struct Heard {
|
||||
pub struct MixerBackend {
|
||||
mixer: Arc<Mutex<Mixer>>,
|
||||
root: PathBuf,
|
||||
cache: HashMap<String, Option<(Arc<Vec<f32>>, u32)>>,
|
||||
cache: Arc<Mutex<SoundCache>>,
|
||||
/// The decoder thread for static sounds, started with the first one.
|
||||
decoder: Option<std::sync::mpsc::Sender<Decode>>,
|
||||
next: ChannelId,
|
||||
live: bool,
|
||||
}
|
||||
/// Decoded samples and their rate (`None`: the file did not load).
|
||||
type Samples = Option<(Arc<Vec<f32>>, u32)>;
|
||||
/// Decoded static sounds (`SoundBufferLibrary`'s buffers), dropped least
|
||||
/// recently used first past `CACHE_BYTES`: kept forever, every sound heard in
|
||||
/// a session stayed (up to 1.4 GiB of samples, villages playing hundreds).
|
||||
#[derive(Default)]
|
||||
struct SoundCache {
|
||||
entries: HashMap<String, (Samples, u64)>,
|
||||
bytes: usize,
|
||||
clock: u64,
|
||||
}
|
||||
const CACHE_BYTES: usize = 64 << 20;
|
||||
impl SoundCache {
|
||||
fn get(&mut self, key: &str) -> Option<Samples> {
|
||||
self.clock += 1;
|
||||
let entry = self.entries.get_mut(key)?;
|
||||
entry.1 = self.clock;
|
||||
Some(entry.0.clone())
|
||||
}
|
||||
fn insert(&mut self, key: String, samples: Samples) {
|
||||
self.clock += 1;
|
||||
self.bytes += Self::size(&samples);
|
||||
if let Some((old, _)) = self.entries.insert(key, (samples, self.clock)) {
|
||||
self.bytes -= Self::size(&old);
|
||||
}
|
||||
while self.bytes > CACHE_BYTES && self.entries.len() > 1 {
|
||||
let Some(oldest) = self
|
||||
.entries
|
||||
.iter()
|
||||
.min_by_key(|(_, (_, used))| *used)
|
||||
.map(|(k, _)| k.clone())
|
||||
else {
|
||||
break;
|
||||
};
|
||||
if let Some((gone, _)) = self.entries.remove(&oldest) {
|
||||
self.bytes -= Self::size(&gone);
|
||||
}
|
||||
}
|
||||
}
|
||||
fn size(samples: &Samples) -> usize {
|
||||
samples
|
||||
.as_ref()
|
||||
.map_or(0, |(s, _)| s.len() * std::mem::size_of::<f32>())
|
||||
}
|
||||
}
|
||||
/// A static sound to decode for a channel waiting on it.
|
||||
struct Decode {
|
||||
key: String,
|
||||
file: PathBuf,
|
||||
channel: ChannelId,
|
||||
}
|
||||
/// Give a channel waiting on its sound the decoded samples and start it, as
|
||||
/// the buffer future attaching and playing the source. A source stopped or
|
||||
/// released meanwhile stays so.
|
||||
fn attach(mixer: &Mutex<Mixer>, channel: ChannelId, samples: Samples, looping: bool) {
|
||||
if let Some(samples) = samples
|
||||
&& let Ok(mut m) = mixer.lock()
|
||||
&& let Some(c) = m.channels.get_mut(&channel)
|
||||
&& c.state == State::Initial
|
||||
{
|
||||
c.samples = Some(samples);
|
||||
c.position = 0.0;
|
||||
c.state = State::Playing;
|
||||
c.looping |= looping;
|
||||
}
|
||||
}
|
||||
pub struct Audio {
|
||||
_stream: Option<cpal::Stream>,
|
||||
pub engine: SoundEngine,
|
||||
@@ -176,53 +244,59 @@ impl Backend for MixerBackend {
|
||||
fn set_relative(&mut self, channel: ChannelId, relative: bool) {
|
||||
self.with(channel, |c| c.relative = relative);
|
||||
}
|
||||
/// `getCompleteBuffer` (decoded now, cached) or `getStream` (decoded on a
|
||||
/// worker), then attach and `alSourcePlay`. A sound that cannot load
|
||||
/// leaves its source initial, as a failed buffer future does in vanilla.
|
||||
/// `getCompleteBuffer` (cached, else decoded on the decoder thread) or
|
||||
/// `getStream` (decoded on a thread of its own), then attach and
|
||||
/// `alSourcePlay`. A sound that cannot load leaves its source initial,
|
||||
/// as a failed buffer future does in vanilla.
|
||||
fn start(&mut self, channel: ChannelId, sound: &Sound, looping: bool) {
|
||||
if !self.live {
|
||||
return;
|
||||
}
|
||||
let (namespace, path) = sound.path();
|
||||
let file = self.root.join("assets").join(namespace).join(&path);
|
||||
let attach = move |mixer: &Mutex<Mixer>, samples: Option<(Arc<Vec<f32>>, u32)>| {
|
||||
if let Some(samples) = samples
|
||||
&& let Ok(mut m) = mixer.lock()
|
||||
&& let Some(c) = m.channels.get_mut(&channel)
|
||||
{
|
||||
c.samples = Some(samples);
|
||||
c.position = 0.0;
|
||||
c.state = State::Playing;
|
||||
}
|
||||
};
|
||||
if sound.stream {
|
||||
// A streamed sound loops by restarting its stream.
|
||||
let mixer = self.mixer.clone();
|
||||
std::thread::spawn(move || {
|
||||
let samples = decode(&file).map(|(s, r)| (Arc::new(s), r));
|
||||
if looping
|
||||
&& let Ok(mut m) = mixer.lock()
|
||||
&& let Some(c) = m.channels.get_mut(&channel)
|
||||
{
|
||||
c.looping = true;
|
||||
}
|
||||
attach(&mixer, samples);
|
||||
attach(&mixer, channel, samples, looping);
|
||||
});
|
||||
} else {
|
||||
let samples = self
|
||||
.cache
|
||||
.entry(format!("{namespace}:{path}"))
|
||||
.or_insert_with(|| decode(&file).map(|(s, r)| (Arc::new(s), r)))
|
||||
.clone();
|
||||
attach(&self.mixer, samples);
|
||||
return;
|
||||
}
|
||||
}
|
||||
fn stop(&mut self, channel: ChannelId) {
|
||||
self.with(channel, |c| {
|
||||
if c.state != State::Initial {
|
||||
c.state = State::Stopped;
|
||||
}
|
||||
let key = format!("{namespace}:{path}");
|
||||
let cached = self.cache.lock().ok().and_then(|mut c| c.get(&key));
|
||||
if let Some(samples) = cached {
|
||||
attach(&self.mixer, channel, samples, false);
|
||||
return;
|
||||
}
|
||||
let decoder = self.decoder.get_or_insert_with(|| {
|
||||
let (send, receive) = std::sync::mpsc::channel::<Decode>();
|
||||
let (mixer, cache) = (self.mixer.clone(), self.cache.clone());
|
||||
std::thread::Builder::new()
|
||||
.name("sound decoder".into())
|
||||
.spawn(move || {
|
||||
for job in receive {
|
||||
// Queued twice before the first decode finished.
|
||||
let cached = cache.lock().ok().and_then(|mut c| c.get(&job.key));
|
||||
let samples = cached.unwrap_or_else(|| {
|
||||
let samples = decode(&job.file).map(|(s, r)| (Arc::new(s), r));
|
||||
if let Ok(mut c) = cache.lock() {
|
||||
c.insert(job.key, samples.clone());
|
||||
}
|
||||
samples
|
||||
});
|
||||
attach(&mixer, job.channel, samples, false);
|
||||
}
|
||||
})
|
||||
.expect("sound decoder thread");
|
||||
send
|
||||
});
|
||||
let _ = decoder.send(Decode { key, file, channel });
|
||||
}
|
||||
/// `alSourceStop`: any state becomes stopped, an initial source too (a
|
||||
/// sound still decoding then never starts).
|
||||
fn stop(&mut self, channel: ChannelId) {
|
||||
self.with(channel, |c| c.state = State::Stopped);
|
||||
}
|
||||
fn pause(&mut self, channel: ChannelId) {
|
||||
self.with(channel, |c| {
|
||||
@@ -327,6 +401,71 @@ fn mix(mixer: &mut Mixer, data: &mut [f32], channels: usize, rate: u32) {
|
||||
*s = s.clamp(-1.0, 1.0);
|
||||
}
|
||||
}
|
||||
/// Why the game should not open an output, if it should not:
|
||||
/// `HORIZON_NO_AUDIO`, or a virtual screen (`vscreen`), where agents and
|
||||
/// harnesses run the game and must stay off the user's speakers.
|
||||
/// `HORIZON_AUDIO=1` opens the output anyway (testing into a null sink).
|
||||
fn output_disabled(var: impl Fn(&str) -> Option<std::ffi::OsString>) -> Option<String> {
|
||||
if var("HORIZON_NO_AUDIO").is_some() {
|
||||
return Some("audio off: HORIZON_NO_AUDIO is set".into());
|
||||
}
|
||||
if var("HORIZON_AUDIO").is_some_and(|v| v == "1") {
|
||||
return None;
|
||||
}
|
||||
let display = var("WAYLAND_DISPLAY")?;
|
||||
Path::new(&display)
|
||||
.components()
|
||||
.any(|c| c.as_os_str() == "vscreen")
|
||||
.then(|| "audio off on a virtual screen (vscreen); HORIZON_AUDIO=1 opens it".into())
|
||||
}
|
||||
/// An output stream on `device` in its default configuration, whatever its
|
||||
/// sample format: the mixer works in f32 and converts.
|
||||
fn open_device(
|
||||
device: &cpal::Device,
|
||||
mixer: Arc<Mutex<Mixer>>,
|
||||
) -> Result<(cpal::Stream, cpal::SupportedStreamConfig)> {
|
||||
use cpal::SampleFormat as F;
|
||||
let config = device.default_output_config()?;
|
||||
let stream = match config.sample_format() {
|
||||
F::F32 => build_stream::<f32>(device, &config, mixer),
|
||||
F::F64 => build_stream::<f64>(device, &config, mixer),
|
||||
F::I16 => build_stream::<i16>(device, &config, mixer),
|
||||
F::I32 => build_stream::<i32>(device, &config, mixer),
|
||||
F::I8 => build_stream::<i8>(device, &config, mixer),
|
||||
F::U16 => build_stream::<u16>(device, &config, mixer),
|
||||
F::U32 => build_stream::<u32>(device, &config, mixer),
|
||||
F::U8 => build_stream::<u8>(device, &config, mixer),
|
||||
other => Err(format!("unsupported sample format {other}").into()),
|
||||
}?;
|
||||
stream.play()?;
|
||||
Ok((stream, config))
|
||||
}
|
||||
fn build_stream<T: cpal::SizedSample + cpal::FromSample<f32>>(
|
||||
device: &cpal::Device,
|
||||
config: &cpal::SupportedStreamConfig,
|
||||
mixer: Arc<Mutex<Mixer>>,
|
||||
) -> Result<cpal::Stream> {
|
||||
let rate = config.sample_rate().0;
|
||||
let channels = config.channels() as usize;
|
||||
let mut scratch = Vec::new();
|
||||
let stream = device.build_output_stream(
|
||||
&config.config(),
|
||||
move |data: &mut [T], _| {
|
||||
scratch.resize(data.len(), 0.0f32);
|
||||
if let Ok(mut m) = mixer.lock() {
|
||||
mix(&mut m, &mut scratch, channels, rate);
|
||||
} else {
|
||||
scratch.fill(0.0);
|
||||
}
|
||||
for (out, s) in data.iter_mut().zip(&scratch) {
|
||||
*out = T::from_sample(*s);
|
||||
}
|
||||
},
|
||||
|e| eprintln!("Horizon: audio stream error: {e}"),
|
||||
None,
|
||||
)?;
|
||||
Ok(stream)
|
||||
}
|
||||
impl Audio {
|
||||
pub fn new(root: &Path) -> Self {
|
||||
Self::with_output(root, true)
|
||||
@@ -347,7 +486,7 @@ impl Audio {
|
||||
Ok(v) => (Some(v), true),
|
||||
Err(e) => {
|
||||
if output {
|
||||
eprintln!("Horizon: audio unavailable: {e}");
|
||||
eprintln!("Horizon: no sound: {e}");
|
||||
}
|
||||
(None, false)
|
||||
}
|
||||
@@ -360,7 +499,8 @@ impl Audio {
|
||||
backend: MixerBackend {
|
||||
mixer,
|
||||
root: root.to_owned(),
|
||||
cache: HashMap::new(),
|
||||
cache: Arc::default(),
|
||||
decoder: None,
|
||||
next: 0,
|
||||
live,
|
||||
},
|
||||
@@ -374,32 +514,40 @@ impl Audio {
|
||||
}
|
||||
}
|
||||
fn open(mixer: Arc<Mutex<Mixer>>) -> Result<cpal::Stream> {
|
||||
// Test runs (harnesses, agents) launch the game on a virtual screen;
|
||||
// keep them off the user's speakers.
|
||||
if std::env::var_os("HORIZON_NO_AUDIO").is_some() {
|
||||
return Err("audio output disabled by HORIZON_NO_AUDIO".into());
|
||||
if let Some(reason) = output_disabled(|name| std::env::var_os(name)) {
|
||||
return Err(reason.into());
|
||||
}
|
||||
let host = cpal::default_host();
|
||||
let device = host
|
||||
.default_output_device()
|
||||
.ok_or("no audio output device")?;
|
||||
let config = device.default_output_config()?;
|
||||
let rate = config.sample_rate().0;
|
||||
let channels = config.channels() as usize;
|
||||
let stream = device.build_output_stream(
|
||||
&config.into(),
|
||||
move |data: &mut [f32], _| {
|
||||
if let Ok(mut m) = mixer.lock() {
|
||||
mix(&mut m, data, channels, rate);
|
||||
} else {
|
||||
data.fill(0.0);
|
||||
let mut errors = Vec::new();
|
||||
// The default device first, then any other output a host lists:
|
||||
// a default that cannot open (busy, or a stale ALSA default) should
|
||||
// not leave the game silent when another device works.
|
||||
let default = host.default_output_device();
|
||||
let others = host.output_devices().into_iter().flatten();
|
||||
let mut tried = Vec::new();
|
||||
for device in default.into_iter().chain(others) {
|
||||
let name = device.name().unwrap_or_else(|_| "unnamed device".into());
|
||||
if tried.contains(&name) {
|
||||
continue;
|
||||
}
|
||||
tried.push(name.clone());
|
||||
match open_device(&device, mixer.clone()) {
|
||||
Ok((stream, config)) => {
|
||||
eprintln!(
|
||||
"Horizon: audio output {name} ({} Hz, {} channels, {})",
|
||||
config.sample_rate().0,
|
||||
config.channels(),
|
||||
config.sample_format()
|
||||
);
|
||||
return Ok(stream);
|
||||
}
|
||||
},
|
||||
|e| eprintln!("Horizon: audio stream error: {e}"),
|
||||
None,
|
||||
)?;
|
||||
stream.play()?;
|
||||
Ok(stream)
|
||||
Err(e) => errors.push(format!("{name}: {e}")),
|
||||
}
|
||||
}
|
||||
if errors.is_empty() {
|
||||
return Err(format!("no audio output device on the {} host", host.id().name()).into());
|
||||
}
|
||||
Err(format!("no audio output device opened ({})", errors.join("; ")).into())
|
||||
}
|
||||
fn bound(&mut self) -> Bound<'_, MixerBackend> {
|
||||
Bound {
|
||||
@@ -525,6 +673,21 @@ impl Audio {
|
||||
self.release(id);
|
||||
}
|
||||
/// Every sound event id (for command completion).
|
||||
/// The engine's table sizes and the mixer's channels (`status`).
|
||||
pub fn census(&self) -> serde_json::Value {
|
||||
let mut census = self.engine.census();
|
||||
census["channels"] = self
|
||||
.backend
|
||||
.mixer
|
||||
.lock()
|
||||
.map_or(0, |m| m.channels.len())
|
||||
.into();
|
||||
if let Ok(cache) = self.backend.cache.lock() {
|
||||
census["decoded"] = cache.entries.len().into();
|
||||
census["decoded_mib"] = (cache.bytes >> 20).into();
|
||||
}
|
||||
census
|
||||
}
|
||||
pub fn event_names(&self) -> Vec<String> {
|
||||
let mut v: Vec<String> = self.engine.registry.names().cloned().collect();
|
||||
v.sort();
|
||||
@@ -590,6 +753,47 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decoded_sounds_stay_within_the_budget_least_recently_used_out() {
|
||||
let mut cache = SoundCache::default();
|
||||
let quarter = CACHE_BYTES / 4 / std::mem::size_of::<f32>();
|
||||
let sound = || Some((Arc::new(vec![0.0f32; quarter]), 44100));
|
||||
for key in ["a", "b", "c", "d"] {
|
||||
cache.insert(key.into(), sound());
|
||||
}
|
||||
assert!(cache.get("a").is_some());
|
||||
cache.insert("e".into(), sound());
|
||||
assert!(cache.bytes <= CACHE_BYTES);
|
||||
assert!(
|
||||
cache.get("b").is_none(),
|
||||
"the least recently used goes first"
|
||||
);
|
||||
assert!(cache.get("a").is_some() && cache.get("e").is_some());
|
||||
// A failed load is remembered without counting.
|
||||
cache.insert("missing".into(), None);
|
||||
assert_eq!(cache.get("missing"), Some(None));
|
||||
}
|
||||
#[test]
|
||||
fn output_stays_off_on_a_virtual_screen_unless_asked() {
|
||||
let env = |pairs: &'static [(&'static str, &'static str)]| {
|
||||
move |name: &str| {
|
||||
pairs
|
||||
.iter()
|
||||
.find(|(k, _)| *k == name)
|
||||
.map(|(_, v)| std::ffi::OsString::from(*v))
|
||||
}
|
||||
};
|
||||
assert!(output_disabled(env(&[("WAYLAND_DISPLAY", "wayland-1")])).is_none());
|
||||
assert!(output_disabled(env(&[])).is_none());
|
||||
const VSCREEN: &str = "/run/user/1000/vscreen/main/wayland-1";
|
||||
assert!(output_disabled(env(&[("WAYLAND_DISPLAY", VSCREEN)])).is_some());
|
||||
assert!(
|
||||
output_disabled(env(&[("WAYLAND_DISPLAY", VSCREEN), ("HORIZON_AUDIO", "1")])).is_none()
|
||||
);
|
||||
assert!(
|
||||
output_disabled(env(&[("HORIZON_NO_AUDIO", "1"), ("HORIZON_AUDIO", "1")])).is_some()
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn linear_attenuation_reaches_zero_at_the_max_distance() {
|
||||
assert_eq!(distance_gain(None, 100.0), 1.0);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -39,7 +39,6 @@ mod game_mode;
|
||||
mod ghasts;
|
||||
mod golems;
|
||||
mod growth;
|
||||
mod hand;
|
||||
mod item_draw;
|
||||
mod jukebox;
|
||||
mod lan;
|
||||
@@ -686,18 +685,12 @@ 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,
|
||||
@@ -2796,7 +2789,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 +2841,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(())
|
||||
@@ -6381,6 +6374,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 +6423,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 +6451,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;
|
||||
@@ -7296,7 +7319,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,
|
||||
@@ -7469,20 +7491,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;
|
||||
}
|
||||
}
|
||||
@@ -8333,13 +8357,10 @@ 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,
|
||||
dragging: false,
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -491,6 +491,7 @@ impl Game<'_> {
|
||||
"present_mode": format!("{:?}", r.config.present_mode),
|
||||
"drawn_sections": r.drawn_sections,
|
||||
"uploaded_bytes": r.uploaded_bytes,
|
||||
"resident_sections": r.resident_sections(),
|
||||
})
|
||||
});
|
||||
json!({
|
||||
@@ -510,6 +511,7 @@ impl Game<'_> {
|
||||
"ticks": self.ticks,
|
||||
"day_time": self.world.day_time,
|
||||
"music": self.music.current(&self.audio.engine),
|
||||
"sounds": self.audio.census(),
|
||||
"fishing": self.hook_status(),
|
||||
"tridents": self.tridents.len(),
|
||||
"rockets": self.rockets.len(),
|
||||
@@ -519,6 +521,8 @@ impl Game<'_> {
|
||||
"using": self.using,
|
||||
"render_distance": self.streaming.radius,
|
||||
"chunks": self.world.chunks.len(),
|
||||
"mobs": self.mobs.len(),
|
||||
"block_entities": self.world.block_entities.len(),
|
||||
"pending_chunks": self.streaming.pending(),
|
||||
"generated_chunks": self.streaming.generated_total,
|
||||
"meshed_sections": self.streaming.meshed_total,
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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.
|
||||
@@ -142,9 +139,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,
|
||||
@@ -220,6 +214,24 @@ fn mip_chain(pixels: &[u8], size: u32, layers: u32) -> (Vec<u8>, u32) {
|
||||
}
|
||||
(out, levels)
|
||||
}
|
||||
/// What to tell a player whose system offers no usable graphics adapter.
|
||||
/// Vulkan is loaded at run time (the game links no graphics library), so a
|
||||
/// missing driver shows here rather than as a missing-DLL dialog.
|
||||
#[cfg(target_os = "windows")]
|
||||
const NO_GPU_DRIVER: &str = "No Vulkan graphics driver found: Horizon needs a GPU driver with Vulkan \
|
||||
(vulkan-1.dll comes with it). Install the latest driver for your NVIDIA, AMD or Intel GPU \
|
||||
and start the game again";
|
||||
#[cfg(target_os = "macos")]
|
||||
const NO_GPU_DRIVER: &str = "No Metal graphics device found";
|
||||
#[cfg(not(any(target_os = "windows", target_os = "macos")))]
|
||||
const NO_GPU_DRIVER: &str = "No Vulkan graphics driver found: Horizon needs the Vulkan loader \
|
||||
(libvulkan.so.1) and a Vulkan driver for your GPU (Mesa's, or NVIDIA's)";
|
||||
impl Renderer {
|
||||
/// Sections with GPU buffers, for soak tests watching residency.
|
||||
pub fn resident_sections(&self) -> usize {
|
||||
self.sections.len()
|
||||
}
|
||||
}
|
||||
impl Renderer {
|
||||
pub async fn new(
|
||||
window: Arc<Window>,
|
||||
@@ -240,7 +252,8 @@ impl Renderer {
|
||||
force_fallback_adapter: false,
|
||||
..Default::default()
|
||||
})
|
||||
.await?;
|
||||
.await
|
||||
.map_err(|e| format!("{NO_GPU_DRIVER} ({e})"))?;
|
||||
let info = adapter.get_info();
|
||||
eprintln!(
|
||||
"Horizon renderer: {} ({:?}, {:?})",
|
||||
@@ -476,12 +489,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 +684,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 +1084,6 @@ impl Renderer {
|
||||
screen_buffer,
|
||||
screen_capacity,
|
||||
entity_group,
|
||||
hand_group,
|
||||
hand_globals,
|
||||
entity_buffer,
|
||||
entity_capacity,
|
||||
group,
|
||||
@@ -1532,20 +1511,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 +1822,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(
|
||||
|
||||
@@ -41,7 +41,6 @@ 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;
|
||||
|
||||
@@ -5,6 +5,13 @@ mod game;
|
||||
fn main() {
|
||||
if let Err(error) = game::run() {
|
||||
eprintln!("Horizon: {error}");
|
||||
// Started from Explorer, the console window closes with the game:
|
||||
// keep the message on screen until it is read.
|
||||
#[cfg(windows)]
|
||||
if std::io::IsTerminal::is_terminal(&std::io::stdin()) {
|
||||
eprintln!("Press Enter to close.");
|
||||
let _ = std::io::stdin().read_line(&mut String::new());
|
||||
}
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,7 @@ use horizon_core::{
|
||||
geometry::Vec3,
|
||||
random::{LegacyRandom, RandomSource},
|
||||
};
|
||||
use std::collections::HashMap;
|
||||
|
||||
/// `MemorySlot.NEVER_EXPIRE`.
|
||||
pub(super) const NEVER: i64 = i64::MAX;
|
||||
@@ -843,12 +844,20 @@ impl Poi {
|
||||
}
|
||||
/// The POI a block state is (`PoiTypes`): job site blocks, bed heads, bells.
|
||||
pub fn poi_at(w: &MobWorld, x: i32, y: i32, z: i32) -> Option<Poi> {
|
||||
let name = w.block_name(x, y, z);
|
||||
poi_of_state(w, w.world.block(x, y, z))
|
||||
}
|
||||
/// The POI a block state is, if any (`PoiTypes.forState`).
|
||||
fn poi_of_state(w: &MobWorld, state: horizon_world::registry::BlockStateId) -> Option<Poi> {
|
||||
let name = &*w.facts.get(state).name;
|
||||
if name == "minecraft:bell" {
|
||||
return Some(Poi::Meeting);
|
||||
}
|
||||
if name.ends_with("_bed") {
|
||||
return (w.prop(x, y, z, "part") == Some("head")).then_some(Poi::Home);
|
||||
let part = w
|
||||
.registry
|
||||
.state(state)
|
||||
.and_then(|s| s.properties.get("part"));
|
||||
return (part.map(String::as_str) == Some("head")).then_some(Poi::Home);
|
||||
}
|
||||
super::villager::job_site_profession(name).map(Poi::Job)
|
||||
}
|
||||
@@ -920,12 +929,30 @@ fn pois_in_range(
|
||||
let chunks = radius.div_euclid(16) + 1;
|
||||
let y0 = (c[1] - radius).max(crate::chunk::MIN_Y);
|
||||
let y1 = (c[1] + radius).min(crate::chunk::MIN_Y + crate::chunk::HEIGHT - 1);
|
||||
// The POI each block state is, worked out once per search, and the
|
||||
// sections whose palette holds none skipped whole, as PoiManager only
|
||||
// visits sections with records: the same positions in the same order,
|
||||
// without reading and naming every block of the sphere.
|
||||
let mut kinds: HashMap<u32, Option<Poi>> = HashMap::new();
|
||||
let mut kind = |state: u32| {
|
||||
*kinds
|
||||
.entry(state)
|
||||
.or_insert_with(|| poi_of_state(w, horizon_world::registry::BlockStateId(state)))
|
||||
};
|
||||
for qz in cz - chunks..=cz + chunks {
|
||||
for qx in cx - chunks..=cx + chunks {
|
||||
if !w.world.chunks.contains_key(&(qx, qz)) {
|
||||
let Some(chunk) = w.world.chunks.get(&(qx, qz)) else {
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let holds: Vec<bool> = chunk
|
||||
.sections
|
||||
.iter()
|
||||
.map(|s| s.blocks.maybe_has(|v| kind(v).is_some()))
|
||||
.collect();
|
||||
for y in y0..=y1 {
|
||||
if !holds[((y - crate::chunk::MIN_Y) >> 4) as usize] {
|
||||
continue;
|
||||
}
|
||||
for z in qz * 16..qz * 16 + 16 {
|
||||
for x in qx * 16..qx * 16 + 16 {
|
||||
if (x - c[0]).abs() > radius || (z - c[2]).abs() > radius {
|
||||
@@ -937,11 +964,11 @@ fn pois_in_range(
|
||||
if d > r2 {
|
||||
continue;
|
||||
}
|
||||
let state = w.world.block(x, y, z);
|
||||
let state = chunk.block(x & 15, y, z & 15);
|
||||
if w.facts.is_air(state) {
|
||||
continue;
|
||||
}
|
||||
let Some(poi) = poi_at(w, x, y, z) else {
|
||||
let Some(poi) = kind(state.0) else {
|
||||
continue;
|
||||
};
|
||||
if !poi_matches(m, f, poi) {
|
||||
|
||||
@@ -445,6 +445,17 @@ impl SoundEngine {
|
||||
self.instances.remove(&id);
|
||||
}
|
||||
}
|
||||
/// Table sizes, for soak tests watching what the engine holds.
|
||||
pub fn census(&self) -> serde_json::Value {
|
||||
serde_json::json!({
|
||||
"instances": self.instances.len(),
|
||||
"playing": self.instance_to_channel.len(),
|
||||
"handles": self.handles.len(),
|
||||
"ticking": self.ticking.len(),
|
||||
"queued": self.queued.len(),
|
||||
"used": self.used,
|
||||
})
|
||||
}
|
||||
pub fn instance(&self, id: InstanceId) -> Option<&SoundInstance> {
|
||||
self.instances.get(&id)
|
||||
}
|
||||
|
||||
@@ -358,20 +358,24 @@ impl Streaming {
|
||||
}
|
||||
Done::Generated(mut chunk) => {
|
||||
let key = (chunk.x, chunk.z);
|
||||
world
|
||||
.block_entities
|
||||
.extend(std::mem::take(&mut chunk.block_entities));
|
||||
world
|
||||
.block_names
|
||||
.extend(std::mem::take(&mut chunk.block_names));
|
||||
let entities = std::mem::take(&mut chunk.entities);
|
||||
if !entities.is_empty() {
|
||||
world.loaded_entities.push(((chunk.x, chunk.z), entities));
|
||||
}
|
||||
self.generating.remove(&key);
|
||||
self.finishing.remove(&key);
|
||||
self.decorated.insert(key);
|
||||
// A column that finished after the player moved away is
|
||||
// dropped whole: its block entities and saved entities
|
||||
// too, or they would stay in the world (and the entities
|
||||
// spawn again, duplicated, when the column loads later).
|
||||
if self.in_range(key.0, key.1, 2) || self.near_finished(key.0, key.1, 1) {
|
||||
world
|
||||
.block_entities
|
||||
.extend(std::mem::take(&mut chunk.block_entities));
|
||||
world
|
||||
.block_names
|
||||
.extend(std::mem::take(&mut chunk.block_names));
|
||||
let entities = std::mem::take(&mut chunk.entities);
|
||||
if !entities.is_empty() {
|
||||
world.loaded_entities.push(((chunk.x, chunk.z), entities));
|
||||
}
|
||||
self.generated_total += 1;
|
||||
// A chunk saved with its light needs no light job; its
|
||||
// unlit neighbours still light themselves from it.
|
||||
@@ -424,6 +428,12 @@ impl Streaming {
|
||||
if self.meshing.get(&(x, sy, z)) != Some(&seq) {
|
||||
continue;
|
||||
}
|
||||
// Its column unloaded while it was meshed: nothing would
|
||||
// ever remove the uploaded section.
|
||||
if !world.chunks.contains_key(&(x, z)) {
|
||||
self.meshing.remove(&(x, sy, z));
|
||||
continue;
|
||||
}
|
||||
self.meshing.remove(&(x, sy, z));
|
||||
self.meshed_total += 1;
|
||||
uploads.push(((x, sy, z), mesh));
|
||||
@@ -449,6 +459,13 @@ impl Streaming {
|
||||
}
|
||||
self.lit.remove(key);
|
||||
self.meshed.remove(key);
|
||||
self.lighting.remove(key);
|
||||
self.light_snapshots.remove(key);
|
||||
}
|
||||
if !far.is_empty() {
|
||||
let far: HashSet<(i32, i32)> = far.iter().copied().collect();
|
||||
self.meshing.retain(|k, _| !far.contains(&(k.0, k.2)));
|
||||
world.dirty_sections.retain(|k| !far.contains(&(k.0, k.2)));
|
||||
}
|
||||
// Drop far proto chunks that are not checked out.
|
||||
let areas: Vec<((i32, i32), i64)> =
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 170 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 110 KiB |
@@ -55,6 +55,15 @@ Worlds autosave every 6000 ticks (5 minutes), when chunks unload and on quit.
|
||||
On headless Linux use `vscreen start ./target/release/horizon-client` and
|
||||
`vscreen shot`.
|
||||
|
||||
Sound: the game prints the output it opened (`Horizon: audio output ...`) or
|
||||
why it has none (`Horizon: no sound: ...`). It opens none with
|
||||
`HORIZON_NO_AUDIO` set, nor on a `vscreen`, where tests run beside someone
|
||||
listening; `HORIZON_AUDIO=1` opens it there anyway, so send it to a null sink
|
||||
(`PIPEWIRE_PROPS='{ target.object=<sink> node.dont-fallback=true }'`). Don't
|
||||
mute a test run's stream in the mixer: PipeWire remembers the mute for every
|
||||
later `horizon-client`. The launcher starts the game as "Horizon" for that
|
||||
reason.
|
||||
|
||||
### Drive the game from scripts (`horizon-ctl`)
|
||||
|
||||
`--control` opens a local Unix socket (`$XDG_RUNTIME_DIR/horizon-control.sock`,
|
||||
|
||||
@@ -351,6 +351,12 @@ pub fn client_command(tag: &str) -> Command {
|
||||
let _ = fs::create_dir_all(&home);
|
||||
let mut cmd = Command::new(version_dir(tag).join(CLIENT));
|
||||
cmd.current_dir(home).arg("--resources").arg(resources(tag));
|
||||
// PipeWire remembers volume and mute per application name. The game's own name is
|
||||
// "PipeWire ALSA [horizon-client]", shared with development and test runs, where
|
||||
// muting it once left every later game silent: played from here it is "Horizon".
|
||||
if std::env::var_os("PIPEWIRE_PROPS").is_none() {
|
||||
cmd.env("PIPEWIRE_PROPS", "{ application.name = \"Horizon\" media.role = \"Game\" }");
|
||||
}
|
||||
cmd
|
||||
}
|
||||
|
||||
@@ -799,6 +805,16 @@ mod tests {
|
||||
use super::*;
|
||||
use std::cmp::Ordering::*;
|
||||
|
||||
#[test]
|
||||
fn the_game_plays_under_its_own_audio_stream_name() {
|
||||
if std::env::var_os("PIPEWIRE_PROPS").is_some() {
|
||||
return;
|
||||
}
|
||||
let cmd = client_command("v2026.10.13");
|
||||
let props = cmd.get_envs().find(|(k, _)| *k == "PIPEWIRE_PROPS").and_then(|(_, v)| v);
|
||||
assert!(props.is_some_and(|v| v.to_string_lossy().contains("application.name = \"Horizon\"")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tags_never_mix() {
|
||||
assert!(is_game_tag("v2026.10.13"));
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Fail when a release binary needs a library its players may not have.
|
||||
|
||||
Windows .exe: every DLL in the import and delay-import tables must ship with
|
||||
Windows (no VCRUNTIME140.dll, vulkan-1.dll, ...): Vulkan and the like are
|
||||
loaded at run time, so a missing driver is an error message, not a dialog.
|
||||
Linux ELF: the loader must be the standard one (not a /nix/store path) and the
|
||||
needed libraries the ones every desktop distribution has.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import struct
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# DLLs that are part of Windows 10 and 11. api-ms-win-* and ext-ms-win-* API
|
||||
# sets resolve inside Windows too.
|
||||
WINDOWS_SYSTEM = {
|
||||
"advapi32.dll", "bcrypt.dll", "bcryptprimitives.dll", "cfgmgr32.dll", "combase.dll",
|
||||
"comctl32.dll", "comdlg32.dll", "crypt32.dll", "d2d1.dll", "d3d11.dll", "d3d12.dll",
|
||||
"d3dcompiler_47.dll", "dbghelp.dll", "dcomp.dll", "dwmapi.dll", "dwrite.dll", "dxgi.dll",
|
||||
"gdi32.dll", "hid.dll", "icuuc.dll", "imm32.dll", "iphlpapi.dll", "kernel32.dll",
|
||||
"kernelbase.dll", "mfplat.dll", "mmdevapi.dll", "msvcrt.dll", "ncrypt.dll", "ntdll.dll",
|
||||
"ole32.dll", "oleaut32.dll", "opengl32.dll", "propsys.dll", "secur32.dll", "setupapi.dll",
|
||||
"shcore.dll", "shell32.dll", "shlwapi.dll", "ucrtbase.dll", "user32.dll", "userenv.dll",
|
||||
"uxtheme.dll", "version.dll", "winmm.dll", "ws2_32.dll", "xinput1_4.dll",
|
||||
}
|
||||
LINUX_LOADER = "/lib64/ld-linux-x86-64.so.2"
|
||||
LINUX_SYSTEM = {
|
||||
"libc.so.6", "libm.so.6", "libgcc_s.so.1", "libdl.so.2", "libpthread.so.0", "librt.so.1",
|
||||
"ld-linux-x86-64.so.2", "libasound.so.2",
|
||||
}
|
||||
|
||||
|
||||
def windows_allowed(name):
|
||||
name = name.lower()
|
||||
return name in WINDOWS_SYSTEM or name.startswith(("api-ms-win-", "ext-ms-win-"))
|
||||
|
||||
|
||||
def pe_imports(data):
|
||||
"""DLL names in a PE32+ file's import and delay-import tables."""
|
||||
pe = struct.unpack_from("<I", data, 0x3C)[0]
|
||||
if data[pe:pe + 4] != b"PE\0\0":
|
||||
raise ValueError("not a PE file")
|
||||
sections_count, optional_size = struct.unpack_from("<H12xH", data, pe + 6)
|
||||
optional = pe + 24
|
||||
magic = struct.unpack_from("<H", data, optional)[0]
|
||||
directories = optional + (112 if magic == 0x20B else 96)
|
||||
sections = [struct.unpack_from("<8xIIII", data, optional + optional_size + 40 * i)
|
||||
for i in range(sections_count)]
|
||||
|
||||
def offset(rva):
|
||||
for size, address, raw_size, raw in sections:
|
||||
if address <= rva < address + max(size, raw_size):
|
||||
return rva - address + raw
|
||||
raise ValueError(f"RVA {rva:#x} outside every section")
|
||||
|
||||
def name_at(rva):
|
||||
start = offset(rva)
|
||||
return data[start:data.index(b"\0", start)].decode("ascii")
|
||||
|
||||
names = []
|
||||
# Directory 1: imports (20-byte descriptors, name at +12); 13: delay imports (32 bytes, name at +4).
|
||||
for index, size, name_field in ((1, 20, 12), (13, 32, 4)):
|
||||
rva = struct.unpack_from("<I", data, directories + 8 * index)[0]
|
||||
if not rva:
|
||||
continue
|
||||
at = offset(rva)
|
||||
while any(data[at:at + size]):
|
||||
names.append(name_at(struct.unpack_from("<I", data, at + name_field)[0]))
|
||||
at += size
|
||||
return names
|
||||
|
||||
|
||||
def elf_needs(data):
|
||||
"""The interpreter and DT_NEEDED libraries of a little-endian ELF64 file."""
|
||||
if data[:4] != b"\x7fELF" or data[4] != 2:
|
||||
raise ValueError("not an ELF64 file")
|
||||
phoff, phentsize, phnum = struct.unpack_from("<Q", data, 32)[0], *struct.unpack_from("<HH", data, 54)
|
||||
interpreter, dynamic, loads = None, None, []
|
||||
for i in range(phnum):
|
||||
kind, _, offset, vaddr, _, filesz = struct.unpack_from("<IIQQQQ", data, phoff + i * phentsize)
|
||||
if kind == 3:
|
||||
interpreter = data[offset:offset + filesz].rstrip(b"\0").decode()
|
||||
elif kind == 2:
|
||||
dynamic = (offset, filesz)
|
||||
elif kind == 1:
|
||||
loads.append((vaddr, offset, filesz))
|
||||
|
||||
def file_offset(address):
|
||||
for vaddr, offset, size in loads:
|
||||
if vaddr <= address < vaddr + size:
|
||||
return address - vaddr + offset
|
||||
raise ValueError(f"address {address:#x} outside every segment")
|
||||
|
||||
entries = []
|
||||
if dynamic:
|
||||
for at in range(dynamic[0], dynamic[0] + dynamic[1], 16):
|
||||
tag, value = struct.unpack_from("<qQ", data, at)
|
||||
if tag == 0:
|
||||
break
|
||||
entries.append((tag, value))
|
||||
strtab = next((file_offset(v) for t, v in entries if t == 5), None)
|
||||
|
||||
def string(index):
|
||||
start = strtab + index
|
||||
return data[start:data.index(b"\0", start)].decode()
|
||||
|
||||
needed = [string(v) for t, v in entries if t == 1]
|
||||
runpath = [string(v) for t, v in entries if t in (15, 29)]
|
||||
return interpreter, needed, runpath
|
||||
|
||||
|
||||
def check(path):
|
||||
data = Path(path).read_bytes()
|
||||
problems = []
|
||||
if data[:2] == b"MZ":
|
||||
names = pe_imports(data)
|
||||
problems += [f"imports {n}, which Windows does not ship" for n in names if not windows_allowed(n)]
|
||||
summary = ", ".join(sorted(set(n.lower() for n in names)))
|
||||
else:
|
||||
interpreter, needed, runpath = elf_needs(data)
|
||||
if interpreter not in (None, LINUX_LOADER):
|
||||
problems.append(f"loader is {interpreter}, not {LINUX_LOADER}")
|
||||
problems += [f"needs {n}, which not every distribution has" for n in needed if n not in LINUX_SYSTEM]
|
||||
problems += [f"has a run path to {p}" for p in runpath if "/nix/store" in p]
|
||||
summary = f"{interpreter}; " + ", ".join(needed)
|
||||
print(f"{path}: {summary}")
|
||||
for problem in problems:
|
||||
print(f" {path} {problem}", file=sys.stderr)
|
||||
return not problems
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
||||
parser.add_argument("binaries", nargs="+")
|
||||
args = parser.parse_args()
|
||||
ok = all([check(path) for path in args.binaries])
|
||||
sys.exit(0 if ok else 1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -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