Native mods: the item panel and recipe pages for native mods

The item panel beside container screens and its recipe pages move to
game/native_panel.rs and game/native_panel_recipes.rs, built in every
build, with their state in Game::panel and native types (NativeStack,
NativeRecipeView, NativeRecipeLayout, NativePanelEvent). A native mod
shows them with native_panel_items and native_panel_recipes and hears
the player's clicks, searches, page turns and keys over items through
the new hook point native_panel_event: NATIVE_HOOKS_API is 2.

A WebAssembly mod's panel feeds the same state through game/mod_panel.rs
(feature `mods`) and hears its events as before; a native mod's panel
wins when both show one. Players see no change.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Ludovic MarieandClaude Opus 5.5 committed 2026-10-29 01:12:08 +01:00
1 parent e6bb2f74c7
commit 11f22e26fa
8 files changed
+630 -139

No files matched your search

+7 -3
View File
@@ -108,10 +108,9 @@ mod minecarts;
mod mob_blocks;
mod mob_gear;
mod mob_sounds;
mod mod_browser;
#[cfg(feature = "mods")]
mod mod_browser_recipes;
mod mod_input;
#[cfg(feature = "mods")]
mod mod_panel;
mod mod_render;
mod mods;
mod mount_menu;
@@ -121,6 +120,8 @@ mod moving_pistons;
mod music;
mod names;
mod native_hooks;
mod native_panel;
mod native_panel_recipes;
mod native_recipes;
mod native_shaders;
mod natural_spawning;
@@ -1823,6 +1824,8 @@ struct Game<'a> {
mods: mods::ModsState,
/// Every item and recipe, for mods (`game/native_recipes.rs`), made once.
recipe_tables: native_recipes::RecipeTables,
/// The item panel beside container screens (`game/native_panel.rs`).
panel: native_panel::Panel,
/// Horizon's own shader pack, while one is picked (`game/native_shaders.rs`).
native_shaders: native_shaders::NativeShaders,
/// Meshed sections waiting for an upload budget (Horizon settings).
@@ -14090,6 +14093,7 @@ impl<'a> Game<'a> {
extras: Default::default(),
mods: Default::default(),
recipe_tables: Default::default(),
panel: Default::default(),
native_shaders: Default::default(),
sky_darken_memo: Default::default(),
persist: Default::default(),
@@ -130,6 +130,7 @@ impl Game<'_> {
&& let Some(name) = horizon_client::keybinds::key_name(key)
&& let Some(stack) = self.mod_browser_pointed()
{
let stack = super::mod_panel::browser_stack(stack);
self.mods.mods.browser_key(name, &stack);
return;
}
+114
View File
@@ -0,0 +1,114 @@
//! Capability `ui`'s item panel of a WebAssembly mod (WIT `interface
//! browser`, docs/mods.md): its items and recipe page as the native panel's
//! (`native_panel.rs`), and the panel's events back in the runtime's types.
use super::{
Game,
native_hooks::{
NativePanelEvent as BrowserEvent, NativeRecipeLayout, NativeRecipeView,
NativeStack as BrowserStack,
},
native_panel::{Owner, Shown},
native_panel_recipes::RecipePage,
};
use horizon_mods as m;
/// A WebAssembly mod's panel, converted once per `set-items` or
/// `set-recipes`.
impl Game<'_> {
pub(super) fn panel_wasm_sync(&mut self) {
let Some((index, version, items)) = self.mods.mods.browser_items() else {
self.panel.wasm = None;
return;
};
let owner = Owner::Wasm(index);
let wasm = self.panel.wasm.as_ref();
if wasm.is_some_and(|s| s.owner == owner && s.version == version) {
return;
}
let items = items.iter().map(stack).collect();
let recipes = self.mods.mods.browser_recipes(index).map(page);
self.panel.wasm = Some(Shown {
owner,
version,
items,
recipes,
});
}
}
pub(super) fn stack(s: &m::BrowserStack) -> BrowserStack {
BrowserStack {
item: s.item.clone(),
count: s.count,
components: s.components.clone(),
}
}
/// A native stack as the runtime's (`browser_key`).
pub(super) fn browser_stack(s: BrowserStack) -> m::BrowserStack {
m::BrowserStack {
item: s.item,
count: s.count,
components: s.components,
}
}
fn layout(l: m::BrowserLayout) -> NativeRecipeLayout {
match l {
m::BrowserLayout::Grid => NativeRecipeLayout::Grid,
m::BrowserLayout::Cooking => NativeRecipeLayout::Cooking,
m::BrowserLayout::Single => NativeRecipeLayout::Single,
m::BrowserLayout::Smithing => NativeRecipeLayout::Smithing,
m::BrowserLayout::Brewing => NativeRecipeLayout::Brewing,
}
}
fn stacks(list: &[m::BrowserStack]) -> Vec<BrowserStack> {
list.iter().map(stack).collect()
}
pub(super) fn page(p: &m::RecipePage) -> RecipePage {
RecipePage {
title: p.title.clone(),
tabs: stacks(&p.tabs),
tab: p.tab,
recipes: p
.recipes
.iter()
.map(|r| NativeRecipeView {
layout: layout(r.layout),
width: r.width,
height: r.height,
slots: r.slots.iter().map(|s| stacks(s)).collect(),
results: stacks(&r.results),
note: r.note.clone(),
action: r.action,
})
.collect(),
}
}
pub(super) fn event(e: BrowserEvent) -> m::BrowserEvent {
match e {
BrowserEvent::Clicked { index, right } => {
m::BrowserEvent::Clicked(m::BrowserClick { index, right })
}
BrowserEvent::Search(text) => m::BrowserEvent::Search(text),
// Not sent to WebAssembly mods: they hear their own keys
// (`browser_key`).
BrowserEvent::Key { key, stack } => m::BrowserEvent::Key(m::BrowserItemKey {
key,
stack: browser_stack(stack),
}),
BrowserEvent::SlotClicked {
recipe,
slot,
stack,
right,
} => m::BrowserEvent::SlotClicked(m::BrowserSlotClick {
recipe,
slot,
stack: browser_stack(stack),
right,
}),
BrowserEvent::Action(i) => m::BrowserEvent::Action(i),
BrowserEvent::Page(i) => m::BrowserEvent::Page(i),
BrowserEvent::Back => m::BrowserEvent::Back,
BrowserEvent::Tab(i) => m::BrowserEvent::Tab(i),
}
}
-3
View File
@@ -92,9 +92,6 @@ pub(super) struct ModsState {
/// Capability `recipes`'s tables, made once a mod reads them.
#[cfg(feature = "mods")]
pub(super) recipes: Option<std::sync::Arc<horizon_mods::Recipes>>,
/// 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,
+117 -2
View File
@@ -6,6 +6,12 @@
//! Horizon's own HUD extras do, and keep a mod's settings where the
//! WebAssembly mod of the same id kept them.
//!
//! API 2 adds the item panel beside container screens and its recipe pages
//! (`native_panel_items`, `native_panel_recipes`, `native_panel_event`),
//! and the game's items and recipes (`native_recipes`,
//! `native_known_recipes`, `native_place_recipe`, `native_give`): what a
//! recipe viewer needs.
//!
//! Names, parameters and call sites are kept from release to release, and
//! hook points are only ever added: each addition raises [`NATIVE_HOOKS_API`].
//! A bundle that hooks only these names the API it needs (`hooks`), and the
@@ -13,7 +19,7 @@
//! that would break such a bundle is not made.
//! Bodies have no `cfg` and no macros: horizon-mixins refuses a target with
//! either (`tests::hook_points_stay_hookable`).
use super::{Game, Screen, execution_role::ExecutionRole};
use super::{Game, Screen, execution_role::ExecutionRole, native_panel_recipes::RecipePage};
use horizon_client::{
chat::ChatSource,
extras::Corner,
@@ -29,7 +35,7 @@ use winit::{event::MouseButton, keyboard::KeyCode};
/// The hook points' API version. The launcher reads this line from the
/// source it builds; keep its form.
#[allow(dead_code)]
pub(crate) const NATIVE_HOOKS_API: u32 = 1;
pub(crate) const NATIVE_HOOKS_API: u32 = 2;
/// The zoom a native mod asks for (`native_zoom`): the factor (1 is none),
/// eased through the field of view like the spyglass when `smooth`, and the
@@ -80,6 +86,83 @@ pub(crate) struct NativeMod {
pub(crate) keys: Vec<[String; 3]>,
}
/// An item the panel shows (`native_panel_items`): its id
/// (`minecraft:diamond`), a count (1 draws none) and its components beyond
/// the item's own, in vanilla's item syntax, or "".
#[derive(Clone, Debug, Default, PartialEq)]
pub(crate) struct NativeStack {
pub(crate) item: String,
pub(crate) count: u32,
pub(crate) components: String,
}
/// How the panel lays out a recipe of `native_panel_recipes`.
#[allow(dead_code)]
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum NativeRecipeLayout {
/// 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 the panel shows (`native_panel_recipes`). Each slot, and the
/// result, cycles through its stacks, one a second; an empty slot shows
/// nothing.
#[derive(Clone, Debug, PartialEq)]
pub(crate) struct NativeRecipeView {
pub(crate) layout: NativeRecipeLayout,
/// The grid's size (`Grid` only), at most 3 x 3.
pub(crate) width: u32,
pub(crate) height: u32,
pub(crate) slots: Vec<Vec<NativeStack>>,
pub(crate) results: Vec<NativeStack>,
/// A short line under the recipe (a cooking time), or "".
pub(crate) note: String,
/// The action button beside it: none, enabled, or shown greyed.
pub(crate) action: Option<bool>,
}
/// What the player did on a native mod's panel (`native_panel_event`).
#[allow(dead_code)]
#[derive(Clone, Debug, PartialEq)]
pub(crate) enum NativePanelEvent {
/// A click on an item of the panel, by its index in
/// `native_panel_items`' list.
Clicked { index: u32, right: bool },
/// The search box's text, each time the player changes it.
Search(String),
/// A key (`key.keyboard.r`; compare it with `native_mod_key`) went down
/// while the pointer was on an item: a slot of the screen's, or the
/// panel's.
Key { key: String, stack: NativeStack },
/// A click on a slot of a recipe the panel shows: the recipe's index in
/// `native_panel_recipes`' list and the slot's in it (the results come
/// after the slots), and the stack it showed then.
SlotClicked {
recipe: u32,
slot: u32,
stack: NativeStack,
right: bool,
},
/// The action button of the recipe at this index.
Action(u32),
/// The player turned the recipes' page: the index of the first recipe
/// now shown.
Page(u32),
/// The recipe page's back button.
Back,
/// The player picked the tab at this index.
Tab(u32),
}
/// An item of [`NativeRecipes::items`], by its index there, with a count
/// and the components it has beyond the item's own, in vanilla's item
/// syntax (`[minecraft:potion_contents={potion:"minecraft:swiftness"}]`),
@@ -230,6 +313,11 @@ impl Game<'_> {
pub(super) fn native_settings_click(&mut self, mod_id: &str, button: usize) {
let _ = (mod_id, button);
}
/// The player did `event` on mod `mod_id`'s panel (`native_panel_items`):
/// act on it when it is yours. API 2.
pub(super) fn native_panel_event(&mut self, mod_id: &str, event: &NativePanelEvent) {
let _ = (mod_id, event);
}
}
/// Helpers for native mods, kept as stable as the hook points.
@@ -351,6 +439,30 @@ impl Game<'_> {
let dir = self.native_data_dir(mod_id);
let _ = fs::create_dir_all(&dir).and_then(|()| fs::write(dir.join(key), value));
}
/// Shows the panel beside container screens with these items (at most
/// 8192), as the game's own slots in pages, with a search box; the
/// player's clicks, searches and keys over items come back as
/// `native_panel_event`. The panel is the last mod's to show one; empty
/// items and no recipe page take yours away.
pub(super) fn native_panel_items(&mut self, mod_id: &str, items: Vec<NativeStack>) {
self.panel.show_items(mod_id, items);
}
/// 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. At most 256
/// recipes and 16 tabs; no recipes shows the items again. The same title
/// and tab again keep the page the player turned to.
pub(super) fn native_panel_recipes(
&mut self,
mod_id: &str,
title: &str,
tabs: Vec<NativeStack>,
tab: u32,
views: Vec<NativeRecipeView>,
) {
let page = RecipePage::new(title, tabs, tab, views);
self.panel.show_recipes(mod_id, page);
}
/// Every item and recipe the game knows, made once (the same for every
/// call); none outside a world. They are the window's own: the world's
/// in single player, vanilla's on a server, which sends a client no
@@ -434,6 +546,9 @@ impl Game<'_> {
return;
}
if let Some(name) = keybinds::key_name(key) {
if pressed {
self.native_panel_key(name);
}
self.native_key(name, pressed);
}
}
@@ -1,64 +1,144 @@
//! 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},
//! The item panel beside container screens: the items a recipe viewer mod
//! lists (a native mod's `native_panel_items`, or WIT `interface browser`
//! of a WebAssembly mod, docs/mods.md), 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 keys went down over an item
//! (`native_panel_event`). With no mod showing a panel, each hook here
//! returns after one check.
use super::{
Game, Screen,
native_hooks::{NativePanelEvent as BrowserEvent, NativeStack as BrowserStack},
native_panel_recipes::RecipePage,
};
use horizon_client::{
hud::{HudBatch, rgba},
items::ItemStack,
ui::EditBox,
};
#[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;
/// Items a panel may hold.
const ITEMS: usize = 8192;
/// Whose panel it is: a native mod's id, or a running WebAssembly mod's
/// index.
#[derive(Clone, Debug, PartialEq)]
pub(super) enum Owner {
Native(String),
#[cfg(feature = "mods")]
Wasm(usize),
}
impl Owner {
fn native_id(&self) -> Option<&str> {
match self {
Owner::Native(id) => Some(id),
#[cfg(feature = "mods")]
Owner::Wasm(_) => None,
}
}
}
/// What a mod shows on the panel: its items, and the recipes shown in
/// their place.
#[derive(Clone, Debug, PartialEq)]
pub(super) struct Shown {
pub(super) owner: Owner,
/// Which change the panel is from, so the game converts it once.
pub(super) version: u64,
pub(super) items: Vec<BrowserStack>,
pub(super) recipes: Option<RecipePage>,
}
/// The panel's state in the game.
#[cfg(feature = "mods")]
#[derive(Default)]
pub(super) struct Browser {
pub(super) struct Panel {
/// A native mod's panel: the last one to show something.
native: Option<Shown>,
/// A WebAssembly mod's, converted once per change.
#[cfg(feature = "mods")]
pub(super) wasm: Option<Shown>,
/// The last native change.
versions: u64,
/// The items' owner and version they were made from.
of: Option<(usize, u64)>,
of: Option<(Owner, 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>,
/// The recipes shown instead of the items.
pub(super) recipes: Option<super::native_panel_recipes::Pages>,
}
impl Panel {
/// What the panel shows: a native mod's (one `on`), else a
/// WebAssembly mod's.
pub(super) fn shown(&self, on: impl Fn(&str) -> bool) -> Option<&Shown> {
let native = self
.native
.as_ref()
.filter(|s| s.owner.native_id().is_some_and(&on));
#[cfg(feature = "mods")]
let native = native.or(self.wasm.as_ref());
native
}
/// `native_panel_items`.
pub(super) fn show_items(&mut self, mod_id: &str, mut items: Vec<BrowserStack>) {
items.truncate(ITEMS);
if let Some(shown) = self.native_of(mod_id, !items.is_empty()) {
shown.items = items;
self.native_changed();
}
}
/// `native_panel_recipes`.
pub(super) fn show_recipes(&mut self, mod_id: &str, page: Option<RecipePage>) {
if let Some(shown) = self.native_of(mod_id, page.is_some()) {
shown.recipes = page;
self.native_changed();
}
}
/// Mod `mod_id`'s panel, made when it shows something on another's;
/// none when it clears a panel that isn't its own.
fn native_of(&mut self, mod_id: &str, showing: bool) -> Option<&mut Shown> {
let own = self
.native
.as_ref()
.is_some_and(|s| s.owner.native_id() == Some(mod_id));
if !own {
if !showing {
return None;
}
self.native = Some(Shown {
owner: Owner::Native(mod_id.to_owned()),
version: 0,
items: Vec::new(),
recipes: None,
});
}
self.native.as_mut()
}
/// A native panel changed: a new version, and no panel once it shows
/// nothing.
fn native_changed(&mut self) {
self.versions += 1;
if let Some(shown) = self.native.as_mut() {
shown.version = self.versions;
if shown.items.is_empty() && shown.recipes.is_none() {
self.native = None;
}
}
}
}
/// 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],
@@ -70,7 +150,6 @@ pub(super) struct Layout {
pub(super) arrows_y: f32,
pub(super) search: [f32; 4],
}
#[cfg(feature = "mods")]
impl Layout {
fn per_page(&self) -> usize {
self.columns * self.rows
@@ -93,48 +172,62 @@ impl Layout {
}
}
#[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> {
/// change; none while no mod shows one or no container screen is up.
fn browser_sync(&mut self) -> Option<Owner> {
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();
self.panel_wasm_sync();
let disabled = &self.settings.mods.disabled;
let Some(shown) = self.panel.shown(|id| !disabled.contains(id)) else {
self.panel.of = None;
self.panel.stacks.clear();
return None;
};
if self.mods.browser.of != Some((owner, version)) {
let of = (shown.owner.clone(), shown.version);
if self.panel.of.as_ref() != Some(&of) {
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;
let stacks = shown
.items
.iter()
.map(|s| browser_stack(items, s))
.collect();
let shown = shown.recipes.clone();
self.panel.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
let old = self.panel.recipes.take();
self.panel.recipes = shown
.as_ref()
.map(|shown| super::native_panel_recipes::Pages::new(items, shown, old));
self.panel.of = Some(of.clone());
}
Some(of.0)
}
/// What the panel shows now.
fn panel_shown(&self) -> Option<&Shown> {
let disabled = &self.settings.mods.disabled;
self.panel.shown(|id| !disabled.contains(id))
}
/// The player did `event` on `owner`'s panel.
pub(super) fn panel_event(&mut self, owner: &Owner, event: BrowserEvent) {
match owner {
Owner::Native(id) => self.native_panel_event(id, &event),
#[cfg(feature = "mods")]
Owner::Wasm(i) => self
.mods
.mods
.browser_recipes(owner)
.map(|shown| super::mod_browser_recipes::Pages::new(items, shown, old));
self.mods.browser.of = Some((owner, version));
.browser_event(*i, super::mod_panel::event(event)),
}
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.
@@ -161,17 +254,12 @@ impl Game<'_> {
})
}
fn browser_pages(&self, layout: &Layout) -> usize {
self.mods
.browser
.stacks
.len()
.div_ceil(layout.per_page())
.max(1)
self.panel.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 self.panel.recipes.is_some() {
if let Some((_, sent, stack)) = self.recipe_tab_hovered(layout) {
return Some((sent, stack));
}
@@ -179,26 +267,26 @@ impl Game<'_> {
return Some((sent, stack));
}
let (index, stack) = self.browser_hovered(layout)?;
let (_, _, items) = self.mods.mods.browser_items()?;
let items = &self.panel_shown()?.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() {
if self.panel.recipes.is_some() {
return None;
}
let first = self.mods.browser.page * layout.per_page();
let first = self.panel.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()?;
let stack = self.panel.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 search = self.panel.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;
@@ -211,8 +299,8 @@ impl Game<'_> {
/// 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;
self.panel.page = self.panel.page.min(pages - 1);
let page = self.panel.page;
let cursor = self.cursor;
if pages > 1 {
let labels = [
@@ -247,7 +335,7 @@ impl Game<'_> {
}
let first = page * layout.per_page();
for n in 0..layout.per_page() {
let Some(stack) = self.mods.browser.stacks.get(first + n) else {
let Some(stack) = self.panel.stacks.get(first + n) else {
break;
};
let Some(stack) = stack.clone() else {
@@ -285,7 +373,7 @@ impl Game<'_> {
return;
};
let cursor = self.cursor;
if self.mods.browser.recipes.is_some() {
if self.panel.recipes.is_some() {
self.draw_browser_recipes(batch, &layout);
} else {
self.draw_browser_items(batch, &layout);
@@ -296,7 +384,7 @@ impl Game<'_> {
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);
self.panel.search = Some(search);
if self.carried.is_some() {
self.draw_carried(batch);
} else if let Some(stack) = self.browser_pointed_stack(&layout) {
@@ -323,7 +411,7 @@ impl Game<'_> {
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 search = self.panel.search.get_or_insert_with(|| {
let mut edit = EditBox::new(0.0, 0.0, 0.0, 0.0);
edit.max_length = 50;
edit
@@ -336,12 +424,12 @@ impl Game<'_> {
return true;
}
search.focused = false;
if self.mods.browser.recipes.is_some() {
return self.browser_recipes_press(&layout, owner, right)
if self.panel.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 page = self.panel.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)
@@ -354,17 +442,17 @@ impl Game<'_> {
None
};
if let Some(to) = step {
self.mods.browser.page = to;
self.panel.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 {
let event = BrowserEvent::Clicked {
index: index as u32,
right,
});
self.mods.mods.browser_event(owner, event);
};
self.panel_event(&owner, event);
return true;
}
inside(cursor, layout.area)
@@ -373,8 +461,8 @@ impl Game<'_> {
/// 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)
&& self.panel.of.is_some()
&& self.panel.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) {
@@ -382,7 +470,7 @@ impl Game<'_> {
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 {
let Some(search) = self.panel.search.as_mut() else {
return;
};
if let Some(shortcut) = shortcut {
@@ -405,7 +493,7 @@ impl Game<'_> {
}
/// 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 {
let Some(search) = self.panel.search.as_mut() else {
return;
};
if backspace {
@@ -417,27 +505,24 @@ impl Game<'_> {
}
/// 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 {
let (Some((owner, _)), Some(search)) = (&self.panel.of, &self.panel.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));
if search.value != self.panel.sent {
let (owner, value) = (owner.clone(), search.value.clone());
self.panel.sent.clone_from(&value);
self.panel.page = 0;
self.panel_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() {
if let Some(search) = self.panel.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.
/// (the `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
@@ -459,11 +544,24 @@ impl Game<'_> {
components: super::native_recipes::components(&stack),
})
}
/// A key `key` (`key.keyboard.r`) went down: over an item on a container
/// screen, a native mod's panel hears it as a `Key` event.
pub(super) fn native_panel_key(&mut self, key: &str) {
if self.screen != Screen::Inventory || self.title.is_some() || self.mod_browser_typing() {
return;
}
let Some(owner) = self.browser_sync().filter(|o| o.native_id().is_some()) else {
return;
};
if let Some(stack) = self.mod_browser_pointed() {
let key = key.to_owned();
self.panel_event(&owner, BrowserEvent::Key { key, 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,
@@ -483,3 +581,114 @@ pub(super) fn browser_stack(
extra,
})
}
/// No WebAssembly mods: no panel of theirs (`mod_panel.rs` with them).
#[cfg(not(feature = "mods"))]
impl Game<'_> {
fn panel_wasm_sync(&mut self) {}
}
#[cfg(test)]
mod tests {
use super::{BrowserStack, Owner, Panel, RecipePage};
use crate::game::native_hooks::{NativeRecipeLayout, NativeRecipeView};
fn stack(item: &str) -> BrowserStack {
BrowserStack {
item: item.into(),
count: 1,
components: String::new(),
}
}
fn view() -> NativeRecipeView {
NativeRecipeView {
layout: NativeRecipeLayout::Grid,
width: 5,
height: 1,
slots: vec![vec![stack("minecraft:oak_log")]; 12],
results: vec![stack("minecraft:oak_planks")],
note: String::new(),
action: Some(true),
}
}
/// A native mod's items and recipe page are what the panel holds, and
/// its owner (who hears the panel's events) is that mod.
#[test]
fn native_owner_holds_the_panel() {
let mut panel = Panel::default();
let items = vec![stack("minecraft:stone"), stack("minecraft:dirt")];
panel.show_items("recipes", items.clone());
let shown = panel.shown(|_| true).expect("a panel");
assert_eq!(shown.owner, Owner::Native("recipes".into()));
assert_eq!(shown.items, items);
assert_eq!(shown.recipes, None);
let first = shown.version;
let tabs = vec![stack("minecraft:crafting_table")];
let page = RecipePage::new("Oak Planks", tabs.clone(), 0, vec![view()]);
panel.show_recipes("recipes", page);
let shown = panel.shown(|_| true).expect("a panel");
assert_ne!(shown.version, first, "the game converts each change");
assert_eq!(shown.items, items);
let page = shown.recipes.as_ref().expect("a recipe page");
assert_eq!(
(page.title.as_str(), &page.tabs, page.tab),
("Oak Planks", &tabs, 0)
);
// Cut as `set-recipes` cuts it: a 3 x 3 grid at most, 9 slots.
let r = &page.recipes[0];
assert_eq!((r.width, r.height, r.slots.len()), (3, 1, 9));
assert_eq!(r.results, [stack("minecraft:oak_planks")]);
// Another mod clearing its own (empty) panel leaves this one.
panel.show_items("other", Vec::new());
let owner = panel.shown(|_| true).map(|s| s.owner.clone());
assert_eq!(owner, Some(Owner::Native("recipes".into())));
// A mod the player turned off shows nothing.
assert!(panel.shown(|id| id != "recipes").is_none());
// No recipes: the items again; then no items: no panel.
panel.show_recipes("recipes", RecipePage::new("", Vec::new(), 0, Vec::new()));
let shown = panel.shown(|_| true).expect("a panel");
assert_eq!((shown.items.len(), shown.recipes.is_none()), (2, true));
panel.show_items("recipes", Vec::new());
assert!(panel.shown(|_| true).is_none());
}
/// A native mod's panel wins over a WebAssembly mod's, and events go
/// back to a WebAssembly owner in the runtime's form.
#[cfg(feature = "mods")]
#[test]
fn native_owner_wins_over_webassembly() {
use super::{BrowserEvent, Shown};
use crate::game::mod_panel as wasm;
let mut panel = Panel {
wasm: Some(Shown {
owner: Owner::Wasm(0),
version: 7,
items: vec![stack("minecraft:stone")],
recipes: None,
}),
..Panel::default()
};
let owner = |p: &Panel| p.shown(|_| true).map(|s| s.owner.clone());
assert_eq!(owner(&panel), Some(Owner::Wasm(0)));
panel.show_items("recipes", vec![stack("minecraft:dirt")]);
assert_eq!(owner(&panel), Some(Owner::Native("recipes".into())));
panel.show_items("recipes", Vec::new());
assert_eq!(owner(&panel), Some(Owner::Wasm(0)));
let event = BrowserEvent::SlotClicked {
recipe: 2,
slot: 9,
stack: stack("minecraft:oak_planks"),
right: true,
};
let horizon_mods::BrowserEvent::SlotClicked(c) = wasm::event(event) else {
panic!("a slot click");
};
assert_eq!((c.recipe, c.slot, c.right), (2, 9, true));
assert_eq!(c.stack.item, "minecraft:oak_planks");
}
}
@@ -1,17 +1,20 @@
//! `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
//! A mod's recipes shown in its item panel's place (`native_panel_recipes`,
//! or `browser.set-recipes` of a WebAssembly mod, docs/mods.md), 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},
native_hooks::{
NativePanelEvent as BrowserEvent, NativeRecipeLayout as BrowserLayout,
NativeRecipeView as BrowserRecipe, NativeStack as BrowserStack,
},
native_panel::{Layout, Owner, 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;
@@ -21,6 +24,54 @@ const TITLE: f32 = 18.0;
const TAB: f32 = 20.0;
const GAP: f32 = 6.0;
/// Recipes a page may show, slots in one, stacks a slot cycles, and tabs.
const RECIPES: usize = 256;
const RECIPE_SLOTS: usize = 9;
const SLOT_STACKS: usize = 64;
const TABS: usize = 16;
/// What the panel shows instead of its items: a title, tabs (`tab` the
/// selected one) and the recipes.
#[derive(Clone, Debug, PartialEq)]
pub(super) struct RecipePage {
pub(super) title: String,
pub(super) tabs: Vec<BrowserStack>,
pub(super) tab: u32,
pub(super) recipes: Vec<BrowserRecipe>,
}
impl RecipePage {
/// `native_panel_recipes`' page, cut as `set-recipes` cuts one; none
/// without recipes (the items again).
pub(super) fn new(
title: &str,
tabs: Vec<BrowserStack>,
tab: u32,
recipes: Vec<BrowserRecipe>,
) -> Option<Self> {
let cycle = |mut list: Vec<BrowserStack>| {
list.truncate(SLOT_STACKS);
list
};
let recipes: Vec<BrowserRecipe> = recipes
.into_iter()
.take(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),
..r
})
.collect();
(!recipes.is_empty()).then(|| Self {
title: title.to_owned(),
tabs: tabs.into_iter().take(TABS).collect(),
tab,
recipes,
})
}
}
/// One recipe as the game draws it.
pub(super) struct Shown {
layout: BrowserLayout,
@@ -180,7 +231,7 @@ impl Game<'_> {
}
/// 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 {
let Some(pages) = &self.panel.recipes else {
return Vec::new();
};
let starts = Self::recipe_pages(pages, layout);
@@ -202,7 +253,7 @@ impl Game<'_> {
}
/// 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 pages = self.panel.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))
@@ -212,7 +263,7 @@ impl Game<'_> {
&self,
layout: &Layout,
) -> Option<(usize, BrowserStack, ItemStack)> {
let pages = self.mods.browser.recipes.as_ref()?;
let pages = self.panel.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]) {
@@ -227,7 +278,7 @@ impl Game<'_> {
&self,
layout: &Layout,
) -> Option<(usize, usize, BrowserStack, ItemStack)> {
let pages = self.mods.browser.recipes.as_ref()?;
let pages = self.panel.recipes.as_ref()?;
self.recipes_shown(layout)
.into_iter()
.find_map(|(i, (x, y))| {
@@ -244,7 +295,7 @@ impl Game<'_> {
/// 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 {
let Some(pages) = &self.panel.recipes else {
return;
};
let cursor = self.cursor;
@@ -324,7 +375,7 @@ impl Game<'_> {
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 {
let Some(r) = self.panel.recipes.as_ref().map(|p| &p.recipes[i]) else {
break;
};
let (at, arrow, action) = r.places(layout.width());
@@ -405,7 +456,7 @@ impl Game<'_> {
/// 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 {
let Some(pages) = &self.panel.recipes else {
return;
};
let places = tab_places(pages, layout);
@@ -451,10 +502,10 @@ impl Game<'_> {
pub(super) fn browser_recipes_press(
&mut self,
layout: &Layout,
owner: usize,
owner: &Owner,
right: bool,
) -> bool {
let Some(pages) = &self.mods.browser.recipes else {
let Some(pages) = &self.panel.recipes else {
return false;
};
let cursor = self.cursor;
@@ -469,38 +520,34 @@ impl Game<'_> {
None
};
if let Some(to) = turn {
if let Some(p) = self.mods.browser.recipes.as_mut() {
if let Some(p) = self.panel.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));
self.panel_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));
self.panel_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);
self.panel_event(owner, BrowserEvent::Back);
return true;
}
if let Some((recipe, slot, stack, _)) = self.recipe_slot_hovered(layout) {
let event = BrowserEvent::SlotClicked(BrowserSlotClick {
let event = BrowserEvent::SlotClicked {
recipe: recipe as u32,
slot: slot as u32,
stack,
right,
});
self.mods.mods.browser_event(owner, event);
};
self.panel_event(owner, event);
return true;
}
let action = self.recipes_shown(layout).into_iter().find(|&(i, (x, y))| {
@@ -512,9 +559,7 @@ impl Game<'_> {
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));
self.panel_event(owner, BrowserEvent::Action(i as u32));
}
return true;
}
+6
View File
@@ -32,6 +32,12 @@ mod was tested on. In its own copy of the mod it sets `horizon_revision` to the
official mods (Zoom, FPS counter, Keystrokes, Minimap, Recipes) are made this way, so any of them build together
with no re-release for each Nightly. The `mods` feature (WebAssembly mods) is passed to the build only when the source has it.
API 2 adds the item panel beside container screens and its recipe pages (`native_panel_items`,
`native_panel_recipes`, and the hook `native_panel_event` for the player's clicks, searches and keys over items), and
the game's items and recipes (`native_recipes`, `native_known_recipes`, `native_place_recipe`, `native_give`): what a
recipe viewer needs, in builds with or without the `mods` feature. A WebAssembly mod's panel shows only while no native
mod shows one.
## What the launcher does
In the window: install a native mod from the Mods page, turn on Settings → Game → **Native mods (experimental)**,