new input system

This commit is contained in:
Jorijn van der Graaf 2026-05-12 00:24:48 +02:00
commit ac2eb7fb0a
31 changed files with 3292 additions and 781 deletions

View file

@ -0,0 +1,241 @@
// =====================================================================
// InputSystem — guided tour of Crafter::Input
// =====================================================================
//
// Run this with a window focused. The console narrates each event as
// it fires; the on-screen window only exists so the OS routes keyboard
// and mouse input to us. Plug a gamepad in (Xbox / DualShock /
// Switch Pro) and it will be picked up live.
//
// What this demo demonstrates, in order:
//
// 1. Three runtime layers stacked end-to-end:
// - Crafter::Key(CrafterKeys::X) compile-time abstract → raw
// platform KeyCode
// - Window::onRawKeyDown raw keyboard channel for UI /
// text input (NOT used for game
// input — actions handle that)
// - Crafter::Input::Map named actions → bindings →
// events
// - Crafter::Gamepad hot-plug, button + axis state,
// rumble
//
// 2. All four action types: Button, Axis1D, Vector2, with multiple
// bindings per action (any-of semantics — press Space OR press
// gamepad-A; both trigger Jump).
//
// 3. Composite bindings: WASDBind (4 keys → Vector2),
// GamepadStickBind (analog stick → Vector2), MouseDeltaBind
// (mouse movement → Vector2).
//
// 4. Runtime rebinding: press R to enter rebind mode; the next key /
// mouse button / gamepad button you press becomes the new Jump
// binding. The captured input does NOT fire Jump that frame.
//
// 5. Serialization: bindings are round-tripped to text strings at
// startup. Use these as the on-disk format for your settings file.
//
// 6. Hot-plug: connect / disconnect a gamepad while running. The
// Map's polling-based evaluation seamlessly switches over —
// bindings to a vanished gamepad become inert until reconnect.
//
// Controls:
// Space / Gamepad A → Jump
// WASD / Left stick → Move
// Mouse-Left / Right Trigger → Fire
// Mouse delta / Right stick → Look
// Mouse wheel → Zoom (Axis1D)
// R → Start rebinding "Jump" — press
// any key/button next to assign it
// Esc → Quit
//
// Output is intentionally chatty so you can SEE the system working.
// In a real game, replace the print statements with your gameplay
// code — that's it.
// =====================================================================
import Crafter.Graphics;
import Crafter.Event; // EventListener<>: Crafter.Graphics imports
import Crafter.Math; // Vector<float,2>: it transitively, but
// C++20 module-reachability rules require
// us to name them explicitly to instantiate
// the templates in this TU.
import std;
using namespace Crafter;
using namespace Crafter::Input;
int main() {
Device::Initialize();
Window window(640, 360, "Crafter Input — give the window focus");
// =================================================================
// Build the action map.
//
// AddAction returns a reference; we keep it so we can subscribe.
// The map owns the actions (vector<unique_ptr>); references stay
// valid for the map's lifetime.
// =================================================================
Map map;
Action& jump = map.AddAction("Jump", ActionType::Button);
Action& move = map.AddAction("Move", ActionType::Vector2);
Action& fire = map.AddAction("Fire", ActionType::Button);
Action& look = map.AddAction("Look", ActionType::Vector2);
Action& zoom = map.AddAction("Zoom", ActionType::Axis1D);
Action& reset = map.AddAction("Reset", ActionType::Button);
Action& quit = map.AddAction("Quit", ActionType::Button);
// =================================================================
// Default bindings.
//
// Key(CrafterKeys::X) is `consteval` — these literals fold to
// per-platform scancodes at compile time, no runtime translation.
// Multiple bindings per action are OR'd: pressing EITHER triggers
// the action.
//
// gamepadId == 0 is a sentinel here meaning "first connected
// gamepad". For multi-player you'd save the stable id from a
// specific Device and use that.
// =================================================================
jump.bindings = {
KeyBind{ Key(CrafterKeys::Space) },
GamepadButtonBind{ 0, Gamepad::Button::South },
};
move.bindings = {
WASDBind{
Key(CrafterKeys::W), Key(CrafterKeys::S),
Key(CrafterKeys::A), Key(CrafterKeys::D),
},
GamepadStickBind{ 0, Gamepad::Stick::Left },
};
fire.bindings = {
MouseButtonBind{ 0 }, // left click
GamepadAxisBind{ 0, Gamepad::Axis::RightTrigger, false }, // analog R2
};
look.bindings = {
MouseDeltaBind{ 0.01f }, // scale down
GamepadStickBind{ 0, Gamepad::Stick::Right },
};
zoom.bindings = {
MouseScrollBind{},
};
reset.bindings = { KeyBind{ Key(CrafterKeys::R) } };
quit.bindings = { KeyBind{ Key(CrafterKeys::Escape) } };
// =================================================================
// Serialization round-trip — call sites can save these strings to
// disk and load them back into Action::bindings later.
//
// IMPORTANT: KeyCode values are platform-specific (Win32 scancodes
// vs. Wayland kernel keycodes). A saved file from one platform
// does NOT load on the other. Store your config under platform-
// specific paths.
// =================================================================
std::println("─── Default bindings (serialized form) ───");
for (auto& a : map.actions) {
for (auto& b : a->bindings) {
std::string s = BindingToString(b);
// Parse it back to prove the format roundtrips.
auto parsed = BindingFromString(s);
std::println(" {:<6} {} (roundtrip OK: {})",
a->name, s, parsed.has_value());
}
}
std::println("");
// =================================================================
// Subscribe to action events.
//
// EventListeners use the same RAII pattern as the rest of the
// library — declare them in scope, they unsubscribe when they go
// out of scope. Capturing references to the Actions is safe
// because the Map (and therefore its actions) outlive these
// listeners (both are in main()'s scope).
// =================================================================
EventListener<void> jumpPerf(&jump.onPerformed,
[] { std::println("[Jump] performed"); });
EventListener<void> jumpCanc(&jump.onCanceled,
[] { std::println("[Jump] canceled"); });
EventListener<Vector<float,2>> moveChanged(&move.onVector2Changed,
[](Vector<float,2> v) {
std::println("[Move] ({:+.2f}, {:+.2f})", v.x, v.y);
});
EventListener<void> firePerf(&fire.onPerformed,
[] { std::println("[Fire] performed"); });
EventListener<void> fireCanc(&fire.onCanceled,
[] { std::println("[Fire] canceled"); });
EventListener<Vector<float,2>> lookChanged(&look.onVector2Changed,
[](Vector<float,2> v) {
// Mouse delta fires every frame the mouse moves; throttle
// by checking magnitude so the console doesn't drown.
if (std::abs(v.x) + std::abs(v.y) > 0.01f) {
std::println("[Look] ({:+.3f}, {:+.3f})", v.x, v.y);
}
});
EventListener<float> zoomChanged(&zoom.onValueChanged,
[](float v) { std::println("[Zoom] delta {:+.0f}", v); });
EventListener<void> quitPerf(&quit.onPerformed,
[&] {
std::println("[Quit] shutting down");
window.open = false;
});
// =================================================================
// Rebinding flow.
//
// Pressing R fires Reset → we call StartRebind for the Jump
// action. While rebinding, the next qualifying input does NOT
// fire Jump (or any other action that uses the same input). It
// routes to the onCaptured callback exactly once.
//
// CaptureMask::Any accepts keyboard | mouse | gamepad. Narrow it
// (Keyboard, Gamepad, etc.) to restrict what counts as a valid
// binding for a given action.
// =================================================================
EventListener<void> resetPerf(&reset.onPerformed, [&] {
std::println("[Rebind] Press any key / button to bind \"Jump\" "
"(Esc to cancel)…");
map.StartRebind(jump, CaptureMask::Any, [&](Binding captured) {
jump.bindings.clear();
jump.bindings.push_back(captured);
std::println("[Rebind] Jump → {}", BindingToString(captured));
});
});
// =================================================================
// Gamepad lifecycle observers.
//
// Gamepad::Tick() is called automatically inside Window::StartSync
// every frame, so these events fire on the main thread alongside
// every other event. Hot-plug a controller while running and you
// should see the connect line below.
// =================================================================
EventListener<Gamepad::Device*> gpAdded(&Gamepad::onConnected,
[](Gamepad::Device* d) {
std::println("[Gamepad] connected: id={} name=\"{}\"",
d->id, d->name);
});
EventListener<Gamepad::Device*> gpRemoved(&Gamepad::onDisconnected,
[](Gamepad::Device* d) {
std::println("[Gamepad] disconnected: id={}", d->id);
});
// =================================================================
// Connect the map to the window. Map polls window state + gamepad
// state every Tick; we trigger Tick from onBeforeUpdate so it
// runs after the OS event pump (and Gamepad::Tick) for that frame.
// =================================================================
map.Attach(window);
EventListener<void> tick(&window.onBeforeUpdate, [&] { map.Tick(); });
std::println("─── Ready. Focus the window and try the controls. ───");
window.Render();
window.StartSync();
std::println("Done.");
return 0;
}