Merge pull request #2016 from ludovic111/feat/mod-ui-key

🤖 Agent: Players rebind a mod's keys on its Options > Mods page (the game's own key rows, saved in horizon-mods.txt), with no WIT type change, so the published mods keep loading. Verified with main merged: clippy, the client, mods and SDK tests, and the in-game check in both modes (24/24).
This commit is contained in:
Ludovic Marie
2026-10-26 06:50:11 +01:00
committed by GitHub
9 changed files with 305 additions and 30 deletions
+4 -3
View File
@@ -10625,6 +10625,9 @@ impl Game<'_> {
}
return Ok(());
}
if self.screen == Screen::Mod && self.mod_screen_key(key) {
return Ok(());
}
if self.sort_key_press(key) {
return Ok(());
}
@@ -11554,9 +11557,7 @@ impl ApplicationHandler for Game<'_> {
self.stats_click(size);
}
Screen::Stats if button == MouseButton::Left => self.stats_release(),
Screen::Mod if pressed && button == MouseButton::Left => {
self.mod_screen_click()
}
Screen::Mod if pressed => self.mod_screen_click(button),
_ => {}
}
}
+142 -10
View File
@@ -27,8 +27,9 @@ use {
text::{self, Text},
ui,
},
horizon_mods::{Clock, Mods, Output, Player, Snapshot, Status, UiScreen, UiWidget},
horizon_mods::{Clock, ModKey, Mods, Output, Player, Snapshot, Status, UiScreen, UiWidget},
std::time::Instant,
winit::{event::MouseButton, keyboard::KeyCode},
};
#[derive(Default)]
@@ -101,6 +102,8 @@ struct ModScreen {
under: Screen,
/// One scroll per `list` widget, in widget order.
scrolls: Vec<ui::Scroll>,
/// A key row waiting for the key to bind: the mod key's name.
binding: Option<String>,
}
impl ModsState {
/// The chat's command tree: vanilla's, plus the running mods' commands.
@@ -240,7 +243,10 @@ impl Game<'_> {
pub(super) fn open_mod_screen(&mut self, _owner: usize, _settings: bool) {}
pub(super) fn close_mod_screen(&mut self, _tell: bool) {}
pub(super) fn draw_mod_screen(&mut self, _batch: &mut HudBatch) {}
pub(super) fn mod_screen_click(&mut self) {}
pub(super) fn mod_screen_click(&mut self, _button: winit::event::MouseButton) {}
pub(super) fn mod_screen_key(&mut self, _key: winit::keyboard::KeyCode) -> bool {
false
}
pub(super) fn mod_screen_scroll(&mut self, _delta: f32) {}
}
@@ -263,6 +269,8 @@ impl Game<'_> {
let on = |id: &str| enabled.contains(id) || dev_ids.iter().any(|d| d == id);
let mods = Mods::load(&paths, on, vanilla_command);
self.mods.mods = mods;
let chosen = &self.settings.mods;
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;
self.list_mods();
@@ -429,6 +437,8 @@ impl Game<'_> {
{
self.mods.checked = Some(Instant::now());
if self.mods.mods.reload_changed(vanilla_command) {
let chosen = &self.settings.mods;
self.mods.mods.set_bindings(|id| chosen.keys_of(id));
self.list_mods();
self.sync_mod_commands();
self.system_message(Text::literal("Mods reloaded").with_color(text::GRAY));
@@ -571,7 +581,7 @@ impl Game<'_> {
fn mod_ui_view<'a>(mods: &'a Mods, ms: &ModScreen) -> Option<(&'a str, &'a [UiWidget])> {
if ms.settings {
let widgets = mods.settings_widgets(ms.owner);
if widgets.is_empty() {
if widgets.is_empty() && mods.keys_of(ms.owner).is_empty() {
return None;
}
let name = mods
@@ -585,11 +595,78 @@ impl Game<'_> {
Some((s.title.as_str(), s.widgets.as_slice()))
}
}
/// The keys a settings page lists first: the mod's own (`input`).
fn mod_page_keys<'a>(mods: &'a Mods, ms: &ModScreen) -> &'a [ModKey] {
if ms.settings {
mods.keys_of(ms.owner)
} else {
&[]
}
}
/// A key row's label as Key Binds shows one, "Zoom: Z", marked
/// "> Zoom: Z <" in yellow while it waits for the new key.
fn mod_key_label(k: &ModKey, waiting: bool) -> Text {
let value = format!("{}: {}", k.label, keybinds::display_name(&k.key).plain());
if waiting {
Text::literal(format!("> {value} <")).with_color(text::YELLOW)
} else {
Text::literal(value)
}
}
/// The player bound the key row that waited to `key`: the mod's key
/// follows it now and at the next start (`horizon-mods.txt`).
fn bind_mod_key(&mut self, key: String) {
let Some(ms) = self.mods.screen.as_mut() else {
return;
};
let Some(name) = ms.binding.take() else {
return;
};
let owner = ms.owner;
if !self.mods.mods.rebind(owner, &name, &key) {
return;
}
let Some(id) = self
.mods
.mods
.entries()
.get(owner)
.and_then(|e| e.manifest.as_ref())
.map(|m| m.id.clone())
else {
return;
};
self.settings
.mods
.keys
.entry(id)
.or_default()
.insert(name, key);
self.settings.save(&Self::options_path());
}
/// A key while a mod screen is up: true when a key row waited for
/// it (Key Binds: Escape binds none).
pub(super) fn mod_screen_key(&mut self, key: KeyCode) -> bool {
if self
.mods
.screen
.as_ref()
.is_none_or(|ms| ms.binding.is_none())
{
return false;
}
let value = match key {
KeyCode::Escape => None,
k => keybinds::key_name(k),
};
self.bind_mod_key(value.unwrap_or("key.keyboard.unknown").to_owned());
true
}
/// The layout the mod screen's widgets get at `h`: (widget, top,
/// height), the column being `COLUMN` wide down the middle.
fn mod_ui_layout(h: f32, widgets: &[UiWidget]) -> Vec<(f32, f32)> {
fn mod_ui_layout(h: f32, top: f32, widgets: &[UiWidget]) -> Vec<(f32, f32)> {
let mut out = Vec::with_capacity(widgets.len());
let mut y = MOD_UI_TOP;
let mut y = top;
for w in widgets {
let wh = match w {
UiWidget::Text(_) => 12.0,
@@ -613,7 +690,8 @@ impl Game<'_> {
}
if settings {
self.mods.mods.ui_settings_opened(owner);
if self.mods.mods.settings_widgets(owner).is_empty() {
let keys = self.mods.mods.keys_of(owner);
if self.mods.mods.settings_widgets(owner).is_empty() && keys.is_empty() {
self.show_mod_output();
return;
}
@@ -623,6 +701,7 @@ impl Game<'_> {
settings,
under: self.screen,
scrolls: Vec::new(),
binding: None,
});
self.screen = Screen::Mod;
if self.player.is_some() {
@@ -684,7 +763,27 @@ impl Game<'_> {
);
let cx = (batch.width / 2.0).floor();
let x = cx - MOD_UI_COLUMN / 2.0;
let layout = Self::mod_ui_layout(batch.height, widgets);
// A settings page starts with the mod's keys, as Key Binds rows.
let keys = Self::mod_page_keys(&self.mods.mods, ms);
for (i, k) in keys.iter().enumerate() {
let wy = MOD_UI_TOP + i as f32 * MOD_UI_KEY_ROW;
let hover = ui::hovered(self.cursor, x, wy, MOD_UI_COLUMN, 20.0);
let waiting = ms.binding.as_deref() == Some(k.name.as_str());
let label = Self::mod_key_label(k, waiting);
ui::button(
batch,
atlas,
x,
wy,
MOD_UI_COLUMN,
20.0,
&label,
true,
hover,
);
}
let top = MOD_UI_TOP + keys.len() as f32 * MOD_UI_KEY_ROW;
let layout = Self::mod_ui_layout(batch.height, top, widgets);
let mut list_i = 0usize;
for (w, &(wy, wh)) in widgets.iter().zip(&layout) {
match w {
@@ -804,7 +903,20 @@ impl Game<'_> {
}
/// A click on the mod screen: Done leaves it, a `list` row is the mod's
/// `ui-events::select`.
pub(super) fn mod_screen_click(&mut self) {
pub(super) fn mod_screen_click(&mut self, button: MouseButton) {
// Key Binds: a mouse button pressed while waiting binds it.
if self
.mods
.screen
.as_ref()
.is_some_and(|ms| ms.binding.is_some())
{
self.bind_mod_key(keybinds::mouse_name(button));
return;
}
if button != MouseButton::Left {
return;
}
let (w, h) = self.gui_size();
let cursor = self.cursor;
if ui::hovered(cursor, (w / 2.0).floor() - 100.0, h - 27.0, 200.0, 20.0) {
@@ -820,7 +932,22 @@ impl Game<'_> {
};
let owner = ms.owner;
let x = (w / 2.0).floor() - MOD_UI_COLUMN / 2.0;
let layout = Self::mod_ui_layout(h, widgets);
// A key row waits for the key to bind.
let keys = Self::mod_page_keys(&self.mods.mods, ms);
let picked = (0..keys.len()).find(|&i| {
let wy = MOD_UI_TOP + i as f32 * MOD_UI_KEY_ROW;
ui::hovered(cursor, x, wy, MOD_UI_COLUMN, 20.0)
});
if let Some(i) = picked {
let name = keys[i].name.clone();
self.audio.play_button_click();
if let Some(ms) = self.mods.screen.as_mut() {
ms.binding = Some(name);
}
return;
}
let top = MOD_UI_TOP + keys.len() as f32 * MOD_UI_KEY_ROW;
let layout = Self::mod_ui_layout(h, top, widgets);
let mut list_i = 0usize;
// The `ui_select` call needs `&mut`: find the pick first.
let mut hit = None;
@@ -864,7 +991,9 @@ impl Game<'_> {
return;
};
let x = (w / 2.0).floor() - MOD_UI_COLUMN / 2.0;
let layout = Self::mod_ui_layout(h, widgets);
let top =
MOD_UI_TOP + Self::mod_page_keys(&self.mods.mods, ms).len() as f32 * MOD_UI_KEY_ROW;
let layout = Self::mod_ui_layout(h, top, widgets);
let mut list_i = 0usize;
for (w, &(wy, wh)) in widgets.iter().zip(&layout) {
let UiWidget::List(l) = w else { continue };
@@ -884,3 +1013,6 @@ impl Game<'_> {
const MOD_UI_TOP: f32 = 40.0;
#[cfg(feature = "mods")]
const MOD_UI_COLUMN: f32 = 200.0;
/// A key row's height with its gap, as the widgets below it space out.
#[cfg(feature = "mods")]
const MOD_UI_KEY_ROW: f32 = 26.0;
+46 -6
View File
@@ -1,7 +1,11 @@
//! Which of the installed Horizon mods are on (`docs/mods.md`), kept in
//! `horizon-mods.txt` beside `options.txt` as `mod.<id>:true` lines. Every
//! mod is off until the player turns it on in Options > Mods.
use std::{collections::BTreeSet, fs, path::Path};
use std::{
collections::{BTreeMap, BTreeSet},
fs,
path::Path,
};
pub const FILE: &str = "horizon-mods.txt";
/// The folder holding `.hmod` files, beside `options.txt`.
@@ -11,6 +15,9 @@ pub const DIR: &str = "horizon-mods";
pub struct ModList {
/// Ids of the mods the player turned on.
pub enabled: BTreeSet<String>,
/// The keys the player chose for mods' keys, mod id → key name → key
/// (`key.<id>.<name>:key.keyboard.x` lines).
pub keys: BTreeMap<String, BTreeMap<String, String>>,
/// What the Mods page shows, filled by the game from the mods it found.
pub installed: Vec<Installed>,
}
@@ -45,20 +52,41 @@ impl ModList {
.filter_map(|(k, _)| k.trim().strip_prefix("mod."))
.map(str::to_owned)
.collect();
let mut keys: BTreeMap<String, BTreeMap<String, String>> = BTreeMap::new();
for (k, v) in text.lines().filter_map(|l| l.split_once(':')) {
// Mod ids have no dots; a key's name may.
if let Some((id, name)) = k
.trim()
.strip_prefix("key.")
.and_then(|r| r.split_once('.'))
{
let entry = keys.entry(id.to_owned()).or_default();
entry.insert(name.to_owned(), v.trim().to_owned());
}
}
Self {
enabled,
keys,
installed: Vec::new(),
}
}
pub fn serialize(&self) -> String {
self.enabled
.iter()
.map(|id| format!("mod.{id}:true\n"))
.collect()
let on = self.enabled.iter().map(|id| format!("mod.{id}:true\n"));
let keys = self.keys.iter().flat_map(|(id, keys)| {
keys.iter()
.map(move |(name, key)| format!("key.{id}.{name}:{key}\n"))
});
on.chain(keys).collect()
}
/// Mod `id`'s chosen keys, for `Mods::set_bindings`.
pub fn keys_of(&self, id: &str) -> Vec<(&str, &str)> {
self.keys.get(id).map_or_else(Vec::new, |k| {
k.iter().map(|(n, k)| (n.as_str(), k.as_str())).collect()
})
}
pub fn save(&self, dir: &Path) {
let path = dir.join(FILE);
if self.enabled.is_empty() && !path.exists() {
if self.enabled.is_empty() && self.keys.is_empty() && !path.exists() {
return;
}
let _ = fs::write(path, self.serialize());
@@ -91,6 +119,18 @@ mod tests {
assert_eq!(ModList::default().serialize(), "");
}
#[test]
fn keeps_the_keys_players_chose() {
let text = "mod.zoom:true\nkey.zoom.zoom.in:key.mouse.4\nkey.zoom.out:key.keyboard.x\n";
let list = ModList::parse(text);
assert_eq!(
list.keys_of("zoom"),
[("out", "key.keyboard.x"), ("zoom.in", "key.mouse.4")]
);
assert!(list.keys_of("other").is_empty());
assert_eq!(ModList::parse(&list.serialize()), list);
}
#[test]
fn toggles_installed_mods_but_not_dev_ones() {
let installed = |id: &str, dev| Installed {
+3 -1
View File
@@ -220,7 +220,9 @@ interface input {
claim-items: func(ids: list<string>);
/// A key of the mod's: `name` comes back in `key-down` and `key-up`,
/// `label` says what it does, `key` is the key as options.txt writes
/// it (`key.keyboard.r`). At most 16; an unknown key is an error.
/// it (`key.keyboard.r`), the default: the player can bind another
/// one on the mod's page in Options > Mods, and that one wins. At most
/// 16; an unknown key is an error.
register-key: func(name: string, label: string, key: string) -> result<_, string>;
/// The main hand's stack, as of this tick; none when it is empty.
held: func() -> option<held-item>;
+66 -4
View File
@@ -152,11 +152,13 @@ pub struct Entry {
}
impl Entry {
/// The running mod has a settings page (capability `ui` set widgets
/// with `ui.set-settings`): its row in Options > Mods gains a button.
/// with `ui.set-settings`, or `input` registered keys the player may
/// bind there): its row in Options > Mods gains a button.
pub fn has_ui_settings(&self) -> bool {
self.run
.as_ref()
.is_some_and(|r| !r.store.data().ui.settings.is_empty())
self.run.as_ref().is_some_and(|r| {
let d = r.store.data();
!d.ui.settings.is_empty() || !d.input.keys.is_empty()
})
}
}
@@ -564,6 +566,48 @@ impl Mods {
d.input.keys.iter().map(move |k| (d.name.as_str(), k))
})
}
/// Mod `i`'s keys, as it registered them (with the player's choices).
pub fn keys_of(&self, i: usize) -> &[ModKey] {
self.entries
.get(i)
.and_then(|e| e.run.as_ref())
.map_or(&[], |r| r.store.data().input.keys.as_slice())
}
/// The keys the player chose (`ui`'s key widget), by mod id: each
/// running mod's `name → key`. They replace the defaults registered
/// so far and later ones. Keys that aren't keys are skipped.
pub fn set_bindings<'a>(&mut self, chosen: impl Fn(&str) -> Vec<(&'a str, &'a str)>) {
for e in &mut self.entries {
let (Some(m), Some(run)) = (&e.manifest, e.run.as_mut()) else {
continue;
};
let input = &mut run.store.data_mut().input;
input.bindings = chosen(&m.id)
.into_iter()
.filter(|(_, key)| key_ok(key))
.map(|(n, k)| (n.to_owned(), k.to_owned()))
.collect();
for (name, key) in input.bindings.clone() {
input.rebind(&name, key);
}
}
}
/// The player binds mod `i`'s key `name` to `key` (`key.keyboard.x`,
/// `key.mouse.4`, `key.keyboard.unknown` for none). False when the mod
/// has no such key or `key` isn't one.
pub fn rebind(&mut self, i: usize, name: &str, key: &str) -> bool {
let Some(run) = self.entries.get_mut(i).and_then(|e| e.run.as_mut()) else {
return false;
};
let input = &mut run.store.data_mut().input;
if !key_ok(key) || !input.keys.iter().any(|k| k.name == name) {
return false;
}
input.bindings.retain(|(n, _)| n != name);
input.bindings.push((name.to_owned(), key.to_owned()));
input.rebind(name, key.to_owned());
true
}
/// The use button went down with `item`: `use-start` to each mod that
/// claimed it.
pub fn use_start(&mut self, item: &HeldItem) {
@@ -873,6 +917,9 @@ struct InputState {
down: Vec<String>,
/// It holds the mouse wheel.
wheel: bool,
/// The keys the player chose, `name → key`: they win over
/// `register-key`'s.
bindings: Vec<(String, String)>,
}
/// Events kept for `poll`, items claimed and keys, per mod.
const INPUT_EVENTS: usize = 256;
@@ -893,6 +940,17 @@ fn key_ok(key: &str) -> bool {
}
impl InputState {
/// Key `name` is `key` now; held, it lets go first.
fn rebind(&mut self, name: &str, key: String) {
let Some(k) = self.keys.iter_mut().find(|k| k.name == name) else {
return;
};
k.key = key;
if let Some(i) = self.down.iter().position(|n| n == name) {
let name = self.down.swap_remove(i);
self.push(InputEvent::KeyUp(name));
}
}
fn push(&mut self, event: InputEvent) {
if self.events.len() == INPUT_EVENTS {
self.events.pop_front();
@@ -912,6 +970,10 @@ impl input::Host for State {
));
}
let (name, label) = (clip(name), clip(label));
let key = match self.input.bindings.iter().find(|(n, _)| *n == name) {
Some((_, chosen)) => chosen.clone(),
None => key,
};
if let Some(k) = self.input.keys.iter_mut().find(|k| k.name == name) {
(k.label, k.key) = (label, key);
return Ok(());
+37
View File
@@ -553,6 +553,43 @@ fn input_claims_keys_and_events() {
assert_eq!(chat(&mut mods), ["key-down wheel", "key-up wheel"]);
}
#[test]
fn the_players_key_choice_wins() {
let chat = |mods: &mut Mods| -> Vec<String> {
mods.drain()
.into_iter()
.filter_map(|o| match o {
Output::Chat { text, .. } => Some(text),
_ => None,
})
.collect()
};
let mut mods = load("horizon_mods_test_ui_guest.hmod");
assert!(mods.command("input_claim"));
// Its keys alone give it a page in Options > Mods, where the game lists them.
assert!(mods.settings_widgets(0).is_empty() && mods.entries()[0].has_ui_settings());
// Rebinding while held lets the old key go.
mods.key("key.keyboard.r", true);
assert!(mods.rebind(0, "wheel", "key.mouse.4"));
assert_eq!(mods.keys_of(0)[0].key, "key.mouse.4");
mods.key("key.keyboard.r", false);
mods.key("key.mouse.4", true);
assert!(!mods.rebind(0, "wheel", "R") && !mods.rebind(0, "nope", "key.keyboard.g"));
chat(&mut mods);
assert!(mods.command("input_poll"));
assert_eq!(
chat(&mut mods),
["key-down wheel", "key-up wheel", "key-down wheel"]
);
// A choice saved earlier replaces the default, registered before or after.
let mut mods = load("horizon_mods_test_ui_guest.hmod");
mods.set_bindings(|_| vec![("wheel", "key.keyboard.g"), ("bad", "G")]);
assert!(mods.command("input_claim"));
assert_eq!(mods.keys_of(0)[0].key, "key.keyboard.g");
mods.set_bindings(|_| vec![("wheel", "key.keyboard.h")]);
assert_eq!(mods.keys_of(0)[0].key, "key.keyboard.h");
}
#[test]
fn input_wheel_is_the_mods_while_claimed() {
let mut mods = load("horizon_mods_test_ui_guest.hmod");
+1 -1
View File
@@ -278,7 +278,7 @@ A mapping as the player has bound it.
| Function | Signature | What it does |
| --- | --- | --- |
| `claim-items` | `func(ids: list<string>)` | The items whose use the mod handles instead of the game, by id (at most 64). Replaces the previous list. |
| `register-key` | `func(name: string, label: string, key: string) -> result<_, string>` | A key of the mod's: `name` comes back in `key-down` and `key-up`, `label` says what it does, `key` is the key as options.txt writes it (`key.keyboard.r`). At most 16; an unknown key is an error. |
| `register-key` | `func(name: string, label: string, key: string) -> result<_, string>` | A key of the mod's: `name` comes back in `key-down` and `key-up`, `label` says what it does, `key` is the key as options.txt writes it (`key.keyboard.r`), the default: the player can bind another one on the mod's page in Options > Mods, and that one wins. At most 16; an unknown key is an error. |
| `held` | `func() -> option<held-item>` | The main hand's stack, as of this tick; none when it is empty. |
| `poll` | `func() -> list<event>` | The events since the last call, oldest first. |
| `claim-wheel` | `func(on: bool)` | Takes the mouse wheel while playing (true) or gives it back: while taken, its steps are the mod's `wheel` events and the hotbar doesn't move. The game gives it back when the mod stops. |
+5 -4
View File
@@ -26,12 +26,13 @@ promise, how something is deprecated and removed, and the tests that keep old mo
## What counts as a breaking change
Breaking: removing or renaming anything; changing a type, a default, a unit or a meaning; making something optional
required; refusing input that was accepted; a new case in a WIT `enum` or `variant` the game sends to mods (an older
component cannot decode it); a JSON field that changes type.
required; refusing input that was accepted; any change to a WIT type a function uses, a new `enum` or `variant` case
included, in either direction (the runtime links a component only when each imported function's types match exactly:
a mod built before the change no longer loads); a JSON field that changes type.
Not breaking: a new function, interface, capability, command, endpoint, tool or `server.toml` key; a new optional
argument or JSON field (readers must ignore fields they don't know); a new WIT case the mod *sends* (the game decodes
every case it ever served); faster, or fixing behaviour to what the reference already says.
argument or JSON field (readers must ignore fields they don't know); a new WIT function or type (mods built earlier
don't import it); faster, or fixing behaviour to what the reference already says.
## Stability levels
+1 -1
View File
@@ -90,7 +90,7 @@ folder, loads no runtime and plays exactly like vanilla 26.3.
| `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 |
| `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"]` |
| `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). 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`; `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"]` |
| `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"]` |
| Hook (export) | When |
|---|---|