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:
@@ -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),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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(());
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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. |
|
||||
|
||||
@@ -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
@@ -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 |
|
||||
|---|---|
|
||||
|
||||
Reference in New Issue
Block a user