/* 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 ApplyRadialDeadzone(Vector v, float dz) { float mag = std::sqrt(v.x * v.x + v.y * v.y); if (mag <= dz) return Vector{0.0f, 0.0f}; float scaled = (mag - dz) / (1.0f - dz); float k = scaled / mag; return Vector{ 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 vec { 0.0f, 0.0f }; }; Eval EvalBinding(const Binding& b, Window* w, std::int32_t scrollAccum, Vector mouseDelta, float deadzone) { Eval r; std::visit([&](auto const& bb) { using T = std::decay_t; if constexpr (std::is_same_v) { 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) { 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) { r.value = (float)scrollAccum; r.button = scrollAccum != 0; } else if constexpr (std::is_same_v) { r.vec = Vector{ mouseDelta.x * bb.scale, mouseDelta.y * bb.scale }; } else if constexpr (std::is_same_v) { 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) { 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) { 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 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) { Vector 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 mouseDelta) { bool nextPressed = false; float nextValue = 0.0f; Vector 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 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(); 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>( &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 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 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 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; if constexpr (std::is_same_v) { out = std::format("key:{:x}", bb.code); } else if constexpr (std::is_same_v) { out = std::format("mb:{}", (int)bb.button); } else if constexpr (std::is_same_v) { out = "mscroll"; } else if constexpr (std::is_same_v) { out = std::format("mdelta:{}", bb.scale); } else if constexpr (std::is_same_v) { out = std::format("gpb:{}:{}", bb.gamepadId, (int)bb.button); } else if constexpr (std::is_same_v) { out = std::format("gpa:{}:{}:{}", bb.gamepadId, (int)bb.axis, bb.invert ? 1 : 0); } else if constexpr (std::is_same_v) { out = std::format("gps:{}:{}", bb.gamepadId, (int)bb.stick); } else if constexpr (std::is_same_v) { 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 Split(std::string_view s) { std::vector 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 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; }