Crafter.Graphics/implementations/Crafter.Graphics-Input.cpp

454 lines
19 KiB
C++
Raw Normal View History

2026-05-12 00:24:48 +02:00
/*
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;
}