Merge remote-tracking branch 'upstream/main' into fix/thrown-projectile-flight
This commit is contained in:
@@ -144,6 +144,7 @@ jobs:
|
||||
with:
|
||||
base: ${{ github.event.pull_request.base.sha }}
|
||||
head: ${{ github.event.pull_request.head.sha }}
|
||||
full: ${{ contains(github.event.pull_request.labels.*.name, 'full-parity') }}
|
||||
# Horizon never downloads the game. These jobs need a self-hosted Linux runner
|
||||
# (label horizon-game) holding the maintainer's own copy: the official launcher's
|
||||
# game directory in HORIZON_MINECRAFT_DIR and the pinned server jar in
|
||||
|
||||
@@ -25,8 +25,8 @@ jobs:
|
||||
parity-main:
|
||||
# A Mac mini runner of its own (ludovics-mac-mini-2 holds only this label since 2026-10-26): behind PR parity jobs
|
||||
# this run waited 2 h while every merge replaced the pending one, and on the release runner a release then waited
|
||||
# behind the next run. PR parity jobs have ludovics-mac-mini and the desktop. Not the desktop: a job slowed down
|
||||
# because the maintainer came back could time out, and a failure here bisects and comments on merges.
|
||||
# behind the next run. PR parity jobs have ludovics-mac-mini and playhorizon. Not playhorizon: its 4 CPUs
|
||||
# make a core-crate run several times longer, and a failure here bisects and comments on merges.
|
||||
runs-on: [self-hosted, macOS, horizon-parity-main]
|
||||
timeout-minutes: 240 # a core-crate batch reruns ~300 harnesses (~60-90 min), a bisect a few rounds of the failures
|
||||
steps:
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
name: parity
|
||||
# The vanilla harnesses a pull request's changes can affect, on the Mac mini's runner, which holds the maintainer's
|
||||
# own Minecraft files (Horizon never downloads the game). Called by ci.yml for pull requests from this repository
|
||||
# The vanilla harnesses a pull request's changes can affect, on the Mac mini's runner or playhorizon's, which hold the
|
||||
# maintainer's own Minecraft files (Horizon never downloads the game). Called by ci.yml for pull requests from this repository
|
||||
# and by run-parity.yml when a maintainer labels a fork's pull request run-parity. See docs/release-flow.md.
|
||||
on:
|
||||
workflow_call:
|
||||
@@ -13,6 +13,13 @@ on:
|
||||
description: The commit to check out and test
|
||||
type: string
|
||||
required: true
|
||||
full:
|
||||
description: >-
|
||||
The pull request carries the full-parity label (a change every harness reaches, such as a crate split): it
|
||||
runs on playhorizon's runner only, so the Mac mini's runners stay free for the others. While that runner is
|
||||
busy the job waits for it.
|
||||
type: boolean
|
||||
default: false
|
||||
permissions:
|
||||
contents: read
|
||||
env:
|
||||
@@ -21,8 +28,10 @@ env:
|
||||
|
||||
jobs:
|
||||
parity:
|
||||
runs-on: [self-hosted, horizon-game]
|
||||
timeout-minutes: 120 # a change to core crates reruns ~300 harnesses: ~60-90 min on the Mac mini
|
||||
runs-on: ${{ inputs.full && fromJSON('["self-hosted", "horizon-game", "playhorizon"]') || fromJSON('["self-hosted", "horizon-game"]') }}
|
||||
# A change to core crates reruns ~350 harnesses: ~60-90 min on the Mac mini; on playhorizon (4 of its 8 vCPUs, 4
|
||||
# harnesses at a time) up to a few hours, and any pull request's job may land there.
|
||||
timeout-minutes: ${{ inputs.full && 360 || 180 }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
|
||||
@@ -22,3 +22,4 @@ jobs:
|
||||
with:
|
||||
base: ${{ github.event.pull_request.base.sha }}
|
||||
head: ${{ github.event.pull_request.head.sha }}
|
||||
full: ${{ contains(github.event.pull_request.labels.*.name, 'full-parity') }}
|
||||
|
||||
Generated
+7
@@ -2978,6 +2978,13 @@ dependencies = [
|
||||
"objc2-quartz-core 0.3.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "recipes"
|
||||
version = "1.0.0"
|
||||
dependencies = [
|
||||
"horizon-mod-sdk",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "redox_syscall"
|
||||
version = "0.4.1"
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[workspace]
|
||||
exclude = ["launcher", "crates/horizon-telemetry-server", "crates/horizon-mod-registry", "third_party/wgpu-hal"]
|
||||
members = ["crates/horizon-core", "crates/horizon-dualsense", "crates/horizon-threads", "crates/horizon-hdr", "crates/horizon-world", "crates/horizon-client", "crates/horizon-server-files", "crates/horizon-server", "crates/horizon-bots", "crates/horizon-assets", "crates/horizon-protocol", "crates/horizon-theme", "crates/horizon-prepare", "crates/horizon-telemetry", "crates/horizon-bench", "crates/horizon-friends", "crates/horizon-relay", "crates/horizon-mod-sdk", "crates/horizon-mods", "crates/horizon-mods/tests/guest", "crates/horizon-mods/tests/ui_guest", "crates/cargo-horizon-mod", "crates/horizon-port-pack", "crates/horizon-shaders", "crates/horizon-shader-api", "crates/horizon-release-sign", "crates/horizon-mod-sdk/examples/coords-hud", "crates/horizon-mod-sdk/examples/predator-missile", "crates/horizon-mod-sdk/examples/welcome", "crates/horizon-mod-sdk/examples/beacons", "mods/official/fps-overlay", "mods/official/keystrokes", "mods/official/minimap", "mods/official/zoom", "xtask"]
|
||||
members = ["crates/horizon-core", "crates/horizon-dualsense", "crates/horizon-threads", "crates/horizon-hdr", "crates/horizon-world", "crates/horizon-client", "crates/horizon-server-files", "crates/horizon-server", "crates/horizon-bots", "crates/horizon-assets", "crates/horizon-protocol", "crates/horizon-theme", "crates/horizon-prepare", "crates/horizon-telemetry", "crates/horizon-bench", "crates/horizon-friends", "crates/horizon-relay", "crates/horizon-mod-sdk", "crates/horizon-mods", "crates/horizon-mods/tests/guest", "crates/horizon-mods/tests/ui_guest", "crates/cargo-horizon-mod", "crates/horizon-port-pack", "crates/horizon-shaders", "crates/horizon-shader-api", "crates/horizon-release-sign", "crates/horizon-mod-sdk/examples/coords-hud", "crates/horizon-mod-sdk/examples/predator-missile", "crates/horizon-mod-sdk/examples/welcome", "crates/horizon-mod-sdk/examples/beacons", "mods/official/fps-overlay", "mods/official/keystrokes", "mods/official/minimap", "mods/official/zoom", "xtask", "mods/official/recipes"]
|
||||
resolver = "2"
|
||||
|
||||
[workspace.package]
|
||||
|
||||
@@ -273,6 +273,10 @@ fn play(path: &Path, report: &mut Report) -> Result<(), String> {
|
||||
}
|
||||
Output::OpenScreen { .. } => "opened a screen".to_string(),
|
||||
Output::CloseScreen { .. } => "closed its screen".to_string(),
|
||||
Output::PlaceRecipe { book_id, all } => {
|
||||
format!("placed recipe {book_id}{}", if all { " (all)" } else { "" })
|
||||
}
|
||||
Output::Give { item, count, .. } => format!("gave {count} {item}"),
|
||||
Output::Shake { strength, seconds } => {
|
||||
format!("shook the camera: {strength:.2} for {seconds:.1} s")
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
//! Biome colours from the pinned biome definitions and colormaps
|
||||
//! (`Biome.getGrassColor`/`getFoliageColor`/..., `BlockTintSources`).
|
||||
use crate::Result;
|
||||
use horizon_core::{noise::SimplexNoise, random::LegacyRandom};
|
||||
use horizon_core::{noise::SimplexNoise, position::BlockPos, random::LegacyRandom};
|
||||
use serde_json::Value;
|
||||
use std::{collections::HashMap, fs, path::Path};
|
||||
|
||||
@@ -33,6 +33,8 @@ pub struct BiomeInfo {
|
||||
pub increased_fire_burnout: bool,
|
||||
pub name: String,
|
||||
pub temperature: f32,
|
||||
/// `temperature_modifier: frozen` (the frozen oceans).
|
||||
pub frozen: bool,
|
||||
pub downfall: f32,
|
||||
pub precipitation: bool,
|
||||
pub grass: u32,
|
||||
@@ -45,6 +47,26 @@ pub struct BiomeInfo {
|
||||
pub fog: Option<u32>,
|
||||
pub water_fog: u32,
|
||||
}
|
||||
impl BiomeInfo {
|
||||
/// `Biome.getHeightAdjustedTemperature`: the frozen modifier, then cooler
|
||||
/// with height above `sea_level + 17`.
|
||||
pub fn temperature_at(&self, pos: BlockPos, sea_level: i32) -> f32 {
|
||||
use horizon_world::worldgen::biome_temperature::{
|
||||
BiomeTemperatureSettings, TemperatureModifier,
|
||||
};
|
||||
BiomeTemperatureSettings {
|
||||
base_temperature: self.temperature,
|
||||
modifier: if self.frozen {
|
||||
TemperatureModifier::Frozen
|
||||
} else {
|
||||
TemperatureModifier::None
|
||||
},
|
||||
has_precipitation: self.precipitation,
|
||||
downfall: self.downfall,
|
||||
}
|
||||
.height_adjusted(pos, sea_level)
|
||||
}
|
||||
}
|
||||
pub struct Biomes {
|
||||
pub list: Vec<BiomeInfo>,
|
||||
pub index: HashMap<String, u16>,
|
||||
@@ -135,6 +157,7 @@ impl Biomes {
|
||||
.as_bool()
|
||||
.unwrap_or(false),
|
||||
temperature,
|
||||
frozen: v["temperature_modifier"].as_str() == Some("frozen"),
|
||||
downfall,
|
||||
precipitation: v["has_precipitation"].as_bool().unwrap_or(true),
|
||||
grass: color(&effects["grass_color"])
|
||||
|
||||
@@ -102,6 +102,9 @@ mod maps;
|
||||
mod minecarts;
|
||||
mod mob_blocks;
|
||||
mod mob_gear;
|
||||
mod mod_browser;
|
||||
#[cfg(feature = "mods")]
|
||||
mod mod_browser_recipes;
|
||||
mod mod_input;
|
||||
#[cfg(feature = "mods")]
|
||||
mod mod_recipes;
|
||||
@@ -4517,6 +4520,22 @@ impl Game<'_> {
|
||||
};
|
||||
self.eye += (camera_eye.unwrap_or(eye_height) - self.eye) * 0.5;
|
||||
let crouching = player.pose == horizon_client::pose::Pose::Crouching;
|
||||
// `LivingEntity.baseTick` (Soul Speed) comes before `aiStep` (the
|
||||
// glide's wear): both draw from the level random.
|
||||
if let Some((at, velocity)) = self.tick_soul_speed() {
|
||||
self.particles.send_particles(
|
||||
&horizon_client::particles::ParticleOptions::simple(
|
||||
horizon_client::particles::ParticleType::Soul,
|
||||
),
|
||||
at,
|
||||
velocity,
|
||||
1.0,
|
||||
0,
|
||||
);
|
||||
if let Some(host) = self.lan.as_ref() {
|
||||
lan_particles::send_soul_particle(host, at, velocity);
|
||||
}
|
||||
}
|
||||
// `ItemStack.hurtAndBreak` for the glide: Unbreaking can save the wear.
|
||||
if glider_wear > 0
|
||||
&& !self.creative
|
||||
@@ -5406,6 +5425,7 @@ impl Game<'_> {
|
||||
Screen::CommandBlock => self.draw_command_block(&mut batch),
|
||||
Screen::Inventory => {
|
||||
self.draw_inventory(&mut batch);
|
||||
self.draw_mod_browser(&mut batch);
|
||||
}
|
||||
Screen::Creative => {
|
||||
self.draw_creative(&mut batch);
|
||||
@@ -5798,6 +5818,7 @@ impl Game<'_> {
|
||||
self.last_inv_click = None;
|
||||
}
|
||||
pub(super) fn close_container(&mut self) {
|
||||
self.mod_browser_closed();
|
||||
self.reset_inventory_clicks();
|
||||
self.close_station();
|
||||
self.mount_closed();
|
||||
@@ -6525,6 +6546,9 @@ impl Game<'_> {
|
||||
fn inventory_press(&mut self, right: bool) {
|
||||
use horizon_client::container_screen::Target;
|
||||
use horizon_client::menu::Input;
|
||||
if self.mod_browser_press(right) {
|
||||
return;
|
||||
}
|
||||
if !right && self.sort_press() {
|
||||
return;
|
||||
}
|
||||
@@ -6708,24 +6732,8 @@ impl Game<'_> {
|
||||
}
|
||||
/// `hasClickedOutside` of the open screen at the cursor.
|
||||
fn clicked_outside(&self, batch: &HudBatch) -> bool {
|
||||
use horizon_client::container_screen::{Book, Image, clicked_outside};
|
||||
let (ox, oy) = self.inventory_origin(batch);
|
||||
let (shift, width, height) = if self.screen == Screen::Creative {
|
||||
(0.0, Creative::WIDTH as i32, Creative::HEIGHT as i32)
|
||||
} else {
|
||||
let (shift, width) = match self.station.as_ref().map(|s| s.kind) {
|
||||
Some(stations::Kind::Merchant) => (-50.0, 276),
|
||||
Some(stations::Kind::Beacon) => (beacon::SHIFT, 230),
|
||||
_ => (0.0, 176),
|
||||
};
|
||||
(shift, width, self.screen_height() as i32)
|
||||
};
|
||||
let image = Image {
|
||||
left: (ox + shift) as i32,
|
||||
top: oy as i32,
|
||||
width,
|
||||
height,
|
||||
};
|
||||
use horizon_client::container_screen::{Book, clicked_outside};
|
||||
let image = self.container_image(batch);
|
||||
let book = self
|
||||
.recipe_book_area(batch)
|
||||
.map(|(visible, tab_hovered)| Book {
|
||||
@@ -6739,6 +6747,26 @@ impl Game<'_> {
|
||||
book,
|
||||
)
|
||||
}
|
||||
/// The open container screen's window: its place and size.
|
||||
fn container_image(&self, batch: &HudBatch) -> horizon_client::container_screen::Image {
|
||||
let (ox, oy) = self.inventory_origin(batch);
|
||||
let (shift, width, height) = if self.screen == Screen::Creative {
|
||||
(0.0, Creative::WIDTH as i32, Creative::HEIGHT as i32)
|
||||
} else {
|
||||
let (shift, width) = match self.station.as_ref().map(|s| s.kind) {
|
||||
Some(stations::Kind::Merchant) => (-50.0, 276),
|
||||
Some(stations::Kind::Beacon) => (beacon::SHIFT, 230),
|
||||
_ => (0.0, 176),
|
||||
};
|
||||
(shift, width, self.screen_height() as i32)
|
||||
};
|
||||
horizon_client::container_screen::Image {
|
||||
left: (ox + shift) as i32,
|
||||
top: oy as i32,
|
||||
width,
|
||||
height,
|
||||
}
|
||||
}
|
||||
/// A click outside the window (slot id -999): `THROW` does nothing; a
|
||||
/// `PICKUP` drops the carried stack (left) or one of it (right), thrown
|
||||
/// from the hand (`AbstractContainerMenu.doClick`, `LivingEntity.drop`).
|
||||
@@ -6899,7 +6927,7 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
fn click_inventory(&mut self, right: bool) {
|
||||
if self.mount_click(right) {
|
||||
if self.mod_browser_press(right) || self.mount_click(right) {
|
||||
return;
|
||||
}
|
||||
if self.recipe_book_click(right) {
|
||||
@@ -8071,6 +8099,7 @@ impl Game<'_> {
|
||||
creative: bool,
|
||||
create: Option<&horizon_client::title::WorldOptions>,
|
||||
) -> Result<()> {
|
||||
world_loading::world_data_used();
|
||||
if self.defer_world_entry(PendingWorldEntry::Local {
|
||||
folder: folder.to_owned(),
|
||||
name: name.map(str::to_owned),
|
||||
@@ -12119,6 +12148,14 @@ impl ApplicationHandler for Game<'_> {
|
||||
// The recipe book's search box takes every key but Escape.
|
||||
self.recipe_key(&event);
|
||||
}
|
||||
WindowEvent::KeyboardInput { event, .. }
|
||||
if self.mod_browser_typing()
|
||||
&& event.state == ElementState::Pressed
|
||||
&& !matches!(event.physical_key, PhysicalKey::Code(KeyCode::Escape)) =>
|
||||
{
|
||||
// A mod's item panel's search box, as the recipe book's.
|
||||
self.mod_browser_key(&event);
|
||||
}
|
||||
WindowEvent::KeyboardInput { event, .. }
|
||||
if self.station_typing()
|
||||
&& event.state == ElementState::Pressed
|
||||
@@ -13027,6 +13064,17 @@ impl Game<'_> {
|
||||
if self.rest_loader.is_some() {
|
||||
return Ok(());
|
||||
}
|
||||
let on_world_screens = self.title.as_ref().is_some_and(|title| {
|
||||
!matches!(
|
||||
title.mode,
|
||||
horizon_client::title::Mode::Main | horizon_client::title::Mode::Multiplayer(_)
|
||||
)
|
||||
});
|
||||
world_loading::preload_world_data(
|
||||
on_world_screens,
|
||||
&self.options.resources,
|
||||
self.pack.assets.registry,
|
||||
);
|
||||
if std::mem::take(&mut self.pending_pack_reload) {
|
||||
self.reload_packs();
|
||||
}
|
||||
|
||||
@@ -115,6 +115,7 @@ impl Game<'_> {
|
||||
"in_fire" | "campfire" | "hot_floor" => DamageKind::InFire,
|
||||
"lava" => DamageKind::Lava,
|
||||
"drown" => DamageKind::Drown,
|
||||
"dry_out" => DamageKind::DryOut,
|
||||
"magic" | "indirect_magic" | "dragon_breath" => DamageKind::Magic,
|
||||
"sting" => DamageKind::Sting,
|
||||
"cactus" | "sweet_berry_bush" => DamageKind::Cactus,
|
||||
|
||||
@@ -628,6 +628,9 @@ impl Game<'_> {
|
||||
// The port, or the game rules' search and number boxes.
|
||||
Screen::WorldOptions if text == "\\b" => self.world_options_text(None),
|
||||
Screen::WorldOptions => self.world_options_text(Some(text)),
|
||||
Screen::Inventory if self.mod_browser_typing() => {
|
||||
self.mod_browser_text((text != "\\b").then_some(text), text == "\\b")
|
||||
}
|
||||
Screen::Inventory if self.recipe_search_focused() => {
|
||||
self.recipe_text(Some(text), false)
|
||||
}
|
||||
|
||||
@@ -3524,6 +3524,7 @@ pub(super) fn damage_type(kind: horizon_client::mobs::DamageKind, player: bool)
|
||||
K::InFire => "minecraft:in_fire",
|
||||
K::Lava => "minecraft:lava",
|
||||
K::Drown => "minecraft:drown",
|
||||
K::DryOut => "minecraft:dry_out",
|
||||
K::Magic => "minecraft:magic",
|
||||
K::Sting => "minecraft:sting",
|
||||
K::Cactus => "minecraft:cactus",
|
||||
|
||||
@@ -3,10 +3,14 @@
|
||||
//! unbreaking (`minecraft:item_damage`), sharpness/smite/bane and power
|
||||
//! (`minecraft:damage`), knockback, fire aspect, infinity (`minecraft:ammo_use`),
|
||||
//! thorns (`minecraft:post_attack`), mending (`minecraft:repair_with_xp`) and
|
||||
//! the armor attribute effects (respiration, depth strider, ...).
|
||||
//! the armor attribute effects (respiration, depth strider, ...) and Soul
|
||||
//! Speed's `location_changed` and `tick` effects.
|
||||
use super::Game;
|
||||
use horizon_client::{enchant::EffectContext, items::ItemStack};
|
||||
use horizon_core::random::RandomSource;
|
||||
use horizon_client::{
|
||||
enchant::{EffectContext, Enchantments, level_value},
|
||||
items::ItemStack,
|
||||
};
|
||||
use horizon_core::{position::BlockPos, random::RandomSource};
|
||||
|
||||
/// Where Mending found a damaged item (`EnchantedItemInUse.inSlot`).
|
||||
#[derive(Clone, Copy)]
|
||||
@@ -458,6 +462,134 @@ impl Game<'_> {
|
||||
}
|
||||
ignite
|
||||
}
|
||||
/// Soul Speed (`data/minecraft/enchantment/soul_speed.json`), the parts of
|
||||
/// `Enchantment.runLocationChangedEffects` and `tick` that file uses. The
|
||||
/// `location_changed` effects run when the player enters a new block, lands,
|
||||
/// or its boots change:
|
||||
/// - the speed and movement efficiency modifiers turn on over a soul speed
|
||||
/// block (not flying, not riding) and stay on while airborne, then off
|
||||
/// (`enchantment_active_check`);
|
||||
/// - the boots lose 1 durability with a 4% chance when on such a block.
|
||||
///
|
||||
/// Every fifth tick the player walking on one also gets a soul particle
|
||||
/// (returned as its position and velocity, for the caller to show or send
|
||||
/// to the players around) and sometimes a sound. Runs for the window's own
|
||||
/// player and for each guest on the server (`guest_server_tick`); a remote
|
||||
/// client leaves it to its server.
|
||||
pub(super) fn tick_soul_speed(&mut self) -> Option<([f64; 3], [f64; 3])> {
|
||||
const ID: &str = "minecraft:soul_speed";
|
||||
let player = self.player.as_mut()?;
|
||||
if player.client_side {
|
||||
return None;
|
||||
}
|
||||
let level = self.armor[3]
|
||||
.as_ref()
|
||||
.map_or(0, |s| s.enchantment_level(ID));
|
||||
let position = player.position;
|
||||
let (on_ground, flying) = (player.ground.on_ground, player.flying);
|
||||
let moved = position.subtract(player.old_position);
|
||||
let (tick, width) = (player.health.tick_count, player.dimensions.width);
|
||||
let fx = &mut player.location_effects;
|
||||
let on_soul = fx.on_soul_speed_block;
|
||||
let block = BlockPos::containing(position);
|
||||
let changed_block = fx.block.replace(block) != Some(block);
|
||||
let landed = std::mem::take(&mut fx.landed);
|
||||
// Changed boots: `stopLocationBasedEffects`, then the effects run again.
|
||||
let equipped = std::mem::replace(&mut fx.boots_level, level) != level;
|
||||
let was_active = fx.applied_level;
|
||||
let mut active = was_active > 0 && !equipped;
|
||||
let mut wear = false;
|
||||
if level > 0 && (changed_block || landed || equipped) {
|
||||
let riding = self.riding.is_some();
|
||||
let wanted = !riding && !flying && (on_soul || (active && !on_ground));
|
||||
active = wanted;
|
||||
// `random_chance` is the first term of the wear's `all_of`: it
|
||||
// draws from the level random before the block is looked at.
|
||||
let roll = self.world.random.next_float() < 0.04;
|
||||
wear = roll && on_ground && on_soul;
|
||||
}
|
||||
fx.applied_level = if active { level } else { 0 };
|
||||
// `Entity.getKnownMovement` horizontal speed of at least 1.0E-5.
|
||||
let walking = moved.x * moved.x + moved.z * moved.z >= 1.0e-10;
|
||||
let tick_effects = level > 0 && !flying && on_ground && walking && on_soul && tick % 5 == 0;
|
||||
// The attribute effects were applied to the player before: swap them.
|
||||
if was_active > 0 && (!active || equipped) {
|
||||
for (attribute, id, _, op) in
|
||||
soul_speed_modifiers(&self.res.stations().enchantments, was_active)
|
||||
{
|
||||
let _ = player.set_modifier(&attribute, &id, 0.0, &op, false);
|
||||
}
|
||||
}
|
||||
if active && (was_active == 0 || equipped) {
|
||||
for (attribute, id, amount, op) in
|
||||
soul_speed_modifiers(&self.res.stations().enchantments, level)
|
||||
{
|
||||
let _ = player.set_modifier(&attribute, &id, amount, &op, true);
|
||||
}
|
||||
}
|
||||
if wear && !self.creative {
|
||||
self.wear_boots(1);
|
||||
}
|
||||
if level == 0 {
|
||||
return None;
|
||||
}
|
||||
let r = &mut self.world.random;
|
||||
// `spawn_particles`: in the bounding box, 0.1 above the feet, thrown
|
||||
// back against the movement (-0.2) and up (0.1).
|
||||
let particle = tick_effects.then(|| {
|
||||
let x = position.x + (r.next_double() - 0.5) * f64::from(width);
|
||||
let z = position.z + (r.next_double() - 0.5) * f64::from(width);
|
||||
(
|
||||
[x, position.y + 0.1, z],
|
||||
[moved.x * -0.2, 0.1, moved.z * -0.2],
|
||||
)
|
||||
});
|
||||
// `play_sound`: its `random_chance` (35%) comes first in its `all_of`,
|
||||
// so the level random is drawn every tick the boots are worn; volume
|
||||
// 0.6, pitch 0.6 to 1.0.
|
||||
if r.next_float() < 0.35 && tick_effects {
|
||||
let pitch = r.next_float() * 0.4 + 0.6;
|
||||
self.audio.play(
|
||||
"minecraft:particle.soul_escape",
|
||||
Some([position.x, position.y, position.z]),
|
||||
0.6,
|
||||
pitch,
|
||||
);
|
||||
}
|
||||
particle
|
||||
}
|
||||
/// `ItemStack.hurtAndBreak` on the worn boots (`change_item_damage`):
|
||||
/// Unbreaking applies, and they break when worn out.
|
||||
fn wear_boots(&mut self, amount: u32) {
|
||||
let Some(stack) = self.armor[3].clone() else {
|
||||
return;
|
||||
};
|
||||
let loss = self.durability_loss(&stack, amount);
|
||||
let max = self
|
||||
.res
|
||||
.items()
|
||||
.get(&stack.item)
|
||||
.map_or(0, |info| info.max_damage);
|
||||
if max == 0 {
|
||||
return;
|
||||
}
|
||||
let Some(boots) = self.armor[3].as_mut() else {
|
||||
return;
|
||||
};
|
||||
boots.damage += loss;
|
||||
if boots.damage >= max {
|
||||
self.armor[3] = None;
|
||||
if let Some(eye) = self.eye_position() {
|
||||
self.audio.play(
|
||||
"minecraft:entity.item.break",
|
||||
Some(eye),
|
||||
0.8,
|
||||
0.8 + self.world.random.next_float() * 0.4,
|
||||
);
|
||||
}
|
||||
self.apply_equipment();
|
||||
}
|
||||
}
|
||||
/// Enchantment attribute modifiers of worn armor (respiration, depth strider...).
|
||||
/// Each piece's modifier id ends with its slot (`EnchantmentAttributeEffect.idForSlot`),
|
||||
/// so the same enchantment on two pieces stacks instead of replacing itself.
|
||||
@@ -481,3 +613,28 @@ impl Game<'_> {
|
||||
self.potions.enchant_modifiers = wanted;
|
||||
}
|
||||
}
|
||||
|
||||
/// Soul Speed's `attribute` effects at `level` (attribute, id, amount, operation),
|
||||
/// the id taking the boots slot like `EnchantmentAttributeEffect.idForSlot`.
|
||||
fn soul_speed_modifiers(
|
||||
enchantments: &Enchantments,
|
||||
level: u32,
|
||||
) -> Vec<(String, String, f64, String)> {
|
||||
let Some(e) = enchantments.get("minecraft:soul_speed") else {
|
||||
return Vec::new();
|
||||
};
|
||||
e.effects["minecraft:location_changed"][0]["effect"]["effects"]
|
||||
.as_array()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter(|a| a["type"] == "minecraft:attribute")
|
||||
.map(|a| {
|
||||
(
|
||||
a["attribute"].as_str().unwrap_or("").to_owned(),
|
||||
format!("{}/feet", a["id"].as_str().unwrap_or("")),
|
||||
f64::from(level_value(&a["amount"], level)),
|
||||
a["operation"].as_str().unwrap_or("add_value").to_owned(),
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
@@ -24,11 +24,24 @@ use horizon_protocol::{
|
||||
};
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
sync::{Arc, OnceLock},
|
||||
sync::{Arc, Mutex},
|
||||
};
|
||||
|
||||
/// The registries and tags are the same for every world: load them once.
|
||||
static SYNC: OnceLock<std::result::Result<Arc<SyncData>, String>> = OnceLock::new();
|
||||
static SYNC: Mutex<Option<std::result::Result<Arc<SyncData>, String>>> = Mutex::new(None);
|
||||
|
||||
/// The registries and tags a server sends, loaded from `root` the first time.
|
||||
pub(super) fn sync_data(root: &std::path::Path) -> std::result::Result<Arc<SyncData>, String> {
|
||||
let mut sync = SYNC.lock().unwrap_or_else(|e| e.into_inner());
|
||||
sync.get_or_insert_with(|| SyncData::load(root).map(Arc::new))
|
||||
.clone()
|
||||
}
|
||||
|
||||
/// Let go of what `sync_data` loaded: for a preload no world used
|
||||
/// (game/world_loading.rs).
|
||||
pub(super) fn forget_sync_data() {
|
||||
*SYNC.lock().unwrap_or_else(|e| e.into_inner()) = None;
|
||||
}
|
||||
|
||||
/// The settings of the open world (WorldOptionsScreen's multiplayer section).
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
@@ -562,10 +575,7 @@ impl Game<'_> {
|
||||
config: HubConfig,
|
||||
host: Option<u128>,
|
||||
) -> Result<()> {
|
||||
let root = self.options.resources.clone();
|
||||
let sync = SYNC
|
||||
.get_or_init(|| SyncData::load(&root).map(Arc::new))
|
||||
.clone()?;
|
||||
let sync = sync_data(&self.options.resources)?;
|
||||
let biome_names: Vec<String> = self
|
||||
.res
|
||||
.biomes()
|
||||
|
||||
@@ -2183,6 +2183,7 @@ impl Game<'_> {
|
||||
let in_wall = !g.sleeping && self.in_wall(g.position, g.eye_height());
|
||||
let at = g.position;
|
||||
let sneaking = g.sneaking;
|
||||
let mut soul = None;
|
||||
let events = self.as_guest(host, conn, |game| {
|
||||
let (rules, motion) = (game.world.health_rules, game.motion);
|
||||
let health = &motion.health;
|
||||
@@ -2252,6 +2253,7 @@ impl Game<'_> {
|
||||
w.player.shift_key_down = sneaking;
|
||||
w.player.freeze_immune = freeze_immune;
|
||||
let _ = w.server_block_damage(start);
|
||||
let _ = w.update_soul_speed_block();
|
||||
let _ = w.tick_environment_damage();
|
||||
let _ = w.apply_fire_contact();
|
||||
let _ = w.server_hazards_tick(in_wall);
|
||||
@@ -2279,6 +2281,9 @@ impl Game<'_> {
|
||||
// LivingEntity.baseTick: outside the world border, then tickEffects.
|
||||
game.border_damage();
|
||||
game.tick_mob_effects();
|
||||
// Soul Speed's effects (LivingEntity.baseTick's location_changed
|
||||
// and tick effects); its particle goes to the players around.
|
||||
soul = game.tick_soul_speed();
|
||||
let Some(player) = game.player.as_mut() else {
|
||||
return (Vec::new(), None);
|
||||
};
|
||||
@@ -2307,6 +2312,9 @@ impl Game<'_> {
|
||||
let Some((events, marked)) = events else {
|
||||
return;
|
||||
};
|
||||
if let Some((at, velocity)) = soul {
|
||||
super::lan_particles::send_soul_particle(host, at, velocity);
|
||||
}
|
||||
self.as_guest(host, conn, |game| game.tick_player_sculk_events());
|
||||
for event in events {
|
||||
match event {
|
||||
|
||||
@@ -24,6 +24,35 @@ fn rgb(c: [f32; 4]) -> [f32; 3] {
|
||||
[c[0], c[1], c[2]]
|
||||
}
|
||||
|
||||
/// `ServerLevel.sendParticles` of one soul particle moving by `velocity`
|
||||
/// (Soul Speed's `spawn_particles`): sent to the players within 32 blocks.
|
||||
pub(super) fn send_soul_particle(host: &super::lan::LanHost, at: [f64; 3], velocity: [f64; 3]) {
|
||||
let Some(id) = host
|
||||
.hub
|
||||
.registry_id("minecraft:particle_type", "minecraft:soul")
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let packet = C::LevelParticles(play::LevelParticles {
|
||||
particle: Particle { id, data: None },
|
||||
override_limiter: false,
|
||||
always_show: false,
|
||||
pos: at,
|
||||
dist: velocity.map(|v| v as f32),
|
||||
max_speed: [1.0; 3],
|
||||
count: 0,
|
||||
randomization: 0,
|
||||
});
|
||||
for g in host.hub.guests.iter().filter(|g| host.hub.here(g)) {
|
||||
let distance: f64 = (0..3)
|
||||
.map(|k| (g.position[k].floor() + 0.5 - at[k]).powi(2))
|
||||
.sum();
|
||||
if distance < 32.0 * 32.0 {
|
||||
g.send(&packet);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
/// `SonicBoom.tick` uses `ServerLevel.sendParticles` for each ray point.
|
||||
/// Keep the local event's rendering; connected players receive one at rest.
|
||||
|
||||
@@ -0,0 +1,485 @@
|
||||
//! Capability `ui`'s panel beside container screens (WIT `interface
|
||||
//! browser`, docs/mods.md): the items a recipe viewer mod lists, drawn as
|
||||
//! the game's own item slots in pages right of the screen's window, with a
|
||||
//! search box under them. The mod says what the panel holds; the game pages
|
||||
//! it and tells the mod what the player clicked, typed, and which of the
|
||||
//! mod's keys went down over an item. With no mod showing a panel, each
|
||||
//! hook here returns after one check.
|
||||
use super::Game;
|
||||
#[cfg(feature = "mods")]
|
||||
use {
|
||||
super::Screen,
|
||||
horizon_client::{
|
||||
hud::{HudBatch, rgba},
|
||||
items::ItemStack,
|
||||
ui::EditBox,
|
||||
},
|
||||
horizon_mods::{BrowserClick, BrowserEvent, BrowserStack},
|
||||
};
|
||||
|
||||
#[cfg(not(feature = "mods"))]
|
||||
impl Game<'_> {
|
||||
pub(super) fn draw_mod_browser(&mut self, _batch: &mut horizon_client::hud::HudBatch) {}
|
||||
pub(super) fn mod_browser_press(&mut self, _right: bool) -> bool {
|
||||
false
|
||||
}
|
||||
pub(super) fn mod_browser_typing(&self) -> bool {
|
||||
false
|
||||
}
|
||||
pub(super) fn mod_browser_key(&mut self, _event: &winit::event::KeyEvent) {}
|
||||
pub(super) fn mod_browser_text(&mut self, _text: Option<&str>, _backspace: bool) {}
|
||||
pub(super) fn mod_browser_closed(&mut self) {}
|
||||
}
|
||||
|
||||
/// One slot of the panel, in GUI pixels.
|
||||
#[cfg(feature = "mods")]
|
||||
const SLOT: f32 = 18.0;
|
||||
/// The most columns the panel takes, and the fewest it shows with.
|
||||
#[cfg(feature = "mods")]
|
||||
const COLUMNS: (usize, usize) = (12, 2);
|
||||
/// The gap between the window and the panel, and the screen's edges.
|
||||
#[cfg(feature = "mods")]
|
||||
const MARGIN: f32 = 8.0;
|
||||
|
||||
/// The panel's state in the game.
|
||||
#[cfg(feature = "mods")]
|
||||
#[derive(Default)]
|
||||
pub(super) struct Browser {
|
||||
/// The items' owner and version they were made from.
|
||||
of: Option<(usize, u64)>,
|
||||
/// The mod's items as stacks; `None` for an item the game doesn't have.
|
||||
stacks: Vec<Option<ItemStack>>,
|
||||
page: usize,
|
||||
search: Option<EditBox>,
|
||||
/// The search text the mod last heard.
|
||||
sent: String,
|
||||
/// `set-recipes`' recipes, shown instead of the items.
|
||||
pub(super) recipes: Option<super::mod_browser_recipes::Pages>,
|
||||
}
|
||||
|
||||
/// Where the panel's parts are this frame.
|
||||
#[cfg(feature = "mods")]
|
||||
pub(super) struct Layout {
|
||||
/// The panel's whole area, for clicks that hit none of its parts.
|
||||
pub(super) area: [f32; 4],
|
||||
/// The grid's top left corner, its columns and rows.
|
||||
pub(super) grid: (f32, f32),
|
||||
pub(super) columns: usize,
|
||||
pub(super) rows: usize,
|
||||
/// The page arrows' row.
|
||||
pub(super) arrows_y: f32,
|
||||
pub(super) search: [f32; 4],
|
||||
}
|
||||
#[cfg(feature = "mods")]
|
||||
impl Layout {
|
||||
fn per_page(&self) -> usize {
|
||||
self.columns * self.rows
|
||||
}
|
||||
pub(super) fn width(&self) -> f32 {
|
||||
self.columns as f32 * SLOT
|
||||
}
|
||||
fn slot(&self, n: usize) -> (f32, f32) {
|
||||
(
|
||||
self.grid.0 + (n % self.columns) as f32 * SLOT,
|
||||
self.grid.1 + (n / self.columns) as f32 * SLOT,
|
||||
)
|
||||
}
|
||||
/// The page arrows: backward, then forward.
|
||||
pub(super) fn arrows(&self) -> [(f32, f32); 2] {
|
||||
[
|
||||
(self.grid.0, self.arrows_y),
|
||||
(self.grid.0 + self.width() - 12.0, self.arrows_y),
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "mods")]
|
||||
pub(super) fn inside(p: (f32, f32), [x, y, w, h]: [f32; 4]) -> bool {
|
||||
p.0 >= x && p.1 >= y && p.0 < x + w && p.1 < y + h
|
||||
}
|
||||
|
||||
#[cfg(feature = "mods")]
|
||||
impl Game<'_> {
|
||||
fn browser_batch(&self) -> Option<HudBatch> {
|
||||
let r = self.renderer.as_ref()?;
|
||||
Some(HudBatch::new([r.config.width, r.config.height]))
|
||||
}
|
||||
/// The mod showing a panel, with its items made into stacks once per
|
||||
/// `set-items`; none while no mod shows one or no container screen is up.
|
||||
fn browser_sync(&mut self) -> Option<usize> {
|
||||
if self.screen != Screen::Inventory || self.title.is_some() {
|
||||
return None;
|
||||
}
|
||||
let Some((owner, version)) = self.mods.mods.browser_items().map(|(o, v, _)| (o, v)) else {
|
||||
self.mods.browser.of = None;
|
||||
self.mods.browser.stacks.clear();
|
||||
return None;
|
||||
};
|
||||
if self.mods.browser.of != Some((owner, version)) {
|
||||
let items = self.res.items();
|
||||
let stacks = self
|
||||
.mods
|
||||
.mods
|
||||
.browser_items()
|
||||
.map_or_else(Vec::new, |(_, _, list)| {
|
||||
list.iter().map(|s| browser_stack(items, s)).collect()
|
||||
});
|
||||
self.mods.browser.stacks = stacks;
|
||||
// The same title again (the mod updating its buttons) keeps the page.
|
||||
let old = self.mods.browser.recipes.take();
|
||||
self.mods.browser.recipes = self
|
||||
.mods
|
||||
.mods
|
||||
.browser_recipes(owner)
|
||||
.map(|shown| super::mod_browser_recipes::Pages::new(items, shown, old));
|
||||
self.mods.browser.of = Some((owner, version));
|
||||
}
|
||||
Some(owner)
|
||||
}
|
||||
/// The panel's parts: right of the window, from the top of the screen to
|
||||
/// the search box at its foot. None when the screen has no room for it.
|
||||
fn browser_layout(&self, batch: &HudBatch) -> Option<Layout> {
|
||||
let image = self.container_image(batch);
|
||||
let left = (image.left + image.width) as f32 + MARGIN;
|
||||
let right = batch.width - MARGIN;
|
||||
let columns = (((right - left) / SLOT).floor().max(0.0) as usize).min(COLUMNS.0);
|
||||
if columns < COLUMNS.1 {
|
||||
return None;
|
||||
}
|
||||
let x = (left + (right - left - columns as f32 * SLOT) / 2.0).floor();
|
||||
let arrows_y = MARGIN;
|
||||
let top = arrows_y + 17.0 + 4.0;
|
||||
let search = [x, batch.height - MARGIN - 16.0, columns as f32 * SLOT, 16.0];
|
||||
let rows = ((search[1] - 4.0 - top) / SLOT).floor().max(0.0) as usize;
|
||||
(rows > 0).then_some(Layout {
|
||||
area: [left, 0.0, right - left, batch.height],
|
||||
grid: (x, top),
|
||||
columns,
|
||||
rows,
|
||||
arrows_y,
|
||||
search,
|
||||
})
|
||||
}
|
||||
fn browser_pages(&self, layout: &Layout) -> usize {
|
||||
self.mods
|
||||
.browser
|
||||
.stacks
|
||||
.len()
|
||||
.div_ceil(layout.per_page())
|
||||
.max(1)
|
||||
}
|
||||
/// The stack under the pointer on the panel, an item's or a recipe's,
|
||||
/// as the mod sent it and as a stack.
|
||||
fn browser_pointed_stack(&self, layout: &Layout) -> Option<(BrowserStack, ItemStack)> {
|
||||
if self.mods.browser.recipes.is_some() {
|
||||
if let Some((_, sent, stack)) = self.recipe_tab_hovered(layout) {
|
||||
return Some((sent, stack));
|
||||
}
|
||||
let (_, _, sent, stack) = self.recipe_slot_hovered(layout)?;
|
||||
return Some((sent, stack));
|
||||
}
|
||||
let (index, stack) = self.browser_hovered(layout)?;
|
||||
let (_, _, items) = self.mods.mods.browser_items()?;
|
||||
Some((items.get(index)?.clone(), stack))
|
||||
}
|
||||
/// The panel's item under the pointer: its index and stack.
|
||||
fn browser_hovered(&self, layout: &Layout) -> Option<(usize, ItemStack)> {
|
||||
if self.mods.browser.recipes.is_some() {
|
||||
return None;
|
||||
}
|
||||
let first = self.mods.browser.page * layout.per_page();
|
||||
(0..layout.per_page()).find_map(|n| {
|
||||
let (x, y) = layout.slot(n);
|
||||
if !inside(self.cursor, [x, y, SLOT, SLOT]) {
|
||||
return None;
|
||||
}
|
||||
let stack = self.mods.browser.stacks.get(first + n)?.clone()?;
|
||||
Some((first + n, stack))
|
||||
})
|
||||
}
|
||||
fn browser_search(&mut self, layout: &Layout) -> &mut EditBox {
|
||||
let search = self.mods.browser.search.get_or_insert_with(|| {
|
||||
let mut edit = EditBox::new(0.0, 0.0, 0.0, 0.0);
|
||||
edit.max_length = 50;
|
||||
edit.focused = false;
|
||||
edit
|
||||
});
|
||||
[search.x, search.y, search.width, search.height] = layout.search;
|
||||
search
|
||||
}
|
||||
|
||||
/// The items' page arrows and grid.
|
||||
fn draw_browser_items(&mut self, batch: &mut HudBatch, layout: &Layout) {
|
||||
let pages = self.browser_pages(layout);
|
||||
self.mods.browser.page = self.mods.browser.page.min(pages - 1);
|
||||
let page = self.mods.browser.page;
|
||||
let cursor = self.cursor;
|
||||
if pages > 1 {
|
||||
let labels = [
|
||||
self.translate("gui.recipebook.previous_page", "Previous Page"),
|
||||
self.translate("gui.recipebook.next_page", "Next Page"),
|
||||
];
|
||||
let shown = [page > 0, page + 1 < pages];
|
||||
let atlas = &self.gui.atlas;
|
||||
for (((x, y), name), (label, shown)) in layout
|
||||
.arrows()
|
||||
.into_iter()
|
||||
.zip(["page_backward", "page_forward"])
|
||||
.zip(labels.iter().zip(shown))
|
||||
{
|
||||
if !shown {
|
||||
continue;
|
||||
}
|
||||
let hover = inside(cursor, [x, y, 12.0, 17.0]);
|
||||
let suffix = if hover { "_highlighted" } else { "" };
|
||||
batch.sprite(
|
||||
atlas,
|
||||
&format!("gui/sprites/recipe_book/{name}{suffix}"),
|
||||
x,
|
||||
y,
|
||||
);
|
||||
horizon_client::widgets::record(label, [x, y, 12.0, 17.0], true);
|
||||
}
|
||||
let text = format!("{}/{pages}", page + 1);
|
||||
let w = HudBatch::text_width(atlas, &text);
|
||||
let x = (layout.grid.0 + (layout.width() - w) / 2.0).floor();
|
||||
batch.text(atlas, &text, x, layout.arrows_y + 5.0, u32::MAX, true);
|
||||
}
|
||||
let first = page * layout.per_page();
|
||||
for n in 0..layout.per_page() {
|
||||
let Some(stack) = self.mods.browser.stacks.get(first + n) else {
|
||||
break;
|
||||
};
|
||||
let Some(stack) = stack.clone() else {
|
||||
continue;
|
||||
};
|
||||
let (x, y) = layout.slot(n);
|
||||
self.draw_item(batch, &stack, x + 1.0, y + 1.0);
|
||||
let rect = [x + 1.0, y + 1.0, 16.0, 16.0];
|
||||
if inside(cursor, [x, y, SLOT, SLOT]) {
|
||||
batch.fill(
|
||||
&self.gui.atlas,
|
||||
x + 1.0,
|
||||
y + 1.0,
|
||||
16.0,
|
||||
16.0,
|
||||
rgba(255, 255, 255, 128),
|
||||
);
|
||||
}
|
||||
let name = self.pack.names.display_item(&stack.item);
|
||||
horizon_client::widgets::record_as(
|
||||
&name,
|
||||
rect,
|
||||
true,
|
||||
horizon_client::widgets::Kind::Button,
|
||||
);
|
||||
}
|
||||
}
|
||||
/// Draws the panel over the open container screen, then the carried
|
||||
/// stack or the tooltip again so they stay on top of it.
|
||||
pub(super) fn draw_mod_browser(&mut self, batch: &mut HudBatch) {
|
||||
if self.browser_sync().is_none() {
|
||||
return;
|
||||
}
|
||||
let Some(layout) = self.browser_layout(batch) else {
|
||||
return;
|
||||
};
|
||||
let cursor = self.cursor;
|
||||
if self.mods.browser.recipes.is_some() {
|
||||
self.draw_browser_recipes(batch, &layout);
|
||||
} else {
|
||||
self.draw_browser_items(batch, &layout);
|
||||
}
|
||||
let hint = self.translate("gui.recipebook.search_hint", "Search...");
|
||||
let search = self.browser_search(&layout);
|
||||
// The hint shows in an empty box without focus, as the recipe book's.
|
||||
search.suggestion = (search.value.is_empty() && !search.focused).then_some(hint);
|
||||
let mut search = search.clone();
|
||||
search.draw(batch, &self.gui.atlas, None);
|
||||
self.mods.browser.search = Some(search);
|
||||
if self.carried.is_some() {
|
||||
self.draw_carried(batch);
|
||||
} else if let Some(stack) = self.browser_pointed_stack(&layout) {
|
||||
let lines = self.stack_tooltip(&stack.1);
|
||||
self.draw_tooltip_lines(batch, &lines, cursor.0, cursor.1);
|
||||
} else {
|
||||
self.draw_slot_tooltip(batch);
|
||||
}
|
||||
}
|
||||
|
||||
/// A mouse press on a container screen: true when the panel took it
|
||||
/// (its search box, page arrows, items or empty space; a click there
|
||||
/// never throws the carried stack).
|
||||
pub(super) fn mod_browser_press(&mut self, right: bool) -> bool {
|
||||
let Some(owner) = self.browser_sync() else {
|
||||
return false;
|
||||
};
|
||||
let Some(batch) = self.browser_batch() else {
|
||||
return false;
|
||||
};
|
||||
let Some(layout) = self.browser_layout(&batch) else {
|
||||
return false;
|
||||
};
|
||||
let cursor = self.cursor;
|
||||
let shift = self.has_shift_down();
|
||||
let atlas = &self.gui.atlas;
|
||||
let search = self.mods.browser.search.get_or_insert_with(|| {
|
||||
let mut edit = EditBox::new(0.0, 0.0, 0.0, 0.0);
|
||||
edit.max_length = 50;
|
||||
edit
|
||||
});
|
||||
[search.x, search.y, search.width, search.height] = layout.search;
|
||||
if inside(cursor, layout.search) {
|
||||
search.focused = true;
|
||||
search.click(atlas, cursor.0, shift, false);
|
||||
search.reset_blink();
|
||||
return true;
|
||||
}
|
||||
search.focused = false;
|
||||
if self.mods.browser.recipes.is_some() {
|
||||
return self.browser_recipes_press(&layout, owner, right)
|
||||
|| inside(cursor, layout.area);
|
||||
}
|
||||
let pages = self.browser_pages(&layout);
|
||||
let page = self.mods.browser.page;
|
||||
let [back, forward] = layout.arrows();
|
||||
let step = if pages > 1 && page > 0 && inside(cursor, [back.0, back.1, 12.0, 17.0]) {
|
||||
Some(page - 1)
|
||||
} else if pages > 1
|
||||
&& page + 1 < pages
|
||||
&& inside(cursor, [forward.0, forward.1, 12.0, 17.0])
|
||||
{
|
||||
Some(page + 1)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if let Some(to) = step {
|
||||
self.mods.browser.page = to;
|
||||
self.audio
|
||||
.play("minecraft:ui.button.click", None, 0.25, 1.0);
|
||||
return true;
|
||||
}
|
||||
if let Some((index, _)) = self.browser_hovered(&layout) {
|
||||
let event = BrowserEvent::Clicked(BrowserClick {
|
||||
index: index as u32,
|
||||
right,
|
||||
});
|
||||
self.mods.mods.browser_event(owner, event);
|
||||
return true;
|
||||
}
|
||||
inside(cursor, layout.area)
|
||||
}
|
||||
|
||||
/// Whether keys go to the panel's search box.
|
||||
pub(super) fn mod_browser_typing(&self) -> bool {
|
||||
self.screen == Screen::Inventory
|
||||
&& self.mods.browser.of.is_some()
|
||||
&& self.mods.browser.search.as_ref().is_some_and(|s| s.focused)
|
||||
}
|
||||
/// A key press while the search box has focus.
|
||||
pub(super) fn mod_browser_key(&mut self, event: &winit::event::KeyEvent) {
|
||||
use winit::keyboard::PhysicalKey;
|
||||
let shortcut = self.edit_shortcut(event);
|
||||
let ctrl = self.has_control_down();
|
||||
let shift = self.has_shift_down();
|
||||
let Some(search) = self.mods.browser.search.as_mut() else {
|
||||
return;
|
||||
};
|
||||
if let Some(shortcut) = shortcut {
|
||||
search.shortcut(shortcut, &mut self.clipboard);
|
||||
} else {
|
||||
let handled = match event.physical_key {
|
||||
PhysicalKey::Code(code) => search.key(code, ctrl, shift),
|
||||
_ => false,
|
||||
};
|
||||
if !handled
|
||||
&& !ctrl
|
||||
&& let Some(text) = &event.text
|
||||
{
|
||||
let text: String = text.chars().filter(|c| !c.is_control()).collect();
|
||||
search.insert_text(&text);
|
||||
}
|
||||
}
|
||||
search.reset_blink();
|
||||
self.browser_searched();
|
||||
}
|
||||
/// Text typed (or one backspace) into the search box (`horizon-ctl type`).
|
||||
pub(super) fn mod_browser_text(&mut self, text: Option<&str>, backspace: bool) {
|
||||
let Some(search) = self.mods.browser.search.as_mut() else {
|
||||
return;
|
||||
};
|
||||
if backspace {
|
||||
search.delete_chars(-1);
|
||||
} else if let Some(text) = text {
|
||||
search.insert_text(text);
|
||||
}
|
||||
self.browser_searched();
|
||||
}
|
||||
/// The mod hears the search text when it changed.
|
||||
fn browser_searched(&mut self) {
|
||||
let (Some((owner, _)), Some(search)) = (self.mods.browser.of, &self.mods.browser.search)
|
||||
else {
|
||||
return;
|
||||
};
|
||||
if search.value != self.mods.browser.sent {
|
||||
let value = search.value.clone();
|
||||
self.mods.browser.sent.clone_from(&value);
|
||||
self.mods.browser.page = 0;
|
||||
self.mods
|
||||
.mods
|
||||
.browser_event(owner, BrowserEvent::Search(value));
|
||||
}
|
||||
}
|
||||
/// The container screen closed: the search box lets go of the keys.
|
||||
pub(super) fn mod_browser_closed(&mut self) {
|
||||
if let Some(search) = self.mods.browser.search.as_mut() {
|
||||
search.focused = false;
|
||||
}
|
||||
}
|
||||
/// The item under the pointer on a container screen, for the mods' keys
|
||||
/// (`browser`'s `key` event): the panel's, else a slot's.
|
||||
pub(super) fn mod_browser_pointed(&mut self) -> Option<BrowserStack> {
|
||||
let batch = self.browser_batch()?;
|
||||
let stack = match self
|
||||
.browser_sync()
|
||||
.and_then(|_| self.browser_layout(&batch))
|
||||
{
|
||||
// The panel's stacks as the mod sent them.
|
||||
Some(layout) if inside(self.cursor, layout.area) => {
|
||||
return Some(self.browser_pointed_stack(&layout)?.0);
|
||||
}
|
||||
_ => {
|
||||
let slot = self.slot_at(&batch, self.cursor.0, self.cursor.1)?;
|
||||
self.slot_view(slot)?
|
||||
}
|
||||
};
|
||||
Some(BrowserStack {
|
||||
item: stack.item.to_string(),
|
||||
count: stack.count,
|
||||
components: super::mod_recipes::components(&stack),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// A mod's item as a stack: an item the game has, its components read as
|
||||
/// `/give` reads them (ones it can't read are left out).
|
||||
#[cfg(feature = "mods")]
|
||||
pub(super) fn browser_stack(
|
||||
items: &horizon_client::items::Items,
|
||||
s: &BrowserStack,
|
||||
) -> Option<ItemStack> {
|
||||
items.get(&s.item)?;
|
||||
let (damage, extra) = if s.components.is_empty() {
|
||||
(0, None)
|
||||
} else {
|
||||
horizon_client::item_data::command_components(&s.components).map_or((0, None), |tag| {
|
||||
horizon_client::item_data::decode_components(Some(&tag))
|
||||
})
|
||||
};
|
||||
Some(ItemStack {
|
||||
item: s.item.as_str().into(),
|
||||
count: s.count.clamp(1, 99),
|
||||
damage,
|
||||
extra,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,523 @@
|
||||
//! `browser.set-recipes` (docs/mods.md): a mod's recipes shown in its item
|
||||
//! panel's place, laid out by the game with its own slots, arrows and
|
||||
//! buttons, paged to the panel's height, under a title and a back button.
|
||||
//! The mod hears clicks on their slots, their action buttons, the back
|
||||
//! button and page turns.
|
||||
use super::{
|
||||
Game,
|
||||
mod_browser::{Layout, browser_stack, inside},
|
||||
};
|
||||
use horizon_client::{
|
||||
hud::{HudBatch, rgba},
|
||||
items::ItemStack,
|
||||
};
|
||||
use horizon_mods::{BrowserEvent, BrowserLayout, BrowserSlotClick, BrowserStack, RecipePage};
|
||||
use std::time::Instant;
|
||||
|
||||
const SLOT: f32 = 18.0;
|
||||
/// The title row's height, and the gap under each recipe.
|
||||
const TITLE: f32 = 18.0;
|
||||
/// A tab's step: an 18 px slot and a gap.
|
||||
const TAB: f32 = 20.0;
|
||||
const GAP: f32 = 6.0;
|
||||
|
||||
/// One recipe as the game draws it.
|
||||
pub(super) struct Shown {
|
||||
layout: BrowserLayout,
|
||||
width: usize,
|
||||
height: usize,
|
||||
/// Each slot's stacks (the result last), as the mod sent them and as
|
||||
/// stacks; a stack the game doesn't have is left out of the cycle.
|
||||
slots: Vec<Vec<(BrowserStack, ItemStack)>>,
|
||||
note: String,
|
||||
action: Option<bool>,
|
||||
}
|
||||
|
||||
/// The recipes a panel shows, and its page.
|
||||
pub(super) struct Pages {
|
||||
title: String,
|
||||
/// The tabs' icons and the selected one.
|
||||
tabs: Vec<(BrowserStack, Option<ItemStack>)>,
|
||||
tab: usize,
|
||||
recipes: Vec<Shown>,
|
||||
page: usize,
|
||||
since: Instant,
|
||||
}
|
||||
|
||||
impl Pages {
|
||||
pub(super) fn new(
|
||||
items: &horizon_client::items::Items,
|
||||
shown: &RecipePage,
|
||||
old: Option<Pages>,
|
||||
) -> Self {
|
||||
let (title, list, tab) = (&shown.title, &shown.recipes, shown.tab as usize);
|
||||
let kept = old.filter(|o| o.title == *title && o.tab == tab);
|
||||
let cycle = |stacks: &[BrowserStack]| -> Vec<(BrowserStack, ItemStack)> {
|
||||
stacks
|
||||
.iter()
|
||||
.filter_map(|s| Some((s.clone(), browser_stack(items, s)?)))
|
||||
.collect()
|
||||
};
|
||||
let recipes = list
|
||||
.iter()
|
||||
.map(|r| {
|
||||
let mut slots: Vec<_> = r.slots.iter().map(|s| cycle(s)).collect();
|
||||
slots.push(cycle(&r.results));
|
||||
Shown {
|
||||
layout: r.layout,
|
||||
width: r.width as usize,
|
||||
height: r.height as usize,
|
||||
slots,
|
||||
note: r.note.clone(),
|
||||
action: r.action,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
Self {
|
||||
title: title.clone(),
|
||||
tabs: shown
|
||||
.tabs
|
||||
.iter()
|
||||
.map(|t| (t.clone(), browser_stack(items, t)))
|
||||
.collect(),
|
||||
tab,
|
||||
recipes,
|
||||
page: kept.as_ref().map_or(0, |o| o.page),
|
||||
since: kept.map_or_else(Instant::now, |o| o.since),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Shown {
|
||||
/// The inputs' block: its size and each input slot's place in it.
|
||||
fn inputs(&self) -> ((f32, f32), Vec<(f32, f32)>) {
|
||||
let row = |n: usize| (0..n).map(|i| (i as f32 * SLOT, 0.0)).collect();
|
||||
match self.layout {
|
||||
BrowserLayout::Grid => {
|
||||
let (w, h) = (self.width.max(1), self.height.max(1));
|
||||
let at = (0..w * h)
|
||||
.map(|i| ((i % w) as f32 * SLOT, (i / w) as f32 * SLOT))
|
||||
.collect();
|
||||
((w as f32 * SLOT, h as f32 * SLOT), at)
|
||||
}
|
||||
// The input over the flame.
|
||||
BrowserLayout::Cooking => ((SLOT, 2.0 * SLOT), vec![(0.0, 0.0)]),
|
||||
BrowserLayout::Single => ((SLOT, SLOT), row(1)),
|
||||
BrowserLayout::Smithing => ((3.0 * SLOT, SLOT), row(3)),
|
||||
BrowserLayout::Brewing => ((2.0 * SLOT, SLOT), row(2)),
|
||||
}
|
||||
}
|
||||
/// The height of its slots, arrow and button, `room` wide.
|
||||
fn block(&self, room: f32) -> f32 {
|
||||
let h = self.inputs().0.1;
|
||||
let (_, _, action) = self.places(room);
|
||||
match self.action {
|
||||
Some(_) => h.max(action.1 + 12.0),
|
||||
None => h,
|
||||
}
|
||||
}
|
||||
fn height(&self, room: f32) -> f32 {
|
||||
let note = if self.note.is_empty() { 0.0 } else { 11.0 };
|
||||
self.block(room) + note + GAP
|
||||
}
|
||||
/// Every slot's place from the recipe's corner (the result last), the
|
||||
/// arrow's and the action button's: beside the result, or under it
|
||||
/// when the panel is `room` wide and that doesn't fit.
|
||||
fn places(&self, room: f32) -> (Vec<(f32, f32)>, (f32, f32), (f32, f32)) {
|
||||
let ((w, h), mut at) = self.inputs();
|
||||
// The mod's slots beyond the layout's are not shown.
|
||||
at.truncate(self.slots.len() - 1);
|
||||
let arrow = (w + 4.0, ((h - 16.0) / 2.0).floor());
|
||||
let result = (w + 32.0, ((h - SLOT) / 2.0).floor());
|
||||
let action = if result.0 + SLOT + 16.0 <= room {
|
||||
(result.0 + SLOT + 4.0, ((h - 12.0) / 2.0).floor())
|
||||
} else {
|
||||
(result.0 + 3.0, result.1 + SLOT + 2.0)
|
||||
};
|
||||
while at.len() < self.slots.len() - 1 {
|
||||
at.push((f32::NAN, f32::NAN));
|
||||
}
|
||||
at.push(result);
|
||||
(at, arrow, action)
|
||||
}
|
||||
}
|
||||
|
||||
/// The tabs' places, in rows under the title row.
|
||||
fn tab_places(pages: &Pages, layout: &Layout) -> Vec<(f32, f32)> {
|
||||
let per_row = ((layout.width() / TAB).floor() as usize).max(1);
|
||||
(0..pages.tabs.len())
|
||||
.map(|i| {
|
||||
(
|
||||
layout.grid.0 + (i % per_row) as f32 * TAB,
|
||||
layout.grid.1 + TITLE + 3.0 + (i / per_row) as f32 * TAB,
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
/// Where the recipes start: under the title row and the tabs.
|
||||
fn recipes_top(pages: &Pages, layout: &Layout) -> f32 {
|
||||
let tabs = tab_places(pages, layout);
|
||||
tabs.last()
|
||||
.map_or(layout.grid.1 + TITLE, |&(_, y)| y + TAB + 3.0)
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
/// The first recipe of each page, filling the panel from the tabs to
|
||||
/// the search box.
|
||||
fn recipe_pages(pages: &Pages, layout: &Layout) -> Vec<usize> {
|
||||
let room = layout.search[1] - 4.0 - recipes_top(pages, layout);
|
||||
let mut starts = vec![0];
|
||||
let mut used = 0.0;
|
||||
for (i, r) in pages.recipes.iter().enumerate() {
|
||||
let h = r.height(layout.width());
|
||||
if used > 0.0 && used + h > room {
|
||||
starts.push(i);
|
||||
used = 0.0;
|
||||
}
|
||||
used += h;
|
||||
}
|
||||
starts
|
||||
}
|
||||
/// The recipes on the open page, each with its corner.
|
||||
fn recipes_shown(&self, layout: &Layout) -> Vec<(usize, (f32, f32))> {
|
||||
let Some(pages) = &self.mods.browser.recipes else {
|
||||
return Vec::new();
|
||||
};
|
||||
let starts = Self::recipe_pages(pages, layout);
|
||||
let page = pages.page.min(starts.len() - 1);
|
||||
let end = starts.get(page + 1).copied().unwrap_or(pages.recipes.len());
|
||||
let mut y = recipes_top(pages, layout);
|
||||
(starts[page]..end)
|
||||
.map(|i| {
|
||||
let at = (layout.grid.0, y);
|
||||
y += pages.recipes[i].height(layout.width());
|
||||
(i, at)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
fn back_button(&self, layout: &Layout) -> ([f32; 4], String) {
|
||||
let label = self.translate("gui.back", "Back");
|
||||
let w = HudBatch::text_width(&self.gui.atlas, &label) + 8.0;
|
||||
([layout.grid.0, layout.grid.1, w, 14.0], label)
|
||||
}
|
||||
/// What a slot of a shown recipe holds now: its stacks cycle once a second.
|
||||
fn recipe_slot_now(&self, recipe: usize, slot: usize) -> Option<&(BrowserStack, ItemStack)> {
|
||||
let pages = self.mods.browser.recipes.as_ref()?;
|
||||
let stacks = pages.recipes.get(recipe)?.slots.get(slot)?;
|
||||
let n = pages.since.elapsed().as_secs() as usize;
|
||||
stacks.get(n % stacks.len().max(1))
|
||||
}
|
||||
/// The tab under the pointer: its index and icon.
|
||||
pub(super) fn recipe_tab_hovered(
|
||||
&self,
|
||||
layout: &Layout,
|
||||
) -> Option<(usize, BrowserStack, ItemStack)> {
|
||||
let pages = self.mods.browser.recipes.as_ref()?;
|
||||
let places = tab_places(pages, layout);
|
||||
places.iter().enumerate().find_map(|(i, &(x, y))| {
|
||||
if !inside(self.cursor, [x, y, SLOT, SLOT]) {
|
||||
return None;
|
||||
}
|
||||
let (sent, stack) = pages.tabs.get(i)?;
|
||||
Some((i, sent.clone(), stack.clone()?))
|
||||
})
|
||||
}
|
||||
/// The recipe slot under the pointer: recipe, slot, and its stack now.
|
||||
pub(super) fn recipe_slot_hovered(
|
||||
&self,
|
||||
layout: &Layout,
|
||||
) -> Option<(usize, usize, BrowserStack, ItemStack)> {
|
||||
let pages = self.mods.browser.recipes.as_ref()?;
|
||||
self.recipes_shown(layout)
|
||||
.into_iter()
|
||||
.find_map(|(i, (x, y))| {
|
||||
let (at, _, _) = pages.recipes[i].places(layout.width());
|
||||
at.iter().enumerate().find_map(|(k, &(sx, sy))| {
|
||||
if !inside(self.cursor, [x + sx, y + sy, SLOT, SLOT]) {
|
||||
return None;
|
||||
}
|
||||
let (mod_stack, stack) = self.recipe_slot_now(i, k)?.clone();
|
||||
Some((i, k, mod_stack, stack))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/// Draws the title row, the page's recipes and the page arrows.
|
||||
pub(super) fn draw_browser_recipes(&mut self, batch: &mut HudBatch, layout: &Layout) {
|
||||
let Some(pages) = &self.mods.browser.recipes else {
|
||||
return;
|
||||
};
|
||||
let cursor = self.cursor;
|
||||
let starts = Self::recipe_pages(pages, layout);
|
||||
let page = pages.page.min(starts.len() - 1);
|
||||
let (title, pages_n) = (pages.title.clone(), starts.len());
|
||||
let (back, label) = self.back_button(layout);
|
||||
let atlas = &self.gui.atlas;
|
||||
let hover = inside(cursor, back);
|
||||
let sprite = if hover {
|
||||
"gui/sprites/widget/button_highlighted"
|
||||
} else {
|
||||
"gui/sprites/widget/button"
|
||||
};
|
||||
horizon_client::ui::nine_slice(
|
||||
batch,
|
||||
atlas,
|
||||
sprite,
|
||||
back[0],
|
||||
back[1],
|
||||
back[2],
|
||||
back[3],
|
||||
u32::MAX,
|
||||
);
|
||||
batch.text(atlas, &label, back[0] + 4.0, back[1] + 3.0, u32::MAX, true);
|
||||
horizon_client::widgets::record(&label, back, true);
|
||||
let room = layout.width() - back[2] - 4.0;
|
||||
// Cut to the panel with vanilla's ellipsis (CommonComponents.ELLIPSIS).
|
||||
let shown = if atlas.font.fit(&title, room) >= title.chars().count() {
|
||||
title
|
||||
} else {
|
||||
let fit = atlas.font.fit(&title, room - atlas.font.width("..."));
|
||||
let kept: String = title.chars().take(fit).collect();
|
||||
format!("{}...", kept.trim_end())
|
||||
};
|
||||
batch.text(
|
||||
atlas,
|
||||
&shown,
|
||||
back[0] + back[2] + 4.0,
|
||||
back[1] + 3.0,
|
||||
u32::MAX,
|
||||
true,
|
||||
);
|
||||
self.draw_recipe_tabs(batch, layout);
|
||||
if pages_n > 1 {
|
||||
let labels = [
|
||||
self.translate("gui.recipebook.previous_page", "Previous Page"),
|
||||
self.translate("gui.recipebook.next_page", "Next Page"),
|
||||
];
|
||||
let visible = [page > 0, page + 1 < pages_n];
|
||||
let atlas = &self.gui.atlas;
|
||||
for (((x, y), name), (label, visible)) in layout
|
||||
.arrows()
|
||||
.into_iter()
|
||||
.zip(["page_backward", "page_forward"])
|
||||
.zip(labels.iter().zip(visible))
|
||||
{
|
||||
if !visible {
|
||||
continue;
|
||||
}
|
||||
let suffix = if inside(cursor, [x, y, 12.0, 17.0]) {
|
||||
"_highlighted"
|
||||
} else {
|
||||
""
|
||||
};
|
||||
batch.sprite(
|
||||
atlas,
|
||||
&format!("gui/sprites/recipe_book/{name}{suffix}"),
|
||||
x,
|
||||
y,
|
||||
);
|
||||
horizon_client::widgets::record(label, [x, y, 12.0, 17.0], true);
|
||||
}
|
||||
let text = format!("{}/{pages_n}", page + 1);
|
||||
let w = HudBatch::text_width(atlas, &text);
|
||||
let x = (layout.grid.0 + (layout.width() - w) / 2.0).floor();
|
||||
batch.text(atlas, &text, x, layout.arrows_y + 5.0, u32::MAX, true);
|
||||
}
|
||||
for (i, (x, y)) in self.recipes_shown(layout) {
|
||||
let Some(r) = self.mods.browser.recipes.as_ref().map(|p| &p.recipes[i]) else {
|
||||
break;
|
||||
};
|
||||
let (at, arrow, action) = r.places(layout.width());
|
||||
let h = r.block(layout.width());
|
||||
let (cooking, note, button) =
|
||||
(r.layout == BrowserLayout::Cooking, r.note.clone(), r.action);
|
||||
let atlas = &self.gui.atlas;
|
||||
for &(sx, sy) in at.iter().filter(|p| p.0.is_finite()) {
|
||||
batch.sprite(atlas, "gui/sprites/container/slot", x + sx, y + sy);
|
||||
}
|
||||
if cooking {
|
||||
batch.sprite(
|
||||
atlas,
|
||||
"gui/sprites/container/furnace/lit_progress",
|
||||
x + 2.0,
|
||||
y + SLOT + 2.0,
|
||||
);
|
||||
}
|
||||
batch.sprite(
|
||||
atlas,
|
||||
"gui/sprites/container/furnace/burn_progress",
|
||||
x + arrow.0,
|
||||
y + arrow.1,
|
||||
);
|
||||
if let Some(enabled) = button {
|
||||
let rect = [x + action.0, y + action.1, 12.0, 12.0];
|
||||
let sprite = match (enabled, inside(cursor, rect)) {
|
||||
(false, _) => "gui/sprites/widget/button_disabled",
|
||||
(true, true) => "gui/sprites/widget/button_highlighted",
|
||||
(true, false) => "gui/sprites/widget/button",
|
||||
};
|
||||
horizon_client::ui::nine_slice(
|
||||
batch,
|
||||
atlas,
|
||||
sprite,
|
||||
rect[0],
|
||||
rect[1],
|
||||
12.0,
|
||||
12.0,
|
||||
u32::MAX,
|
||||
);
|
||||
let color = if enabled {
|
||||
u32::MAX
|
||||
} else {
|
||||
rgba(160, 160, 160, 255)
|
||||
};
|
||||
batch.text(atlas, "+", rect[0] + 3.0, rect[1] + 2.0, color, true);
|
||||
horizon_client::widgets::record(&format!("+ {i}"), rect, enabled);
|
||||
}
|
||||
if !note.is_empty() {
|
||||
batch.text(atlas, ¬e, x, y + h + 2.0, rgba(224, 224, 224, 255), true);
|
||||
}
|
||||
for (k, &(sx, sy)) in at.iter().enumerate().filter(|(_, p)| p.0.is_finite()) {
|
||||
let Some((_, stack)) = self.recipe_slot_now(i, k).cloned() else {
|
||||
continue;
|
||||
};
|
||||
self.draw_item(batch, &stack, x + sx + 1.0, y + sy + 1.0);
|
||||
if inside(cursor, [x + sx, y + sy, SLOT, SLOT]) {
|
||||
batch.fill(
|
||||
&self.gui.atlas,
|
||||
x + sx + 1.0,
|
||||
y + sy + 1.0,
|
||||
16.0,
|
||||
16.0,
|
||||
rgba(255, 255, 255, 128),
|
||||
);
|
||||
}
|
||||
let name = self.pack.names.display_item(&stack.item);
|
||||
horizon_client::widgets::record_as(
|
||||
&format!("{name} {i}:{k}"),
|
||||
[x + sx + 1.0, y + sy + 1.0, 16.0, 16.0],
|
||||
true,
|
||||
horizon_client::widgets::Kind::Button,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The tabs, on slots; the selected one framed.
|
||||
fn draw_recipe_tabs(&mut self, batch: &mut HudBatch, layout: &Layout) {
|
||||
let Some(pages) = &self.mods.browser.recipes else {
|
||||
return;
|
||||
};
|
||||
let places = tab_places(pages, layout);
|
||||
let tabs: Vec<Option<ItemStack>> = pages.tabs.iter().map(|t| t.1.clone()).collect();
|
||||
let selected = pages.tab;
|
||||
for (i, (&(x, y), stack)) in places.iter().zip(tabs).enumerate() {
|
||||
let atlas = &self.gui.atlas;
|
||||
batch.sprite(atlas, "gui/sprites/container/slot", x, y);
|
||||
let Some(stack) = stack else {
|
||||
continue;
|
||||
};
|
||||
self.draw_item(batch, &stack, x + 1.0, y + 1.0);
|
||||
// The selected tab: a white frame around its slot.
|
||||
if i == selected {
|
||||
let white = rgba(255, 255, 255, 255);
|
||||
let atlas = &self.gui.atlas;
|
||||
batch.fill(atlas, x - 1.0, y - 1.0, SLOT + 2.0, 1.0, white);
|
||||
batch.fill(atlas, x - 1.0, y + SLOT, SLOT + 2.0, 1.0, white);
|
||||
batch.fill(atlas, x - 1.0, y, 1.0, SLOT, white);
|
||||
batch.fill(atlas, x + SLOT, y, 1.0, SLOT, white);
|
||||
}
|
||||
if inside(self.cursor, [x, y, SLOT, SLOT]) {
|
||||
batch.fill(
|
||||
&self.gui.atlas,
|
||||
x + 1.0,
|
||||
y + 1.0,
|
||||
16.0,
|
||||
16.0,
|
||||
rgba(255, 255, 255, 128),
|
||||
);
|
||||
}
|
||||
let name = self.pack.names.display_item(&stack.item);
|
||||
horizon_client::widgets::record_as(
|
||||
&format!("{name} tab"),
|
||||
[x + 1.0, y + 1.0, 16.0, 16.0],
|
||||
true,
|
||||
horizon_client::widgets::Kind::Tab(i == selected),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A press on the recipes: true when it hit one of their parts.
|
||||
pub(super) fn browser_recipes_press(
|
||||
&mut self,
|
||||
layout: &Layout,
|
||||
owner: usize,
|
||||
right: bool,
|
||||
) -> bool {
|
||||
let Some(pages) = &self.mods.browser.recipes else {
|
||||
return false;
|
||||
};
|
||||
let cursor = self.cursor;
|
||||
let starts = Self::recipe_pages(pages, layout);
|
||||
let page = pages.page.min(starts.len() - 1);
|
||||
let [back_arrow, forward] = layout.arrows();
|
||||
let turn = if page > 0 && inside(cursor, [back_arrow.0, back_arrow.1, 12.0, 17.0]) {
|
||||
Some(page - 1)
|
||||
} else if page + 1 < starts.len() && inside(cursor, [forward.0, forward.1, 12.0, 17.0]) {
|
||||
Some(page + 1)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if let Some(to) = turn {
|
||||
if let Some(p) = self.mods.browser.recipes.as_mut() {
|
||||
p.page = to;
|
||||
}
|
||||
self.audio
|
||||
.play("minecraft:ui.button.click", None, 0.25, 1.0);
|
||||
self.mods
|
||||
.mods
|
||||
.browser_event(owner, BrowserEvent::Page(starts[to] as u32));
|
||||
return true;
|
||||
}
|
||||
if let Some((tab, _, _)) = self.recipe_tab_hovered(layout) {
|
||||
self.audio
|
||||
.play("minecraft:ui.button.click", None, 0.25, 1.0);
|
||||
self.mods
|
||||
.mods
|
||||
.browser_event(owner, BrowserEvent::Tab(tab as u32));
|
||||
return true;
|
||||
}
|
||||
if inside(cursor, self.back_button(layout).0) {
|
||||
self.audio
|
||||
.play("minecraft:ui.button.click", None, 0.25, 1.0);
|
||||
self.mods.mods.browser_event(owner, BrowserEvent::Back);
|
||||
return true;
|
||||
}
|
||||
if let Some((recipe, slot, stack, _)) = self.recipe_slot_hovered(layout) {
|
||||
let event = BrowserEvent::SlotClicked(BrowserSlotClick {
|
||||
recipe: recipe as u32,
|
||||
slot: slot as u32,
|
||||
stack,
|
||||
right,
|
||||
});
|
||||
self.mods.mods.browser_event(owner, event);
|
||||
return true;
|
||||
}
|
||||
let action = self.recipes_shown(layout).into_iter().find(|&(i, (x, y))| {
|
||||
let r = &pages.recipes[i];
|
||||
let (_, _, (ax, ay)) = r.places(layout.width());
|
||||
r.action.is_some() && inside(cursor, [x + ax, y + ay, 12.0, 12.0])
|
||||
});
|
||||
if let Some((i, _)) = action {
|
||||
if pages.recipes[i].action == Some(true) {
|
||||
self.audio
|
||||
.play("minecraft:ui.button.click", None, 0.25, 1.0);
|
||||
self.mods
|
||||
.mods
|
||||
.browser_event(owner, BrowserEvent::Action(i as u32));
|
||||
}
|
||||
return true;
|
||||
}
|
||||
false
|
||||
}
|
||||
}
|
||||
@@ -111,6 +111,18 @@ impl Game<'_> {
|
||||
if repeat || !self.mods.mods.has_keys() {
|
||||
return;
|
||||
}
|
||||
// Over an item on a container screen, the mods' keys are `browser`
|
||||
// events (a recipe viewer's R and U).
|
||||
if pressed
|
||||
&& self.screen == Screen::Inventory
|
||||
&& self.title.is_none()
|
||||
&& !self.mod_browser_typing()
|
||||
&& let Some(name) = horizon_client::keybinds::key_name(key)
|
||||
&& let Some(stack) = self.mod_browser_pointed()
|
||||
{
|
||||
self.mods.mods.browser_key(name, &stack);
|
||||
return;
|
||||
}
|
||||
if pressed && (self.screen != Screen::Playing || self.title.is_some()) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -35,7 +35,7 @@ impl Index {
|
||||
|
||||
/// A stack's components beyond its item's, as vanilla's item syntax writes
|
||||
/// them (`[minecraft:potion_contents={potion:"minecraft:swiftness"}]`), or "".
|
||||
fn components(s: &ItemStack) -> String {
|
||||
pub(super) fn components(s: &ItemStack) -> String {
|
||||
use horizon_world::nbt::Tag;
|
||||
let Some(Tag::Compound(entries)) = horizon_client::item_data::encode_components(s) else {
|
||||
return String::new();
|
||||
@@ -70,6 +70,28 @@ fn recipe(id: String, kind: RecipeKind) -> Recipe {
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
/// `recipes.give`: a creative player takes the stack as from the
|
||||
/// creative inventory; a server hears it as `SetCreativeModeSlot`
|
||||
/// through the inventory's sync, and refuses it unless creative.
|
||||
pub(super) fn mod_give(&mut self, item: &str, count: u32, components: &str) {
|
||||
let Some(max) = self.res.items().get(item).map(|i| i.max_stack) else {
|
||||
return;
|
||||
};
|
||||
if !self.creative {
|
||||
return;
|
||||
}
|
||||
let (damage, extra) = horizon_client::item_data::command_components(components)
|
||||
.map_or((0, None), |tag| {
|
||||
horizon_client::item_data::decode_components(Some(&tag))
|
||||
});
|
||||
let _ = self.give(ItemStack {
|
||||
item: item.into(),
|
||||
// One stack at most, as the creative inventory hands out.
|
||||
count: count.min(max),
|
||||
damage,
|
||||
extra,
|
||||
});
|
||||
}
|
||||
/// Capability `recipes`'s tables and known displays for this tick's
|
||||
/// snapshot; nothing while no running mod reads them.
|
||||
pub(super) fn mod_recipes(&mut self) -> (Option<Arc<Recipes>>, Arc<[i32]>) {
|
||||
|
||||
@@ -95,6 +95,9 @@ pub(super) struct ModsState {
|
||||
/// The recipe book's known displays, and the book's version they are of.
|
||||
#[cfg(feature = "mods")]
|
||||
pub(super) known_recipes: (Option<u64>, std::sync::Arc<[i32]>),
|
||||
/// Capability `ui`'s item panel beside container screens.
|
||||
#[cfg(feature = "mods")]
|
||||
pub(super) browser: super::mod_browser::Browser,
|
||||
/// Extras that are official mods now, being installed.
|
||||
#[cfg(feature = "mods")]
|
||||
pub(super) moved: super::moved_extras::MovedState,
|
||||
@@ -471,6 +474,8 @@ impl Game<'_> {
|
||||
view_hit: self.mod_view_hit(),
|
||||
recipes,
|
||||
known_recipes,
|
||||
placing: self.mod_recipe_screen(),
|
||||
creative: self.creative,
|
||||
}
|
||||
}
|
||||
/// The first block along the player's view within
|
||||
@@ -708,6 +713,18 @@ impl Game<'_> {
|
||||
self.mods.shake.add(strength, seconds);
|
||||
continue;
|
||||
}
|
||||
Output::PlaceRecipe { book_id, all } => {
|
||||
self.mod_place_recipe(book_id, all);
|
||||
continue;
|
||||
}
|
||||
Output::Give {
|
||||
item,
|
||||
count,
|
||||
components,
|
||||
} => {
|
||||
self.mod_give(&item, count, &components);
|
||||
continue;
|
||||
}
|
||||
Output::CloseScreen { owner } => {
|
||||
if self.mods.screen.as_ref().is_some_and(|s| s.owner == owner) {
|
||||
self.close_mod_screen(false);
|
||||
|
||||
@@ -845,6 +845,34 @@ impl Game<'_> {
|
||||
self.recipe_ui.ghost.clear();
|
||||
self.place_recipe(book_type, entry_index, use_max);
|
||||
}
|
||||
/// Whether the open screen takes `recipes.place`: a crafting grid's
|
||||
/// book (the inventory's or a crafting table's).
|
||||
#[cfg(feature = "mods")]
|
||||
pub(super) fn mod_recipe_screen(&self) -> bool {
|
||||
self.screen == Screen::Inventory
|
||||
&& self.active_book() == Some(BookType::Crafting)
|
||||
&& !self.spectator()
|
||||
}
|
||||
/// `recipes.place`: the book's click on the recipe with this display
|
||||
/// id, refused as `handlePlaceRecipe` refuses it (no crafting screen,
|
||||
/// not in the player's book; an impossible one in `place_recipe`).
|
||||
#[cfg(feature = "mods")]
|
||||
pub(super) fn mod_place_recipe(&mut self, display_id: i32, all: bool) {
|
||||
if !self.mod_recipe_screen() || !self.recipe_ui.client.displays().contains(&display_id) {
|
||||
return;
|
||||
}
|
||||
self.ensure_book();
|
||||
let Some(index) = self
|
||||
.recipe_ui
|
||||
.book
|
||||
.as_ref()
|
||||
.and_then(|b| b.entries.iter().position(|e| e.display_id == display_id))
|
||||
else {
|
||||
return;
|
||||
};
|
||||
self.recipe_ui.ghost.clear();
|
||||
self.place_recipe(BookType::Crafting, index, all);
|
||||
}
|
||||
/// `ServerboundPlaceRecipePacket`: the server places the recipe
|
||||
/// (`ServerPlaceRecipe` on the menu engine) and answers with a ghost
|
||||
/// recipe when the items are missing.
|
||||
|
||||
@@ -196,6 +196,82 @@ impl WorldLoader {
|
||||
}
|
||||
}
|
||||
|
||||
/// How long the preloaded world data outlives the world screens: a player
|
||||
/// who goes back to the title or to Multiplayer and opens no world for this
|
||||
/// long gets the memory back.
|
||||
const PRELOAD_KEPT: std::time::Duration = std::time::Duration::from_secs(60);
|
||||
|
||||
enum Preload {
|
||||
Idle,
|
||||
/// Read, or being read, on the "World data preload" thread. `left`: when
|
||||
/// the player last left the world screens.
|
||||
Started {
|
||||
thread: std::thread::JoinHandle<()>,
|
||||
left: Option<std::time::Instant>,
|
||||
},
|
||||
/// A world opened: the data is the world's now, and stays.
|
||||
Used,
|
||||
}
|
||||
|
||||
static PRELOAD: std::sync::Mutex<Preload> = std::sync::Mutex::new(Preload::Idle);
|
||||
|
||||
/// Each frame of the title: read what every world opening loads and keeps
|
||||
/// for the rest of the process (the worldgen bundle, the loot tables and the
|
||||
/// registries the server sends) on a helper thread while the player is on the
|
||||
/// world screens (`on_world_screens`: Singleplayer, Create World and their
|
||||
/// dialogs), so it overlaps the time spent choosing a world. A world opened
|
||||
/// meanwhile waits for the part being read instead of reading it again. Not
|
||||
/// at the title screen itself: many players only go to Multiplayer, and
|
||||
/// small machines need the memory. Dropped after `PRELOAD_KEPT` away from
|
||||
/// the world screens without a world opening.
|
||||
pub(super) fn preload_world_data(
|
||||
on_world_screens: bool,
|
||||
resources: &std::path::Path,
|
||||
registry: &'static horizon_world::registry::BlockRegistry,
|
||||
) {
|
||||
let mut preload = PRELOAD.lock().unwrap_or_else(|e| e.into_inner());
|
||||
match &mut *preload {
|
||||
Preload::Used => {}
|
||||
Preload::Idle if on_world_screens => {
|
||||
let resources = resources.to_owned();
|
||||
let spawned = std::thread::Builder::new()
|
||||
.name("World data preload".into())
|
||||
.spawn(move || {
|
||||
horizon_threads::behind_server();
|
||||
// Errors are left for the world opening to report.
|
||||
let _ = horizon_client::vanillagen::shared_data(
|
||||
&resources.join("worldgen"),
|
||||
registry,
|
||||
);
|
||||
let _ = horizon_client::structure_blocks::loot_data(&resources);
|
||||
let _ = super::lan::sync_data(&resources);
|
||||
});
|
||||
match spawned {
|
||||
Ok(thread) => *preload = Preload::Started { thread, left: None },
|
||||
Err(e) => log::warn!("Horizon: world data preload: {e}"),
|
||||
}
|
||||
}
|
||||
Preload::Idle => {}
|
||||
Preload::Started { left, .. } if on_world_screens => *left = None,
|
||||
Preload::Started { thread, left } => {
|
||||
let left = *left.get_or_insert_with(std::time::Instant::now);
|
||||
if left.elapsed() >= PRELOAD_KEPT && thread.is_finished() {
|
||||
horizon_client::vanillagen::forget_shared_data();
|
||||
horizon_client::structure_blocks::forget_loot_data();
|
||||
super::lan::forget_sync_data();
|
||||
horizon_threads::release_free_memory();
|
||||
*preload = Preload::Idle;
|
||||
log::info!("Horizon: preloaded world data dropped, no world opened");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A world opens: what `preload_world_data` read is the world's from now on.
|
||||
pub(super) fn world_data_used() {
|
||||
*PRELOAD.lock().unwrap_or_else(|e| e.into_inner()) = Preload::Used;
|
||||
}
|
||||
|
||||
pub(super) fn make_streaming(
|
||||
res: &Resources,
|
||||
pack: &PackResources,
|
||||
|
||||
@@ -517,6 +517,8 @@ pub enum DamageKind {
|
||||
InFire,
|
||||
Lava,
|
||||
Drown,
|
||||
/// `dry_out`: an axolotl out of water for too long.
|
||||
DryOut,
|
||||
Magic,
|
||||
Sting,
|
||||
Cactus,
|
||||
@@ -575,7 +577,11 @@ impl Damage {
|
||||
pub fn causes_panic(&self) -> bool {
|
||||
!matches!(
|
||||
self.kind,
|
||||
DamageKind::Fall | DamageKind::Drown | DamageKind::Generic | DamageKind::InWall
|
||||
DamageKind::Fall
|
||||
| DamageKind::Drown
|
||||
| DamageKind::DryOut
|
||||
| DamageKind::Generic
|
||||
| DamageKind::InWall
|
||||
)
|
||||
}
|
||||
/// DamageTypeTags.BYPASSES_ARMOR
|
||||
@@ -608,6 +614,7 @@ impl Damage {
|
||||
| DamageKind::InFire
|
||||
| DamageKind::Lava
|
||||
| DamageKind::Drown
|
||||
| DamageKind::DryOut
|
||||
| DamageKind::Magic
|
||||
| DamageKind::Cactus
|
||||
| DamageKind::Generic
|
||||
@@ -1880,6 +1887,12 @@ impl Mob {
|
||||
}
|
||||
}
|
||||
}
|
||||
// Squid.hurtServer: a squid hurt after a living entity hit it squirts ink.
|
||||
if matches!(self.species(), Species::Squid | Species::GlowSquid)
|
||||
&& self.ai.last_hurt_by.is_some()
|
||||
{
|
||||
aquatic::spawn_ink(self);
|
||||
}
|
||||
true
|
||||
}
|
||||
/// LivingEntity.knockback(power, xd, zd) towards the negative direction.
|
||||
|
||||
@@ -444,6 +444,34 @@ pub fn tick_swing(m: &mut Mob) {
|
||||
}
|
||||
m.anim.attack_anim = m.ai.swing_time as f32 / 6.0;
|
||||
}
|
||||
/// `LivingEntity.baseTick` refills a breather's air by 4, then
|
||||
/// `Axolotl.handleAirSupply` (unless the mob has no AI): water or rain fills
|
||||
/// it, anywhere else it runs down from the pre-tick value, and at -20 the
|
||||
/// axolotl dries out.
|
||||
fn axolotl_air_supply(m: &mut Mob, w: &MobWorld) {
|
||||
let before = m.air;
|
||||
m.air = (m.air + 4).min(super::axolotl::MAX_AIR);
|
||||
if m.no_ai {
|
||||
return;
|
||||
}
|
||||
// Entity.isInRain: rain at the block, or at the top of the box.
|
||||
let (x, y, z) = (
|
||||
m.position.x.floor() as i32,
|
||||
m.position.y.floor() as i32,
|
||||
m.position.z.floor() as i32,
|
||||
);
|
||||
let top = (m.position.y + f64::from(m.height())).floor() as i32;
|
||||
let in_rain = w.raining_at(x, y, z) || w.raining_at(x, top, z);
|
||||
if m.fluids.in_water || in_rain {
|
||||
m.air = super::axolotl::MAX_AIR;
|
||||
} else {
|
||||
m.air = before - 1;
|
||||
if m.air <= -20 {
|
||||
m.air = 0;
|
||||
m.damage(2.0, Damage::of(DamageKind::DryOut));
|
||||
}
|
||||
}
|
||||
}
|
||||
/// Entity.baseTick + LivingEntity.baseTick + Mob.baseTick.
|
||||
fn base_tick(m: &mut Mob, w: &MobWorld, events: &mut Vec<MobEvent>) {
|
||||
m.was_eye_in_water = m.fluids.eye_in_water;
|
||||
@@ -503,7 +531,9 @@ fn base_tick(m: &mut Mob, w: &MobWorld, events: &mut Vec<MobEvent>) {
|
||||
m.damage(1.0, Damage::of(DamageKind::InWall));
|
||||
}
|
||||
// Air supply and drowning (WaterAnimal.handleAirSupply for aquatic mobs).
|
||||
if m.kind.flags.needs_water {
|
||||
if m.species() == Species::Axolotl {
|
||||
axolotl_air_supply(m, w);
|
||||
} else if m.kind.flags.needs_water {
|
||||
if !m.fluids.in_water {
|
||||
m.air -= 1;
|
||||
if m.air == -20 {
|
||||
|
||||
@@ -24,6 +24,58 @@ const Z_BODY_ROT: usize = 3;
|
||||
const TENTACLE_SPEED: usize = 4;
|
||||
const ROTATE_SPEED: usize = 5;
|
||||
|
||||
/// `Squid.spawnInk`: the squirt sound and 30 ink particles shot from under
|
||||
/// the squid, turned by its body rotation.
|
||||
pub fn spawn_ink(m: &mut Mob) {
|
||||
let sound = if m.species() == Species::GlowSquid {
|
||||
"entity.glow_squid.squirt"
|
||||
} else {
|
||||
"entity.squid.squirt"
|
||||
};
|
||||
let kind = if m.species() == Species::GlowSquid {
|
||||
"glow_squid_ink"
|
||||
} else {
|
||||
"squid_ink"
|
||||
};
|
||||
// makeSound draws the voice pitch even for a silent squid.
|
||||
let pitch = m.voice_pitch();
|
||||
if !m.silent {
|
||||
let at = [m.position.x, m.position.y, m.position.z];
|
||||
m.outbox.push(MobEvent::Sound(
|
||||
sound,
|
||||
at,
|
||||
super::ai::sound_volume(m),
|
||||
pitch,
|
||||
));
|
||||
}
|
||||
// rotateVector: xRot(xBodyRotO), then yRot(-yBodyRotO).
|
||||
let to_radians = (std::f64::consts::PI / 180.0) as f32;
|
||||
let (x_rot, y_rot) = (
|
||||
m.anim.vo[X_BODY_ROT] * to_radians,
|
||||
-m.previous_body_yaw * to_radians,
|
||||
);
|
||||
let rotate = |v: Vec3| v.rotate_x(x_rot).rotate_y(y_rot);
|
||||
let pos = rotate(Vec3::new(0.0, -1.0, 0.0)).add_vector(m.position);
|
||||
let offset_scale: f32 = if m.is_baby() { 0.1 } else { 0.3 };
|
||||
for _ in 0..30 {
|
||||
let x = f64::from(m.random.next_float()) * 0.6 - 0.3;
|
||||
let z = f64::from(m.random.next_float()) * 0.6 - 0.3;
|
||||
let dir = rotate(Vec3::new(x, -1.0, z));
|
||||
let d = dir.scale(f64::from(offset_scale + m.random.next_float() * 2.0));
|
||||
// ServerLevel.sendParticles with a count of 0 and speed 0.1: one
|
||||
// particle moving at the float offset times the float speed.
|
||||
let speed = f64::from(0.1f32);
|
||||
m.outbox.push(MobEvent::ParticleAt {
|
||||
kind,
|
||||
at: [pos.x, pos.y + 0.5, pos.z],
|
||||
velocity: [
|
||||
speed * f64::from(d.x as f32),
|
||||
speed * f64::from(d.y as f32),
|
||||
speed * f64::from(d.z as f32),
|
||||
],
|
||||
});
|
||||
}
|
||||
}
|
||||
pub fn register(m: &mut Mob) {
|
||||
use Species as S;
|
||||
match m.species() {
|
||||
@@ -36,6 +88,7 @@ pub fn register(m: &mut Mob) {
|
||||
m.anim.v[TENTACLE_SPEED] = speed;
|
||||
}
|
||||
S::Axolotl => {
|
||||
m.air = super::axolotl::MAX_AIR;
|
||||
// Native controller scaling is essential: the base speed attribute is 1.0.
|
||||
m.ai.mov.kind = MoveKind::SmoothSwimming {
|
||||
max_turn_x: 85.0,
|
||||
|
||||
@@ -3,6 +3,8 @@ use super::{Mob, MobEvent, MobWorld, Species, Who, animal_brain, keyframes::Anim
|
||||
use horizon_core::random::RandomSource;
|
||||
pub const PLAY_DEAD_TICKS: i32 = 200;
|
||||
pub const HUNTING_COOLDOWN: i64 = 2400;
|
||||
/// `Axolotl.getMaxAirSupply`.
|
||||
pub const MAX_AIR: i32 = 6000;
|
||||
|
||||
/// AxolotlAttackablesSensor: always-hostile targets bypass the hunting cooldown.
|
||||
pub fn prey_eligible(
|
||||
|
||||
@@ -206,6 +206,19 @@ impl MobWorld<'_> {
|
||||
}
|
||||
self.world.light(x, y, z) >> 4 == 15
|
||||
}
|
||||
/// Level.isRainingAt: raining, open to the sky, nothing motion blocking
|
||||
/// above, and the biome's precipitation at the position is rain, not snow
|
||||
/// or none.
|
||||
pub fn raining_at(&self, x: i32, y: i32, z: i32) -> bool {
|
||||
if !self.raining || !self.can_see_sky(x, y, z) || self.motion_blocking_height(x, z) > y {
|
||||
return false;
|
||||
}
|
||||
let Some(chunk) = self.world.chunks.get(&(x >> 4, z >> 4)) else {
|
||||
return false;
|
||||
};
|
||||
let biome = self.biomes.get(chunk.biome(x, y, z));
|
||||
biome.precipitation && biome.temperature_at(BlockPos::new(x, y, z), self.sea_level) >= 0.15
|
||||
}
|
||||
/// Level.getLightLevelDependentMagicValue: the dimension's brightness curve
|
||||
/// at the raw (sky darkened) light level.
|
||||
pub fn brightness(&self, x: i32, y: i32, z: i32) -> f32 {
|
||||
|
||||
@@ -52,6 +52,8 @@ pub struct MotionResources {
|
||||
pub climbable: BTreeSet<String>,
|
||||
/// `BlockTags.CAN_GLIDE_THROUGH`: climbable blocks a gliding player passes.
|
||||
pub glide_through: BTreeSet<String>,
|
||||
/// `BlockTags.SOUL_SPEED_BLOCKS`: what Soul Speed's effects look for.
|
||||
pub soul_speed_blocks: BTreeSet<String>,
|
||||
pub fall_reset: BTreeSet<String>,
|
||||
pub landing: BlockLandingRegistry,
|
||||
pub health: crate::health::HealthResources,
|
||||
@@ -111,11 +113,29 @@ impl MotionResources {
|
||||
})?,
|
||||
climbable: tag("minecraft:climbable")?,
|
||||
glide_through: tag("minecraft:can_glide_through")?,
|
||||
soul_speed_blocks: tag("minecraft:soul_speed_blocks")?,
|
||||
fall_reset: tag("minecraft:fall_damage_resetting")?,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// What the `location_changed` enchantment effects (Soul Speed) need to know
|
||||
/// between ticks.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct LocationEffects {
|
||||
/// `MovementAffectedByPredicate`: the block under the player
|
||||
/// (`getBlockPosBelowThatAffectsMyMovement`) is in `#minecraft:soul_speed_blocks`.
|
||||
pub on_soul_speed_block: bool,
|
||||
/// `LivingEntity.lastPos`: the block the player was in last tick.
|
||||
pub block: Option<BlockPos>,
|
||||
/// A landing ran `onChangedBlock` (`LivingEntity.checkFallDamage`).
|
||||
pub landed: bool,
|
||||
/// The Soul Speed level of the boots seen last tick (0: none).
|
||||
pub boots_level: u32,
|
||||
/// The Soul Speed level whose attribute modifiers are on the player (0: none).
|
||||
pub applied_level: u32,
|
||||
}
|
||||
|
||||
/// Ordered requests and phase records for world/audio/network consumers. Landing
|
||||
/// hurt dispatch updates stored health synchronously before these are drained.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
@@ -258,6 +278,8 @@ pub struct PlayerMotion {
|
||||
pub glide_ending: bool,
|
||||
/// Glider durability to take this tick (updateFallFlying).
|
||||
pub glider_wear: u32,
|
||||
/// Soul Speed's `location_changed` bookkeeping.
|
||||
pub location_effects: LocationEffects,
|
||||
/// `LivingEntity.autoSpinAttackTicks` and `autoSpinAttackDmg`: Riptide's
|
||||
/// spin. The damage stays until the game has dealt the hit.
|
||||
pub auto_spin_attack_ticks: i32,
|
||||
@@ -449,6 +471,7 @@ impl PlayerMotion {
|
||||
fall_fly_ticks: 0,
|
||||
glide_ending: false,
|
||||
glider_wear: 0,
|
||||
location_effects: LocationEffects::default(),
|
||||
auto_spin_attack_ticks: 0,
|
||||
auto_spin_attack_dmg: 0.0,
|
||||
spin_targets: Vec::new(),
|
||||
@@ -1006,6 +1029,7 @@ impl MotionWorld<'_, '_, '_> {
|
||||
)
|
||||
});
|
||||
self.player.try_add_frost(on_air)?;
|
||||
self.update_soul_speed_block()?;
|
||||
if self.player.health.tick_count % 40 == 0
|
||||
&& self.player.is_fully_frozen()
|
||||
&& self.player.can_freeze()
|
||||
@@ -1018,6 +1042,22 @@ impl MotionWorld<'_, '_, '_> {
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
/// Refresh whether the player stands on a soul speed block, for the
|
||||
/// `location_changed` enchantment effects (a server's player too, whose
|
||||
/// client does the walking).
|
||||
pub fn update_soul_speed_block(&mut self) -> Result<()> {
|
||||
self.player.location_effects.on_soul_speed_block = self.on_soul_speed_block()?;
|
||||
Ok(())
|
||||
}
|
||||
/// `MovementAffectedByPredicate` for `#minecraft:soul_speed_blocks`.
|
||||
fn on_soul_speed_block(&mut self) -> Result<bool> {
|
||||
let on = self.support(movement_support::MOVEMENT_SUPPORT_OFFSET)?;
|
||||
Ok(self
|
||||
.assets
|
||||
.registry
|
||||
.state(self.block(on))
|
||||
.is_some_and(|s| self.resources.soul_speed_blocks.contains(&s.name)))
|
||||
}
|
||||
/// Player.travel: swim steering, then LivingEntity.travel (fluids first,
|
||||
/// then gliding, then air), keeping 0.6 of the vertical speed while flying.
|
||||
fn player_travel(&mut self, input: Vec3) -> Result<()> {
|
||||
@@ -1915,6 +1955,10 @@ impl EntityMoveWorld for MotionWorld<'_, '_, '_> {
|
||||
if self.player.status.slow_falling || self.player.status.levitation.is_some() {
|
||||
self.player.fall_distance = 0.0;
|
||||
}
|
||||
// LivingEntity.checkFallDamage: a landing runs onChangedBlock.
|
||||
if grounded && self.player.fall_distance > 0.0 {
|
||||
self.player.location_effects.landed = true;
|
||||
}
|
||||
if self.player.spawn_extra_particles_on_fall && grounded && self.player.fall_distance > 0.0
|
||||
{
|
||||
self.player.events.push(MotionEvent::SmashLanding {
|
||||
|
||||
@@ -9,7 +9,7 @@ use horizon_world::{
|
||||
};
|
||||
use std::{
|
||||
path::Path,
|
||||
sync::{Arc, OnceLock},
|
||||
sync::{Arc, Mutex},
|
||||
};
|
||||
|
||||
/// The `custom_data` key holding an exploration map's search until the game
|
||||
@@ -114,11 +114,13 @@ pub fn has_pending_map(item: &ItemStack) -> bool {
|
||||
})
|
||||
}
|
||||
|
||||
static LOOT: OnceLock<Option<Arc<LootData>>> = OnceLock::new();
|
||||
/// None until `loot_data` runs; `Some(None)` when the tables failed to load.
|
||||
static LOOT: Mutex<Option<Option<Arc<LootData>>>> = Mutex::new(None);
|
||||
|
||||
/// Loot data of the resource root, loaded once per process.
|
||||
pub fn loot_data(root: &Path) -> Option<Arc<LootData>> {
|
||||
LOOT.get_or_init(|| match LootData::load(root) {
|
||||
let mut loot = LOOT.lock().unwrap_or_else(|e| e.into_inner());
|
||||
loot.get_or_insert_with(|| match LootData::load(root) {
|
||||
Ok(data) => Some(Arc::new(data)),
|
||||
Err(e) => {
|
||||
log::warn!("Horizon: loot tables unavailable, structure containers stay empty: {e}");
|
||||
@@ -130,7 +132,16 @@ pub fn loot_data(root: &Path) -> Option<Arc<LootData>> {
|
||||
|
||||
/// The loot data `loot_data` loaded, if it ran.
|
||||
pub fn loaded_loot_data() -> Option<Arc<LootData>> {
|
||||
LOOT.get().cloned().flatten()
|
||||
LOOT.lock()
|
||||
.unwrap_or_else(|e| e.into_inner())
|
||||
.clone()
|
||||
.flatten()
|
||||
}
|
||||
|
||||
/// Let go of the loot data: for a preload no world used
|
||||
/// (game/world_loading.rs). The next `loot_data` loads it again.
|
||||
pub fn forget_loot_data() {
|
||||
*LOOT.lock().unwrap_or_else(|e| e.into_inner()) = None;
|
||||
}
|
||||
|
||||
/// `RandomizableContainer.unpackLootTable` of a container at `pos`: `table`
|
||||
|
||||
@@ -333,15 +333,23 @@ impl VanillaTerrain {
|
||||
u64::try_from(self.created.elapsed().as_millis()).unwrap_or(u64::MAX)
|
||||
}
|
||||
}
|
||||
static SHARED_DATA: std::sync::Mutex<Vec<(std::path::PathBuf, Arc<GeneratorData>)>> =
|
||||
std::sync::Mutex::new(Vec::new());
|
||||
/// Let go of the bundles `shared_data` loaded: for a preload no world used
|
||||
/// (game/world_loading.rs). A generator that holds one keeps it.
|
||||
pub fn forget_shared_data() {
|
||||
SHARED_DATA
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner())
|
||||
.clear();
|
||||
}
|
||||
/// The worldgen bundle at `dir`, loaded once and shared by every generator
|
||||
/// and by live feature placement (sapling growth, bone meal).
|
||||
pub fn shared_data(
|
||||
dir: &Path,
|
||||
registry: &'static BlockRegistry,
|
||||
) -> crate::Result<Arc<GeneratorData>> {
|
||||
static CACHE: std::sync::Mutex<Vec<(std::path::PathBuf, Arc<GeneratorData>)>> =
|
||||
std::sync::Mutex::new(Vec::new());
|
||||
let mut cache = CACHE.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let mut cache = SHARED_DATA.lock().unwrap_or_else(|e| e.into_inner());
|
||||
if let Some((_, data)) = cache.iter().find(|(path, _)| path == dir) {
|
||||
return Ok(data.clone());
|
||||
}
|
||||
|
||||
@@ -39,7 +39,9 @@ wit_bindgen::generate!({
|
||||
|
||||
pub use exports::horizon::mods::events::{Guest as __Guest, Registration};
|
||||
pub use exports::horizon::mods::ui_events::Guest as __UiEventsGuest;
|
||||
pub use horizon::mods::{chat, command, game, hud, input, log, recipes, render, storage, ui};
|
||||
pub use horizon::mods::{
|
||||
browser, chat, command, game, hud, input, log, recipes, render, storage, ui,
|
||||
};
|
||||
|
||||
/// The game's calls into a mod. Only the hooks [`Registration`] asks for
|
||||
/// are called; the others may stay as their defaults.
|
||||
|
||||
@@ -507,6 +507,111 @@ interface recipes {
|
||||
all: func() -> list<recipe>;
|
||||
/// The `book-id`s of the recipes the player's recipe book holds now.
|
||||
known: func() -> list<s32>;
|
||||
/// Places a recipe in the open screen's grid as a click in the recipe
|
||||
/// book does (`ServerboundPlaceRecipePacket`): the inventory's grid or
|
||||
/// a crafting table. `all` places as many as the items allow, as with
|
||||
/// Shift. Refused while no such screen is open, for a recipe the
|
||||
/// player's book doesn't hold and for one that can't be placed; one
|
||||
/// missing items shows as a ghost in the grid, as in the book.
|
||||
place: func(book-id: s32, all: bool) -> result<_, string>;
|
||||
/// Creative mode only: gives the player the stack, as taking it from
|
||||
/// the creative inventory does (`ServerboundSetCreativeModeSlot`): the
|
||||
/// selected slot first, then the hotbar, then the inventory. The count
|
||||
/// is cut to one stack of the item (64, 16 or 1).
|
||||
give: func(stack: stack) -> result<_, string>;
|
||||
}
|
||||
|
||||
/// Capability `ui` (experimental): a recipe viewer's panel beside the
|
||||
/// game's inventory and container screens (not the creative inventory), on
|
||||
/// the right of the screen. The game draws it with its own widgets and
|
||||
/// slots, pages it and keeps its search box; the mod says what it holds.
|
||||
/// The first loaded mod with a panel shows.
|
||||
interface browser {
|
||||
/// An item to show: its id, a count (1 draws none) and its components
|
||||
/// beyond the item's own, in vanilla's item syntax, or "".
|
||||
record stack {
|
||||
item: string,
|
||||
count: u32,
|
||||
components: string,
|
||||
}
|
||||
/// A click on an item of the panel, by its index in `set-items`'s list.
|
||||
record click {
|
||||
index: u32,
|
||||
right: bool,
|
||||
}
|
||||
/// A key the mod registered (`input.register-key`) went down while the
|
||||
/// pointer was on an item: a slot of the screen's, or the panel's.
|
||||
record item-key {
|
||||
key: string,
|
||||
stack: stack,
|
||||
}
|
||||
/// A click on a slot of a recipe the panel shows (`set-recipes`).
|
||||
record slot-click {
|
||||
/// The recipe's index in `set-recipes`'s list and the slot's in
|
||||
/// it; the results come after the slots.
|
||||
recipe: u32,
|
||||
slot: u32,
|
||||
/// The stack the slot showed at that moment.
|
||||
stack: stack,
|
||||
right: bool,
|
||||
}
|
||||
variant event {
|
||||
clicked(click),
|
||||
/// The search box's text, each time the player changes it.
|
||||
search(string),
|
||||
key(item-key),
|
||||
slot-clicked(slot-click),
|
||||
/// The action button of the recipe at this index in
|
||||
/// `set-recipes`'s list.
|
||||
action(u32),
|
||||
/// The player turned the recipes' page (`set-recipes`): the index
|
||||
/// of the first recipe now shown.
|
||||
page(u32),
|
||||
/// The back button of `set-recipes`.
|
||||
back,
|
||||
/// The player picked the tab at this index of `set-recipes`' tabs.
|
||||
tab(u32),
|
||||
}
|
||||
/// How the game lays out a recipe of `set-recipes`.
|
||||
enum layout {
|
||||
/// A crafting grid of `width` x `height` slots, an arrow and the
|
||||
/// result.
|
||||
grid,
|
||||
/// One input over a flame, an arrow and the result (furnaces,
|
||||
/// smokers, campfires).
|
||||
cooking,
|
||||
/// One input, an arrow and the result (the stonecutter).
|
||||
single,
|
||||
/// Template, base and addition, an arrow and the result.
|
||||
smithing,
|
||||
/// A potion and an ingredient, an arrow and the result.
|
||||
brewing,
|
||||
}
|
||||
/// A recipe to show. Each slot, and the result, cycles through its
|
||||
/// stacks, one a second; an empty slot shows nothing.
|
||||
record recipe-view {
|
||||
layout: layout,
|
||||
/// The grid's size (`grid` only), at most 3 x 3.
|
||||
width: u32,
|
||||
height: u32,
|
||||
slots: list<list<stack>>,
|
||||
results: list<stack>,
|
||||
/// A short line under the recipe (a cooking time), or "".
|
||||
note: string,
|
||||
/// The action button beside it: none, enabled, or shown greyed.
|
||||
action: option<bool>,
|
||||
}
|
||||
/// The panel's items, at most 8192 (an empty list hides the panel).
|
||||
set-items: func(items: list<stack>);
|
||||
/// Shows these recipes in the panel instead of its items, as many as
|
||||
/// fit a page, under `title` and a back button, with a row of `tabs`
|
||||
/// (each an icon; `tab` is the selected one) between them; an empty
|
||||
/// list of recipes shows the items again. At most 256 recipes and 16
|
||||
/// tabs.
|
||||
set-recipes: func(title: string, tabs: list<stack>, tab: u32, recipes: list<recipe-view>);
|
||||
/// What the player did on the panel since the last poll, at most 256
|
||||
/// kept.
|
||||
poll: func() -> list<event>;
|
||||
}
|
||||
|
||||
/// Capability `players`, server mods only (manifest `api = "0.3"`,
|
||||
@@ -644,6 +749,7 @@ world horizon-ui-mod {
|
||||
import render;
|
||||
import input;
|
||||
import recipes;
|
||||
import browser;
|
||||
export ui-events;
|
||||
}
|
||||
|
||||
@@ -662,4 +768,5 @@ world render-host {
|
||||
/// The game's side of capability `recipes` (imports only).
|
||||
world recipes-host {
|
||||
import recipes;
|
||||
import browser;
|
||||
}
|
||||
|
||||
@@ -7,13 +7,13 @@
|
||||
//! version's level so the launcher can tell before it installs.
|
||||
|
||||
/// The newest level this Horizon serves.
|
||||
pub const LEVEL: u32 = 5;
|
||||
pub const LEVEL: u32 = 6;
|
||||
|
||||
/// Every function the game offers mods, as `interface.function`, with its
|
||||
/// level. Level 0 is the contract of Horizon 0.2.67 to 0.2.69. A new
|
||||
/// function takes the next level and raises [`LEVEL`] (the `api_reference`
|
||||
/// test fails until it is listed here).
|
||||
pub const FUNCTIONS: [(&str, u32); 40] = [
|
||||
pub const FUNCTIONS: [(&str, u32); 45] = [
|
||||
("log.write", 0),
|
||||
("game.get-player", 0),
|
||||
("game.get-clock", 0),
|
||||
@@ -54,6 +54,11 @@ pub const FUNCTIONS: [(&str, u32); 40] = [
|
||||
("recipes.items", 5),
|
||||
("recipes.all", 5),
|
||||
("recipes.known", 5),
|
||||
("browser.set-items", 6),
|
||||
("browser.poll", 6),
|
||||
("browser.set-recipes", 6),
|
||||
("recipes.place", 6),
|
||||
("recipes.give", 6),
|
||||
];
|
||||
|
||||
/// What the game's program carries so the launcher can read its level
|
||||
|
||||
@@ -167,7 +167,9 @@ impl Capability {
|
||||
}
|
||||
Capability::Players => "see who is on the server and message them",
|
||||
Capability::World => "read and change the world's blocks",
|
||||
Capability::Recipes => "read the game's recipes and items, and which recipes you know",
|
||||
Capability::Recipes => {
|
||||
"read the game's recipes and items and which recipes you know, place recipes in your crafting grid and, in creative mode, give you items"
|
||||
}
|
||||
}
|
||||
}
|
||||
/// The capability a component import needs, `Ok(None)` for one every
|
||||
@@ -182,6 +184,10 @@ impl Capability {
|
||||
if interface == "log" {
|
||||
return Ok(None);
|
||||
}
|
||||
// A recipe viewer's panel is part of capability `ui`.
|
||||
if interface == "browser" {
|
||||
return Ok(Some(Capability::Ui));
|
||||
}
|
||||
Capability::ALL
|
||||
.into_iter()
|
||||
.filter(|c| *c != Capability::Registries)
|
||||
|
||||
@@ -88,11 +88,17 @@ mod recipes_bindings {
|
||||
world: "recipes-host",
|
||||
});
|
||||
}
|
||||
/// Capability `ui`'s recipe viewer panel (WIT `interface browser`).
|
||||
pub use recipes_bindings::horizon::mods::browser::{
|
||||
Click as BrowserClick, Event as BrowserEvent, ItemKey as BrowserItemKey,
|
||||
Layout as BrowserLayout, RecipeView as BrowserRecipe, SlotClick as BrowserSlotClick,
|
||||
Stack as BrowserStack,
|
||||
};
|
||||
/// What capability `recipes` reads (WIT `interface recipes`).
|
||||
pub use recipes_bindings::horizon::mods::recipes::{
|
||||
Item as RecipeItem, Kind as RecipeKind, Recipe, Stack as RecipeStack,
|
||||
};
|
||||
use recipes_bindings::{RecipesHost, horizon::mods::recipes};
|
||||
use recipes_bindings::{RecipesHost, horizon::mods::browser, horizon::mods::recipes};
|
||||
|
||||
/// A mod's linear memory cap.
|
||||
const MEMORY: usize = 64 << 20;
|
||||
@@ -155,6 +161,10 @@ pub struct Snapshot {
|
||||
pub recipes: Option<Arc<Recipes>>,
|
||||
/// The `book-id`s of the recipes the player's book holds.
|
||||
pub known_recipes: Arc<[i32]>,
|
||||
/// The open screen takes recipes (`recipes.place`).
|
||||
pub placing: bool,
|
||||
/// The player is in creative mode (`recipes.give`).
|
||||
pub creative: bool,
|
||||
}
|
||||
|
||||
/// Capability `recipes`'s tables: `items` in the registry's order, which
|
||||
@@ -226,6 +236,15 @@ pub enum Output {
|
||||
CloseScreen { owner: usize },
|
||||
/// `render.shake`: strength 0 to 1, fading out over `seconds` (≤ 10).
|
||||
Shake { strength: f32, seconds: f32 },
|
||||
/// `recipes.place`: the recipe book's click on this display id, all of
|
||||
/// it when `all`.
|
||||
PlaceRecipe { book_id: i32, all: bool },
|
||||
/// `recipes.give`: a stack for a creative player.
|
||||
Give {
|
||||
item: String,
|
||||
count: u32,
|
||||
components: String,
|
||||
},
|
||||
}
|
||||
|
||||
/// The game's world as capability `world` reads it during a call: the
|
||||
@@ -338,6 +357,46 @@ struct UiState {
|
||||
close: bool,
|
||||
}
|
||||
|
||||
/// Capability `ui`'s panel beside container screens (`browser`).
|
||||
#[derive(Default)]
|
||||
struct BrowserState {
|
||||
items: Vec<BrowserStack>,
|
||||
/// Which `set-items` or `set-recipes` call the panel is from, unique
|
||||
/// across mods and reloads, so the game converts them once.
|
||||
version: u64,
|
||||
/// `set-recipes`' title and recipes, shown instead of the items.
|
||||
recipes: Option<RecipePage>,
|
||||
events: std::collections::VecDeque<BrowserEvent>,
|
||||
}
|
||||
/// What `browser.set-recipes` shows: a title, tabs (`tab` the selected
|
||||
/// one) and the recipes.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct RecipePage {
|
||||
pub title: String,
|
||||
pub tabs: Vec<BrowserStack>,
|
||||
pub tab: u32,
|
||||
pub recipes: Vec<BrowserRecipe>,
|
||||
}
|
||||
static BROWSER_VERSION: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
|
||||
/// Items a panel may hold, and events kept for `browser.poll`.
|
||||
const BROWSER_ITEMS: usize = 8192;
|
||||
/// `recipes.place` and `give` calls a hook call may make.
|
||||
const RECIPE_ACTIONS: usize = 36;
|
||||
/// Recipes `set-recipes` may show, slots in one, and stacks a slot cycles.
|
||||
const BROWSER_RECIPES: usize = 256;
|
||||
const RECIPE_SLOTS: usize = 9;
|
||||
const RECIPE_TABS: usize = 16;
|
||||
const SLOT_STACKS: usize = 64;
|
||||
const BROWSER_EVENTS: usize = 256;
|
||||
impl BrowserState {
|
||||
fn push(&mut self, event: BrowserEvent) {
|
||||
if self.events.len() == BROWSER_EVENTS {
|
||||
self.events.pop_front();
|
||||
}
|
||||
self.events.push_back(event);
|
||||
}
|
||||
}
|
||||
|
||||
struct State {
|
||||
name: String,
|
||||
id: String,
|
||||
@@ -354,6 +413,7 @@ struct State {
|
||||
/// Capability `render`'s minimap, when the mod shows one.
|
||||
minimap: Option<ModMinimap>,
|
||||
input: InputState,
|
||||
browser: BrowserState,
|
||||
/// Capability `storage`'s folder; made on first `set`.
|
||||
data_dir: PathBuf,
|
||||
/// Bytes `storage::set` may still write before this hook call ends
|
||||
@@ -547,6 +607,7 @@ impl Mods {
|
||||
zoom: None,
|
||||
minimap: None,
|
||||
input: InputState::default(),
|
||||
browser: BrowserState::default(),
|
||||
data_dir,
|
||||
storage_budget: STORAGE_WRITE_BUDGET,
|
||||
limits: StoreLimitsBuilder::new()
|
||||
@@ -857,6 +918,51 @@ impl Mods {
|
||||
.iter()
|
||||
.any(|e| e.run.as_ref().is_some_and(|r| r.raycasts))
|
||||
}
|
||||
/// The panel beside container screens (`browser.set-items`): the first
|
||||
/// running mod with items or recipes, its index, the panel's version and
|
||||
/// the items.
|
||||
pub fn browser_items(&self) -> Option<(usize, u64, &[BrowserStack])> {
|
||||
self.entries.iter().enumerate().find_map(|(i, e)| {
|
||||
let b = &e.run.as_ref()?.store.data().browser;
|
||||
(!b.items.is_empty() || b.recipes.is_some()).then_some((
|
||||
i,
|
||||
b.version,
|
||||
b.items.as_slice(),
|
||||
))
|
||||
})
|
||||
}
|
||||
/// What mod `owner`'s panel shows instead of its items
|
||||
/// (`browser.set-recipes`).
|
||||
pub fn browser_recipes(&self, owner: usize) -> Option<&RecipePage> {
|
||||
let b = &self.entries.get(owner)?.run.as_ref()?.store.data().browser;
|
||||
b.recipes.as_ref()
|
||||
}
|
||||
/// The player did `event` on mod `owner`'s panel.
|
||||
pub fn browser_event(&mut self, owner: usize, event: BrowserEvent) {
|
||||
if let Some(run) = self.entries.get_mut(owner).and_then(|e| e.run.as_mut()) {
|
||||
run.store.data_mut().browser.push(event);
|
||||
}
|
||||
}
|
||||
/// The key `key` went down with the pointer on `stack` in a container
|
||||
/// screen: each mod with a key on it hears its own name for it.
|
||||
pub fn browser_key(&mut self, key: &str, stack: &BrowserStack) {
|
||||
for run in self.entries.iter_mut().filter_map(|e| e.run.as_mut()) {
|
||||
let data = run.store.data_mut();
|
||||
let names: Vec<String> = data
|
||||
.input
|
||||
.keys
|
||||
.iter()
|
||||
.filter(|k| k.key == key)
|
||||
.map(|k| k.name.clone())
|
||||
.collect();
|
||||
for key in names {
|
||||
data.browser.push(BrowserEvent::Key(BrowserItemKey {
|
||||
key,
|
||||
stack: stack.clone(),
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
/// Some running mod reads capability `recipes`: the snapshot needs
|
||||
/// [`Snapshot::recipes`] and [`Snapshot::known_recipes`].
|
||||
pub fn wants_recipes(&self) -> bool {
|
||||
@@ -1159,7 +1265,10 @@ fn reason(e: &wasmtime::Error) -> String {
|
||||
}
|
||||
|
||||
fn clip(text: String) -> String {
|
||||
match text.char_indices().nth(LINE_CHARS) {
|
||||
clip_to(text, LINE_CHARS)
|
||||
}
|
||||
fn clip_to(text: String, chars: usize) -> String {
|
||||
match text.char_indices().nth(chars) {
|
||||
Some((at, _)) => text[..at].to_string(),
|
||||
None => text,
|
||||
}
|
||||
@@ -1220,6 +1329,60 @@ impl game::Host for State {
|
||||
(*at <= distance.min(RAYCAST_MAX) && !distance.is_nan()).then(|| hit.clone())
|
||||
}
|
||||
}
|
||||
impl browser::Host for State {
|
||||
fn set_items(&mut self, items: Vec<BrowserStack>) {
|
||||
self.browser.items = items
|
||||
.into_iter()
|
||||
.take(BROWSER_ITEMS)
|
||||
.map(|s| BrowserStack {
|
||||
item: clip(s.item),
|
||||
components: clip_to(s.components, 4096),
|
||||
..s
|
||||
})
|
||||
.collect();
|
||||
self.browser.version = BROWSER_VERSION.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||
}
|
||||
fn set_recipes(
|
||||
&mut self,
|
||||
title: String,
|
||||
tabs: Vec<BrowserStack>,
|
||||
tab: u32,
|
||||
recipes: Vec<BrowserRecipe>,
|
||||
) {
|
||||
let stack = |s: BrowserStack| BrowserStack {
|
||||
item: clip(s.item),
|
||||
components: clip_to(s.components, 4096),
|
||||
..s
|
||||
};
|
||||
let cycle = |list: Vec<BrowserStack>| -> Vec<BrowserStack> {
|
||||
list.into_iter().take(SLOT_STACKS).map(stack).collect()
|
||||
};
|
||||
self.browser.recipes = (!recipes.is_empty()).then(|| {
|
||||
let recipes = recipes
|
||||
.into_iter()
|
||||
.take(BROWSER_RECIPES)
|
||||
.map(|r| BrowserRecipe {
|
||||
width: r.width.clamp(1, 3),
|
||||
height: r.height.clamp(1, 3),
|
||||
slots: r.slots.into_iter().take(RECIPE_SLOTS).map(cycle).collect(),
|
||||
results: cycle(r.results),
|
||||
note: clip(r.note),
|
||||
..r
|
||||
})
|
||||
.collect();
|
||||
RecipePage {
|
||||
title: clip(title),
|
||||
tabs: tabs.into_iter().take(RECIPE_TABS).map(stack).collect(),
|
||||
tab,
|
||||
recipes,
|
||||
}
|
||||
});
|
||||
self.browser.version = BROWSER_VERSION.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||
}
|
||||
fn poll(&mut self) -> Vec<BrowserEvent> {
|
||||
self.browser.events.drain(..).collect()
|
||||
}
|
||||
}
|
||||
impl recipes::Host for State {
|
||||
fn items(&mut self) -> Vec<RecipeItem> {
|
||||
self.snapshot
|
||||
@@ -1236,6 +1399,54 @@ impl recipes::Host for State {
|
||||
fn known(&mut self) -> Vec<i32> {
|
||||
self.snapshot.known_recipes.to_vec()
|
||||
}
|
||||
fn place(&mut self, book_id: i32, all: bool) -> Result<(), String> {
|
||||
self.recipe_action()?;
|
||||
if !self.snapshot.placing {
|
||||
return Err("no screen that takes recipes is open".into());
|
||||
}
|
||||
if !self.snapshot.known_recipes.contains(&book_id) {
|
||||
return Err(format!(
|
||||
"recipe {book_id} is not in the player's recipe book"
|
||||
));
|
||||
}
|
||||
self.out.push(Output::PlaceRecipe { book_id, all });
|
||||
Ok(())
|
||||
}
|
||||
fn give(&mut self, stack: RecipeStack) -> Result<(), String> {
|
||||
self.recipe_action()?;
|
||||
if !self.snapshot.creative {
|
||||
return Err("only in creative mode".into());
|
||||
}
|
||||
let item = self
|
||||
.snapshot
|
||||
.recipes
|
||||
.as_ref()
|
||||
.and_then(|r| r.items.get(stack.item as usize))
|
||||
.ok_or_else(|| format!("no item {}", stack.item))?
|
||||
.id
|
||||
.clone();
|
||||
self.out.push(Output::Give {
|
||||
item,
|
||||
count: stack.count.clamp(1, 99),
|
||||
components: clip_to(stack.components, 4096),
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
impl State {
|
||||
/// `recipes.place` and `give` take at most [`RECIPE_ACTIONS`] a call.
|
||||
fn recipe_action(&self) -> Result<(), String> {
|
||||
let done = self
|
||||
.out
|
||||
.iter()
|
||||
.filter(|o| matches!(o, Output::PlaceRecipe { .. } | Output::Give { .. }))
|
||||
.count();
|
||||
if done < RECIPE_ACTIONS {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(format!("at most {RECIPE_ACTIONS} places and gives a call"))
|
||||
}
|
||||
}
|
||||
}
|
||||
impl State {
|
||||
/// Queues a chat line unless the call already sent [`CHAT_PER_CALL`].
|
||||
|
||||
@@ -255,8 +255,8 @@ experimental = ["ui", "render", "input", "recipes"]
|
||||
needed(imports(&wasm).unwrap().iter().map(String::as_str)).0
|
||||
};
|
||||
assert_eq!(level("coords_hud.hmod"), 0);
|
||||
// The UI guest reads the recipes (`recipes`, level 5).
|
||||
assert_eq!(level("horizon_mods_test_ui_guest.hmod"), 5);
|
||||
// The UI guest fills a browser panel (`browser`, level 6).
|
||||
assert_eq!(level("horizon_mods_test_ui_guest.hmod"), 6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1084,8 +1084,9 @@ fn recipes_reads_the_tables() {
|
||||
recipes[1].kind = RecipeKind::Brewing;
|
||||
recipes[1].results[0].components =
|
||||
"[minecraft:potion_contents={potion:\"minecraft:swiftness\"}]".into();
|
||||
let mods_recipes = Some(std::sync::Arc::new(Recipes { items, recipes }));
|
||||
mods.set_snapshot(Snapshot {
|
||||
recipes: Some(std::sync::Arc::new(Recipes { items, recipes })),
|
||||
recipes: mods_recipes.clone(),
|
||||
known_recipes: vec![1, 5, 9].into(),
|
||||
..Snapshot::default()
|
||||
});
|
||||
@@ -1116,6 +1117,46 @@ fn recipes_reads_the_tables() {
|
||||
.all(|e| e.status == horizon_mods::Status::Running),
|
||||
"it ran out of fuel"
|
||||
);
|
||||
// Actions: refused without a crafting screen, for an unknown recipe,
|
||||
// and outside creative mode.
|
||||
assert_eq!(
|
||||
said(&mut mods, "recipe_place minecraft:recipe_5"),
|
||||
["place minecraft:recipe_5: no screen that takes recipes is open"]
|
||||
);
|
||||
let mut snap = Snapshot {
|
||||
recipes: mods_recipes.clone(),
|
||||
known_recipes: vec![1, 5, 9].into(),
|
||||
placing: true,
|
||||
..Snapshot::default()
|
||||
};
|
||||
mods.set_snapshot(snap.clone());
|
||||
assert_eq!(
|
||||
said(&mut mods, "recipe_place minecraft:recipe_6"),
|
||||
["place minecraft:recipe_6: recipe 6 is not in the player's recipe book"]
|
||||
);
|
||||
assert!(mods.command("recipe_place minecraft:recipe_5 all"));
|
||||
assert_eq!(
|
||||
mods.drain()[0],
|
||||
Output::PlaceRecipe {
|
||||
book_id: 5,
|
||||
all: true
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
said(&mut mods, "recipe_give minecraft:item_7 3"),
|
||||
["give minecraft:item_7: only in creative mode"]
|
||||
);
|
||||
snap.creative = true;
|
||||
mods.set_snapshot(snap);
|
||||
assert!(mods.command("recipe_give minecraft:item_7 3"));
|
||||
assert_eq!(
|
||||
mods.drain()[0],
|
||||
Output::Give {
|
||||
item: "minecraft:item_7".into(),
|
||||
count: 3,
|
||||
components: String::new()
|
||||
}
|
||||
);
|
||||
// Without the game's tables (no mod read them yet): empty, not a trap.
|
||||
mods.set_snapshot(Snapshot::default());
|
||||
assert_eq!(
|
||||
@@ -1123,3 +1164,124 @@ fn recipes_reads_the_tables() {
|
||||
["0 items, 0 recipes, 0 known, 0 brewing"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn browser_lists_items_and_hears_the_player() {
|
||||
use horizon_mods::{BrowserClick, BrowserEvent, BrowserStack};
|
||||
let mut mods = load("horizon_mods_test_ui_guest.hmod");
|
||||
let said = |mods: &mut Mods, command: &str| {
|
||||
assert!(mods.command(command));
|
||||
mods.drain()
|
||||
.into_iter()
|
||||
.filter_map(|o| match o {
|
||||
Output::Chat { text, .. } => Some(text),
|
||||
_ => None,
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
};
|
||||
assert!(mods.browser_items().is_none());
|
||||
let potion = "[minecraft:potion_contents={potion:\"minecraft:swiftness\"}]";
|
||||
assert_eq!(
|
||||
said(
|
||||
&mut mods,
|
||||
&format!("browser minecraft:stone minecraft:potion{potion}")
|
||||
),
|
||||
["panel of 2"]
|
||||
);
|
||||
let (owner, version, items) = mods.browser_items().unwrap();
|
||||
assert_eq!(
|
||||
(items[0].item.as_str(), items[1].components.as_str()),
|
||||
("minecraft:stone", potion)
|
||||
);
|
||||
mods.browser_event(
|
||||
owner,
|
||||
BrowserEvent::Clicked(BrowserClick {
|
||||
index: 1,
|
||||
right: true,
|
||||
}),
|
||||
);
|
||||
mods.browser_event(owner, BrowserEvent::Search("oak".into()));
|
||||
let log = BrowserStack {
|
||||
item: "minecraft:oak_log".into(),
|
||||
count: 3,
|
||||
components: String::new(),
|
||||
};
|
||||
// R is the guest's `recipe`; X is none of its keys.
|
||||
mods.browser_key("key.keyboard.r", &log);
|
||||
mods.browser_key("key.keyboard.x", &log);
|
||||
assert_eq!(
|
||||
said(&mut mods, "browser_poll"),
|
||||
[
|
||||
"3 events",
|
||||
"clicked 1 right",
|
||||
"search \"oak\"",
|
||||
"key recipe on minecraft:oak_log x3"
|
||||
]
|
||||
);
|
||||
// Each `set-items` is a new version; an empty list hides the panel.
|
||||
said(&mut mods, "browser minecraft:dirt");
|
||||
assert_ne!(mods.browser_items().unwrap().1, version);
|
||||
assert_eq!(said(&mut mods, "browser"), ["panel of 0"]);
|
||||
assert!(mods.browser_items().is_none());
|
||||
// Recipes in the panel's place: the guest shows those making planks.
|
||||
use horizon_mods::{BrowserLayout, Recipe, RecipeItem, RecipeKind, RecipeStack, Recipes};
|
||||
let item = |id: &str| RecipeItem {
|
||||
id: id.into(),
|
||||
name: id.into(),
|
||||
tags: Vec::new(),
|
||||
};
|
||||
let stack = |item| RecipeStack {
|
||||
item,
|
||||
count: 1,
|
||||
components: String::new(),
|
||||
};
|
||||
mods.set_snapshot(Snapshot {
|
||||
recipes: Some(std::sync::Arc::new(Recipes {
|
||||
items: vec![item("minecraft:oak_log"), item("minecraft:oak_planks")],
|
||||
recipes: vec![Recipe {
|
||||
id: "minecraft:oak_planks".into(),
|
||||
kind: RecipeKind::CraftingShapeless,
|
||||
category: "building".into(),
|
||||
group: "planks".into(),
|
||||
width: 0,
|
||||
height: 0,
|
||||
slots: vec![vec![stack(0)]],
|
||||
results: vec![RecipeStack {
|
||||
count: 4,
|
||||
..stack(1)
|
||||
}],
|
||||
cooking_time: 0,
|
||||
experience: 0.0,
|
||||
book_id: 1,
|
||||
}],
|
||||
})),
|
||||
..Snapshot::default()
|
||||
});
|
||||
said(&mut mods, "browser minecraft:dirt");
|
||||
assert_eq!(
|
||||
said(&mut mods, "browser_recipes minecraft:oak_planks"),
|
||||
["recipes shown: 1"]
|
||||
);
|
||||
let page = mods.browser_recipes(owner).unwrap();
|
||||
assert_eq!(page.title, "Recipes for minecraft:oak_planks");
|
||||
assert_eq!(
|
||||
(page.tabs[0].item.as_str(), page.tab),
|
||||
("minecraft:crafting_table", 0)
|
||||
);
|
||||
let r = &page.recipes[0];
|
||||
assert_eq!(
|
||||
(r.layout, r.width, r.height, r.action),
|
||||
(BrowserLayout::Grid, 1, 1, Some(false))
|
||||
);
|
||||
assert_eq!(
|
||||
(r.slots[0][0].item.as_str(), r.results[0].count),
|
||||
("minecraft:oak_log", 4)
|
||||
);
|
||||
assert_eq!(said(&mut mods, "browser_recipes"), ["recipes shown: 0"]);
|
||||
assert!(mods.browser_recipes(owner).is_none());
|
||||
// At most 256 events wait for a poll: the oldest go.
|
||||
for _ in 0..300 {
|
||||
mods.browser_event(owner, BrowserEvent::Page(1));
|
||||
}
|
||||
assert_eq!(said(&mut mods, "browser_poll")[0], "256 events");
|
||||
}
|
||||
|
||||
@@ -8,8 +8,15 @@
|
||||
//! `/raycast DISTANCE` says what `game.raycast` sees; `/quiet A;B` runs
|
||||
//! lines with `command.run-quiet` (`/quiet_many N`: N of them). `/recipes`
|
||||
//! counts what `recipes` reads; `/recipes ID` describes that recipe.
|
||||
//! `/browser ID...` fills `browser`'s panel (`/browser N`: the first N
|
||||
//! items, `/browser` alone empties it) and registers the keys `recipe` (R)
|
||||
//! and `uses` (U); `/browser_poll` says what the player did on it;
|
||||
//! `/browser_recipes ID` shows the recipes making ID (`/browser_recipes`
|
||||
//! alone: the items again). `/recipe_place ID [all]` places that recipe
|
||||
//! with `recipes.place`; `/recipe_give ITEM [COUNT]` gives with
|
||||
//! `recipes.give`.
|
||||
use horizon_mod_sdk::{
|
||||
Mod, Registration, chat, command, game,
|
||||
Mod, Registration, browser, chat, command, game,
|
||||
input::{self, Event},
|
||||
recipes,
|
||||
render::{self, FillShape, ItemShape, Point, QuadShape, Rect, Shape, SpriteShape, TextShape},
|
||||
@@ -64,7 +71,11 @@ fn shapes() -> Vec<Shape> {
|
||||
]
|
||||
}
|
||||
|
||||
struct Guest;
|
||||
struct Guest {
|
||||
/// `/recipe_place ID [all] later`: placed on the first tick a crafting
|
||||
/// screen is open.
|
||||
pending: Option<(i32, bool, String)>,
|
||||
}
|
||||
|
||||
fn widgets() -> Vec<Widget> {
|
||||
vec![
|
||||
@@ -105,12 +116,18 @@ impl Mod for Guest {
|
||||
"quiet",
|
||||
"quiet_many",
|
||||
"recipes",
|
||||
"browser",
|
||||
"browser_poll",
|
||||
"browser_recipes",
|
||||
"recipe_place",
|
||||
"recipe_give",
|
||||
"corner",
|
||||
"readings",
|
||||
]
|
||||
.into_iter()
|
||||
.fold(Registration::new(), Registration::command);
|
||||
Ok((Guest, hooks))
|
||||
.fold(Registration::new(), Registration::command)
|
||||
.on_tick();
|
||||
Ok((Guest { pending: None }, hooks))
|
||||
}
|
||||
fn command(&mut self, name: &str, args: &str) -> Result<Option<String>, String> {
|
||||
match name {
|
||||
@@ -279,6 +296,168 @@ impl Mod for Guest {
|
||||
),
|
||||
});
|
||||
}
|
||||
"browser" => {
|
||||
let stack = |item: &str| {
|
||||
let (item, components) = match item.find('[') {
|
||||
Some(at) => (&item[..at], &item[at..]),
|
||||
None => (item, ""),
|
||||
};
|
||||
browser::Stack {
|
||||
item: item.into(),
|
||||
count: 1,
|
||||
components: components.into(),
|
||||
}
|
||||
};
|
||||
let items: Vec<browser::Stack> = match args.parse::<usize>() {
|
||||
Ok(n) => recipes::items()
|
||||
.iter()
|
||||
.skip(1)
|
||||
.take(n)
|
||||
.map(|i| stack(&i.id))
|
||||
.collect(),
|
||||
Err(_) => args.split_whitespace().map(stack).collect(),
|
||||
};
|
||||
browser::set_items(&items);
|
||||
input::register_key("recipe", "Show recipes", "key.keyboard.r")?;
|
||||
input::register_key("uses", "Show uses", "key.keyboard.u")?;
|
||||
chat::show(&format!("panel of {}", items.len()));
|
||||
}
|
||||
"browser_recipes" => {
|
||||
let items = recipes::items();
|
||||
let id = |i: u32| {
|
||||
items
|
||||
.get(i as usize)
|
||||
.map_or(String::new(), |x| x.id.clone())
|
||||
};
|
||||
let stack = |s: &recipes::Stack| browser::Stack {
|
||||
item: id(s.item),
|
||||
count: s.count,
|
||||
components: s.components.clone(),
|
||||
};
|
||||
let views: Vec<browser::RecipeView> = recipes::all()
|
||||
.iter()
|
||||
.filter(|r| !args.is_empty() && r.results.iter().any(|s| id(s.item) == args))
|
||||
.map(|r| {
|
||||
use recipes::Kind;
|
||||
let crafting =
|
||||
matches!(r.kind, Kind::CraftingShaped | Kind::CraftingShapeless);
|
||||
let shapeless = (r.slots.len() as u32).clamp(1, 3);
|
||||
browser::RecipeView {
|
||||
layout: match r.kind {
|
||||
Kind::CraftingShaped | Kind::CraftingShapeless => {
|
||||
browser::Layout::Grid
|
||||
}
|
||||
Kind::Stonecutting => browser::Layout::Single,
|
||||
Kind::Smithing => browser::Layout::Smithing,
|
||||
Kind::Brewing => browser::Layout::Brewing,
|
||||
_ => browser::Layout::Cooking,
|
||||
},
|
||||
width: if r.width == 0 { shapeless } else { r.width },
|
||||
height: if r.height == 0 {
|
||||
(r.slots.len() as u32).div_ceil(3).max(1)
|
||||
} else {
|
||||
r.height
|
||||
},
|
||||
slots: r
|
||||
.slots
|
||||
.iter()
|
||||
.map(|s| s.iter().map(stack).collect())
|
||||
.collect(),
|
||||
results: r.results.iter().map(stack).collect(),
|
||||
note: if r.cooking_time > 0 {
|
||||
format!("{} ticks", r.cooking_time)
|
||||
} else {
|
||||
String::new()
|
||||
},
|
||||
action: crafting.then_some(r.kind == Kind::CraftingShaped),
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
// A tab per layout shown, the first selected.
|
||||
let mut tabs: Vec<browser::Stack> = Vec::new();
|
||||
for v in &views {
|
||||
let icon = match v.layout {
|
||||
browser::Layout::Grid => "minecraft:crafting_table",
|
||||
browser::Layout::Cooking => "minecraft:furnace",
|
||||
browser::Layout::Single => "minecraft:stonecutter",
|
||||
browser::Layout::Smithing => "minecraft:smithing_table",
|
||||
browser::Layout::Brewing => "minecraft:brewing_stand",
|
||||
};
|
||||
if !tabs.iter().any(|t| t.item == icon) {
|
||||
tabs.push(browser::Stack {
|
||||
item: icon.into(),
|
||||
count: 1,
|
||||
components: String::new(),
|
||||
});
|
||||
}
|
||||
}
|
||||
browser::set_recipes(&format!("Recipes for {args}"), &tabs, 0, &views);
|
||||
chat::show(&format!("recipes shown: {}", views.len()));
|
||||
}
|
||||
"recipe_place" => {
|
||||
let mut words = args.split_whitespace();
|
||||
let id = words.next().unwrap_or_default();
|
||||
let rest: Vec<&str> = words.collect();
|
||||
let all = rest.contains(&"all");
|
||||
let book = recipes::all()
|
||||
.iter()
|
||||
.find(|r| r.id == id)
|
||||
.map(|r| r.book_id);
|
||||
if let Some(b) = book.filter(|_| rest.contains(&"later")) {
|
||||
self.pending = Some((b, all, id.to_owned()));
|
||||
chat::show(&format!("place {id}: waiting"));
|
||||
return Ok(None);
|
||||
}
|
||||
chat::show(&match book.map(|b| recipes::place(b, all)) {
|
||||
Some(Ok(())) => format!("place {id}: ok"),
|
||||
Some(Err(e)) => format!("place {id}: {e}"),
|
||||
None => format!("place {id}: no such recipe"),
|
||||
});
|
||||
}
|
||||
"recipe_give" => {
|
||||
let mut words = args.split_whitespace();
|
||||
let id = words.next().unwrap_or_default();
|
||||
let count = words.next().and_then(|c| c.parse().ok()).unwrap_or(1);
|
||||
let item = recipes::items().iter().position(|i| i.id == id);
|
||||
let result = item.map(|i| {
|
||||
recipes::give(&recipes::Stack {
|
||||
item: i as u32,
|
||||
count,
|
||||
components: String::new(),
|
||||
})
|
||||
});
|
||||
chat::show(&match result {
|
||||
Some(Ok(())) => format!("give {id}: ok"),
|
||||
Some(Err(e)) => format!("give {id}: {e}"),
|
||||
None => format!("give {id}: no such item"),
|
||||
});
|
||||
}
|
||||
"browser_poll" => {
|
||||
let stack = |s: &browser::Stack| format!("{} x{}{}", s.item, s.count, s.components);
|
||||
// A hook shows at most 8 chat lines: the count, then the first 7.
|
||||
let events = browser::poll();
|
||||
chat::show(&format!("{} events", events.len()));
|
||||
for event in events.into_iter().take(7) {
|
||||
chat::show(&match event {
|
||||
browser::Event::Clicked(c) => {
|
||||
format!(
|
||||
"clicked {} {}",
|
||||
c.index,
|
||||
if c.right { "right" } else { "left" }
|
||||
)
|
||||
}
|
||||
browser::Event::Search(text) => format!("search {text:?}"),
|
||||
browser::Event::Key(k) => format!("key {} on {}", k.key, stack(&k.stack)),
|
||||
browser::Event::SlotClicked(c) => {
|
||||
format!("slot {} {} {}", c.recipe, c.slot, stack(&c.stack))
|
||||
}
|
||||
browser::Event::Action(i) => format!("action {i}"),
|
||||
browser::Event::Page(first) => format!("page {first}"),
|
||||
browser::Event::Back => "back".into(),
|
||||
browser::Event::Tab(i) => format!("tab {i}"),
|
||||
});
|
||||
}
|
||||
}
|
||||
// Clamped to (1, 10); the two after it shake nothing.
|
||||
"render_shake" => {
|
||||
render::shake(2.0, 99.0);
|
||||
@@ -289,6 +468,15 @@ impl Mod for Guest {
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
fn tick(&mut self, _tick: u64) {
|
||||
if let Some((book, all, id)) = self.pending.take() {
|
||||
match recipes::place(book, all) {
|
||||
Err(e) if e.starts_with("no screen") => self.pending = Some((book, all, id)),
|
||||
Ok(()) => chat::show(&format!("place {id}: ok")),
|
||||
Err(e) => chat::show(&format!("place {id}: {e}")),
|
||||
}
|
||||
}
|
||||
}
|
||||
fn ui_select(&mut self, id: u32, row: u32) {
|
||||
chat::show(&format!("selected {id}:{row}"));
|
||||
}
|
||||
|
||||
@@ -126,6 +126,38 @@ fn idle() -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
/// Give back to the OS the memory the allocator holds free, after something
|
||||
/// large was dropped: glibc keeps freed pages in the arena of the thread that
|
||||
/// allocated them, and macOS in its zones until memory runs short. Nothing
|
||||
/// elsewhere.
|
||||
pub fn release_free_memory() {
|
||||
release();
|
||||
}
|
||||
|
||||
#[cfg(all(target_os = "linux", target_env = "gnu"))]
|
||||
fn release() {
|
||||
// SAFETY: returns free heap pages of every arena to the OS; no live
|
||||
// allocation moves.
|
||||
unsafe {
|
||||
libc::malloc_trim(0);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_vendor = "apple")]
|
||||
fn release() {
|
||||
unsafe extern "C" {
|
||||
fn malloc_zone_pressure_relief(zone: *mut std::ffi::c_void, goal: usize) -> usize;
|
||||
}
|
||||
// SAFETY: a null zone means every zone and a goal of 0 as much as it can;
|
||||
// it frees only pages no allocation uses.
|
||||
unsafe {
|
||||
malloc_zone_pressure_relief(std::ptr::null_mut(), 0);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(any(all(target_os = "linux", target_env = "gnu"), target_vendor = "apple")))]
|
||||
fn release() {}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
/// The hint holds on the platforms that take it.
|
||||
|
||||
@@ -18,7 +18,7 @@ The contract between Horizon and its mods: the WIT package `horizon:mods@0.1.0`,
|
||||
| `input` | [`input`](#input) | see the keys you hold and your clicks, take over the use button on its own items and the mouse wheel, and add keys |
|
||||
| `players` | [`players`](#players) | see who is on the server and message them |
|
||||
| `world` | [`world`](#world) | read and change the world's blocks |
|
||||
| `recipes` | [`recipes`](#recipes) | read the game's recipes and items, and which recipes you know |
|
||||
| `recipes` | [`recipes`](#recipes) | read the game's recipes and items and which recipes you know, place recipes in your crafting grid and, in creative mode, give you items |
|
||||
|
||||
## World `horizon-mod`
|
||||
|
||||
@@ -38,7 +38,7 @@ A server mod (manifest `side = "server"`, `api = "0.3"`, experimental): runs whe
|
||||
|
||||
A mod with widgets (capability `ui`): the SDK builds every mod against this world. Mods built for `horizon-mod` alone (no `ui-events` export) still load and simply hear nothing of their widgets.
|
||||
|
||||
- Imports (what the game offers): `ui`, `render`, `input`, `recipes`, `log`, `game`, `chat`, `hud`, `storage`, `command`
|
||||
- Imports (what the game offers): `ui`, `render`, `input`, `recipes`, `browser`, `log`, `game`, `chat`, `hud`, `storage`, `command`
|
||||
- Exports (what the mod provides): `ui-events`, `events`
|
||||
|
||||
## World `ui-host`
|
||||
@@ -59,7 +59,7 @@ The game's side of capability `render` (imports only).
|
||||
|
||||
The game's side of capability `recipes` (imports only).
|
||||
|
||||
- Imports (what the game offers): `recipes`
|
||||
- Imports (what the game offers): `recipes`, `browser`
|
||||
- Exports (what the mod provides):
|
||||
|
||||
## `log`
|
||||
@@ -531,6 +531,98 @@ The station a recipe is made at.
|
||||
| `items` | `func() -> list<item>` | Every item, in the registry's order: `stack.item` indexes this list. |
|
||||
| `all` | `func() -> list<recipe>` | Every recipe: the book's in its order, then campfire, stonecutting, smithing and brewing. They don't change while a world is open. |
|
||||
| `known` | `func() -> list<s32>` | The `book-id`s of the recipes the player's recipe book holds now. |
|
||||
| `place` | `func(book-id: s32, all: bool) -> result<_, string>` | Places a recipe in the open screen's grid as a click in the recipe book does (`ServerboundPlaceRecipePacket`): the inventory's grid or a crafting table. `all` places as many as the items allow, as with Shift. Refused while no such screen is open, for a recipe the player's book doesn't hold and for one that can't be placed; one missing items shows as a ghost in the grid, as in the book. |
|
||||
| `give` | `func(stack: stack) -> result<_, string>` | Creative mode only: gives the player the stack, as taking it from the creative inventory does (`ServerboundSetCreativeModeSlot`): the selected slot first, then the hotbar, then the inventory. The count is cut to one stack of the item (64, 16 or 1). |
|
||||
|
||||
## `browser`
|
||||
|
||||
Access: not granted by any capability.
|
||||
|
||||
Capability `ui` (experimental): a recipe viewer's panel beside the game's inventory and container screens (not the creative inventory), on the right of the screen. The game draws it with its own widgets and slots, pages it and keeps its search box; the mod says what it holds. The first loaded mod with a panel shows.
|
||||
|
||||
### record `stack`
|
||||
|
||||
An item to show: its id, a count (1 draws none) and its components beyond the item's own, in vanilla's item syntax, or "".
|
||||
|
||||
| Field | Type | |
|
||||
| --- | --- | --- |
|
||||
| `item` | `string` | |
|
||||
| `count` | `u32` | |
|
||||
| `components` | `string` | |
|
||||
|
||||
### record `click`
|
||||
|
||||
A click on an item of the panel, by its index in `set-items`'s list.
|
||||
|
||||
| Field | Type | |
|
||||
| --- | --- | --- |
|
||||
| `index` | `u32` | |
|
||||
| `right` | `bool` | |
|
||||
|
||||
### record `item-key`
|
||||
|
||||
A key the mod registered (`input.register-key`) went down while the pointer was on an item: a slot of the screen's, or the panel's.
|
||||
|
||||
| Field | Type | |
|
||||
| --- | --- | --- |
|
||||
| `key` | `string` | |
|
||||
| `stack` | `stack` | |
|
||||
|
||||
### record `slot-click`
|
||||
|
||||
A click on a slot of a recipe the panel shows (`set-recipes`).
|
||||
|
||||
| Field | Type | |
|
||||
| --- | --- | --- |
|
||||
| `recipe` | `u32` | The recipe's index in `set-recipes`'s list and the slot's in it; the results come after the slots. |
|
||||
| `slot` | `u32` | |
|
||||
| `stack` | `stack` | The stack the slot showed at that moment. |
|
||||
| `right` | `bool` | |
|
||||
|
||||
### variant `event`
|
||||
|
||||
| Case | Payload | |
|
||||
| --- | --- | --- |
|
||||
| `clicked` | `click` | |
|
||||
| `search` | `string` | The search box's text, each time the player changes it. |
|
||||
| `key` | `item-key` | |
|
||||
| `slot-clicked` | `slot-click` | |
|
||||
| `action` | `u32` | The action button of the recipe at this index in `set-recipes`'s list. |
|
||||
| `page` | `u32` | The player turned the recipes' page (`set-recipes`): the index of the first recipe now shown. |
|
||||
| `back` | | The back button of `set-recipes`. |
|
||||
| `tab` | `u32` | The player picked the tab at this index of `set-recipes`' tabs. |
|
||||
|
||||
### enum `layout`
|
||||
|
||||
How the game lays out a recipe of `set-recipes`.
|
||||
|
||||
| Case | |
|
||||
| --- | --- |
|
||||
| `grid` | A crafting grid of `width` x `height` slots, an arrow and the result. |
|
||||
| `cooking` | One input over a flame, an arrow and the result (furnaces, smokers, campfires). |
|
||||
| `single` | One input, an arrow and the result (the stonecutter). |
|
||||
| `smithing` | Template, base and addition, an arrow and the result. |
|
||||
| `brewing` | A potion and an ingredient, an arrow and the result. |
|
||||
|
||||
### record `recipe-view`
|
||||
|
||||
A recipe to show. Each slot, and the result, cycles through its stacks, one a second; an empty slot shows nothing.
|
||||
|
||||
| Field | Type | |
|
||||
| --- | --- | --- |
|
||||
| `layout` | `layout` | |
|
||||
| `width` | `u32` | The grid's size (`grid` only), at most 3 x 3. |
|
||||
| `height` | `u32` | |
|
||||
| `slots` | `list<list<stack>>` | |
|
||||
| `results` | `list<stack>` | |
|
||||
| `note` | `string` | A short line under the recipe (a cooking time), or "". |
|
||||
| `action` | `option<bool>` | The action button beside it: none, enabled, or shown greyed. |
|
||||
|
||||
| Function | Signature | What it does |
|
||||
| --- | --- | --- |
|
||||
| `set-items` | `func(items: list<stack>)` | The panel's items, at most 8192 (an empty list hides the panel). |
|
||||
| `set-recipes` | `func(title: string, tabs: list<stack>, tab: u32, recipes: list<recipe-view>)` | Shows these recipes in the panel instead of its items, as many as fit a page, under `title` and a back button, with a row of `tabs` (each an icon; `tab` is the selected one) between them; an empty list of recipes shows the items again. At most 256 recipes and 16 tabs. |
|
||||
| `poll` | `func() -> list<event>` | What the player did on the panel since the last poll, at most 256 kept. |
|
||||
|
||||
## `players`
|
||||
|
||||
|
||||
@@ -38,7 +38,7 @@ don't import it); faster, or fixing behaviour to what the reference already says
|
||||
|
||||
A new WIT function doesn't break older mods, but a mod that calls it can't run on a Horizon from before it. So every
|
||||
function the game offers mods has an **API level**, the one it arrived at (`crates/horizon-mods/src/api_level.rs`):
|
||||
level 0 is the contract of Horizon 0.2.67 to 0.2.69, level 1 adds `render.set-minimap` and server mods' `players`, level 2 `game.raycast`, level 3 `command.run-quiet`, level 4 server mods' `world`, level 5 `recipes` (`items`, `all`, `known`). A mod needs the level of
|
||||
level 0 is the contract of Horizon 0.2.67 to 0.2.69, level 1 adds `render.set-minimap` and server mods' `players`, level 2 `game.raycast`, level 3 `command.run-quiet`, level 4 server mods' `world`, level 5 `recipes` (`items`, `all`, `known`), level 6 `browser` (`set-items`, `poll`, `set-recipes`) and `recipes.place` and `recipes.give`. A mod needs the level of
|
||||
the newest function it imports (a component imports only the functions it calls). The level is read from the file,
|
||||
never declared:
|
||||
|
||||
|
||||
+5
-4
@@ -15,7 +15,7 @@ folder, loads no runtime and plays exactly like vanilla 26.3.
|
||||
| The contract (WIT) | `crates/horizon-mod-sdk/wit/horizon.wit` | 0.1: log, game-read, chat, hud, storage, command, ui; tick, frame, chat and command hooks |
|
||||
| SDK for mod authors | `crates/horizon-mod-sdk` | 0.1 |
|
||||
| Example mods | `crates/horizon-mod-sdk/examples/coords-hud`, `examples/predator-missile` | coordinates, facing and time HUD, `/where`, `/coords`; `/missile`, a guided missile from vanilla commands |
|
||||
| Horizon's official mods | `mods/official/` | the extras moving to the registry: FPS Counter (`fps-overlay`), Keystrokes, Zoom, Minimap |
|
||||
| Horizon's official mods | `mods/official/` | the extras moving to the registry: FPS Counter (`fps-overlay`), Keystrokes, Zoom, Minimap; and Recipes, a recipe viewer |
|
||||
| Runtime | `crates/horizon-mods` | wasmtime 48 LTS, component model |
|
||||
| Authoring tool | `crates/cargo-horizon-mod` | `cargo horizon-mod new [--template code\|content] / build / run / test / check`, and the registry's CI (`registry build / review / list`) |
|
||||
| In the game | `crates/horizon-client/src/game/mods.rs`, Options > Mods | client mods |
|
||||
@@ -88,10 +88,10 @@ folder, loads no runtime and plays exactly like vanilla 26.3.
|
||||
| `hud` | `hud` | `set-panel(corner, lines)`, `clear()`: up to 16 lines of 256 characters |
|
||||
| `storage` | `storage` | `get(key)`, `set(key, value)`, `delete(key)`: a per-mod folder of up to 64 files, 4 MiB total, at most 1 MiB written per call |
|
||||
| `command` | `command` | `run(line)`: a vanilla command run as the player, with the player's permissions, after the hook returns (8 per call); mods' own commands are not run. `run-quiet(lines)`: up to 256 a call, the same rights, nothing in the chat (single player and Horizon's servers run them 64 a tick and log one line a batch; elsewhere they go as chat commands and their feedback shows) |
|
||||
| `ui` | `ui` | `open-screen`, `close-screen`, `set-settings`: a screen the mod owns and a page in Options > Mods, of text, button, item and list widgets the game lays out and draws with its own menus' code. **Experimental:** the manifest needs `experimental = ["ui"]` |
|
||||
| `ui` | `ui` | `open-screen`, `close-screen`, `set-settings`: a screen the mod owns and a page in Options > Mods, of text, button, item and list widgets the game lays out and draws with its own menus' code; `browser.set-items`, `browser.poll`: a recipe viewer's panel of items right of the inventory and container screens (not the creative one), which the game pages and draws with its own slots and search box, and the mod hears its clicks, its searches and the mod's own keys (`input.register-key`) pressed over any item on the screen; `browser.set-recipes`: recipes in the panel's place (crafting grids, cooking, stonecutting, smithing and brewing layouts, their slots cycling through their stacks, a note and an action button each), paged by the game, with clicks on their slots, action buttons, page turns and a back button. **Experimental:** the manifest needs `experimental = ["ui"]` |
|
||||
| `render` | `render` | `set-hud(shapes)`: up to 1024 fills, four-cornered shapes, GUI sprites, items and text, kept on the HUD (over the hotbar, under chat and screens, hidden with F1) until replaced; `set-corner(corner, w, h, shapes)`: a box the game places in a corner, stacked with its overlays and other panels, as `hud`'s panels are; `text-width(text)`; `frame-stats()`: the game's FPS, min and 1% low; `gui-size()`; `shake(strength, seconds)`: the view turns, the player doesn't, scaled by Distortion Effects; `set-zoom(zoom)`: the field of view divided by up to 10, eased or at once, with slower turning, while playing and not through a spyglass (the strongest of several mods' zooms counts); `set-minimap(minimap)`: a map of the world around the player in a corner, as a map item colours it (the floor underground and under roofs), square or round, turning with the player or north up, with the player's arrow; the game keeps its tiles within its own frame budget (one map at a time, the first loaded mod's). The game's own HUD renderer draws them: a mod never supplies shader code. **Experimental:** `experimental = ["render"]` |
|
||||
| `input` | `input` | `claim-items(ids)`: the use button on those items is the mod's (`use-start`, `use-stop` with the ticks held; the game doesn't place, eat or swing); `register-key(name, label, key)`: up to 16 keys (`key.keyboard.r`), heard while playing as `key-down` / `key-up` (the game's own mapping on that key still runs; a screen opening lets held keys up), mouse buttons as `key.mouse.4`. The player rebinds them on the mod's page in Options > Mods, where the game lists them first as Key Binds rows (click, then press a key or mouse button; Esc binds none), even for a mod without `ui`; the choice is kept in `horizon-mods.txt` (`key.<mod>.<name>:key.keyboard.z`) and wins over `register-key`'s default; `claim-wheel(on)`: the wheel's steps become `wheel` events and the hotbar doesn't move (the one input a mod takes from the game; a mod's keys never do); `held()`; `mapping(name)`: a movement or click mapping's key label and whether it is held; `clicks-per-second()`; `poll()` returns the events, at most 256 kept. **Experimental:** `experimental = ["input"]` |
|
||||
| `recipes` | `recipes` | `items()`: every item in the registry's order, with its name in the player's language and its tags; `all()`: every recipe (crafting, smelting, blasting, smoking, campfire, stonecutting, smithing, brewing), its slots as the stacks that fit each, its results, cooking time and experience, and the recipe book's id for it; `known()`: the book ids the player's recipe book holds. The recipes are the world's in single player and on Horizon's servers, vanilla 26.3's on any other server (a client is sent no others), while `known` follows that server's book. The game makes the tables once, when a mod first reads them. **Experimental:** `experimental = ["recipes"]` |
|
||||
| `recipes` | `recipes` | `items()`: every item in the registry's order, with its name in the player's language and its tags; `all()`: every recipe (crafting, smelting, blasting, smoking, campfire, stonecutting, smithing, brewing), its slots as the stacks that fit each, its results, cooking time and experience, and the recipe book's id for it; `known()`: the book ids the player's recipe book holds; `place(book-id, all)`: the recipe book's click on a recipe, in the open inventory's or crafting table's grid (refused for a recipe the player's book doesn't hold, as vanilla's server refuses it); `give(stack)`: in creative mode, the stack into the player's inventory, as from the creative inventory. The recipes are the world's in single player and on Horizon's servers, vanilla 26.3's on any other server (a client is sent no others), while `known` follows that server's book. The game makes the tables once, when a mod first reads them. **Experimental:** `experimental = ["recipes"]` |
|
||||
|
||||
| Hook (export) | When |
|
||||
|---|---|
|
||||
@@ -571,7 +571,7 @@ says how). The formats: [api/mod-index.md](api/mod-index.md).
|
||||
|
||||
### Horizon's official mods
|
||||
|
||||
The Horizon extras that are mods now live in `mods/official/<name>/`, one crate each in the game's workspace, so
|
||||
The Horizon extras that are mods now, and Horizon's own recipe viewer, live in `mods/official/<name>/`, one crate each in the game's workspace, so
|
||||
`cargo clippy --workspace` and pr_ready cover them. They use only the public API, like any other mod, with the same
|
||||
options on their settings page as the extra had and the same look. Each is published by a maintainer: build it on
|
||||
`main` with the toolchain CI uses (`cd mods/official/<name> && cargo horizon-mod build`), then open a pull request in
|
||||
@@ -585,6 +585,7 @@ so the same rustc and `Cargo.lock` give the same file.
|
||||
| Keystrokes | `keystrokes` | Extras > Keystrokes | `corner`: as above, default `top_left`; `cps`: `true` (default), `false` |
|
||||
| Zoom | `zoom` | Extras > Zoom | `level`: `2`, `3`, `4` (default), `6`, `8`, `10`; `smooth`: `true` (default), `false`; `sensitivity` (slower turning): `true` (default), `false`. The key is the game's: `key.zoom.zoom` in `horizon-mods.txt`, default `key.keyboard.z` |
|
||||
| Minimap | `minimap` | Extras > Minimap | `corner`: as above, default `top_right`; `size`: `64`, `96` (default), `128`; `shape`: `square` (default), `round`; `zoom` (blocks per pixel): `0.5`, `1` (default), `2`, `4`; `rotate`: `false` (default), `true`. The game draws the map (`render.set-minimap`) |
|
||||
| Recipes | `recipes` | nothing (new) | `give` (a click on the item panel gives the item in creative mode: one, a stack with a right click): `false` (default), `true`. The keys are the game's: `key.recipes.recipe` (default `key.keyboard.r`) and `key.recipes.uses` (default `key.keyboard.u`) in `horizon-mods.txt`. Every item sits in the item panel (`browser`) with a search box (words in the name or id, `@namespace`, `#tag`); R or a click shows an item's recipes, U or a right click its uses, a tab per station, and + fills the grid with a recipe the player knows (`recipes.place`). On servers that are not Horizon it shows the game's own 26.3 recipes; known recipes and + follow the server's recipe book |
|
||||
|
||||
## Versions and compatibility
|
||||
|
||||
|
||||
@@ -46,7 +46,7 @@ Harnesses a host can't run are listed in the file: `local_only` (`save_roundtrip
|
||||
|
||||
### Any command on playhorizon: `tools/remote.sh`
|
||||
|
||||
For Linux work that doesn't need a whole parity run (a crate's tests, a few harnesses, a server build), `tools/remote.sh <worktree> <command...>` runs the command on playhorizon at the worktree's commit and streams its output back; its exit status is the command's. Commit and push first: the host fetches the commit from GitHub, and nothing else leaves this machine (no files, no credentials, no environment). Each worktree keeps one checkout there, `~/remote-work/<name>` with its own `target/`, so the next run only rebuilds what changed, and one run per name goes at a time. The command runs niced in the host's CI slice, pinned to CPUs 4-7 (`--cpus`; 0-3 stay free for benchmarks), with scratch game and launcher homes and the vanilla data linked as CI links it; a command naming `parity`, `lan_e2e` or `horizon-prepare` also gets `.local/horizon-client`, prepared as CI prepares it. Logs stay in `~/remote-work/<name>.logs/`; `--list` shows the checkouts, `--gc [DAYS]` removes old ones.
|
||||
For Linux work that doesn't need a whole parity run (a crate's tests, a few harnesses, a server build), `tools/remote.sh <worktree> <command...>` runs the command on playhorizon at the worktree's commit and streams its output back; its exit status is the command's. Commit and push first: the host fetches the commit from GitHub, and nothing else leaves this machine (no files, no credentials, no environment). Each worktree keeps one checkout there, `~/remote-work/<name>` with its own `target/`, so the next run only rebuilds what changed, and one run per name goes at a time. The command runs niced in the host's CI slice, pinned to CPUs 4-7 (`--cpus`; 0-3 stay free for benchmarks), with scratch game and launcher homes and the vanilla data linked as CI links it; a command naming `parity`, `lan_e2e` or `horizon-prepare` also gets `.local/horizon-client`, prepared as CI prepares it. Whatever the command leaves running (a game, a daemon) is stopped when it ends, and stopping `remote.sh` (or losing the connection) stops the command on the host. Logs stay in `~/remote-work/<name>.logs/`; `--list` shows the checkouts, `--gc [DAYS]` removes old ones.
|
||||
|
||||
```sh
|
||||
tools/remote.sh .claude/worktrees/my-fix cargo test -p horizon-world --lib # 33 s, warm
|
||||
|
||||
+19
-7
@@ -13,7 +13,7 @@ reopened; labels never start it):
|
||||
| Job | Runs on | What | Time |
|
||||
| --- | --- | --- | --- |
|
||||
| `verify` | GitHub `ubuntu-latest` | Public-tree check, Python tests, rustfmt, clippy with warnings denied, Rust tests. Dependencies and the workspace build come from a cache saved by main's runs | ~4 min cold, less warm |
|
||||
| `parity / parity` | A runner with the label `horizon-game`: the Mac mini's two, and the desktop's while nobody uses it (below) | Only for a change to a harness, an oracle, a probe or the runner (or the `run-parity` label): the vanilla harnesses the change can affect (`tools/parity.py --changed <base>`). Other pull requests skip it: [parity-main](#parity-on-main) checks them on main | seconds when nothing is affected; minutes per affected harness |
|
||||
| `parity / parity` | A runner with the label `horizon-game`: the Mac mini's and playhorizon's (below) | Only for a change to a harness, an oracle, a probe or the runner (or the `run-parity` label): the vanilla harnesses the change can affect (`tools/parity.py --changed <base>`). Other pull requests skip it: [parity-main](#parity-on-main) checks them on main | seconds when nothing is affected; minutes per affected harness |
|
||||
|
||||
Pushes to main run `verify` only; the nightly audit covers main's harnesses.
|
||||
|
||||
@@ -27,8 +27,14 @@ quick steps and finishes in seconds.
|
||||
The job is `.github/workflows/parity.yml`, a reusable workflow: `checks` calls it for pull requests from this
|
||||
repository, `run-parity.yml` for a fork's pull request a maintainer labels `run-parity`.
|
||||
|
||||
A pull request every harness reaches (a crate split moves files every probe links: #2160 ran all 352 harnesses, 71
|
||||
minutes on a Mac mini runner, while five other pull requests' jobs queued behind it) carries the `full-parity` label:
|
||||
its job runs on playhorizon's runner only (4 harnesses at a time), and waits for it while that runner is busy
|
||||
instead of taking the Mac mini's. Labels don't start `checks`: add it when opening the pull request
|
||||
(`gh pr create --label full-parity`), or before the next push.
|
||||
|
||||
Horizon never downloads the game, so harnesses that compare with vanilla run only where the maintainer's own
|
||||
copy is: the Mac mini, his Linux desktop and playhorizon (below). Their runners run as the maintainer's account and keep their checkout
|
||||
copy is: the Mac mini and playhorizon (below). Their runners run as the maintainer's account and keep their checkout
|
||||
between runs (`clean: false`), so the probes build incrementally into `.local/parity-target` and earlier passes
|
||||
stand.
|
||||
|
||||
@@ -77,8 +83,9 @@ release's macOS build back. Each keeps its own checkout, `target/` and Rust inst
|
||||
`_work/horizon/shared-local` links to the first's, so they share the vanilla data, the preparations (one at a
|
||||
time, under a lock) and the parity passes and slots.
|
||||
|
||||
**The desktop's runner.** `ludodesktop-horizon` (labels `self-hosted`, `Linux`, `X64`, `ludodesktop`, and
|
||||
`horizon-game` once a full trial has passed there) runs on the maintainer's Linux desktop while nobody uses it: its
|
||||
**The desktop's runner (retired).** On 2026-10-26 the maintainer asked that CI stop using his desktop: its services are
|
||||
stopped and `ludodesktop-horizon` has no label a job asks for (only `self-hosted`, `Linux`, `X64`), so nothing waits for
|
||||
it. What follows describes it as it ran. It ran on the maintainer's Linux desktop while nobody used it: its
|
||||
screen locked, or untouched for 15 minutes (his rule, 2026-10-26). `tools/ci/linux-runner.sh install`, run there as
|
||||
him, registers it and installs two systemd user services: `horizon-runner` (the runner, in
|
||||
`~/.actions-runners/horizon`, nice 19, in `horizon-ci.slice` with CPU and I/O weight 20, half the threads (on all of
|
||||
@@ -99,12 +106,16 @@ and its failures bisect and comment on merges.
|
||||
|
||||
**playhorizon's runner.** `playhorizon-horizon` runs on a server a hosting partner lends the project (Ubuntu, 8
|
||||
AMD EPYC threads, 30 GB, in Detroit; reachable only through the maintainer's tailnet). Nobody sits at it, so the
|
||||
watcher never pauses it except by hand, and it gets the machine: `RUNNER_CPUS=7 RUNNER_MEMORY=24 RUNNER_JOBS=6`.
|
||||
watcher never pauses it except by hand. It gets half the machine: `RUNNER_MEMORY=24`, `CARGO_BUILD_JOBS` and the
|
||||
harness slots at 4, and a drop-in (`~/.config/systemd/user/horizon-runner.service.d/cpus.conf`) that starts it under
|
||||
`taskset -c 4-7`, so CPUs 0-3 stay free for benchmarks pinned there (the user slice can't set `AllowedCPUs`: systemd
|
||||
doesn't delegate the cpuset controller to it).
|
||||
Without Nix, `linux-runner.sh` takes the build libraries, the JDK, sccache, Xvfb and Mesa's software Vulkan
|
||||
(lavapipe; there is no GPU) from apt, and Python 3.13 from uv. The partner can reach the machine, so gh is not
|
||||
logged in there: the registration token comes from another machine (`RUNNER_TOKEN`), and no job with secrets may
|
||||
run there (parity has none). It is also a parity host (`playhorizon` in `tools/parity_hosts.json`, 6 jobs). Like
|
||||
the desktop's, it gets the `horizon-game` label only once a full trial has passed there.
|
||||
run there (parity has none). It is also a parity host (`playhorizon` in `tools/parity_hosts.json`, 6 jobs). Since
|
||||
2026-10-26 it has the `horizon-game` label: pull requests' parity jobs run there or on the Mac mini, and `full-parity`
|
||||
ones only there.
|
||||
|
||||
## Merging
|
||||
|
||||
@@ -212,6 +223,7 @@ How releases are numbered, and how installs from before 0.2 reach it: [versionin
|
||||
| `release-now` | Releases as soon as main's checks pass, without waiting for merges to settle |
|
||||
| `skip-release` | Doesn't trigger a release and isn't listed in the notes |
|
||||
| `run-parity` | Runs the parity job once on a fork's pull request |
|
||||
| `full-parity` | Runs the pull request's parity job on playhorizon's runner only (a change every harness reaches) |
|
||||
| `parity-regression` | Opened by the nightly audit for a harness that fails on main |
|
||||
| `release-blocker` | On an issue: no game release is published while it is open ([One channel](#one-channel)) |
|
||||
|
||||
|
||||
@@ -2545,6 +2545,13 @@ pub struct Settings {
|
||||
/// first-run question, or on the Play page once if they answered that before it existed.
|
||||
#[serde(default)]
|
||||
pub ping_notice_seen: bool,
|
||||
/// Seconds the sidebar's partner banner has been on screen, over every start, up to
|
||||
/// `partner::SEEN_BEFORE_HIDE`: only then can the player hide it.
|
||||
#[serde(default)]
|
||||
pub partner_seen_secs: u64,
|
||||
/// The player hid the sidebar's partner banner. The Servers page keeps its own.
|
||||
#[serde(default)]
|
||||
pub partner_hidden: bool,
|
||||
}
|
||||
|
||||
impl Settings {
|
||||
|
||||
+57
-2
@@ -248,6 +248,32 @@ impl Launcher {
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
// Counts the time the sidebar's partner banner is on screen, until it may be hidden.
|
||||
cx.spawn(async move |this, cx| loop {
|
||||
cx.background_executor()
|
||||
.timer(Duration::from_secs(10))
|
||||
.await;
|
||||
let counting = this.update(cx, |this, cx| {
|
||||
let s = &mut this.settings;
|
||||
if s.partner_hidden || s.partner_seen_secs >= partner::SEEN_BEFORE_HIDE {
|
||||
return false;
|
||||
}
|
||||
if this.page != Page::Servers {
|
||||
s.partner_seen_secs += 10;
|
||||
if s.partner_seen_secs % 60 == 0
|
||||
|| s.partner_seen_secs >= partner::SEEN_BEFORE_HIDE
|
||||
{
|
||||
s.save();
|
||||
cx.notify();
|
||||
}
|
||||
}
|
||||
true
|
||||
});
|
||||
if !matches!(counting, Ok(true)) {
|
||||
break;
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
let mut settings = Settings::load();
|
||||
if settings.minecraft_dir.is_none() {
|
||||
settings.minecraft_dir = backend::default_minecraft_dir();
|
||||
@@ -1749,6 +1775,36 @@ impl Launcher {
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
// Sculk's banner, small, above the links: seen without scrolling, on every page but Servers, which
|
||||
// shows it large. After a while on screen a "Hide" beside its label puts it away for good.
|
||||
if self.page != Page::Servers && !self.settings.partner_hidden {
|
||||
let mut side = div().relative().mb(px(8.)).child(partner::banner(
|
||||
"partner-side",
|
||||
theme::SIDEBAR - 20.,
|
||||
&t,
|
||||
));
|
||||
if self.settings.partner_seen_secs >= partner::SEEN_BEFORE_HIDE {
|
||||
let hide = div()
|
||||
.id("partner-hide")
|
||||
.absolute()
|
||||
.top(px(0.))
|
||||
.right(px(0.))
|
||||
.cursor_pointer()
|
||||
.child(
|
||||
text(theme::MICRO)
|
||||
.text_color(t.muted)
|
||||
.hover(move |s| s.text_color(t.ink))
|
||||
.child("HIDE"),
|
||||
)
|
||||
.on_click(cx.listener(|this, _: &ClickEvent, _, cx| {
|
||||
this.settings.partner_hidden = true;
|
||||
this.settings.save();
|
||||
cx.notify();
|
||||
}));
|
||||
side = side.child(keyboard(hide, &t));
|
||||
}
|
||||
foot = foot.child(side);
|
||||
}
|
||||
if !self.links.discord.is_empty() {
|
||||
foot = foot.child(link(
|
||||
"discord",
|
||||
@@ -2131,8 +2187,7 @@ impl Launcher {
|
||||
if let Some(crash) = self.crash.clone() {
|
||||
page = page.child(self.crash_notice(crash, cx));
|
||||
}
|
||||
// Below everything the player needs to start, so it never moves Play.
|
||||
page.child(partner::banner("partner-home", 560., &t))
|
||||
page
|
||||
}
|
||||
|
||||
/// "Horizon crashed": the report and the logs, and where to report it.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
//! Sculk's banner: a server host that sponsors Horizon (Ludovic, 2026-10-26), on the Servers page
|
||||
//! and under Home's rows. The picture is in the binary, never fetched; a click opens Sculk's page in
|
||||
//! Sculk's banner: a server host that sponsors Horizon (Ludovic, 2026-10-26), large on the Servers page
|
||||
//! and small at the foot of the sidebar on the other pages, where it shows without scrolling. The picture is in the binary, never fetched; a click opens Sculk's page in
|
||||
//! the browser. A "Partner" label above it says it is an ad.
|
||||
use crate::theme::Theme;
|
||||
use gpui::{div, img, prelude::*, px, ClickEvent, Div, Image, ImageFormat, Stateful};
|
||||
@@ -7,6 +7,10 @@ use std::sync::{Arc, LazyLock};
|
||||
|
||||
pub const URL: &str = "https://my.sculkhosting.com/ref/horizon";
|
||||
|
||||
/// How long the sidebar's banner stays on screen, counted over every start, before a "Hide" shows
|
||||
/// beside its label: long enough to be seen, then the player may put it away for good.
|
||||
pub const SEEN_BEFORE_HIDE: u64 = 180;
|
||||
|
||||
/// 1200×441: twice the widest it is shown at, for Retina screens. Its transparent edges let the
|
||||
/// characters stand out of the card.
|
||||
const WIDTH: f32 = 1200.;
|
||||
@@ -19,7 +23,7 @@ static BANNER: LazyLock<Arc<Image>> = LazyLock::new(|| {
|
||||
});
|
||||
|
||||
/// The banner `width` logical pixels wide, under its label. Hovering lifts it a little and turns the
|
||||
/// label to ink. The narrowest window (960) leaves the page 648 pixels, so it always fits.
|
||||
/// label to ink. The narrowest window (960) leaves the page 648 pixels, so the Servers one always fits.
|
||||
pub fn banner(id: &'static str, width: f32, t: &Theme) -> Stateful<Div> {
|
||||
let t = *t;
|
||||
let el = div()
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
[package]
|
||||
name = "recipes"
|
||||
version = "1.0.0"
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
publish = false
|
||||
description = "Horizon's official recipe viewer: every item beside the inventory, its recipes and uses."
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib"]
|
||||
|
||||
[dependencies]
|
||||
horizon-mod-sdk = { path = "../../../crates/horizon-mod-sdk" }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
@@ -0,0 +1,9 @@
|
||||
id = "recipes"
|
||||
name = "Recipes"
|
||||
version = "1.0.0"
|
||||
description = "Every item beside your inventory, with a search box. R over an item shows its recipes, U its uses (both rebindable in Controls), and + fills the crafting grid with a recipe you know. In creative mode a click can give you the item (Mod settings). On servers that are not Horizon, it shows this game's own 26.3 recipes; what you know and what + can fill follow the server's recipe book."
|
||||
authors = ["Horizon"]
|
||||
api = "0.1"
|
||||
side = "client"
|
||||
capabilities = ["recipes", "ui", "input", "game-read", "chat", "storage"]
|
||||
experimental = ["recipes", "ui", "input"]
|
||||
@@ -0,0 +1,364 @@
|
||||
//! Horizon's official recipe viewer. Every item sits in the item panel the
|
||||
//! game draws beside the inventory and container screens (`browser`), with
|
||||
//! a search box. R over an item, the panel's or any slot's, shows its
|
||||
//! recipes, U its uses (`input.register-key`, so both can be rebound in
|
||||
//! Controls); a click does the same, left for recipes and right for uses.
|
||||
//! A recipe page has a tab per station, and + beside a crafting recipe the
|
||||
//! player knows fills the grid as the recipe book does (`recipes.place`).
|
||||
//! In creative mode, with "Click gives items" on, a click on a panel item
|
||||
//! gives one, a right click a stack (`recipes.give`).
|
||||
//!
|
||||
//! On a server that is not Horizon the recipes are the game's own 26.3
|
||||
//! ones; `known` and `place` follow the server's recipe book.
|
||||
use horizon_mod_sdk::{
|
||||
Mod, Registration, browser, chat, game, input, recipes,
|
||||
recipes::Kind,
|
||||
storage,
|
||||
ui::{self, ButtonWidget, Widget},
|
||||
};
|
||||
|
||||
const RECIPE_KEY: &str = "recipe";
|
||||
const USES_KEY: &str = "uses";
|
||||
const GIVE_BUTTON: u32 = 1;
|
||||
/// What `set-recipes` takes at most.
|
||||
const MAX_RECIPES: usize = 256;
|
||||
const MAX_STACKS: usize = 64;
|
||||
/// The stations, in the order of their tabs, with each one's icon.
|
||||
const STATIONS: [&str; 8] = [
|
||||
"minecraft:crafting_table",
|
||||
"minecraft:furnace",
|
||||
"minecraft:blast_furnace",
|
||||
"minecraft:smoker",
|
||||
"minecraft:campfire",
|
||||
"minecraft:stonecutter",
|
||||
"minecraft:smithing_table",
|
||||
"minecraft:brewing_stand",
|
||||
];
|
||||
|
||||
fn station(kind: Kind) -> usize {
|
||||
match kind {
|
||||
Kind::CraftingShaped | Kind::CraftingShapeless => 0,
|
||||
Kind::Smelting => 1,
|
||||
Kind::Blasting => 2,
|
||||
Kind::Smoking => 3,
|
||||
Kind::Campfire => 4,
|
||||
Kind::Stonecutting => 5,
|
||||
Kind::Smithing => 6,
|
||||
Kind::Brewing => 7,
|
||||
}
|
||||
}
|
||||
|
||||
fn stack(item: &str) -> browser::Stack {
|
||||
browser::Stack {
|
||||
item: item.into(),
|
||||
count: 1,
|
||||
components: String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// A recipe page: an item's recipes or its uses, on one station's tab.
|
||||
#[derive(Clone)]
|
||||
struct View {
|
||||
item: browser::Stack,
|
||||
uses: bool,
|
||||
tab: usize,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct Recipes {
|
||||
items: Vec<recipes::Item>,
|
||||
all: Vec<recipes::Recipe>,
|
||||
/// The panel's items now: indexes into `items`, after the search.
|
||||
listed: Vec<usize>,
|
||||
search: String,
|
||||
/// The page shown, then the ones Back returns to.
|
||||
history: Vec<View>,
|
||||
/// The book id behind each recipe the page shows (-1: none).
|
||||
shown: Vec<i32>,
|
||||
give: bool,
|
||||
}
|
||||
|
||||
impl Recipes {
|
||||
fn id(&self, s: &recipes::Stack) -> &str {
|
||||
self.items
|
||||
.get(s.item as usize)
|
||||
.map_or("minecraft:air", |i| &i.id)
|
||||
}
|
||||
fn convert(&self, s: &recipes::Stack) -> browser::Stack {
|
||||
browser::Stack {
|
||||
item: self.id(s).into(),
|
||||
count: s.count,
|
||||
components: s.components.clone(),
|
||||
}
|
||||
}
|
||||
/// The same item, and the same components when `want` names some.
|
||||
fn matches(&self, s: &recipes::Stack, want: &browser::Stack) -> bool {
|
||||
self.id(s) == want.item && (want.components.is_empty() || s.components == want.components)
|
||||
}
|
||||
fn name<'a>(&'a self, item: &'a str) -> &'a str {
|
||||
self.items
|
||||
.iter()
|
||||
.find(|i| i.id == item)
|
||||
.map_or(item, |i| &i.name)
|
||||
}
|
||||
|
||||
/// Loads the tables once a world is open, and drops them when it closes.
|
||||
fn load(&mut self) {
|
||||
let in_world = game::get_player().is_some();
|
||||
if in_world && self.items.is_empty() {
|
||||
self.items = recipes::items();
|
||||
self.all = recipes::all();
|
||||
self.list();
|
||||
} else if !in_world && !self.items.is_empty() {
|
||||
browser::set_recipes("", &[], 0, &[]);
|
||||
browser::set_items(&[]);
|
||||
*self = Recipes {
|
||||
give: self.give,
|
||||
..Recipes::default()
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// Fills the panel with the items the search keeps: every word must be
|
||||
/// in the name or the id; `@minecraft` keeps a namespace, `#logs` a tag.
|
||||
fn list(&mut self) {
|
||||
let words: Vec<String> = self
|
||||
.search
|
||||
.to_lowercase()
|
||||
.split_whitespace()
|
||||
.map(String::from)
|
||||
.collect();
|
||||
let keeps = |item: &recipes::Item| {
|
||||
words.iter().all(|w| {
|
||||
if let Some(ns) = w.strip_prefix('@') {
|
||||
item.id.split(':').next().is_some_and(|n| n.starts_with(ns))
|
||||
} else if let Some(tag) = w.strip_prefix('#') {
|
||||
item.tags.iter().any(|t| t.contains(tag))
|
||||
} else {
|
||||
item.name.to_lowercase().contains(w.as_str()) || item.id.contains(w.as_str())
|
||||
}
|
||||
})
|
||||
};
|
||||
self.listed = (self.items.iter().enumerate())
|
||||
.filter(|(_, i)| i.id != "minecraft:air" && keeps(i))
|
||||
.map(|(n, _)| n)
|
||||
.collect();
|
||||
let stacks: Vec<_> = self
|
||||
.listed
|
||||
.iter()
|
||||
.map(|&n| stack(&self.items[n].id))
|
||||
.collect();
|
||||
browser::set_items(&stacks);
|
||||
}
|
||||
|
||||
/// The recipes a view lists, all stations together.
|
||||
fn found(&self, view: &View) -> Vec<&recipes::Recipe> {
|
||||
(self.all.iter())
|
||||
.filter(|r| {
|
||||
if view.uses {
|
||||
r.slots
|
||||
.iter()
|
||||
.flatten()
|
||||
.any(|s| self.matches(s, &view.item))
|
||||
} else {
|
||||
r.results.iter().any(|s| self.matches(s, &view.item))
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Opens a page, pushing it on the history; nothing when it is empty.
|
||||
fn open(&mut self, item: browser::Stack, uses: bool) {
|
||||
let view = View { item, uses, tab: 0 };
|
||||
if self.found(&view).is_empty() {
|
||||
return;
|
||||
}
|
||||
self.history.push(view);
|
||||
self.show();
|
||||
}
|
||||
|
||||
/// Sends the page on top of the history, or the items when there is none.
|
||||
fn show(&mut self) {
|
||||
let Some(mut view) = self.history.last().cloned() else {
|
||||
self.shown.clear();
|
||||
browser::set_recipes("", &[], 0, &[]);
|
||||
return;
|
||||
};
|
||||
let found = self.found(&view);
|
||||
let stations: Vec<usize> = (0..STATIONS.len())
|
||||
.filter(|&s| found.iter().any(|r| station(r.kind) == s))
|
||||
.collect();
|
||||
view.tab = view.tab.min(stations.len().saturating_sub(1));
|
||||
let on: Vec<&recipes::Recipe> = (found.iter().copied())
|
||||
.filter(|r| stations.get(view.tab) == Some(&station(r.kind)))
|
||||
.take(MAX_RECIPES)
|
||||
.collect();
|
||||
let known = recipes::known();
|
||||
let views: Vec<browser::RecipeView> = on.iter().map(|r| self.view(r, &known)).collect();
|
||||
let mut title = format!(
|
||||
"{}: {}",
|
||||
if view.uses { "Uses" } else { "Recipes" },
|
||||
self.name(&view.item.item)
|
||||
);
|
||||
let total = found
|
||||
.iter()
|
||||
.filter(|r| stations.get(view.tab) == Some(&station(r.kind)))
|
||||
.count();
|
||||
if total > on.len() {
|
||||
title.push_str(&format!(" ({} of {total})", on.len()));
|
||||
}
|
||||
let tabs: Vec<_> = stations.iter().map(|&s| stack(STATIONS[s])).collect();
|
||||
browser::set_recipes(&title, &tabs, view.tab as u32, &views);
|
||||
self.shown = on.iter().map(|r| r.book_id).collect();
|
||||
if let Some(top) = self.history.last_mut() {
|
||||
top.tab = view.tab;
|
||||
}
|
||||
}
|
||||
|
||||
fn view(&self, r: &recipes::Recipe, known: &[i32]) -> browser::RecipeView {
|
||||
let mut slots: Vec<Vec<browser::Stack>> = (r.slots.iter())
|
||||
.map(|s| s.iter().take(MAX_STACKS).map(|s| self.convert(s)).collect())
|
||||
.collect();
|
||||
let results = r
|
||||
.results
|
||||
.iter()
|
||||
.take(MAX_STACKS)
|
||||
.map(|s| self.convert(s))
|
||||
.collect();
|
||||
let (layout, width, height, note) = match r.kind {
|
||||
Kind::CraftingShaped => (browser::Layout::Grid, r.width, r.height, String::new()),
|
||||
Kind::CraftingShapeless => {
|
||||
let n = slots.len() as u32;
|
||||
let width = match n {
|
||||
0 | 1 => 1,
|
||||
2..=4 => 2,
|
||||
_ => 3,
|
||||
};
|
||||
let height = n.div_ceil(width).max(1);
|
||||
slots.resize((width * height) as usize, Vec::new());
|
||||
(browser::Layout::Grid, width, height, "Shapeless".into())
|
||||
}
|
||||
Kind::Smelting | Kind::Blasting | Kind::Smoking | Kind::Campfire => {
|
||||
let seconds = r.cooking_time as f32 / 20.0;
|
||||
let note = format!("{seconds} s, {} XP", (r.experience * 100.0).round() / 100.0);
|
||||
(browser::Layout::Cooking, 1, 1, note)
|
||||
}
|
||||
Kind::Stonecutting => (browser::Layout::Single, 1, 1, String::new()),
|
||||
Kind::Smithing => (browser::Layout::Smithing, 3, 1, String::new()),
|
||||
Kind::Brewing => (browser::Layout::Brewing, 2, 1, String::new()),
|
||||
};
|
||||
// + only for what the recipe book shows; greyed until the book has it.
|
||||
let action = (r.book_id >= 0).then(|| known.contains(&r.book_id));
|
||||
browser::RecipeView {
|
||||
layout,
|
||||
width,
|
||||
height,
|
||||
slots,
|
||||
results,
|
||||
note,
|
||||
action,
|
||||
}
|
||||
}
|
||||
|
||||
fn handle(&mut self, event: browser::Event) {
|
||||
match event {
|
||||
browser::Event::Search(text) => {
|
||||
self.search = text;
|
||||
self.list();
|
||||
}
|
||||
browser::Event::Clicked(c) => {
|
||||
let Some(&n) = self.listed.get(c.index as usize) else {
|
||||
return;
|
||||
};
|
||||
let item = self.items[n].id.clone();
|
||||
if self.give {
|
||||
let count = if c.right { 99 } else { 1 };
|
||||
let gift = recipes::Stack {
|
||||
item: n as u32,
|
||||
count,
|
||||
components: String::new(),
|
||||
};
|
||||
if recipes::give(&gift).is_ok() {
|
||||
return;
|
||||
}
|
||||
}
|
||||
self.open(stack(&item), c.right);
|
||||
}
|
||||
browser::Event::Key(k) if k.key == RECIPE_KEY || k.key == USES_KEY => {
|
||||
let item = browser::Stack {
|
||||
count: 1,
|
||||
..k.stack
|
||||
};
|
||||
self.open(item, k.key == USES_KEY);
|
||||
}
|
||||
browser::Event::SlotClicked(c) => {
|
||||
let item = browser::Stack {
|
||||
count: 1,
|
||||
..c.stack
|
||||
};
|
||||
self.open(item, c.right);
|
||||
}
|
||||
browser::Event::Tab(i) => {
|
||||
if let Some(top) = self.history.last_mut() {
|
||||
top.tab = i as usize;
|
||||
}
|
||||
self.show();
|
||||
}
|
||||
browser::Event::Action(i) => {
|
||||
let book = self.shown.get(i as usize).copied();
|
||||
if let Some(Err(e)) = book.map(|b| recipes::place(b, false)) {
|
||||
chat::show(&e);
|
||||
}
|
||||
}
|
||||
browser::Event::Back => {
|
||||
self.history.pop();
|
||||
self.show();
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn page(&self) {
|
||||
ui::set_settings(&[Widget::Button(ButtonWidget {
|
||||
id: GIVE_BUTTON,
|
||||
label: format!(
|
||||
"Click gives items (creative): {}",
|
||||
if self.give { "ON" } else { "OFF" }
|
||||
),
|
||||
})]);
|
||||
}
|
||||
}
|
||||
|
||||
impl Mod for Recipes {
|
||||
fn init() -> Result<(Self, Registration), String> {
|
||||
input::register_key(RECIPE_KEY, "Show recipes", "key.keyboard.r")?;
|
||||
input::register_key(USES_KEY, "Show uses", "key.keyboard.u")?;
|
||||
let give = storage::get("give").is_some_and(|v| v == b"true");
|
||||
let me = Recipes {
|
||||
give,
|
||||
..Recipes::default()
|
||||
};
|
||||
me.page();
|
||||
Ok((me, Registration::new().on_frame()))
|
||||
}
|
||||
fn frame(&mut self, _seconds: f32) {
|
||||
self.load();
|
||||
// The keys matter only over an item, which `browser` reports.
|
||||
let _ = input::poll();
|
||||
for event in browser::poll() {
|
||||
self.handle(event);
|
||||
}
|
||||
}
|
||||
fn ui_select(&mut self, id: u32, _row: u32) {
|
||||
if id == GIVE_BUTTON {
|
||||
self.give = !self.give;
|
||||
let _ = storage::set("give", if self.give { b"true" } else { b"false" });
|
||||
self.page();
|
||||
}
|
||||
}
|
||||
fn ui_settings_opened(&mut self) {
|
||||
self.page();
|
||||
}
|
||||
}
|
||||
|
||||
horizon_mod_sdk::export_mod!(Recipes);
|
||||
+4
-3
@@ -341,8 +341,9 @@ class Game:
|
||||
def wait(self, ticks):
|
||||
self.d('wait', ticks=ticks)
|
||||
|
||||
def script(self, lines):
|
||||
"""Keep tick-sensitive input in one ctl process, through the normal control commands."""
|
||||
def script(self, lines, every=False):
|
||||
"""Keep tick-sensitive input in one ctl process, through the normal control commands. Returns the last
|
||||
reply, or with every=True all of them in order."""
|
||||
result = subprocess.run(self.d.base + ['script'], input='\n'.join(lines) + '\n',
|
||||
cwd=self.work, env=self.d.env, capture_output=True, text=True,
|
||||
timeout=600)
|
||||
@@ -350,7 +351,7 @@ class Game:
|
||||
ticks = [r['ticks'] for r in replies if 'ticks' in r]
|
||||
print(f'[{self.name}] script {lines} -> {replies[-1] if replies else result.stderr[:400]}; ticks={ticks}')
|
||||
assert result.returncode == 0 and replies and not any('error' in r for r in replies), result.stdout
|
||||
return replies[-1]
|
||||
return replies if every else replies[-1]
|
||||
|
||||
def until(self, observe, predicate, ticks=100):
|
||||
"""Wait for an observable answer, within the existing tick budget."""
|
||||
|
||||
+16
-2
@@ -15,6 +15,7 @@
|
||||
# toolchains, a virtual screen and scratch homes for the game and launcher. .local/ links to the host's vanilla
|
||||
# data; a command that runs harnesses (it names parity, lan_e2e or horizon-prepare) also gets .local/horizon-client,
|
||||
# prepared as CI prepares it (tools/ci/vanilla-data.sh). Output streams back and is kept in ~/remote-work/<name>.logs/.
|
||||
# What the command leaves running is stopped when it ends; stopping this script stops the command on the host.
|
||||
# The exit status is the command's.
|
||||
#
|
||||
# The host is lent by a partner who keeps hypervisor access: never send secrets, tokens or credentials there,
|
||||
@@ -96,9 +97,22 @@ export HORIZON_HOME=$scratch/game HORIZON_LAUNCHER_HOME=$scratch/launcher XDG_DA
|
||||
mkdir -p "$HORIZON_HOME" "$HORIZON_LAUNCHER_HOME" "$XDG_DATA_HOME" "$XDG_CONFIG_HOME" ../"$name.logs"
|
||||
log=../"$name.logs/$(date -u +%Y%m%dT%H%M%SZ)-${sha:0:9}.log"
|
||||
echo "remote.sh: $(hostname) at ${sha:0:9} in ~/remote-work/$name, CPUs $cpus, -j $jobs; log ~/remote-work/${log#../}" >&2
|
||||
# The command runs in a scope of its own, stopped with everything in it once the command is done (a game or a
|
||||
# daemon it left behind), when the caller goes away first (the ssh session ends), and before the next run here.
|
||||
unit=remote-$name.scope
|
||||
systemctl --user stop "$unit" 2>/dev/null || true
|
||||
session=$PPID
|
||||
( while kill -0 "$session" 2>/dev/null; do sleep 2; done; systemctl --user stop "$unit" 2>/dev/null ) \
|
||||
< /dev/null > /dev/null 2>&1 9>&- &
|
||||
watcher=$!
|
||||
status=0
|
||||
systemd-run --user --scope --quiet --slice=horizon-ci.slice --unit="remote-$name-$$" \
|
||||
systemd-run --user --scope --quiet --slice=horizon-ci.slice --unit="$unit" \
|
||||
"${pin[@]}" nice -n 10 bash -c "$command" 2>&1 < /dev/null 9>&- | tee "$log" || status=${PIPESTATUS[0]}
|
||||
kill "$watcher" 2>/dev/null || true
|
||||
systemctl --user stop "$unit" 2>/dev/null || true
|
||||
exit "$status"
|
||||
REMOTE
|
||||
} | ssh -o BatchMode=yes "ludovic@$host" bash -s
|
||||
} | ssh -o BatchMode=yes "ludovic@$host" bash -s &
|
||||
# Stopping this script ends the session, and the host then stops the command (above).
|
||||
trap 'kill $! 2>/dev/null' INT TERM HUP
|
||||
wait $!
|
||||
|
||||
@@ -19,15 +19,19 @@ def check_motion(host, guest, out):
|
||||
for game in (host, guest):
|
||||
game('command', command='gamemode creative')
|
||||
tag = 'remote_fireball_parity'
|
||||
host('command', command=f'fill {x + 2} {y + 18} {z - 1} {x + 48} {y + 23} {z + 1} minecraft:air')
|
||||
# A slow fireball (acceleration_power 0.02: under 0.4 blocks a tick, against ~2 by default) stays in the cleared
|
||||
# corridor and in the guest's tracking range for every sample, however many ticks a loaded host takes to answer.
|
||||
host('command', command=f'fill {x + 2} {y + 18} {z - 1} {x + 96} {y + 23} {z + 1} minecraft:air')
|
||||
host('command', command=f'summon minecraft:fireball {x + 3.5} {y + 20} {z + 0.5} '
|
||||
f'{{Tags:["{tag}"],Motion:[0.1d,0.0d,0.0d]}}')
|
||||
f'{{Tags:["{tag}"],Motion:[0.1d,0.0d,0.0d],acceleration_power:0.02d}}')
|
||||
samples = []
|
||||
try:
|
||||
for _ in range(8):
|
||||
balls = [p for p in guest('status')['projectiles'] if p['kind'] == 'LargeFireball']
|
||||
# One ctl process for all samples: a ctl process per sample took 10-16 ticks on a loaded runner, by
|
||||
# which time the fireball had left the cleared corridor and exploded.
|
||||
replies = guest.script(['status', 'wait 1'] * 16, every=True)
|
||||
for status in (reply for reply in replies if 'projectiles' in reply):
|
||||
balls = [p for p in status['projectiles'] if p['kind'] == 'LargeFireball']
|
||||
samples.append(balls[0]['position'][0] if balls else None)
|
||||
guest.wait(2)
|
||||
finally:
|
||||
host('command', command=f'kill @e[tag={tag}]')
|
||||
seen = [p for p in samples if p is not None]
|
||||
|
||||
Reference in New Issue
Block a user