implement key binding system

This commit is contained in:
Crispy 2025-03-30 03:14:45 +02:00
parent 57512a4c6b
commit 70fd50d3bc
7 changed files with 410 additions and 57 deletions

View file

@ -3,6 +3,7 @@ Game store page: https://crispypin.itch.io/marble-machinations
## [Unreleased]
### added
- configurable hotkeys (via file only, only some actions)
- OS clipboard copy/paste, with fallback to old behavior when copying
- in-grid text comments (not yet editable in-game)
- changelog file

View file

@ -6,8 +6,11 @@ logic mostly like https://git.crispypin.cc/CrispyPin/marble
### meta
- itch page text
- engine tests
- blag post about marble movement logic
### game
- comments
- editing
- add to all intro levels
- highlight regions with background colours
- accessibility
- ui scaling

View file

@ -1,26 +0,0 @@
use std::collections::HashMap;
use raylib::ffi::KeyboardKey;
use serde::{Deserialize, Serialize};
#[derive(Debug, Default, Deserialize, Serialize)]
pub struct Config {
hotkeys: Hotkeys,
}
#[derive(Debug, Default, Deserialize, Serialize)]
pub struct Hotkeys {
map: HashMap<String, Hotkey>,
}
#[derive(Debug, Deserialize, Serialize)]
pub struct Hotkey {
modifiers: Vec<u32>,
trigger: Trigger,
}
#[derive(Debug, Deserialize, Serialize)]
pub enum Trigger {
Mouse(u32),
Key(u32),
}

View file

@ -5,18 +5,19 @@ use std::{
time::Instant,
};
use arboard::Clipboard;
use raylib::prelude::*;
use crate::{
blueprint::Blueprint,
board::Board,
input::ActionId,
level::Level,
marble_engine::{grid::*, pos::*, tile::*, Machine},
solution::*,
theme::*,
ui::*,
util::*,
Globals,
};
const HEADER_HEIGHT: i32 = 40;
@ -450,7 +451,7 @@ impl Editor {
self.push_action(Action::SetTile(resize, pos, old_tile, tile));
}
pub fn update(&mut self, rl: &RaylibHandle, clipboard: Option<&mut Clipboard>) {
pub fn update(&mut self, rl: &RaylibHandle, globals: &mut Globals) {
self.tooltip.init_frame(rl);
self.mouse = MouseInput::get(rl);
if self.popup != Popup::None {
@ -495,7 +496,7 @@ impl Editor {
self.step_time = avg_step_time;
self.max_step_time = avg_step_time.max(self.max_step_time);
}
if rl.is_key_pressed(KeyboardKey::KEY_SPACE) {
if globals.input.is_pressed(ActionId::StepSim) {
self.step_pressed()
}
if rl.is_key_pressed(KeyboardKey::KEY_ENTER) {
@ -550,20 +551,20 @@ impl Editor {
}
if self.sim_state == SimState::Editing {
if rl.is_key_down(KeyboardKey::KEY_LEFT_CONTROL) {
if let Some(clipboard) = clipboard {
if rl.is_key_pressed(KeyboardKey::KEY_V) {
if let Ok(text) = clipboard.get_text() {
let b = Board::from_user_str(&text);
self.pasting_board = Some(b);
}
if let Some(clipboard) = &mut globals.clipboard {
if rl.is_key_down(KeyboardKey::KEY_LEFT_CONTROL)
&& rl.is_key_pressed(KeyboardKey::KEY_V)
{
if let Ok(text) = clipboard.get_text() {
let b = Board::from_user_str(&text);
self.pasting_board = Some(b);
}
}
if rl.is_key_pressed(KeyboardKey::KEY_Z) {
self.undo();
} else if rl.is_key_pressed(KeyboardKey::KEY_Y) {
self.redo();
}
}
if globals.input.is_pressed(ActionId::Undo) {
self.undo();
} else if globals.input.is_pressed(ActionId::Redo) {
self.redo();
}
}
}
@ -597,12 +598,7 @@ impl Editor {
}
}
pub fn draw(
&mut self,
d: &mut RaylibDrawHandle,
textures: &Textures,
clipboard: Option<&mut Clipboard>,
) {
pub fn draw(&mut self, d: &mut RaylibDrawHandle, textures: &Textures, globals: &mut Globals) {
d.clear_background(BG_WORLD);
if self.draw_overlay && self.zoom >= 0.5 {
@ -621,7 +617,7 @@ impl Editor {
self.draw_board(d, textures);
self.board_overlay(d, textures);
self.draw_bottom_bar(d, textures, clipboard);
self.draw_bottom_bar(d, textures, globals);
self.draw_top_bar(d, textures);
if self.active_tool == Tool::Blueprint {
@ -999,7 +995,7 @@ impl Editor {
&mut self,
d: &mut RaylibDrawHandle,
textures: &Textures,
clipboard: Option<&mut Clipboard>,
globals: &mut Globals,
) {
let height = d.get_screen_height();
let footer_top = (height - FOOTER_HEIGHT) as f32;
@ -1050,7 +1046,7 @@ impl Editor {
&& d.is_key_down(KeyboardKey::KEY_LEFT_CONTROL))
{
let board = self.get_selected_as_board(selection);
if let Some(clipboard) = clipboard {
if let Some(clipboard) = &mut globals.clipboard {
clipboard
.set_text(serde_json::to_string(&board).unwrap())
.unwrap();

323
src/input.rs Normal file
View file

@ -0,0 +1,323 @@
use std::{collections::HashMap, mem::transmute};
use raylib::{
ffi::{KeyboardKey, MouseButton},
RaylibHandle,
};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, PartialOrd, Ord, Hash)]
#[repr(u8)]
pub enum ActionId {
StepSim,
Undo,
Redo,
// just like in C, because this way doesn't need more depenedencies
_ActionIdSize,
}
impl Default for Input {
fn default() -> Self {
use KeyboardKey::*;
let mut bindings = [(); ActionId::SIZE].map(|_| Vec::new());
let mut bind_key = |action, mods, key| {
bindings[action as usize] = vec![Binding {
modifiers: mods,
trigger: InputTrigger::Key(key),
}];
};
bind_key(ActionId::StepSim, vec![], KEY_SPACE);
bind_key(ActionId::Undo, vec![KEY_LEFT_CONTROL], KEY_Z);
bind_key(ActionId::Redo, vec![KEY_LEFT_CONTROL], KEY_Y);
Self {
bindings,
states: Default::default(),
}
}
}
#[derive(Clone, Copy, Debug, Default, PartialEq)]
enum BindingState {
#[default]
Off,
Pressed,
Held,
Released,
}
type InputMap = HashMap<ActionId, Vec<Binding>>;
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(from = "InputMap", into = "InputMap")]
pub struct Input {
bindings: [Vec<Binding>; ActionId::SIZE],
states: [BindingState; ActionId::SIZE],
}
impl Input {
pub fn update(&mut self, rl: &RaylibHandle) {
for i in 0..ActionId::SIZE {
let bindings = &self.bindings[i];
let mut is_active = false;
for binding in bindings {
if binding.modifiers.iter().all(|&m| rl.is_key_down(m)) {
is_active |= match binding.trigger {
InputTrigger::Mouse(btn) => rl.is_mouse_button_down(btn),
InputTrigger::Key(key) => rl.is_key_down(key),
}
}
}
let state = &mut self.states[i];
*state = if is_active {
match state {
BindingState::Off | BindingState::Released => BindingState::Pressed,
BindingState::Pressed | BindingState::Held => BindingState::Held,
}
} else {
match state {
BindingState::Off | BindingState::Released => BindingState::Off,
BindingState::Pressed | BindingState::Held => BindingState::Released,
}
}
}
}
pub fn is_pressed(&self, action: ActionId) -> bool {
self.states[action as usize] == BindingState::Pressed
}
pub fn is_held(&self, action: ActionId) -> bool {
self.states[action as usize] == BindingState::Pressed
|| self.states[action as usize] == BindingState::Held
}
pub fn is_released(&self, action: ActionId) -> bool {
self.states[action as usize] == BindingState::Released
}
}
impl ActionId {
pub const SIZE: usize = Self::_ActionIdSize as usize;
fn from_usize(val: usize) -> Option<Self> {
if val < Self::SIZE {
Some(unsafe { transmute::<u8, ActionId>(val as u8) })
} else {
None
}
}
}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(from = "BindingSerde", into = "BindingSerde")]
pub struct Binding {
modifiers: Vec<KeyboardKey>,
trigger: InputTrigger,
}
#[derive(Clone, Debug)]
pub enum InputTrigger {
Mouse(MouseButton),
Key(KeyboardKey),
}
// ###### everything below is for serialization of key bindings ######
impl From<InputMap> for Input {
fn from(value: InputMap) -> Self {
let mut new = Self::default();
for (action, saved_bindings) in value {
new.bindings[action as usize] = saved_bindings;
}
new
}
}
impl From<Input> for InputMap {
fn from(value: Input) -> Self {
value
.bindings
.iter()
.enumerate()
// for this to panic, the .bindings array would have to be larger than ActionId::SIZE
.map(|(i, b)| (ActionId::from_usize(i).unwrap(), b.clone()))
.collect()
}
}
#[derive(Clone, Debug, Serialize, Deserialize)]
struct BindingSerde {
modifiers: Vec<String>,
trigger: InputTriggerSerde,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
enum InputTriggerSerde {
Mouse(String),
Key(String),
}
impl From<BindingSerde> for Binding {
fn from(value: BindingSerde) -> Self {
Self {
modifiers: value
.modifiers
.iter()
.map(|s| key_string_to_enum(s))
.collect(),
trigger: value.trigger.into(),
}
}
}
impl From<Binding> for BindingSerde {
fn from(value: Binding) -> Self {
Self {
modifiers: value.modifiers.iter().map(|c| format!("{c:?}")).collect(),
trigger: value.trigger.into(),
}
}
}
impl From<InputTrigger> for InputTriggerSerde {
fn from(value: InputTrigger) -> Self {
match value {
InputTrigger::Mouse(btn) => InputTriggerSerde::Mouse(format!("{btn:?}")),
InputTrigger::Key(key) => InputTriggerSerde::Key(format!("{key:?}")),
}
}
}
impl From<InputTriggerSerde> for InputTrigger {
fn from(value: InputTriggerSerde) -> Self {
match value {
InputTriggerSerde::Mouse(btn) => InputTrigger::Mouse(match btn.as_str() {
"MOUSE_BUTTON_LEFT" => MouseButton::MOUSE_BUTTON_LEFT,
"MOUSE_BUTTON_RIGHT" => MouseButton::MOUSE_BUTTON_RIGHT,
"MOUSE_BUTTON_MIDDLE" => MouseButton::MOUSE_BUTTON_MIDDLE,
"MOUSE_BUTTON_SIDE" => MouseButton::MOUSE_BUTTON_SIDE,
"MOUSE_BUTTON_EXTRA" => MouseButton::MOUSE_BUTTON_EXTRA,
"MOUSE_BUTTON_FORWARD" => MouseButton::MOUSE_BUTTON_FORWARD,
"MOUSE_BUTTON_BACK" => MouseButton::MOUSE_BUTTON_BACK,
_ => panic!("{btn} not a valid mouse button"),
}),
InputTriggerSerde::Key(key) => InputTrigger::Key(key_string_to_enum(key.as_str())),
}
}
}
fn key_string_to_enum(key: &str) -> KeyboardKey {
match key {
"KEY_NULL" => KeyboardKey::KEY_NULL,
"KEY_APOSTROPHE" => KeyboardKey::KEY_APOSTROPHE,
"KEY_COMMA" => KeyboardKey::KEY_COMMA,
"KEY_MINUS" => KeyboardKey::KEY_MINUS,
"KEY_PERIOD" => KeyboardKey::KEY_PERIOD,
"KEY_SLASH" => KeyboardKey::KEY_SLASH,
"KEY_ZERO" => KeyboardKey::KEY_ZERO,
"KEY_ONE" => KeyboardKey::KEY_ONE,
"KEY_TWO" => KeyboardKey::KEY_TWO,
"KEY_THREE" => KeyboardKey::KEY_THREE,
"KEY_FOUR" => KeyboardKey::KEY_FOUR,
"KEY_FIVE" => KeyboardKey::KEY_FIVE,
"KEY_SIX" => KeyboardKey::KEY_SIX,
"KEY_SEVEN" => KeyboardKey::KEY_SEVEN,
"KEY_EIGHT" => KeyboardKey::KEY_EIGHT,
"KEY_NINE" => KeyboardKey::KEY_NINE,
"KEY_SEMICOLON" => KeyboardKey::KEY_SEMICOLON,
"KEY_EQUAL" => KeyboardKey::KEY_EQUAL,
"KEY_A" => KeyboardKey::KEY_A,
"KEY_B" => KeyboardKey::KEY_B,
"KEY_C" => KeyboardKey::KEY_C,
"KEY_D" => KeyboardKey::KEY_D,
"KEY_E" => KeyboardKey::KEY_E,
"KEY_F" => KeyboardKey::KEY_F,
"KEY_G" => KeyboardKey::KEY_G,
"KEY_H" => KeyboardKey::KEY_H,
"KEY_I" => KeyboardKey::KEY_I,
"KEY_J" => KeyboardKey::KEY_J,
"KEY_K" => KeyboardKey::KEY_K,
"KEY_L" => KeyboardKey::KEY_L,
"KEY_M" => KeyboardKey::KEY_M,
"KEY_N" => KeyboardKey::KEY_N,
"KEY_O" => KeyboardKey::KEY_O,
"KEY_P" => KeyboardKey::KEY_P,
"KEY_Q" => KeyboardKey::KEY_Q,
"KEY_R" => KeyboardKey::KEY_R,
"KEY_S" => KeyboardKey::KEY_S,
"KEY_T" => KeyboardKey::KEY_T,
"KEY_U" => KeyboardKey::KEY_U,
"KEY_V" => KeyboardKey::KEY_V,
"KEY_W" => KeyboardKey::KEY_W,
"KEY_X" => KeyboardKey::KEY_X,
"KEY_Y" => KeyboardKey::KEY_Y,
"KEY_Z" => KeyboardKey::KEY_Z,
"KEY_LEFT_BRACKET" => KeyboardKey::KEY_LEFT_BRACKET,
"KEY_BACKSLASH" => KeyboardKey::KEY_BACKSLASH,
"KEY_RIGHT_BRACKET" => KeyboardKey::KEY_RIGHT_BRACKET,
"KEY_GRAVE" => KeyboardKey::KEY_GRAVE,
"KEY_SPACE" => KeyboardKey::KEY_SPACE,
"KEY_ESCAPE" => KeyboardKey::KEY_ESCAPE,
"KEY_ENTER" => KeyboardKey::KEY_ENTER,
"KEY_TAB" => KeyboardKey::KEY_TAB,
"KEY_BACKSPACE" => KeyboardKey::KEY_BACKSPACE,
"KEY_INSERT" => KeyboardKey::KEY_INSERT,
"KEY_DELETE" => KeyboardKey::KEY_DELETE,
"KEY_RIGHT" => KeyboardKey::KEY_RIGHT,
"KEY_LEFT" => KeyboardKey::KEY_LEFT,
"KEY_DOWN" => KeyboardKey::KEY_DOWN,
"KEY_UP" => KeyboardKey::KEY_UP,
"KEY_PAGE_UP" => KeyboardKey::KEY_PAGE_UP,
"KEY_PAGE_DOWN" => KeyboardKey::KEY_PAGE_DOWN,
"KEY_HOME" => KeyboardKey::KEY_HOME,
"KEY_END" => KeyboardKey::KEY_END,
"KEY_CAPS_LOCK" => KeyboardKey::KEY_CAPS_LOCK,
"KEY_SCROLL_LOCK" => KeyboardKey::KEY_SCROLL_LOCK,
"KEY_NUM_LOCK" => KeyboardKey::KEY_NUM_LOCK,
"KEY_PRINT_SCREEN" => KeyboardKey::KEY_PRINT_SCREEN,
"KEY_PAUSE" => KeyboardKey::KEY_PAUSE,
"KEY_F1" => KeyboardKey::KEY_F1,
"KEY_F2" => KeyboardKey::KEY_F2,
"KEY_F3" => KeyboardKey::KEY_F3,
"KEY_F4" => KeyboardKey::KEY_F4,
"KEY_F5" => KeyboardKey::KEY_F5,
"KEY_F6" => KeyboardKey::KEY_F6,
"KEY_F7" => KeyboardKey::KEY_F7,
"KEY_F8" => KeyboardKey::KEY_F8,
"KEY_F9" => KeyboardKey::KEY_F9,
"KEY_F10" => KeyboardKey::KEY_F10,
"KEY_F11" => KeyboardKey::KEY_F11,
"KEY_F12" => KeyboardKey::KEY_F12,
"KEY_LEFT_SHIFT" => KeyboardKey::KEY_LEFT_SHIFT,
"KEY_LEFT_CONTROL" => KeyboardKey::KEY_LEFT_CONTROL,
"KEY_LEFT_ALT" => KeyboardKey::KEY_LEFT_ALT,
"KEY_LEFT_SUPER" => KeyboardKey::KEY_LEFT_SUPER,
"KEY_RIGHT_SHIFT" => KeyboardKey::KEY_RIGHT_SHIFT,
"KEY_RIGHT_CONTROL" => KeyboardKey::KEY_RIGHT_CONTROL,
"KEY_RIGHT_ALT" => KeyboardKey::KEY_RIGHT_ALT,
"KEY_RIGHT_SUPER" => KeyboardKey::KEY_RIGHT_SUPER,
"KEY_KB_MENU" => KeyboardKey::KEY_KB_MENU,
"KEY_KP_0" => KeyboardKey::KEY_KP_0,
"KEY_KP_1" => KeyboardKey::KEY_KP_1,
"KEY_KP_2" => KeyboardKey::KEY_KP_2,
"KEY_KP_3" => KeyboardKey::KEY_KP_3,
"KEY_KP_4" => KeyboardKey::KEY_KP_4,
"KEY_KP_5" => KeyboardKey::KEY_KP_5,
"KEY_KP_6" => KeyboardKey::KEY_KP_6,
"KEY_KP_7" => KeyboardKey::KEY_KP_7,
"KEY_KP_8" => KeyboardKey::KEY_KP_8,
"KEY_KP_9" => KeyboardKey::KEY_KP_9,
"KEY_KP_DECIMAL" => KeyboardKey::KEY_KP_DECIMAL,
"KEY_KP_DIVIDE" => KeyboardKey::KEY_KP_DIVIDE,
"KEY_KP_MULTIPLY" => KeyboardKey::KEY_KP_MULTIPLY,
"KEY_KP_SUBTRACT" => KeyboardKey::KEY_KP_SUBTRACT,
"KEY_KP_ADD" => KeyboardKey::KEY_KP_ADD,
"KEY_KP_ENTER" => KeyboardKey::KEY_KP_ENTER,
"KEY_KP_EQUAL" => KeyboardKey::KEY_KP_EQUAL,
"KEY_BACK" => KeyboardKey::KEY_BACK,
"KEY_VOLUME_UP" => KeyboardKey::KEY_VOLUME_UP,
"KEY_VOLUME_DOWN" => KeyboardKey::KEY_VOLUME_DOWN,
_ => panic!("{key} not a known key name"),
}
}

View file

@ -1,9 +1,18 @@
pub mod blueprint;
pub mod board;
pub mod editor;
pub mod input;
pub mod level;
pub mod marble_engine;
pub mod solution;
pub mod theme;
pub mod ui;
pub mod util;
use arboard::Clipboard;
use input::Input;
pub struct Globals {
pub clipboard: Option<Clipboard>,
pub input: Input,
}

View file

@ -1,6 +1,7 @@
use std::{
collections::HashMap,
fs::{read_dir, read_to_string},
fs::{self, read_dir, read_to_string, File},
io::Write,
};
use arboard::Clipboard;
@ -16,6 +17,7 @@ use ui::{simple_option_button, tex32_button, text_button, text_input, ShapedText
use util::*;
const TITLE_TEXT: &str = concat!("Marble Machinations v", env!("CARGO_PKG_VERSION"));
const CONFIG_FILE_NAME: &str = "config.json";
struct Game {
levels: Vec<LevelListEntry>,
@ -28,7 +30,8 @@ struct Game {
delete_solution: Option<usize>,
editing_solution_name: bool,
level_desc_text: ShapedText,
clipboard: Option<Clipboard>,
globals: Globals,
show_settings: bool,
}
#[derive(Debug)]
@ -58,6 +61,14 @@ impl Game {
let levels = get_levels();
let solutions = get_solutions();
let config_path = userdata_dir().join(CONFIG_FILE_NAME);
let input = fs::read_to_string(config_path)
.ok()
.and_then(|s| {
println!("a");
serde_json::from_str(&s).unwrap()
})
.unwrap_or_default();
Self {
levels,
@ -70,18 +81,23 @@ impl Game {
delete_solution: None,
editing_solution_name: false,
level_desc_text: ShapedText::new(20),
clipboard: Clipboard::new()
.map_err(|e| eprintln!("System clipboard error: {e}"))
.ok(),
globals: Globals {
clipboard: Clipboard::new()
.map_err(|e| eprintln!("System clipboard error: {e}"))
.ok(),
input,
},
show_settings: false,
}
}
fn run(&mut self, rl: &mut RaylibHandle, thread: &RaylibThread) {
while !rl.window_should_close() {
let mut d = rl.begin_drawing(thread);
self.globals.input.update(&d);
if let Some(editor) = &mut self.open_editor {
editor.update(&d, self.clipboard.as_mut());
editor.draw(&mut d, &self.textures, self.clipboard.as_mut());
editor.update(&d, &mut self.globals);
editor.draw(&mut d, &self.textures, &mut self.globals);
match editor.get_exit_state() {
ExitState::Dont => (),
ExitState::ExitAndSave => {
@ -100,6 +116,8 @@ impl Game {
solution.save();
}
}
} else if self.show_settings {
self.draw_settings(&mut d);
} else {
self.draw(&mut d);
}
@ -124,6 +142,16 @@ impl Game {
let screen_height = d.get_screen_height();
d.draw_rectangle(0, 0, level_list_width, screen_height, BG_MEDIUM);
let mouse = MouseInput::get(d);
if text_button(
d,
&mouse,
d.get_screen_width() - 50,
d.get_screen_height() - 40,
40,
"settings",
) {
self.show_settings = true;
}
const ENTRY_SPACING: i32 = 65;
let fit_on_screen = (d.get_screen_height() / ENTRY_SPACING) as usize;
@ -313,6 +341,25 @@ impl Game {
}
tooltip.draw(d);
}
fn draw_settings(&mut self, d: &mut RaylibDrawHandle) {
d.clear_background(BG_DARK);
let mouse = MouseInput::get(d);
if text_button(d, &mouse, 5, 5, 50, "return") {
self.show_settings = false;
}
if text_button(d, &mouse, 5, 45, 50, "save") {
self.save_config();
}
}
fn save_config(&self) {
let path = userdata_dir().join(CONFIG_FILE_NAME);
// todo save more than just input
let json = serde_json::to_string_pretty(&self.globals.input).unwrap();
let mut f = File::create(path).unwrap();
f.write_all(json.as_bytes()).unwrap();
}
}
fn get_levels() -> Vec<LevelListEntry> {