new input system
This commit is contained in:
parent
b3db40ebec
commit
ac2eb7fb0a
31 changed files with 3292 additions and 781 deletions
454
implementations/Crafter.Graphics-Input.cpp
Normal file
454
implementations/Crafter.Graphics-Input.cpp
Normal file
|
|
@ -0,0 +1,454 @@
|
|||
/*
|
||||
Crafter®.Graphics
|
||||
Copyright (C) 2026 Catcrafts®
|
||||
Catcrafts.net
|
||||
|
||||
This library is free software; you can redistribute it and/or
|
||||
modify it under the terms of the GNU Lesser General Public
|
||||
License as published by the Free Software Foundation; either
|
||||
version 3.0 of the License, or (at your option) any later version.
|
||||
|
||||
This library is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
|
||||
Lesser General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Lesser General Public
|
||||
License along with this library; if not, write to the Free Software
|
||||
Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
|
||||
*/
|
||||
module;
|
||||
module Crafter.Graphics;
|
||||
import std;
|
||||
import Crafter.Math;
|
||||
import Crafter.Event;
|
||||
|
||||
using namespace Crafter;
|
||||
using namespace Crafter::Input;
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
// Helpers
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
namespace {
|
||||
// Look up a connected gamepad by stable id. Returns nullptr if not
|
||||
// currently connected — the binding becomes inert until reconnect.
|
||||
Gamepad::Device* FindGamepad(std::uint32_t id) {
|
||||
return Gamepad::FindById(id);
|
||||
}
|
||||
|
||||
float ApplyDeadzone(float v, float dz) {
|
||||
float a = std::abs(v);
|
||||
if (a <= dz) return 0.0f;
|
||||
float sign = v < 0 ? -1.0f : 1.0f;
|
||||
return sign * (a - dz) / (1.0f - dz);
|
||||
}
|
||||
|
||||
Vector<float, 2> ApplyRadialDeadzone(Vector<float, 2> v, float dz) {
|
||||
float mag = std::sqrt(v.x * v.x + v.y * v.y);
|
||||
if (mag <= dz) return Vector<float, 2>{0.0f, 0.0f};
|
||||
float scaled = (mag - dz) / (1.0f - dz);
|
||||
float k = scaled / mag;
|
||||
return Vector<float, 2>{ v.x * k, v.y * k };
|
||||
}
|
||||
|
||||
// ─── Evaluate one binding to its raw contribution. ───────────────
|
||||
// Buttons: bool. Axis: float. Vector2: pair.
|
||||
struct Eval {
|
||||
bool button = false;
|
||||
float value = 0.0f;
|
||||
Vector<float, 2> vec { 0.0f, 0.0f };
|
||||
};
|
||||
|
||||
Eval EvalBinding(const Binding& b, Window* w, std::int32_t scrollAccum,
|
||||
Vector<float, 2> mouseDelta, float deadzone)
|
||||
{
|
||||
Eval r;
|
||||
std::visit([&](auto const& bb) {
|
||||
using T = std::decay_t<decltype(bb)>;
|
||||
if constexpr (std::is_same_v<T, KeyBind>) {
|
||||
if (w && w->heldKeys.contains(bb.code)) r.button = true;
|
||||
r.value = r.button ? 1.0f : 0.0f;
|
||||
} else if constexpr (std::is_same_v<T, MouseButtonBind>) {
|
||||
if (w) {
|
||||
if (bb.button == 0 && w->mouseLeftHeld) r.button = true;
|
||||
if (bb.button == 1 && w->mouseRightHeld) r.button = true;
|
||||
}
|
||||
r.value = r.button ? 1.0f : 0.0f;
|
||||
} else if constexpr (std::is_same_v<T, MouseScrollBind>) {
|
||||
r.value = (float)scrollAccum;
|
||||
r.button = scrollAccum != 0;
|
||||
} else if constexpr (std::is_same_v<T, MouseDeltaBind>) {
|
||||
r.vec = Vector<float, 2>{
|
||||
mouseDelta.x * bb.scale,
|
||||
mouseDelta.y * bb.scale
|
||||
};
|
||||
} else if constexpr (std::is_same_v<T, GamepadButtonBind>) {
|
||||
auto* gp = FindGamepad(bb.gamepadId);
|
||||
if (gp) r.button = gp->buttons[(std::size_t)bb.button];
|
||||
r.value = r.button ? 1.0f : 0.0f;
|
||||
} else if constexpr (std::is_same_v<T, GamepadAxisBind>) {
|
||||
auto* gp = FindGamepad(bb.gamepadId);
|
||||
if (gp) {
|
||||
float v = gp->axes[(std::size_t)bb.axis];
|
||||
if (bb.invert) v = -v;
|
||||
// Triggers (0..1) and sticks (-1..1) share this path;
|
||||
// deadzone applies symmetrically.
|
||||
r.value = ApplyDeadzone(v, deadzone);
|
||||
r.button = std::abs(r.value) > 0.0f;
|
||||
}
|
||||
} else if constexpr (std::is_same_v<T, GamepadStickBind>) {
|
||||
auto* gp = FindGamepad(bb.gamepadId);
|
||||
if (gp) {
|
||||
using A = Gamepad::Axis;
|
||||
A xAxis = (bb.stick == Gamepad::Stick::Left) ? A::LeftStickX : A::RightStickX;
|
||||
A yAxis = (bb.stick == Gamepad::Stick::Left) ? A::LeftStickY : A::RightStickY;
|
||||
Vector<float, 2> raw{
|
||||
gp->axes[(std::size_t)xAxis],
|
||||
gp->axes[(std::size_t)yAxis]
|
||||
};
|
||||
r.vec = ApplyRadialDeadzone(raw, deadzone);
|
||||
}
|
||||
} else if constexpr (std::is_same_v<T, WASDBind>) {
|
||||
Vector<float, 2> v{ 0.0f, 0.0f };
|
||||
if (w) {
|
||||
if (w->heldKeys.contains(bb.left)) v.x -= 1.0f;
|
||||
if (w->heldKeys.contains(bb.right)) v.x += 1.0f;
|
||||
if (w->heldKeys.contains(bb.up)) v.y += 1.0f;
|
||||
if (w->heldKeys.contains(bb.down)) v.y -= 1.0f;
|
||||
}
|
||||
r.vec = v;
|
||||
}
|
||||
}, b);
|
||||
return r;
|
||||
}
|
||||
|
||||
// Combine bindings for the action's type. For Button: OR. For Axis1D:
|
||||
// sum-then-clamp (so two analog inputs add but cap at ±1; two
|
||||
// digital inputs cap at 1 too). For Vector2: per-axis sum-and-clamp.
|
||||
void EvaluateAction(Action& a, Window* w, std::int32_t scrollAccum,
|
||||
Vector<float, 2> mouseDelta)
|
||||
{
|
||||
bool nextPressed = false;
|
||||
float nextValue = 0.0f;
|
||||
Vector<float, 2> nextVec { 0.0f, 0.0f };
|
||||
|
||||
for (const Binding& b : a.bindings) {
|
||||
Eval e = EvalBinding(b, w, scrollAccum, mouseDelta, a.deadzone);
|
||||
if (e.button) nextPressed = true;
|
||||
nextValue += e.value;
|
||||
nextVec.x += e.vec.x;
|
||||
nextVec.y += e.vec.y;
|
||||
}
|
||||
// Clamp scalars/vectors to ±1; sum semantics for combining.
|
||||
auto clamp = [](float v) { return v < -1.0f ? -1.0f : v > 1.0f ? 1.0f : v; };
|
||||
nextValue = clamp(nextValue);
|
||||
nextVec.x = clamp(nextVec.x);
|
||||
nextVec.y = clamp(nextVec.y);
|
||||
|
||||
// Dispatch edges + value changes.
|
||||
if (a.type == ActionType::Button) {
|
||||
if (nextPressed && !a.pressed) {
|
||||
a.pressed = true;
|
||||
a.onPerformed.Invoke();
|
||||
} else if (!nextPressed && a.pressed) {
|
||||
a.pressed = false;
|
||||
a.onCanceled.Invoke();
|
||||
}
|
||||
} else if (a.type == ActionType::Axis1D) {
|
||||
bool wasNonZero = a.value != 0.0f;
|
||||
bool isNonZero = nextValue != 0.0f;
|
||||
if (nextValue != a.value) {
|
||||
a.value = nextValue;
|
||||
a.onValueChanged.Invoke(nextValue);
|
||||
}
|
||||
if (isNonZero && !wasNonZero) a.onPerformed.Invoke();
|
||||
else if (!isNonZero && wasNonZero) a.onCanceled.Invoke();
|
||||
} else { // Vector2
|
||||
bool wasNonZero = (a.vector2.x != 0.0f || a.vector2.y != 0.0f);
|
||||
bool isNonZero = (nextVec.x != 0.0f || nextVec.y != 0.0f);
|
||||
if (nextVec.x != a.vector2.x || nextVec.y != a.vector2.y) {
|
||||
a.vector2 = nextVec;
|
||||
a.onVector2Changed.Invoke(nextVec);
|
||||
}
|
||||
if (isNonZero && !wasNonZero) a.onPerformed.Invoke();
|
||||
else if (!isNonZero && wasNonZero) a.onCanceled.Invoke();
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Rebind detection ───────────────────────────────────────────
|
||||
// Scan for a fresh down-edge against the snapshot taken at
|
||||
// StartRebind. Returns the captured binding if found, nullopt if
|
||||
// nothing fresh is held. The first match wins; preference goes
|
||||
// keyboard > mouse > gamepad to keep the behavior predictable.
|
||||
std::optional<Binding> DetectRebindEdge(Map::RebindState& s, Window* w) {
|
||||
// Keyboard
|
||||
if (w && HasFlag(s.mask, CaptureMask::Keyboard)) {
|
||||
for (KeyCode c : w->heldKeys) {
|
||||
if (!s.keysHeldAtStart.contains(c)) {
|
||||
return Binding{ KeyBind{c} };
|
||||
}
|
||||
}
|
||||
}
|
||||
// Mouse buttons
|
||||
if (w && HasFlag(s.mask, CaptureMask::Mouse)) {
|
||||
if (w->mouseLeftHeld && !s.mouseLeftHeldAtStart) {
|
||||
return Binding{ MouseButtonBind{0} };
|
||||
}
|
||||
if (w->mouseRightHeld && !s.mouseRightHeldAtStart) {
|
||||
return Binding{ MouseButtonBind{1} };
|
||||
}
|
||||
}
|
||||
// Gamepad buttons + axes
|
||||
if (HasFlag(s.mask, CaptureMask::Gamepad)) {
|
||||
for (auto& up : Gamepad::connected) {
|
||||
Gamepad::Device* gp = up.get();
|
||||
auto it = s.gamepadButtonsAtStart.find(gp->id);
|
||||
bool hadSnapshot = it != s.gamepadButtonsAtStart.end();
|
||||
for (std::size_t i = 0; i < (std::size_t)Gamepad::Button::Max; ++i) {
|
||||
bool now = gp->buttons[i];
|
||||
bool before = hadSnapshot ? it->second[i] : false;
|
||||
if (now && !before) {
|
||||
return Binding{ GamepadButtonBind{
|
||||
gp->id, (Gamepad::Button)i
|
||||
} };
|
||||
}
|
||||
}
|
||||
// Axes: any value past 0.5 captures as a 1-D axis bind.
|
||||
constexpr float kRebindAxisThreshold = 0.5f;
|
||||
for (std::size_t i = 0; i < (std::size_t)Gamepad::Axis::Max; ++i) {
|
||||
float v = gp->axes[i];
|
||||
if (std::abs(v) >= kRebindAxisThreshold) {
|
||||
return Binding{ GamepadAxisBind{
|
||||
gp->id, (Gamepad::Axis)i, v < 0.0f
|
||||
} };
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return std::nullopt;
|
||||
}
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
// Map
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
Action& Map::AddAction(std::string name, ActionType type) {
|
||||
auto up = std::make_unique<Action>();
|
||||
up->name = std::move(name);
|
||||
up->type = type;
|
||||
Action& ref = *up;
|
||||
actions.push_back(std::move(up));
|
||||
return ref;
|
||||
}
|
||||
|
||||
Action* Map::Find(std::string_view name) {
|
||||
for (auto& up : actions) {
|
||||
if (up->name == name) return up.get();
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
void Map::Attach(Window& w) {
|
||||
Detach();
|
||||
window = &w;
|
||||
// Only event-driven source we need is mouse scroll — everything
|
||||
// else (held keys, mouse buttons, mouse position, gamepad state) is
|
||||
// polled directly. Accumulate scroll between ticks so a flick that
|
||||
// fires multiple wheel events in one frame all count.
|
||||
scrollListener = std::make_unique<EventListener<std::uint32_t>>(
|
||||
&w.onMouseScroll,
|
||||
[this](std::uint32_t delta) {
|
||||
// Wayland and Win32 both deliver scroll as a signed delta
|
||||
// packed into a uint32; we reinterpret to preserve sign.
|
||||
scrollAccumulator += (std::int32_t)delta;
|
||||
});
|
||||
lastMousePos.reset();
|
||||
scrollAccumulator = 0;
|
||||
}
|
||||
|
||||
void Map::Detach() {
|
||||
if (scrollListener) {
|
||||
scrollListener->Clear();
|
||||
scrollListener.reset();
|
||||
}
|
||||
window = nullptr;
|
||||
scrollAccumulator = 0;
|
||||
lastMousePos.reset();
|
||||
rebind.reset();
|
||||
}
|
||||
|
||||
void Map::Tick() {
|
||||
// Mouse delta: derived from window position vs. last frame.
|
||||
Vector<float, 2> mouseDelta{ 0.0f, 0.0f };
|
||||
if (window) {
|
||||
if (lastMousePos.has_value()) {
|
||||
mouseDelta.x = window->currentMousePos.x - lastMousePos->x;
|
||||
mouseDelta.y = window->currentMousePos.y - lastMousePos->y;
|
||||
}
|
||||
lastMousePos = window->currentMousePos;
|
||||
}
|
||||
|
||||
// Rebind takes priority. If a fresh edge is detected, fire the
|
||||
// callback and exit rebind mode. Actions don't evaluate this tick
|
||||
// for the captured input — the user usually wants the bind to take
|
||||
// effect on the NEXT press of that input.
|
||||
if (rebind.has_value()) {
|
||||
if (auto captured = DetectRebindEdge(*rebind, window)) {
|
||||
auto cb = std::move(rebind->onCaptured);
|
||||
rebind.reset();
|
||||
// Drain scroll so the next Tick doesn't double-fire on the
|
||||
// same wheel input that may have triggered the rebind.
|
||||
scrollAccumulator = 0;
|
||||
cb(*captured);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
std::int32_t scrollThisTick = scrollAccumulator;
|
||||
scrollAccumulator = 0;
|
||||
|
||||
for (auto& up : actions) {
|
||||
EvaluateAction(*up, window, scrollThisTick, mouseDelta);
|
||||
}
|
||||
}
|
||||
|
||||
void Map::StartRebind(Action& action, CaptureMask mask,
|
||||
std::function<void(Binding)> onCaptured)
|
||||
{
|
||||
RebindState s;
|
||||
s.action = &action;
|
||||
s.mask = mask;
|
||||
s.onCaptured = std::move(onCaptured);
|
||||
if (window) {
|
||||
s.keysHeldAtStart = window->heldKeys;
|
||||
s.mouseLeftHeldAtStart = window->mouseLeftHeld;
|
||||
s.mouseRightHeldAtStart = window->mouseRightHeld;
|
||||
}
|
||||
for (auto& up : Gamepad::connected) {
|
||||
std::array<bool, (std::size_t)Gamepad::Button::Max> snap{};
|
||||
for (std::size_t i = 0; i < snap.size(); ++i) {
|
||||
snap[i] = up->buttons[i];
|
||||
}
|
||||
s.gamepadButtonsAtStart[up->id] = snap;
|
||||
}
|
||||
rebind.emplace(std::move(s));
|
||||
}
|
||||
|
||||
void Map::StopRebind() {
|
||||
rebind.reset();
|
||||
}
|
||||
|
||||
bool Map::IsRebinding() const {
|
||||
return rebind.has_value();
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
// Serialization
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
std::string Crafter::Input::BindingToString(const Binding& b) {
|
||||
std::string out;
|
||||
std::visit([&](auto const& bb) {
|
||||
using T = std::decay_t<decltype(bb)>;
|
||||
if constexpr (std::is_same_v<T, KeyBind>) {
|
||||
out = std::format("key:{:x}", bb.code);
|
||||
} else if constexpr (std::is_same_v<T, MouseButtonBind>) {
|
||||
out = std::format("mb:{}", (int)bb.button);
|
||||
} else if constexpr (std::is_same_v<T, MouseScrollBind>) {
|
||||
out = "mscroll";
|
||||
} else if constexpr (std::is_same_v<T, MouseDeltaBind>) {
|
||||
out = std::format("mdelta:{}", bb.scale);
|
||||
} else if constexpr (std::is_same_v<T, GamepadButtonBind>) {
|
||||
out = std::format("gpb:{}:{}", bb.gamepadId, (int)bb.button);
|
||||
} else if constexpr (std::is_same_v<T, GamepadAxisBind>) {
|
||||
out = std::format("gpa:{}:{}:{}", bb.gamepadId, (int)bb.axis, bb.invert ? 1 : 0);
|
||||
} else if constexpr (std::is_same_v<T, GamepadStickBind>) {
|
||||
out = std::format("gps:{}:{}", bb.gamepadId, (int)bb.stick);
|
||||
} else if constexpr (std::is_same_v<T, WASDBind>) {
|
||||
out = std::format("wasd:{:x}:{:x}:{:x}:{:x}",
|
||||
bb.up, bb.down, bb.left, bb.right);
|
||||
}
|
||||
}, b);
|
||||
return out;
|
||||
}
|
||||
|
||||
namespace {
|
||||
// Split on ':' and parse. Tolerates leading whitespace; rejects
|
||||
// malformed input by returning nullopt.
|
||||
std::vector<std::string_view> Split(std::string_view s) {
|
||||
std::vector<std::string_view> parts;
|
||||
std::size_t start = 0;
|
||||
for (std::size_t i = 0; i < s.size(); ++i) {
|
||||
if (s[i] == ':') {
|
||||
parts.emplace_back(s.substr(start, i - start));
|
||||
start = i + 1;
|
||||
}
|
||||
}
|
||||
parts.emplace_back(s.substr(start));
|
||||
return parts;
|
||||
}
|
||||
|
||||
bool ParseHex(std::string_view s, std::uint32_t& out) {
|
||||
auto [p, ec] = std::from_chars(s.data(), s.data() + s.size(), out, 16);
|
||||
return ec == std::errc{} && p == s.data() + s.size();
|
||||
}
|
||||
bool ParseDec(std::string_view s, std::uint32_t& out) {
|
||||
auto [p, ec] = std::from_chars(s.data(), s.data() + s.size(), out, 10);
|
||||
return ec == std::errc{} && p == s.data() + s.size();
|
||||
}
|
||||
bool ParseFloat(std::string_view s, float& out) {
|
||||
auto [p, ec] = std::from_chars(s.data(), s.data() + s.size(), out);
|
||||
return ec == std::errc{} && p == s.data() + s.size();
|
||||
}
|
||||
}
|
||||
|
||||
std::optional<Binding> Crafter::Input::BindingFromString(std::string_view s) {
|
||||
auto parts = Split(s);
|
||||
if (parts.empty()) return std::nullopt;
|
||||
const std::string_view tag = parts[0];
|
||||
|
||||
if (tag == "key" && parts.size() == 2) {
|
||||
std::uint32_t v;
|
||||
if (!ParseHex(parts[1], v)) return std::nullopt;
|
||||
return Binding{ KeyBind{ v } };
|
||||
}
|
||||
if (tag == "mb" && parts.size() == 2) {
|
||||
std::uint32_t v;
|
||||
if (!ParseDec(parts[1], v) || v > 255) return std::nullopt;
|
||||
return Binding{ MouseButtonBind{ (std::uint8_t)v } };
|
||||
}
|
||||
if (tag == "mscroll") return Binding{ MouseScrollBind{} };
|
||||
if (tag == "mdelta" && parts.size() == 2) {
|
||||
float scale;
|
||||
if (!ParseFloat(parts[1], scale)) return std::nullopt;
|
||||
return Binding{ MouseDeltaBind{ scale } };
|
||||
}
|
||||
if (tag == "gpb" && parts.size() == 3) {
|
||||
std::uint32_t id, btn;
|
||||
if (!ParseDec(parts[1], id) || !ParseDec(parts[2], btn)) return std::nullopt;
|
||||
if (btn >= (std::uint32_t)Gamepad::Button::Max) return std::nullopt;
|
||||
return Binding{ GamepadButtonBind{ id, (Gamepad::Button)btn } };
|
||||
}
|
||||
if (tag == "gpa" && parts.size() == 4) {
|
||||
std::uint32_t id, ax, inv;
|
||||
if (!ParseDec(parts[1], id) || !ParseDec(parts[2], ax) || !ParseDec(parts[3], inv)) {
|
||||
return std::nullopt;
|
||||
}
|
||||
if (ax >= (std::uint32_t)Gamepad::Axis::Max) return std::nullopt;
|
||||
return Binding{ GamepadAxisBind{ id, (Gamepad::Axis)ax, inv != 0 } };
|
||||
}
|
||||
if (tag == "gps" && parts.size() == 3) {
|
||||
std::uint32_t id, st;
|
||||
if (!ParseDec(parts[1], id) || !ParseDec(parts[2], st)) return std::nullopt;
|
||||
if (st > 1) return std::nullopt;
|
||||
return Binding{ GamepadStickBind{ id, (Gamepad::Stick)st } };
|
||||
}
|
||||
if (tag == "wasd" && parts.size() == 5) {
|
||||
std::uint32_t u, d, l, r;
|
||||
if (!ParseHex(parts[1], u) || !ParseHex(parts[2], d) ||
|
||||
!ParseHex(parts[3], l) || !ParseHex(parts[4], r)) {
|
||||
return std::nullopt;
|
||||
}
|
||||
return Binding{ WASDBind{ u, d, l, r } };
|
||||
}
|
||||
return std::nullopt;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue