Merge origin/main into refactor/horizon-command
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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
|
||||
|
||||
@@ -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 the desktop's runner only, so the Mac mini's runners stay free for the others. While the desktop is
|
||||
in use 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", "ludodesktop"]') || fromJSON('["self-hosted", "horizon-game"]') }}
|
||||
# A change to core crates reruns ~350 harnesses: ~60-90 min on the Mac mini, ~35 min on the desktop, longer if
|
||||
# someone comes back to the desktop meanwhile (the job then goes on with 2 threads).
|
||||
timeout-minutes: ${{ inputs.full && 300 || 120 }}
|
||||
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
@@ -2997,6 +2997,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-client-core", "crates/horizon-command", "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-client-core", "crates/horizon-command", "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")
|
||||
}
|
||||
|
||||
@@ -102,7 +102,12 @@ 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;
|
||||
mod mod_render;
|
||||
mod mods;
|
||||
mod mount_menu;
|
||||
@@ -4515,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
|
||||
@@ -5404,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);
|
||||
@@ -5796,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();
|
||||
@@ -6523,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;
|
||||
}
|
||||
@@ -6706,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 {
|
||||
@@ -6737,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`).
|
||||
@@ -6897,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) {
|
||||
@@ -8069,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),
|
||||
@@ -12117,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
|
||||
@@ -13025,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();
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -126,28 +126,7 @@ impl Game<'_> {
|
||||
pub(super) fn find_player_spawn(&mut self, suggestion: [i32; 3]) -> Result<Option<Vec3>> {
|
||||
let mut finder = match self.player_spawn {
|
||||
Some(finder) => finder,
|
||||
None if self.level.game_mode == 2 => {
|
||||
// Adventure mode: the suggestion itself.
|
||||
let mut finder = PlayerSpawnFinder::new(suggestion, 0, |_| 0);
|
||||
finder.next_candidate();
|
||||
finder
|
||||
}
|
||||
None => {
|
||||
let mut radius = self.ui.rules.int("respawn_radius").max(0);
|
||||
let to_border = self
|
||||
.border
|
||||
.distance(f64::from(suggestion[0]), f64::from(suggestion[2]))
|
||||
.floor() as i32;
|
||||
if to_border < radius {
|
||||
radius = to_border;
|
||||
}
|
||||
if to_border <= 1 {
|
||||
radius = 1;
|
||||
}
|
||||
PlayerSpawnFinder::new(suggestion, radius, |n| {
|
||||
entity_random().next_int_bound(n).unwrap_or(0)
|
||||
})
|
||||
}
|
||||
None => self.spawn_finder(suggestion),
|
||||
};
|
||||
let found = loop {
|
||||
let Some(candidate) = finder.next_candidate() else {
|
||||
@@ -176,6 +155,58 @@ impl Game<'_> {
|
||||
self.player_spawn = found.is_none().then_some(finder);
|
||||
Ok(found)
|
||||
}
|
||||
/// A new PlayerSpawnFinder around `suggestion`: the respawn radius, cut
|
||||
/// by the world border; the suggestion itself in adventure mode.
|
||||
fn spawn_finder(&self, suggestion: [i32; 3]) -> PlayerSpawnFinder {
|
||||
if self.level.game_mode == 2 {
|
||||
let mut finder = PlayerSpawnFinder::new(suggestion, 0, |_| 0);
|
||||
finder.next_candidate();
|
||||
return finder;
|
||||
}
|
||||
let mut radius = self.ui.rules.int("respawn_radius").max(0);
|
||||
let to_border = self
|
||||
.border
|
||||
.distance(f64::from(suggestion[0]), f64::from(suggestion[2]))
|
||||
.floor() as i32;
|
||||
if to_border < radius {
|
||||
radius = to_border;
|
||||
}
|
||||
if to_border <= 1 {
|
||||
radius = 1;
|
||||
}
|
||||
PlayerSpawnFinder::new(suggestion, radius, |n| {
|
||||
entity_random().next_int_bound(n).unwrap_or(0)
|
||||
})
|
||||
}
|
||||
/// ServerPlayer.adjustSpawnLocation for a server's new player: where
|
||||
/// PlayerSpawnFinder.findSpawn puts their feet around the world spawn
|
||||
/// (`spawn`, the feet there), so that players new to a world don't all
|
||||
/// stand in one spot. Vanilla waits for each candidate's chunk; a server
|
||||
/// tick can't, so a candidate in a chunk not loaded yet is passed over,
|
||||
/// and `spawn` stays when the search never reaches a loaded column.
|
||||
pub(super) fn server_player_spawn(&self, spawn: [f64; 3]) -> [f64; 3] {
|
||||
let suggestion = spawn.map(|v| v.floor() as i32);
|
||||
let mut finder = self.spawn_finder(suggestion);
|
||||
while let Some(candidate) = finder.next_candidate() {
|
||||
let (x, z) = match candidate {
|
||||
SpawnCandidate::Column(x, z) => (x, z),
|
||||
SpawnCandidate::Suggestion => (suggestion[0], suggestion[2]),
|
||||
};
|
||||
if !self.streaming.is_lit(x >> 4, z >> 4) {
|
||||
continue;
|
||||
}
|
||||
let feet = match candidate {
|
||||
SpawnCandidate::Column(x, z) => level_respawn_pos(&self.spawn_columns(), x, z)
|
||||
.filter(|&pos| self.player_fits(pos))
|
||||
.map(bottom_center),
|
||||
SpawnCandidate::Suggestion => Some(self.fixup_spawn_height(suggestion)),
|
||||
};
|
||||
if let Some(feet) = feet {
|
||||
return [feet.x, feet.y, feet.z];
|
||||
}
|
||||
}
|
||||
spawn
|
||||
}
|
||||
/// PlayerSpawnFinder.noCollisionNoLiquid: a player's box standing at the
|
||||
/// block's bottom centre touches no block or liquid.
|
||||
fn player_fits(&self, pos: [i32; 3]) -> bool {
|
||||
|
||||
@@ -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()
|
||||
@@ -1128,8 +1138,17 @@ impl Game<'_> {
|
||||
g.game_mode = default_mode;
|
||||
}
|
||||
if !known {
|
||||
g.position = spawn;
|
||||
g.previous_position = spawn;
|
||||
// ServerPlayer.adjustSpawnLocation: a player new to the world
|
||||
// stands where PlayerSpawnFinder puts them, not all on one spot
|
||||
// (it reads the loaded level, which holds the spawn when it is
|
||||
// the overworld).
|
||||
let feet = if self.dim == super::Dim::Overworld {
|
||||
self.server_player_spawn(spawn)
|
||||
} else {
|
||||
spawn
|
||||
};
|
||||
g.position = feet;
|
||||
g.previous_position = feet;
|
||||
g.health = 20.0;
|
||||
g.food = 20;
|
||||
g.dimension = super::Dim::Overworld.id().to_owned();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,274 @@
|
||||
//! Capability `recipes` (docs/mods.md): every item and recipe the game
|
||||
//! knows, made once for the mods that read them, and the recipe book's
|
||||
//! known displays. The recipes are the window's own data: the world's in
|
||||
//! single player, vanilla's on a server (a client is sent no others), while
|
||||
//! the known displays follow the server's book as the recipe book does.
|
||||
use super::Game;
|
||||
use horizon_client::{
|
||||
crafting::Ingredient,
|
||||
items::ItemStack,
|
||||
recipe_book::{Category, Display},
|
||||
stations::Smithing,
|
||||
};
|
||||
use horizon_mods::{Recipe, RecipeItem, RecipeKind, RecipeStack, Recipes};
|
||||
use std::{collections::HashMap, sync::Arc};
|
||||
|
||||
/// Item id -> its index in [`Recipes::items`].
|
||||
struct Index(HashMap<Arc<str>, u32>);
|
||||
impl Index {
|
||||
fn stack(&self, item: &str, count: u32, components: String) -> Option<RecipeStack> {
|
||||
Some(RecipeStack {
|
||||
item: *self.0.get(item)?,
|
||||
count,
|
||||
components,
|
||||
})
|
||||
}
|
||||
fn of(&self, s: &ItemStack) -> Option<RecipeStack> {
|
||||
self.stack(&s.item, s.count, components(s))
|
||||
}
|
||||
fn any(&self, i: &Ingredient) -> Vec<RecipeStack> {
|
||||
i.1.iter()
|
||||
.filter_map(|id| self.stack(id, 1, String::new()))
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
/// A stack's components beyond its item's, as vanilla's item syntax writes
|
||||
/// them (`[minecraft:potion_contents={potion:"minecraft:swiftness"}]`), or "".
|
||||
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();
|
||||
};
|
||||
let parts: Vec<String> = entries
|
||||
.iter()
|
||||
.map(|(k, v)| {
|
||||
format!(
|
||||
"{}={}",
|
||||
k.to_string_lossy(),
|
||||
horizon_client::snbt::to_snbt(v)
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
format!("[{}]", parts.join(","))
|
||||
}
|
||||
|
||||
fn recipe(id: String, kind: RecipeKind) -> Recipe {
|
||||
Recipe {
|
||||
id,
|
||||
kind,
|
||||
category: String::new(),
|
||||
group: String::new(),
|
||||
width: 0,
|
||||
height: 0,
|
||||
slots: Vec::new(),
|
||||
results: Vec::new(),
|
||||
cooking_time: 0,
|
||||
experience: 0.0,
|
||||
book_id: -1,
|
||||
}
|
||||
}
|
||||
|
||||
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]>) {
|
||||
if !self.mods.mods.wants_recipes() {
|
||||
return (None, Arc::from([]));
|
||||
}
|
||||
if self.mods.recipes.is_none() {
|
||||
self.mods.recipes = Some(Arc::new(self.make_mod_recipes()));
|
||||
}
|
||||
let (version, known) = self.recipe_book_displays();
|
||||
if self.mods.known_recipes.0 != Some(version) {
|
||||
self.mods.known_recipes = (Some(version), known.into());
|
||||
}
|
||||
(self.mods.recipes.clone(), self.mods.known_recipes.1.clone())
|
||||
}
|
||||
|
||||
fn make_mod_recipes(&mut self) -> Recipes {
|
||||
let names = &self.pack.names;
|
||||
let items = self.res.items();
|
||||
let mut tags: HashMap<&str, Vec<String>> = HashMap::new();
|
||||
for (tag, members) in &items.item_tags {
|
||||
for m in members {
|
||||
tags.entry(m).or_default().push(tag.clone());
|
||||
}
|
||||
}
|
||||
let table: Vec<RecipeItem> = items
|
||||
.registry_order
|
||||
.iter()
|
||||
.map(|id| RecipeItem {
|
||||
id: id.to_string(),
|
||||
name: names.display_item(id),
|
||||
tags: tags.remove(&**id).map_or_else(Vec::new, |mut t| {
|
||||
t.sort();
|
||||
t
|
||||
}),
|
||||
})
|
||||
.collect();
|
||||
let index = Index(
|
||||
items
|
||||
.registry_order
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, id)| (id.clone(), i as u32))
|
||||
.collect(),
|
||||
);
|
||||
let mut out = Vec::new();
|
||||
let cooking: HashMap<String, (u32, f32)> = self
|
||||
.res
|
||||
.recipes()
|
||||
.cooking
|
||||
.iter()
|
||||
.map(|c| (format!("minecraft:{}", c.id), (c.time, c.experience)))
|
||||
.collect();
|
||||
for e in self.recipe_book_entries() {
|
||||
let furnace = |c: Category| match c.name().split('_').next() {
|
||||
Some("blast") => RecipeKind::Blasting,
|
||||
Some("smoker") => RecipeKind::Smoking,
|
||||
_ => RecipeKind::Smelting,
|
||||
};
|
||||
let kind = match &e.display {
|
||||
Display::Shaped { .. } => RecipeKind::CraftingShaped,
|
||||
Display::Shapeless(_) => RecipeKind::CraftingShapeless,
|
||||
Display::Furnace(_) => furnace(e.category),
|
||||
};
|
||||
let mut r = recipe(e.id.clone(), kind);
|
||||
r.category = e.category.name().to_owned();
|
||||
r.group = e.group.clone().unwrap_or_default();
|
||||
r.book_id = e.display_id;
|
||||
r.slots = match &e.display {
|
||||
Display::Shaped {
|
||||
width,
|
||||
height,
|
||||
cells,
|
||||
} => {
|
||||
(r.width, r.height) = (*width as u32, *height as u32);
|
||||
cells
|
||||
.iter()
|
||||
.map(|c| c.as_ref().map_or_else(Vec::new, |i| index.any(i)))
|
||||
.collect()
|
||||
}
|
||||
Display::Shapeless(list) => list.iter().map(|i| index.any(i)).collect(),
|
||||
Display::Furnace(i) => vec![index.any(i)],
|
||||
};
|
||||
// Requirements shown as other stacks (an arrow's potions): the
|
||||
// requirement's index counts the filled slots only.
|
||||
let filled: Vec<usize> = (0..r.slots.len())
|
||||
.filter(
|
||||
|&k| !matches!(&e.display, Display::Shaped { cells, .. } if cells[k].is_none()),
|
||||
)
|
||||
.collect();
|
||||
for (req, stacks) in &e.stacks {
|
||||
if let Some(&k) = filled.get(*req) {
|
||||
r.slots[k] = stacks.iter().filter_map(|s| index.of(s)).collect();
|
||||
}
|
||||
}
|
||||
r.results = e.results().iter().filter_map(|s| index.of(s)).collect();
|
||||
if let Some(&(time, xp)) = cooking.get(&e.id) {
|
||||
(r.cooking_time, r.experience) = (time, xp);
|
||||
}
|
||||
out.push(r);
|
||||
}
|
||||
for c in &self.res.recipes().cooking {
|
||||
if c.kind == "minecraft:campfire_cooking" {
|
||||
let mut r = recipe(format!("minecraft:{}", c.id), RecipeKind::Campfire);
|
||||
r.group = c.group.clone();
|
||||
r.slots = vec![index.any(&c.ingredient)];
|
||||
r.results = index.of(&c.result).into_iter().collect();
|
||||
(r.cooking_time, r.experience) = (c.time, c.experience);
|
||||
out.push(r);
|
||||
}
|
||||
}
|
||||
let stations = self.res.stations();
|
||||
for s in &stations.recipes.stonecutting {
|
||||
let mut r = recipe(s.id.clone(), RecipeKind::Stonecutting);
|
||||
r.slots = vec![index.any(&s.input)];
|
||||
r.results = index.of(&s.result).into_iter().collect();
|
||||
out.push(r);
|
||||
}
|
||||
for s in &stations.recipes.smithing {
|
||||
let (id, template, base, addition, results) = match s {
|
||||
Smithing::Transform {
|
||||
id,
|
||||
template,
|
||||
base,
|
||||
addition,
|
||||
result,
|
||||
} => (
|
||||
id,
|
||||
template,
|
||||
base,
|
||||
addition,
|
||||
index.stack(result, 1, String::new()).into_iter().collect(),
|
||||
),
|
||||
Smithing::Trim {
|
||||
id,
|
||||
template,
|
||||
base,
|
||||
addition,
|
||||
..
|
||||
} => (id, template, base, addition, index.any(base)),
|
||||
};
|
||||
let mut r = recipe(id.clone(), RecipeKind::Smithing);
|
||||
r.slots = vec![index.any(template), index.any(base), index.any(addition)];
|
||||
r.results = results;
|
||||
out.push(r);
|
||||
}
|
||||
for b in &stations.brewing.recipes {
|
||||
let potion = |p: &horizon_client::brewing::PotionIngredient| -> Vec<RecipeStack> {
|
||||
let mut item_ids: Vec<&String> = p.items.iter().collect();
|
||||
item_ids.sort();
|
||||
let mut potions: Vec<&String> = p.potions.iter().flatten().collect();
|
||||
potions.sort();
|
||||
item_ids
|
||||
.into_iter()
|
||||
.flat_map(|item| {
|
||||
let one = |c: String| index.stack(item, 1, c);
|
||||
if potions.is_empty() {
|
||||
one(String::new()).into_iter().collect::<Vec<_>>()
|
||||
} else {
|
||||
potions
|
||||
.iter()
|
||||
.filter_map(|p| {
|
||||
one(format!("[minecraft:potion_contents={{potion:\"{p}\"}}]"))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
let mut r = recipe(b.id.clone(), RecipeKind::Brewing);
|
||||
r.slots = vec![potion(&b.input), potion(&b.reagent)];
|
||||
r.results = index.of(&b.output).into_iter().collect();
|
||||
out.push(r);
|
||||
}
|
||||
Recipes {
|
||||
items: table,
|
||||
recipes: out,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -89,6 +89,15 @@ pub(super) struct ModsState {
|
||||
/// The HUD font's advances for `render.text-width`, made once.
|
||||
#[cfg(feature = "mods")]
|
||||
font_widths: std::sync::Arc<[f32]>,
|
||||
/// Capability `recipes`'s tables, made once a mod reads them.
|
||||
#[cfg(feature = "mods")]
|
||||
pub(super) recipes: Option<std::sync::Arc<horizon_mods::Recipes>>,
|
||||
/// 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,
|
||||
@@ -301,6 +310,8 @@ impl Game<'_> {
|
||||
self.mods.mods.set_bindings(|id| chosen.keys_of(id));
|
||||
// Entries were replaced wholesale: a screen's owner index is stale.
|
||||
self.mods.screen = None;
|
||||
// Content mods may have changed the items: remade on the next read.
|
||||
self.mods.recipes = None;
|
||||
self.list_mods();
|
||||
self.sync_mod_commands();
|
||||
self.show_mod_output();
|
||||
@@ -449,6 +460,7 @@ impl Game<'_> {
|
||||
.map(|c| char::from_u32(c).map_or(6.0, |c| atlas.glyph_cell(c).1))
|
||||
.collect();
|
||||
}
|
||||
let (recipes, known_recipes) = self.mod_recipes();
|
||||
Snapshot {
|
||||
player,
|
||||
clock,
|
||||
@@ -460,6 +472,10 @@ impl Game<'_> {
|
||||
font_widths: self.mods.font_widths.clone(),
|
||||
players,
|
||||
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
|
||||
@@ -697,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);
|
||||
|
||||
@@ -338,6 +338,25 @@ impl Game<'_> {
|
||||
ui.client_known = Some(known.clone());
|
||||
ui.client_version += 1;
|
||||
}
|
||||
/// Every entry of the book, in its order (capability `recipes`).
|
||||
#[cfg(feature = "mods")]
|
||||
pub(super) fn recipe_book_entries(&mut self) -> Vec<Entry> {
|
||||
self.ensure_book();
|
||||
self.recipe_ui
|
||||
.book
|
||||
.as_ref()
|
||||
.map_or_else(Vec::new, |b| b.entries.clone())
|
||||
}
|
||||
/// The display ids the player's book holds, and a number that changes
|
||||
/// whenever they do (capability `recipes`).
|
||||
#[cfg(feature = "mods")]
|
||||
pub(super) fn recipe_book_displays(&mut self) -> (u64, Vec<i32>) {
|
||||
self.sync_client_book();
|
||||
(
|
||||
self.recipe_ui.client_version,
|
||||
self.recipe_ui.client.displays(),
|
||||
)
|
||||
}
|
||||
fn ensure_book(&mut self) {
|
||||
if self.recipe_ui.book.is_none() {
|
||||
let mut book = Book::build(self.res.recipes(), self.res.items());
|
||||
@@ -826,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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -448,6 +448,7 @@ pub fn grindstone_experience(
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum Smithing {
|
||||
Transform {
|
||||
id: String,
|
||||
template: Ingredient,
|
||||
base: Ingredient,
|
||||
addition: Ingredient,
|
||||
@@ -527,6 +528,7 @@ impl StationRecipes {
|
||||
);
|
||||
match v["type"].as_str().unwrap_or("") {
|
||||
"minecraft:smithing_transform" => smithing.push(Smithing::Transform {
|
||||
id,
|
||||
template: ingredient(&v["template"], items),
|
||||
base: ingredient(&v["base"], items),
|
||||
addition: ingredient(&v["addition"], items),
|
||||
@@ -630,6 +632,7 @@ impl StationRecipes {
|
||||
base: b,
|
||||
addition: a,
|
||||
result,
|
||||
..
|
||||
} => {
|
||||
if t.test(template) && b.test(base) && a.test(addition) {
|
||||
// SmithingTransformRecipe: transmute the base keeping its components.
|
||||
|
||||
@@ -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, 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.
|
||||
|
||||
@@ -439,6 +439,181 @@ interface events {
|
||||
chat: func(text: string);
|
||||
}
|
||||
|
||||
/// Capability `recipes` (experimental): the recipes and items the game
|
||||
/// knows, for recipe viewers. In single player and on Horizon's servers
|
||||
/// they are the world's; on any other server they are vanilla 26.3's (a
|
||||
/// client is sent no others), while `known` follows that server's book.
|
||||
interface recipes {
|
||||
/// An item of `items`, by its index there, with a count and the
|
||||
/// components it has beyond the item's own, in vanilla's item syntax
|
||||
/// (`[potion_contents={potion:"minecraft:swiftness"}]`), or "".
|
||||
record stack {
|
||||
item: u32,
|
||||
count: u32,
|
||||
components: string,
|
||||
}
|
||||
/// The station a recipe is made at.
|
||||
enum kind {
|
||||
crafting-shaped,
|
||||
crafting-shapeless,
|
||||
smelting,
|
||||
blasting,
|
||||
smoking,
|
||||
campfire,
|
||||
stonecutting,
|
||||
smithing,
|
||||
brewing,
|
||||
}
|
||||
record recipe {
|
||||
/// `minecraft:oak_planks`.
|
||||
id: string,
|
||||
kind: kind,
|
||||
/// The recipe book's tab (`crafting_building_blocks`,
|
||||
/// `furnace_food`), or "".
|
||||
category: string,
|
||||
/// Recipes the book shows on one button share a group, or "".
|
||||
group: string,
|
||||
/// A shaped recipe's grid: `slots` holds its cells row by row.
|
||||
/// 0 for the other kinds.
|
||||
width: u32,
|
||||
height: u32,
|
||||
/// Each slot's stacks, any one of which fits; an empty list is an
|
||||
/// empty cell. Cooking and stonecutting: the input. Smithing: the
|
||||
/// template, the base, the addition. Brewing: the bottle, the
|
||||
/// reagent.
|
||||
slots: list<list<stack>>,
|
||||
/// What it makes; several when that varies (a trim on any armour).
|
||||
results: list<stack>,
|
||||
/// Cooking: game ticks and experience; 0 for the other kinds.
|
||||
cooking-time: u32,
|
||||
experience: f32,
|
||||
/// The recipe book's id for it (one per way the book shows it), as
|
||||
/// `known` lists them; -1 for the kinds the book doesn't show
|
||||
/// (campfire, stonecutting, smithing, brewing).
|
||||
book-id: s32,
|
||||
}
|
||||
record item {
|
||||
/// `minecraft:diamond`, `<mod>:<path>`.
|
||||
id: string,
|
||||
/// In the player's language.
|
||||
name: string,
|
||||
/// Its item tags, without the `#`.
|
||||
tags: list<string>,
|
||||
}
|
||||
/// Every item, in the registry's order: `stack.item` indexes this list.
|
||||
items: func() -> list<item>;
|
||||
/// Every recipe: the book's in its order, then campfire, stonecutting,
|
||||
/// smithing and brewing. They don't change while a world is open.
|
||||
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"`,
|
||||
/// experimental): the players on the server, and messages to them.
|
||||
interface players {
|
||||
@@ -573,6 +748,8 @@ world horizon-ui-mod {
|
||||
import ui;
|
||||
import render;
|
||||
import input;
|
||||
import recipes;
|
||||
import browser;
|
||||
export ui-events;
|
||||
}
|
||||
|
||||
@@ -587,3 +764,9 @@ world render-host {
|
||||
import render;
|
||||
import input;
|
||||
}
|
||||
|
||||
/// 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 = 4;
|
||||
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); 37] = [
|
||||
pub const FUNCTIONS: [(&str, u32); 45] = [
|
||||
("log.write", 0),
|
||||
("game.get-player", 0),
|
||||
("game.get-clock", 0),
|
||||
@@ -51,6 +51,14 @@ pub const FUNCTIONS: [(&str, u32); 37] = [
|
||||
("world.get-block", 4),
|
||||
("world.get-biome", 4),
|
||||
("world.set-blocks", 4),
|
||||
("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
|
||||
|
||||
@@ -100,9 +100,12 @@ pub enum Capability {
|
||||
/// Blocks in the server's loaded chunks, and changes to them (server
|
||||
/// mods; client mods later, where the game runs the world).
|
||||
World,
|
||||
/// The recipes and items the game knows, for recipe viewers
|
||||
/// (interface `recipes`).
|
||||
Recipes,
|
||||
}
|
||||
impl Capability {
|
||||
pub const ALL: [Capability; 11] = [
|
||||
pub const ALL: [Capability; 12] = [
|
||||
Capability::GameRead,
|
||||
Capability::Chat,
|
||||
Capability::Hud,
|
||||
@@ -114,6 +117,7 @@ impl Capability {
|
||||
Capability::Input,
|
||||
Capability::Players,
|
||||
Capability::World,
|
||||
Capability::Recipes,
|
||||
];
|
||||
/// Whether a server mod may have it: the others act on a local player.
|
||||
pub fn on_server(self) -> bool {
|
||||
@@ -136,6 +140,7 @@ impl Capability {
|
||||
Capability::Input => "input",
|
||||
Capability::Players => "players",
|
||||
Capability::World => "world",
|
||||
Capability::Recipes => "recipes",
|
||||
}
|
||||
}
|
||||
/// May change in any release: a mod uses it only with
|
||||
@@ -143,7 +148,7 @@ impl Capability {
|
||||
pub fn is_experimental(self) -> bool {
|
||||
matches!(
|
||||
self,
|
||||
Capability::Ui | Capability::Render | Capability::Input
|
||||
Capability::Ui | Capability::Render | Capability::Input | Capability::Recipes
|
||||
)
|
||||
}
|
||||
/// What the player reads before turning the mod on: "May <this>".
|
||||
@@ -162,6 +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, place recipes in your crafting grid and, in creative mode, give you items"
|
||||
}
|
||||
}
|
||||
}
|
||||
/// The capability a component import needs, `Ok(None)` for one every
|
||||
@@ -176,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)
|
||||
@@ -198,6 +210,7 @@ impl fmt::Display for Capability {
|
||||
Capability::Input => "input",
|
||||
Capability::Players => "players",
|
||||
Capability::World => "world",
|
||||
Capability::Recipes => "recipes",
|
||||
};
|
||||
f.write_str(name)
|
||||
}
|
||||
|
||||
@@ -81,6 +81,24 @@ pub use render_bindings::horizon::mods::render::{
|
||||
Zoom as ModZoom,
|
||||
};
|
||||
use render_bindings::{RenderHost, horizon::mods::render};
|
||||
/// Capability `recipes`'s imports (docs/mods.md): the game's recipes and items.
|
||||
mod recipes_bindings {
|
||||
wasmtime::component::bindgen!({
|
||||
path: "../horizon-mod-sdk/wit",
|
||||
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::browser, horizon::mods::recipes};
|
||||
|
||||
/// A mod's linear memory cap.
|
||||
const MEMORY: usize = 64 << 20;
|
||||
@@ -138,6 +156,23 @@ pub struct Snapshot {
|
||||
/// its distance from the eye, for `game.raycast`; the game works it out
|
||||
/// only while a mod calls it ([`Mods::wants_raycast`]).
|
||||
pub view_hit: Option<(f32, Hit)>,
|
||||
/// Every item and recipe, for capability `recipes`; the game makes them
|
||||
/// once, and only while a mod reads them ([`Mods::wants_recipes`]).
|
||||
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
|
||||
/// each [`RecipeStack::item`] indexes.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct Recipes {
|
||||
pub items: Vec<RecipeItem>,
|
||||
pub recipes: Vec<Recipe>,
|
||||
}
|
||||
|
||||
/// Which mods a game starts: the window's game the client mods, the game
|
||||
@@ -201,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
|
||||
@@ -270,6 +314,8 @@ struct Running {
|
||||
hooks: Hooks,
|
||||
/// It imports `game.raycast`.
|
||||
raycasts: bool,
|
||||
/// It imports capability `recipes`.
|
||||
reads_recipes: bool,
|
||||
}
|
||||
|
||||
/// A running mod's exports and the hooks its `init` asked for.
|
||||
@@ -311,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,
|
||||
@@ -327,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
|
||||
@@ -448,6 +535,8 @@ impl Mods {
|
||||
.map_err(|e| e.to_string())?;
|
||||
RenderHost::add_to_linker::<State, HasSelf<State>>(&mut linker, |s| s)
|
||||
.map_err(|e| e.to_string())?;
|
||||
RecipesHost::add_to_linker::<State, HasSelf<State>>(&mut linker, |s| s)
|
||||
.map_err(|e| e.to_string())?;
|
||||
self.engine = Some((engine, linker));
|
||||
}
|
||||
Ok(self.engine.as_ref().expect("made above"))
|
||||
@@ -518,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()
|
||||
@@ -567,6 +657,7 @@ impl Mods {
|
||||
ui,
|
||||
hooks,
|
||||
raycasts: functions.iter().any(|f| f == "game.raycast"),
|
||||
reads_recipes: functions.iter().any(|f| f.starts_with("recipes.")),
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -827,6 +918,58 @@ 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 {
|
||||
self.entries
|
||||
.iter()
|
||||
.any(|e| e.run.as_ref().is_some_and(|r| r.reads_recipes))
|
||||
}
|
||||
/// Some running mod has a `frame` hook: the game refreshes the snapshot
|
||||
/// every frame for it.
|
||||
pub fn wants_frames(&self) -> bool {
|
||||
@@ -1122,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,
|
||||
}
|
||||
@@ -1183,6 +1329,125 @@ 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
|
||||
.recipes
|
||||
.as_ref()
|
||||
.map_or_else(Vec::new, |r| r.items.clone())
|
||||
}
|
||||
fn all(&mut self) -> Vec<Recipe> {
|
||||
self.snapshot
|
||||
.recipes
|
||||
.as_ref()
|
||||
.map_or_else(Vec::new, |r| r.recipes.clone())
|
||||
}
|
||||
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`].
|
||||
fn say(&mut self, line: impl FnOnce(String) -> Output) -> bool {
|
||||
|
||||
@@ -26,8 +26,8 @@ id = "test_ui_guest"
|
||||
name = "Test UI Guest"
|
||||
version = "0.1.0"
|
||||
api = "0.1"
|
||||
capabilities = ["game-read", "chat", "command", "ui", "render", "input"]
|
||||
experimental = ["ui", "render", "input"]
|
||||
capabilities = ["game-read", "chat", "command", "ui", "render", "input", "recipes"]
|
||||
experimental = ["ui", "render", "input", "recipes"]
|
||||
"#;
|
||||
|
||||
/// Builds the mods once and writes their `.hmod` files; returns the folder.
|
||||
@@ -241,8 +241,8 @@ id = "too_new"
|
||||
name = "Too New"
|
||||
version = "0.1.0"
|
||||
api = "0.1"
|
||||
capabilities = ["game-read", "chat", "hud", "storage", "command", "ui", "render", "input"]
|
||||
experimental = ["ui", "render", "input"]
|
||||
capabilities = ["game-read", "chat", "hud", "storage", "command", "ui", "render", "input", "recipes"]
|
||||
experimental = ["ui", "render", "input", "recipes"]
|
||||
"#;
|
||||
std::fs::write(dir.join("horizon-mod.toml"), manifest).unwrap();
|
||||
std::fs::write(dir.join("mod.wasm"), wasm).unwrap();
|
||||
@@ -255,8 +255,8 @@ experimental = ["ui", "render", "input"]
|
||||
needed(imports(&wasm).unwrap().iter().map(String::as_str)).0
|
||||
};
|
||||
assert_eq!(level("coords_hud.hmod"), 0);
|
||||
// The UI guest runs quiet commands (`command.run-quiet`, level 3).
|
||||
assert_eq!(level("horizon_mods_test_ui_guest.hmod"), 3);
|
||||
// The UI guest fills a browser panel (`browser`, level 6).
|
||||
assert_eq!(level("horizon_mods_test_ui_guest.hmod"), 6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1041,3 +1041,247 @@ fn a_server_mod_runs_only_where_the_world_does() {
|
||||
"a server command isn't the window's"
|
||||
);
|
||||
}
|
||||
|
||||
/// Capability `recipes`: the guest reads tables as big as vanilla's (1,500
|
||||
/// items, 1,600 recipes of nine five-item slots) within one command's fuel.
|
||||
#[test]
|
||||
fn recipes_reads_the_tables() {
|
||||
use horizon_mods::{Recipe, RecipeItem, RecipeKind, RecipeStack, Recipes};
|
||||
let mut mods = load("horizon_mods_test_ui_guest.hmod");
|
||||
let stack = |item| RecipeStack {
|
||||
item,
|
||||
count: 1,
|
||||
components: String::new(),
|
||||
};
|
||||
let items = (0..1500)
|
||||
.map(|i| RecipeItem {
|
||||
id: format!("minecraft:item_{i}"),
|
||||
name: format!("Item {i}"),
|
||||
tags: vec!["planks".into(), "logs".into()],
|
||||
})
|
||||
.collect();
|
||||
let mut recipes: Vec<Recipe> = (0..1600)
|
||||
.map(|i| Recipe {
|
||||
id: format!("minecraft:recipe_{i}"),
|
||||
kind: RecipeKind::CraftingShaped,
|
||||
category: "crafting_misc".into(),
|
||||
group: String::new(),
|
||||
width: 3,
|
||||
height: 3,
|
||||
slots: (0..9)
|
||||
.map(|k| (0..5).map(|j| stack((i + k + j) % 1500)).collect())
|
||||
.collect(),
|
||||
results: vec![RecipeStack {
|
||||
item: 7,
|
||||
count: 4,
|
||||
components: String::new(),
|
||||
}],
|
||||
cooking_time: 0,
|
||||
experience: 0.0,
|
||||
book_id: i as i32,
|
||||
})
|
||||
.collect();
|
||||
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: mods_recipes.clone(),
|
||||
known_recipes: vec![1, 5, 9].into(),
|
||||
..Snapshot::default()
|
||||
});
|
||||
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_eq!(
|
||||
said(&mut mods, "recipes"),
|
||||
["1500 items, 1600 recipes, 3 known, 1 brewing"]
|
||||
);
|
||||
assert_eq!(
|
||||
said(&mut mods, "recipes minecraft:recipe_1"),
|
||||
[
|
||||
"minecraft:recipe_1 Kind::Brewing 3x3 0t slots 9, the first 1 minecraft:item_1 -> \
|
||||
4 minecraft:item_7[minecraft:potion_contents={potion:\"minecraft:swiftness\"}] known true"
|
||||
]
|
||||
);
|
||||
assert!(
|
||||
mods.entries()
|
||||
.iter()
|
||||
.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!(
|
||||
said(&mut mods, "recipes"),
|
||||
["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");
|
||||
}
|
||||
|
||||
@@ -6,10 +6,19 @@
|
||||
//! `input`; `/zoom FACTOR SMOOTH TURN` tests `render.set-zoom`;
|
||||
//! `/corner` and `/readings` the calls the official extras mods use;
|
||||
//! `/raycast DISTANCE` says what `game.raycast` sees; `/quiet A;B` runs
|
||||
//! lines with `command.run-quiet` (`/quiet_many N`: N of them).
|
||||
//! 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},
|
||||
ui::{self, ItemWidget, ListWidget, Screen, TextWidget, Widget},
|
||||
};
|
||||
@@ -62,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![
|
||||
@@ -102,12 +115,19 @@ impl Mod for Guest {
|
||||
"raycast",
|
||||
"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 {
|
||||
@@ -241,6 +261,203 @@ impl Mod for Guest {
|
||||
None => "no hit".into(),
|
||||
});
|
||||
}
|
||||
"recipes" => {
|
||||
let items = recipes::items();
|
||||
let all = recipes::all();
|
||||
let known = recipes::known();
|
||||
let name = |s: &recipes::Stack| {
|
||||
let id = items.get(s.item as usize).map_or("?", |i| i.id.as_str());
|
||||
format!("{} {id}{}", s.count, s.components)
|
||||
};
|
||||
chat::show(&match all.iter().find(|r| r.id == args) {
|
||||
Some(r) => format!(
|
||||
"{} {:?} {}x{} {}t slots {}, the first {} -> {} known {}",
|
||||
r.id,
|
||||
r.kind,
|
||||
r.width,
|
||||
r.height,
|
||||
r.cooking_time,
|
||||
r.slots.len(),
|
||||
r.slots
|
||||
.first()
|
||||
.and_then(|s| s.first())
|
||||
.map_or("-".into(), name),
|
||||
r.results.first().map_or("-".into(), name),
|
||||
known.contains(&r.book_id)
|
||||
),
|
||||
None => format!(
|
||||
"{} items, {} recipes, {} known, {} brewing",
|
||||
items.len(),
|
||||
all.len(),
|
||||
known.len(),
|
||||
all.iter()
|
||||
.filter(|r| r.kind == recipes::Kind::Brewing)
|
||||
.count()
|
||||
),
|
||||
});
|
||||
}
|
||||
"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);
|
||||
@@ -251,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.
|
||||
|
||||
+163
-1
@@ -18,6 +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, place recipes in your crafting grid and, in creative mode, give you items |
|
||||
|
||||
## World `horizon-mod`
|
||||
|
||||
@@ -37,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`, `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`
|
||||
@@ -54,6 +55,13 @@ The game's side of capability `render` (imports only).
|
||||
- Imports (what the game offers): `render`, `input`
|
||||
- Exports (what the mod provides):
|
||||
|
||||
## World `recipes-host`
|
||||
|
||||
The game's side of capability `recipes` (imports only).
|
||||
|
||||
- Imports (what the game offers): `recipes`, `browser`
|
||||
- Exports (what the mod provides):
|
||||
|
||||
## `log`
|
||||
|
||||
Access: always granted.
|
||||
@@ -462,6 +470,160 @@ What the game calls. Only the hooks `init` asks for are ever called.
|
||||
| `command` | `func(name: string, args: string) -> result<option<string>, string>` | `/name args`: ok(some(text)) is shown as feedback, err(text) in red. |
|
||||
| `chat` | `func(text: string)` | Each chat line the player receives, as plain text. |
|
||||
|
||||
## `recipes`
|
||||
|
||||
Access: capability `recipes`.
|
||||
|
||||
Capability `recipes` (experimental): the recipes and items the game knows, for recipe viewers. In single player and on Horizon's servers they are the world's; on any other server they are vanilla 26.3's (a client is sent no others), while `known` follows that server's book.
|
||||
|
||||
### record `stack`
|
||||
|
||||
An item of `items`, by its index there, with a count and the components it has beyond the item's own, in vanilla's item syntax (`[potion_contents={potion:"minecraft:swiftness"}]`), or "".
|
||||
|
||||
| Field | Type | |
|
||||
| --- | --- | --- |
|
||||
| `item` | `u32` | |
|
||||
| `count` | `u32` | |
|
||||
| `components` | `string` | |
|
||||
|
||||
### enum `kind`
|
||||
|
||||
The station a recipe is made at.
|
||||
|
||||
| Case | |
|
||||
| --- | --- |
|
||||
| `crafting-shaped` | |
|
||||
| `crafting-shapeless` | |
|
||||
| `smelting` | |
|
||||
| `blasting` | |
|
||||
| `smoking` | |
|
||||
| `campfire` | |
|
||||
| `stonecutting` | |
|
||||
| `smithing` | |
|
||||
| `brewing` | |
|
||||
|
||||
### record `recipe`
|
||||
|
||||
| Field | Type | |
|
||||
| --- | --- | --- |
|
||||
| `id` | `string` | `minecraft:oak_planks`. |
|
||||
| `kind` | `kind` | |
|
||||
| `category` | `string` | The recipe book's tab (`crafting_building_blocks`, `furnace_food`), or "". |
|
||||
| `group` | `string` | Recipes the book shows on one button share a group, or "". |
|
||||
| `width` | `u32` | A shaped recipe's grid: `slots` holds its cells row by row. 0 for the other kinds. |
|
||||
| `height` | `u32` | |
|
||||
| `slots` | `list<list<stack>>` | Each slot's stacks, any one of which fits; an empty list is an empty cell. Cooking and stonecutting: the input. Smithing: the template, the base, the addition. Brewing: the bottle, the reagent. |
|
||||
| `results` | `list<stack>` | What it makes; several when that varies (a trim on any armour). |
|
||||
| `cooking-time` | `u32` | Cooking: game ticks and experience; 0 for the other kinds. |
|
||||
| `experience` | `f32` | |
|
||||
| `book-id` | `s32` | The recipe book's id for it (one per way the book shows it), as `known` lists them; -1 for the kinds the book doesn't show (campfire, stonecutting, smithing, brewing). |
|
||||
|
||||
### record `item`
|
||||
|
||||
| Field | Type | |
|
||||
| --- | --- | --- |
|
||||
| `id` | `string` | `minecraft:diamond`, `<mod>:<path>`. |
|
||||
| `name` | `string` | In the player's language. |
|
||||
| `tags` | `list<string>` | Its item tags, without the `#`. |
|
||||
|
||||
| Function | Signature | What it does |
|
||||
| --- | --- | --- |
|
||||
| `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`
|
||||
|
||||
Access: capability `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`. 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,9 +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; `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 |
|
||||
|---|---|
|
||||
@@ -116,7 +117,6 @@ loading. Ordered by what the first native ports need:
|
||||
| `keys` | keybinds | registered binds appear in Controls; press/release events |
|
||||
| `target` | the crosshair's block or entity, with its state and NBT summary | Jade/WTHIT-style tooltips |
|
||||
| `inventory-read` | the player's inventory, held item, open container | AppleSkin, recipe viewers |
|
||||
| `recipes` | the recipe registry and lookups | JEI/EMI-style viewers |
|
||||
| `world-read` | blocks and biomes around the player, within the render distance | minimaps, schematics |
|
||||
| `render` (world) | world-space lines, boxes and billboards, beside the HUD shapes `render` has | Litematica-style placement, waypoints |
|
||||
| `registries` | new blocks, items, entities and recipes, mostly data: JSON models, textures, loot and recipes in the `.hmod`'s `assets/` and `data/` | server side; see [Multiplayer](#multiplayer) |
|
||||
@@ -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
|
||||
|
||||
@@ -27,6 +27,12 @@ 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 the desktop's runner only (8 harnesses at a time), and waits for it while the desktop is in use
|
||||
instead of taking a Mac mini runner. 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
|
||||
between runs (`clean: false`), so the probes build incrementally into `.local/parity-target` and earlier passes
|
||||
@@ -212,6 +218,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 the desktop'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()
|
||||
|
||||
@@ -487,6 +487,17 @@ public final class VanillaProtocolClient {
|
||||
long deadCommandAt = 0;
|
||||
// When the client last sent where it is.
|
||||
long positionAt = 0;
|
||||
// Chat lines and furnace slot changes received, and how many there were when the current step sent
|
||||
// its command or click: a step goes on once the server answered, not after a fixed time (a busy
|
||||
// server answers seconds late, and a click sent before the last one's reply carries a stale state id).
|
||||
int replies = 0;
|
||||
int repliesBefore = 0;
|
||||
// A shift-click changes two slots, each its own packet, which a busy server can send ticks apart: a furnace
|
||||
// step waits for both. A full resend of the menu counts as both.
|
||||
int furnaceReplies = 0;
|
||||
int furnaceBefore = 0;
|
||||
// The furnace's lit and cooking totals received: the server sends them a tick after what changed them.
|
||||
int furnaceData = 0;
|
||||
while (System.currentTimeMillis() < deadline) {
|
||||
Received r;
|
||||
try {
|
||||
@@ -501,7 +512,7 @@ public final class VanillaProtocolClient {
|
||||
if (entities != null && !entities.acceptSelf(packet) && step >= ENTITIES) entities.accept(packet);
|
||||
if (r.type.equals("minecraft:chunk_batch_start")) ordered = false;
|
||||
if (respawning && !respawned) {
|
||||
String line = respawnLine(r);
|
||||
String line = respawnLine(r, playerId);
|
||||
// PlayerList.respawn ends with initInventoryMenu: what follows is the
|
||||
// next ticks' (health, entity data, chunks), in each server's own order.
|
||||
respawned = r.type.equals("minecraft:container_set_content") && respawnAt != null;
|
||||
@@ -558,15 +569,18 @@ public final class VanillaProtocolClient {
|
||||
} else if (packet instanceof net.minecraft.network.protocol.game.ClientboundContainerSetContentPacket p
|
||||
&& furnaceWindow > 0 && p.containerId() == furnaceWindow) {
|
||||
furnaceState = p.stateId();
|
||||
furnaceReplies += 2;
|
||||
record("play-furnace", "content " + p.items() + " carried " + p.carriedItem());
|
||||
} else if (packet instanceof net.minecraft.network.protocol.game.ClientboundContainerSetSlotPacket p
|
||||
&& furnaceWindow > 0 && p.getContainerId() == furnaceWindow) {
|
||||
furnaceState = p.getStateId();
|
||||
furnaceReplies++;
|
||||
record("play-furnace", "slot " + p.getSlot() + " " + p.getItem());
|
||||
if (p.getSlot() == 2 && !p.getItem().isEmpty() && smeltedAt == 0) smeltedAt = now;
|
||||
} else if (packet instanceof net.minecraft.network.protocol.game.ClientboundContainerSetDataPacket p
|
||||
&& furnaceWindow > 0 && p.getContainerId() == furnaceWindow && (p.getId() == 1 || p.getId() == 3)) {
|
||||
// Lit total and cooking total; the time left and the cooking timer follow the ticks.
|
||||
furnaceData++;
|
||||
record("play-furnace", "data " + p.getId() + " " + p.getValue());
|
||||
} else if (packet instanceof net.minecraft.network.protocol.game.ClientboundEntityEventPacket p
|
||||
&& step == 5 + REPLIES.length && (int) (byte) field(p, "eventId") == 28) {
|
||||
@@ -583,13 +597,16 @@ public final class VanillaProtocolClient {
|
||||
record("play-death", "command reply " + json(p.content()));
|
||||
respawning = respawn();
|
||||
} else if (packet instanceof ClientboundSystemChatPacket p && step > 0 && step < 5 + REPLIES.length) {
|
||||
replies++;
|
||||
record("play-chat", "system " + p.overlay() + " " + json(p.content()));
|
||||
} else if (packet instanceof ClientboundPlayerChatPacket p && step > 0 && step < 5 + REPLIES.length) {
|
||||
replies++;
|
||||
record("play-chat", "player " + p.sender() + " " + p.index() + " " + p.body().content() + " " + p.signature().isPresent()
|
||||
+ " " + p.unsignedContent().map(VanillaProtocolClient::json) + " " + text(p.filterMask()) + " "
|
||||
+ p.chatType().chatType().getRegisteredName() + " " + json(p.chatType().name()) + " "
|
||||
+ p.chatType().targetName().map(VanillaProtocolClient::json));
|
||||
} else if (packet instanceof ClientboundDisguisedChatPacket p && step > 0 && step < 5 + REPLIES.length) {
|
||||
replies++;
|
||||
record("play-chat", "disguised " + json(p.message()) + " " + p.chatType().chatType().getRegisteredName());
|
||||
} else if (packet instanceof net.minecraft.network.protocol.game.ClientboundAddEntityPacket p
|
||||
&& p.getType() == net.minecraft.world.entity.EntityTypes.SNOWBALL) {
|
||||
@@ -679,20 +696,24 @@ public final class VanillaProtocolClient {
|
||||
record("play-light-layers", inner.size() + " " + inner);
|
||||
step = 1;
|
||||
stepAt = now;
|
||||
repliesBefore = replies;
|
||||
send(new ServerboundChatPacket("hello from the parity client", Instant.now(), 0L, Optional.empty(),
|
||||
new LastSeenMessages.Update(0, new BitSet(), (byte) 0)));
|
||||
} else if (step == 1 && now - stepAt > 1500) {
|
||||
} else if (step == 1 && answered(replies, repliesBefore, stepAt, now)) {
|
||||
step = 2;
|
||||
stepAt = now;
|
||||
repliesBefore = replies;
|
||||
send(new ServerboundChatCommandPacket("list"));
|
||||
} else if (step >= 2 && step < 2 + REPLIES.length && now - stepAt > 1500) {
|
||||
} else if (step >= 2 && step < 2 + REPLIES.length && answered(replies, repliesBefore, stepAt, now)) {
|
||||
// Commands a player without operator rights may run, whose
|
||||
// replies (a failure, a syntax error with its context, a
|
||||
// failure with an argument) are compared component by component.
|
||||
send(new ServerboundChatCommandPacket(REPLIES[step - 2]));
|
||||
step++;
|
||||
stepAt = now;
|
||||
} else if (step == 2 + REPLIES.length && now - stepAt > 1500 && (villager >= 0 || now - stepAt > PATIENCE)) {
|
||||
repliesBefore = replies;
|
||||
} else if (step == 2 + REPLIES.length && answered(replies, repliesBefore, stepAt, now)
|
||||
&& (villager >= 0 || now - stepAt > PATIENCE)) {
|
||||
// Right-click the villager (once the server sent it): the trading screen and its offers.
|
||||
record("play-trade", "villager " + (villager >= 0));
|
||||
if (villager >= 0) {
|
||||
@@ -739,9 +760,13 @@ public final class VanillaProtocolClient {
|
||||
send(new net.minecraft.network.protocol.game.ServerboundUseItemOnPacket(net.minecraft.world.InteractionHand.MAIN_HAND,
|
||||
new net.minecraft.world.phys.BlockHitResult(net.minecraft.world.phys.Vec3.atCenterOf(furnace),
|
||||
net.minecraft.core.Direction.NORTH, furnace, false), 1));
|
||||
furnaceBefore = furnaceReplies;
|
||||
step++;
|
||||
stepAt = now;
|
||||
} else if (step >= 8 + REPLIES.length && step <= 10 + REPLIES.length && furnaceWindow > 0 && now - stepAt > 1500
|
||||
} else if (step >= 8 + REPLIES.length && step <= 10 + REPLIES.length && furnaceWindow > 0
|
||||
&& answered(furnaceReplies, furnaceBefore + 1, stepAt, now)
|
||||
// Both totals with the window, then the cooking total the ore set, then the lit total the coal set.
|
||||
&& (furnaceData >= step - 6 - REPLIES.length || now - stepAt > PATIENCE)
|
||||
// The ingot out once the furnace made it (200 ticks), and its tick's other changes came.
|
||||
&& (step < 10 + REPLIES.length || smeltedAt > 0 && now - smeltedAt > 1000 || now - stepAt > 10_000 + PATIENCE)) {
|
||||
// Shift-click the ore (hotbar 0) into the input, the coal (hotbar 1) into the
|
||||
@@ -752,9 +777,10 @@ public final class VanillaProtocolClient {
|
||||
send(new net.minecraft.network.protocol.game.ServerboundContainerClickPacket(furnaceWindow, furnaceState, (short) slot,
|
||||
(byte) 0, net.minecraft.world.inventory.ContainerInput.QUICK_MOVE,
|
||||
new it.unimi.dsi.fastutil.ints.Int2ObjectOpenHashMap<>(), net.minecraft.network.HashedStack.EMPTY));
|
||||
furnaceBefore = furnaceReplies;
|
||||
step++;
|
||||
stepAt = now;
|
||||
} else if (step == 11 + REPLIES.length && now - stepAt > 1500) {
|
||||
} else if (step == 11 + REPLIES.length && answered(furnaceReplies, furnaceBefore + 1, stepAt, now)) {
|
||||
send(new net.minecraft.network.protocol.game.ServerboundContainerClosePacket(furnaceWindow));
|
||||
record("play-furnace", "closed");
|
||||
step++;
|
||||
@@ -816,6 +842,11 @@ public final class VanillaProtocolClient {
|
||||
record("error", "timed out in play, step " + step + ", chunks " + chunks.size());
|
||||
}
|
||||
|
||||
/** The server answered the step's command or click (at least 1.5 s after it, as before), or had all the time. */
|
||||
private static boolean answered(int count, int before, long sent, long now) {
|
||||
return now - sent > 1500 && (count > before || now - sent > PATIENCE);
|
||||
}
|
||||
|
||||
/**
|
||||
* A right click's snowballs are counted: once the first came and a second could have come with it
|
||||
* (the server spawns each in the tick it handles the click), or once the server had all the time.
|
||||
@@ -857,8 +888,12 @@ public final class VanillaProtocolClient {
|
||||
* packets (time, keep-alive, chunk data and light, whose batching follows each server's
|
||||
* timing) left out; null to skip it.
|
||||
*/
|
||||
private String respawnLine(Received r) {
|
||||
private String respawnLine(Received r, int playerId) {
|
||||
String t = r.type;
|
||||
// Other entities (a squid's swim event, a mob coming into view) land in the window as each server's
|
||||
// ticks fall: only the player's own are PlayerList.respawn's.
|
||||
Integer entity = entityOf(r.packet);
|
||||
if (entity != null && entity != playerId) return null;
|
||||
// Entities moving, blocks the world's ticks change (a lava fall flows again once the game
|
||||
// runs) and the dead player's health land in the window between the death and the respawn
|
||||
// as each server's ticks fall: left out.
|
||||
@@ -891,6 +926,21 @@ public final class VanillaProtocolClient {
|
||||
return t;
|
||||
}
|
||||
|
||||
/** The entity a packet is about, for the packets that name one; null for the rest. */
|
||||
private static Integer entityOf(Packet<?> packet) {
|
||||
if (packet instanceof net.minecraft.network.protocol.game.ClientboundAddEntityPacket p) return p.getId();
|
||||
if (packet instanceof net.minecraft.network.protocol.game.ClientboundUpdateAttributesPacket p) return p.getEntityId();
|
||||
if (packet instanceof net.minecraft.network.protocol.game.ClientboundEntityEventPacket p) return (int) field(p, "entityId");
|
||||
if (packet instanceof net.minecraft.network.protocol.game.ClientboundSetEquipmentPacket p) return p.getEntity();
|
||||
if (packet instanceof net.minecraft.network.protocol.game.ClientboundAnimatePacket p) return p.getId();
|
||||
if (packet instanceof net.minecraft.network.protocol.game.ClientboundHurtAnimationPacket p) return p.id();
|
||||
if (packet instanceof net.minecraft.network.protocol.game.ClientboundDamageEventPacket p) return p.entityId();
|
||||
if (packet instanceof net.minecraft.network.protocol.game.ClientboundUpdateMobEffectPacket p) return p.getEntityId();
|
||||
if (packet instanceof net.minecraft.network.protocol.game.ClientboundRemoveMobEffectPacket p) return p.entityId();
|
||||
if (packet instanceof net.minecraft.network.protocol.game.ClientboundSoundEntityPacket p) return p.getId();
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* A chunk's blocks, biomes, heightmaps and block entities (its
|
||||
* ClientboundLevelChunkPacketData bytes). The light is left out: both servers relight around chunks
|
||||
|
||||
@@ -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);
|
||||
+43
-4
@@ -14,6 +14,8 @@ address. The script then checks, from both sides, what a guest's game used to ge
|
||||
closes on both when the guest closes it; the host keeps playing with its mouse look (#687);
|
||||
- crafting: the guest crafts planks in the inventory's 2x2 grid and opens a crafting table, which
|
||||
leaves the host's screen and mouse look alone;
|
||||
- spawned: the host and a guest new to the world join on different spots (PlayerSpawnFinder), and the
|
||||
guest opens a crafting table, a chest, a furnace and a stonecutter where it joined;
|
||||
- sign: a sign the guest places doesn't open a sign editor on the host;
|
||||
- damage: a guest in survival takes fall damage;
|
||||
- mobs: with the host in creative, a zombie and a skeleton go for the guest and hurt them;
|
||||
@@ -292,7 +294,7 @@ ROOT = Path(__file__).resolve().parents[1]
|
||||
GUEST_NAME = 'Guest'
|
||||
# Match normal single player; --in-frame is the comparison path.
|
||||
SERVER_THREAD = True
|
||||
CHECKS = ['tab', 'self', 'armour', 'nametags', 'walls', 'chest', 'crafting', 'sign', 'damage', 'mobs', 'hazards', 'pvp',
|
||||
CHECKS = ['tab', 'self', 'armour', 'nametags', 'walls', 'chest', 'crafting', 'spawned', 'sign', 'damage', 'mobs', 'hazards', 'pvp',
|
||||
'arrows', 'kills', 'hurt', 'looks', 'leads', 'respawn', 'containers', 'feedback', 'titles', 'chat', 'styles',
|
||||
'commands', 'recipes', 'machines', 'brewing', 'loom', 'stonecutter', 'grindstone', 'anvil', 'smithing',
|
||||
'enchanting', 'beacon', 'crafter', 'lectern', 'cartography', 'trials', 'clicks', 'sleep', 'predict', 'push',
|
||||
@@ -339,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)
|
||||
@@ -348,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."""
|
||||
@@ -554,6 +557,39 @@ def check_crafting(host, guest, out):
|
||||
return {'planks': planks, 'table': counts}
|
||||
|
||||
|
||||
def check_spawned(host, guest, out):
|
||||
"""Players new to a world don't share one spot: ServerPlayer.adjustSpawnLocation spreads them over the
|
||||
respawn radius (PlayerSpawnFinder). Two players standing in each other target each other, so a guest
|
||||
left in the host could open nothing it clicked. At its own spot, the guest opens a crafting table, a
|
||||
chest, a furnace and a stonecutter."""
|
||||
joined = {'host': [round(v, 2) for v in host.joined_at], 'guest': [round(v, 2) for v in guest.joined_at]}
|
||||
apart = abs(joined['host'][0] - joined['guest'][0]) + abs(joined['host'][2] - joined['guest'][2]) >= 0.6
|
||||
guest('command', command='gamemode survival')
|
||||
for game in (host, guest):
|
||||
x, y, z = game.joined_at
|
||||
game('command', command=f'tp @s {x} {y} {z}')
|
||||
guest.wait(20)
|
||||
x, y, z = (int(v // 1) for v in pos_of(guest))
|
||||
at = (x, y, z + 2)
|
||||
grab(host)
|
||||
opened = {}
|
||||
for block in ('crafting_table', 'chest', 'furnace', 'stonecutter'):
|
||||
guest('command', command=f'setblock {at[0]} {at[1]} {at[2]} minecraft:{block}')
|
||||
guest.wait(10)
|
||||
guest.raw('interact', *at)
|
||||
guest.wait(10)
|
||||
opened[block] = guest('state').get('screen')
|
||||
guest.shot(out, f'spawned-{block}')
|
||||
back_to_game(guest)
|
||||
guest('command', command=f'setblock {at[0]} {at[1]} {at[2]} minecraft:air')
|
||||
host_unaffected(host, "the guest's stations")
|
||||
face_each_other(host, guest)
|
||||
assert apart, f'the host and the guest joined on one spot: {joined}'
|
||||
want = {'crafting_table': 'inventory', 'chest': 'container', 'furnace': 'container', 'stonecutter': 'station'}
|
||||
assert opened == want, f'where it joined, the guest opened {opened}'
|
||||
return {'joined': joined, 'opened': opened}
|
||||
|
||||
|
||||
def check_sign(host, guest, out):
|
||||
x, y, z = (int(v // 1) for v in pos_of(guest))
|
||||
spot = (x + 2, y, z)
|
||||
@@ -4669,6 +4705,7 @@ def check_worn(host, guest, out):
|
||||
|
||||
CHECK_FNS = {'tab': check_tab, 'self': check_self, 'armour': check_armour,
|
||||
'nametags': check_nametags, 'chest': check_chest, 'crafting': check_crafting,
|
||||
'spawned': check_spawned,
|
||||
'sign': check_sign, 'damage': check_damage, 'mobs': check_mobs,
|
||||
'hazards': check_hazards, 'pvp': check_pvp, 'respawn': check_respawn,
|
||||
'containers': check_containers, 'feedback': check_feedback, 'titles': check_titles,
|
||||
@@ -4727,6 +4764,8 @@ def run(ctl, out, resources, checks):
|
||||
guest.raw('connect', f'127.0.0.1:{port}', GUEST_NAME)
|
||||
guest.raw('wait-for', 'loaded', '--timeout', 300)
|
||||
guest.wait(40)
|
||||
# Where each joined, before anything moves them (check_spawned).
|
||||
host.joined_at, guest.joined_at = pos_of(host), pos_of(guest)
|
||||
face_each_other(host, guest)
|
||||
host.shot(out, 'joined')
|
||||
guest.shot(out, 'joined')
|
||||
|
||||
@@ -44,7 +44,6 @@
|
||||
"lan_integration": "the whole lan_e2e suite: ~40 min a run on a loaded runner, and single checks flake under load; run it by name, or tools/lan_e2e.py"
|
||||
},
|
||||
"known_flaky": {
|
||||
"dedicated_server": "its Java client moves to the next step on a 1.5 s timer, not on the server's reply: under load a respawn's add_entity/update_attributes or entity_event 19 lands in the wrong step (failed, then passed at the same head, 10-26)",
|
||||
"remote_fireball": "samples the fireball on the wall clock, so a slow host sees it in too few samples (desktop runner, 3 times on 10-26)",
|
||||
"world_reopen": "counted 4 minecarts instead of 3 once on a loaded Mac mini runner, then passed at the same head (10-26)"
|
||||
}
|
||||
|
||||
+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 $!
|
||||
|
||||
@@ -18,15 +18,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