diff --git a/3d/CMakeLists.txt b/3d/CMakeLists.txt index 78fd670..93f45ee 100644 --- a/3d/CMakeLists.txt +++ b/3d/CMakeLists.txt @@ -6,6 +6,7 @@ add_library(sequa_3d src/adapter.cpp src/capabilities.cpp + src/input.cpp src/null_adapter.cpp src/scene.cpp) add_library(sequa::3d ALIAS sequa_3d) @@ -18,6 +19,7 @@ if(SEQUA_BUILD_TESTS) add_executable(sequa_3d_tests tests/main.cpp tests/test_capabilities.cpp + tests/test_input.cpp tests/test_scene.cpp tests/test_null_adapter.cpp) target_link_libraries(sequa_3d_tests PRIVATE sequa::3d doctest::doctest) diff --git a/3d/README.md b/3d/README.md index 05af77e..3744f25 100644 --- a/3d/README.md +++ b/3d/README.md @@ -24,10 +24,11 @@ Phase 0. Bibliothèque `sequa::3d` (`include/sequa/3d/`, espace de noms `sequa:: | Élément | Fichier | Rôle | |---|---|---| -| `I3DEngineAdapter`, `EngineConfig`, `validate_request` | `adapter.hpp` | Interface que chaque moteur implémente : capacités, démarrage, deltas de scène, images | +| `I3DEngineAdapter`, `EngineConfig`, `validate_request` | `adapter.hpp` | Interface que chaque moteur implémente : capacités, démarrage, deltas de scène, images, entrées du joueur | | `FrameBuffer`, `BufferSet`, `EngineCapabilities`, `negotiate` | `capabilities.hpp` | Négociation des capacités : tampons accordés et manquants | | `SceneDelta`, `SceneCommand`, `SceneState` | `scene.hpp` | Synchronisation à sens unique, versions croissantes, tout ou rien, snapshot pour (re)démarrer un moteur | | `FrameRequest`, `FrameResult`, `FrameMeasurements` | `frame.hpp` | Demande d'image et mesures dans un format commun | +| `PlayerInput`, `quantize_axis` | `input.hpp` | Entrées du joueur déjà traduites en actions par l'adaptateur (`poll_input`, [ADR 0011](../docs/adr/0011-entrees-du-joueur-captees-par-l-adaptateur.md)) | | `NullEngineAdapter` | `null_adapter.hpp` | **SIMULÉ** : adaptateur sans moteur, pour les tests et comme référence de comportement | **PROVISOIRE** : types C++ écrits à la main, en attendant le format du contrat (Protobuf ou FlatBuffers, voir [schema/](../schema)). Les tampons GPU eux-mêmes n'y sont pas encore : ils arriveront après le prototype d'échange GPU entre rendu et inférence (§5.6). Les visuels se limitent pour l'instant à des primitives. diff --git a/3d/include/sequa/3d/adapter.hpp b/3d/include/sequa/3d/adapter.hpp index eb11167..1cd5aa1 100644 --- a/3d/include/sequa/3d/adapter.hpp +++ b/3d/include/sequa/3d/adapter.hpp @@ -4,6 +4,7 @@ #include "sequa/3d/capabilities.hpp" #include "sequa/3d/error.hpp" #include "sequa/3d/frame.hpp" +#include "sequa/3d/input.hpp" #include "sequa/3d/scene.hpp" #include @@ -27,8 +28,8 @@ using FrameOutcome = std::variant; // Godot, Unreal ou autre. Ce qu'un moteur ne sait pas faire est déclaré par // capabilities(), jamais retiré de l'interface. // -// Cycle : capabilities() (possible à tout moment) → start() → apply()/render_frame()… → -// stop(). Après start(), la scène du moteur est vide : l'appelant pousse un snapshot. +// Cycle : capabilities() (possible à tout moment) → start() → apply()/render_frame()/ +// poll_input()… → stop(). Après start(), la scène du moteur est vide : l'appelant pousse un snapshot. class I3DEngineAdapter { public: I3DEngineAdapter() = default; @@ -49,6 +50,12 @@ public: // Les tampons demandés doivent faire partie des capacités déclarées (sinon unsupported). [[nodiscard]] virtual FrameOutcome render_frame(const FrameRequest& request) = 0; + + // Entrées du joueur captées pendant la dernière image, déjà traduites en actions par + // l'adaptateur (ADR 0011), à appeler après render_frame(). Neutre quand la fenêtre n'a pas + // le focus ; std::nullopt si le moteur ne capte pas d'entrées (capabilities().player_input + // faux) ou s'il est arrêté. + [[nodiscard]] virtual std::optional poll_input() = 0; }; // Vérifications communes à tous les adaptateurs avant de transmettre une requête au moteur. diff --git a/3d/include/sequa/3d/adapter_library.hpp b/3d/include/sequa/3d/adapter_library.hpp index 9880615..666c84a 100644 --- a/3d/include/sequa/3d/adapter_library.hpp +++ b/3d/include/sequa/3d/adapter_library.hpp @@ -14,7 +14,7 @@ namespace sequa::three_d { // À incrémenter à chaque changement de I3DEngineAdapter ou des types du contrat. -inline constexpr std::uint32_t adapter_interface_version = 1; +inline constexpr std::uint32_t adapter_interface_version = 2; // 2 : poll_input (ADR 0011) inline constexpr const char* create_adapter_symbol = "sequa_create_engine_adapter"; inline constexpr const char* destroy_adapter_symbol = "sequa_destroy_engine_adapter"; diff --git a/3d/include/sequa/3d/capabilities.hpp b/3d/include/sequa/3d/capabilities.hpp index 57d7d7a..7877e62 100644 --- a/3d/include/sequa/3d/capabilities.hpp +++ b/3d/include/sequa/3d/capabilities.hpp @@ -89,6 +89,7 @@ struct EngineCapabilities { bool headless = false; // rendu hors écran (tests, génération de données) std::uint32_t max_width = 0; std::uint32_t max_height = 0; + bool player_input = false; // capte les entrées du joueur (poll_input, ADR 0011) }; struct Negotiation { diff --git a/3d/include/sequa/3d/input.hpp b/3d/include/sequa/3d/input.hpp new file mode 100644 index 0000000..180ed75 --- /dev/null +++ b/3d/include/sequa/3d/input.hpp @@ -0,0 +1,29 @@ +// SPDX-License-Identifier: MPL-2.0 +#pragma once + +#include + +namespace sequa::three_d { + +// Entrées du joueur déjà traduites en actions SEQUA par l'adaptateur (ADR 0011) : les +// appareils (clavier, manette…) et leur correspondance restent dans l'adaptateur ; le Core +// ne reçoit et n'enregistre que ces actions, entières pour que le rejeu soit exact (§10.1). +// Même forme que sim::InputFrame ; la signification des bits appartient au mode de jeu du +// Core (aujourd'hui : bit 0 = saut). +struct PlayerInput { + std::uint32_t buttons = 0; + std::int16_t move_x = 0; // [-axis_max, axis_max] : gauche, droite + std::int16_t move_y = 0; // [-axis_max, axis_max] : bas (ou arrière), haut (ou avant) + + [[nodiscard]] constexpr bool is_neutral() const noexcept { return buttons == 0 && move_x == 0 && move_y == 0; } + + friend constexpr bool operator==(const PlayerInput&, const PlayerInput&) noexcept = default; +}; + +inline constexpr std::int16_t axis_max = 32767; + +// Axe analogique [-1, 1] vers l'entier du contrat : borné, arrondi au plus proche ; une +// valeur non finie (NaN, infini) donne 0. +[[nodiscard]] std::int16_t quantize_axis(double value) noexcept; + +} // namespace sequa::three_d diff --git a/3d/include/sequa/3d/null_adapter.hpp b/3d/include/sequa/3d/null_adapter.hpp index cdbb721..7fc430b 100644 --- a/3d/include/sequa/3d/null_adapter.hpp +++ b/3d/include/sequa/3d/null_adapter.hpp @@ -21,12 +21,16 @@ public: [[nodiscard]] std::optional apply(const SceneDelta& delta) override; [[nodiscard]] FrameOutcome render_frame(const FrameRequest& request) override; + // Renvoie l'entrée fixée par set_input() si capabilities().player_input, sinon nullopt. + [[nodiscard]] std::optional poll_input() override; [[nodiscard]] const SceneState& scene() const noexcept { return scene_; } + void set_input(PlayerInput input) noexcept { input_ = input; } private: EngineCapabilities capabilities_; SceneState scene_; + PlayerInput input_; bool running_ = false; }; diff --git a/3d/src/input.cpp b/3d/src/input.cpp new file mode 100644 index 0000000..e3fcf9a --- /dev/null +++ b/3d/src/input.cpp @@ -0,0 +1,17 @@ +// SPDX-License-Identifier: MPL-2.0 +#include "sequa/3d/input.hpp" + +#include + +namespace sequa::three_d { + +std::int16_t quantize_axis(double value) noexcept +{ + if (!std::isfinite(value)) { + return 0; + } + const double clamped = value < -1.0 ? -1.0 : (value > 1.0 ? 1.0 : value); + return static_cast(std::lround(clamped * axis_max)); +} + +} // namespace sequa::three_d diff --git a/3d/src/null_adapter.cpp b/3d/src/null_adapter.cpp index d537b60..96b716b 100644 --- a/3d/src/null_adapter.cpp +++ b/3d/src/null_adapter.cpp @@ -53,4 +53,12 @@ FrameOutcome NullEngineAdapter::render_frame(const FrameRequest& request) return FrameResult{request.frame_index, scene_.world_version(), request.buffers, {}}; } +std::optional NullEngineAdapter::poll_input() +{ + if (!running_ || !capabilities_.player_input) { + return std::nullopt; + } + return input_; +} + } // namespace sequa::three_d diff --git a/3d/tests/test_input.cpp b/3d/tests/test_input.cpp new file mode 100644 index 0000000..b7d20f1 --- /dev/null +++ b/3d/tests/test_input.cpp @@ -0,0 +1,52 @@ +// SPDX-License-Identifier: MPL-2.0 +#include "sequa/3d/input.hpp" +#include "sequa/3d/null_adapter.hpp" + +#include + +#include + +using namespace sequa::three_d; + +TEST_CASE("quantize_axis : bornes, arrondi, valeurs non finies") +{ + CHECK(quantize_axis(0.0) == 0); + CHECK(quantize_axis(1.0) == axis_max); + CHECK(quantize_axis(-1.0) == -axis_max); + CHECK(quantize_axis(0.5) == 16384); // 16383,5 arrondi au plus proche + CHECK(quantize_axis(-0.5) == -16384); + CHECK(quantize_axis(3.0) == axis_max); + CHECK(quantize_axis(-3.0) == -axis_max); + CHECK(quantize_axis(std::numeric_limits::quiet_NaN()) == 0); + CHECK(quantize_axis(std::numeric_limits::infinity()) == 0); +} + +TEST_CASE("PlayerInput : neutre et egalite") +{ + CHECK(PlayerInput{}.is_neutral()); + CHECK_FALSE(PlayerInput{1, 0, 0}.is_neutral()); + CHECK_FALSE(PlayerInput{0, 0, -5}.is_neutral()); + CHECK(PlayerInput{1, 2, 3} == PlayerInput{1, 2, 3}); + CHECK_FALSE(PlayerInput{1, 2, 3} == PlayerInput{1, 2, 4}); +} + +TEST_CASE("NullEngineAdapter : entrees seulement si declarees et demarre") +{ + NullEngineAdapter silent; + EngineConfig config; + config.headless = true; + REQUIRE_FALSE(silent.start(config)); + CHECK_FALSE(silent.poll_input().has_value()); // capacite non declaree + + EngineCapabilities caps{"null", "0", BufferSet::all(), false, true, 0, 0}; + caps.player_input = true; + NullEngineAdapter adapter(caps); + CHECK_FALSE(adapter.poll_input().has_value()); // pas encore demarre + REQUIRE_FALSE(adapter.start(config)); + REQUIRE(adapter.poll_input().has_value()); + CHECK(adapter.poll_input()->is_neutral()); + adapter.set_input(PlayerInput{1, axis_max, 0}); + CHECK(*adapter.poll_input() == PlayerInput{1, axis_max, 0}); + adapter.stop(); + CHECK_FALSE(adapter.poll_input().has_value()); +} diff --git a/3d/wire/include/sequa/3d/wire.hpp b/3d/wire/include/sequa/3d/wire.hpp index 096b9b2..7f521f4 100644 --- a/3d/wire/include/sequa/3d/wire.hpp +++ b/3d/wire/include/sequa/3d/wire.hpp @@ -2,6 +2,7 @@ #pragma once #include "sequa/3d/capabilities.hpp" +#include "sequa/3d/input.hpp" #include "sequa/3d/scene.hpp" #include @@ -50,9 +51,21 @@ struct SceneDeltaMessage { [[nodiscard]] std::optional decode_scene_delta(const nlohmann::json& payload, std::string* error = nullptr); -// Capacités du moteur : { engineName, engineVersion, buffers: [noms], windowed, headless }. +// Capacités du moteur : { engineName, engineVersion, buffers: [noms], windowed, headless, +// playerInput } ; playerInput est facultatif au décodage (faux par défaut). [[nodiscard]] nlohmann::json encode_capabilities(const EngineCapabilities& caps); [[nodiscard]] std::optional decode_capabilities(const nlohmann::json& value, std::string* error = nullptr); +// Charge utile du message `render/input` (protocole 0.2, ADR 0011) : +// { buttons: int [0, 2^32-1], moveX: int, moveY: int ([-32767, 32767]), capturedAtUs: hex }. +struct PlayerInputMessage { + PlayerInput input; + std::uint64_t captured_at_us = 0; // horloge monotone du rendu, comme sentAtUs +}; + +[[nodiscard]] nlohmann::json encode_player_input(const PlayerInputMessage& message); +[[nodiscard]] std::optional decode_player_input(const nlohmann::json& payload, + std::string* error = nullptr); + } // namespace sequa::three_d::wire diff --git a/3d/wire/src/wire.cpp b/3d/wire/src/wire.cpp index 67d25c8..66ee1ab 100644 --- a/3d/wire/src/wire.cpp +++ b/3d/wire/src/wire.cpp @@ -504,6 +504,7 @@ nlohmann::json encode_capabilities(const EngineCapabilities& caps) out["buffers"] = std::move(buffers); out["windowed"] = caps.windowed; out["headless"] = caps.headless; + out["playerInput"] = caps.player_input; return out; } @@ -560,7 +561,83 @@ std::optional decode_capabilities(const nlohmann::json& valu } caps.windowed = value["windowed"].get(); caps.headless = value["headless"].get(); + if (value.contains("playerInput")) { + if (!value["playerInput"].is_boolean()) { + refuse(error, "playerInput : booléen attendu"); + return std::nullopt; + } + caps.player_input = value["playerInput"].get(); + } return caps; } +nlohmann::json encode_player_input(const PlayerInputMessage& message) +{ + json out; + out["buttons"] = message.input.buttons; + out["moveX"] = message.input.move_x; + out["moveY"] = message.input.move_y; + out["capturedAtUs"] = to_hex64(message.captured_at_us); + return out; +} + +std::optional decode_player_input(const nlohmann::json& payload, std::string* error) +{ + if (!payload.is_object()) { + refuse(error, "render/input : objet attendu"); + return std::nullopt; + } + // Entier JSON dans [low, high] (les flottants, même entiers, sont refusés). + const auto integer = [&](const char* key, std::int64_t low, std::int64_t high) -> std::optional { + if (!payload.contains(key)) { + refuse(error, std::string(key) + " manquant"); + return std::nullopt; + } + const json& field = payload[key]; + std::int64_t raw = 0; + if (field.is_number_unsigned()) { + const std::uint64_t value = field.get(); + raw = value > static_cast(high) ? high + 1 : static_cast(value); + } else if (field.is_number_integer()) { + raw = field.get(); + } else { + refuse(error, std::string(key) + " : entier attendu"); + return std::nullopt; + } + if (raw < low || raw > high) { + refuse(error, std::string(key) + " : hors de [" + std::to_string(low) + ", " + std::to_string(high) + "]"); + return std::nullopt; + } + return raw; + }; + const auto buttons = integer("buttons", 0, 0xFFFFFFFFLL); + if (!buttons) { + return std::nullopt; + } + const auto move_x = integer("moveX", -axis_max, axis_max); + if (!move_x) { + return std::nullopt; + } + const auto move_y = integer("moveY", -axis_max, axis_max); + if (!move_y) { + return std::nullopt; + } + if (!payload.contains("capturedAtUs") || !payload["capturedAtUs"].is_string()) { + refuse(error, "capturedAtUs : chaîne hexadécimale attendue"); + return std::nullopt; + } + const std::optional captured = from_hex64(payload["capturedAtUs"].get()); + if (!captured) { + refuse(error, "capturedAtUs : hexadécimal invalide"); + return std::nullopt; + } + + PlayerInputMessage message; + message.input.buttons = static_cast(*buttons); + message.input.move_x = static_cast(*move_x); + message.input.move_y = static_cast(*move_y); + message.captured_at_us = *captured; + return message; +} + } // namespace sequa::three_d::wire diff --git a/3d/wire/tests/test_wire.cpp b/3d/wire/tests/test_wire.cpp index 8483a28..2233f86 100644 --- a/3d/wire/tests/test_wire.cpp +++ b/3d/wire/tests/test_wire.cpp @@ -27,7 +27,8 @@ namespace { [[nodiscard]] bool capabilities_equal(const EngineCapabilities& left, const EngineCapabilities& right) { return left.engine_name == right.engine_name && left.engine_version == right.engine_version && - left.buffers == right.buffers && left.windowed == right.windowed && left.headless == right.headless; + left.buffers == right.buffers && left.windowed == right.windowed && left.headless == right.headless && + left.player_input == right.player_input; } Transform sample_transform() @@ -385,6 +386,65 @@ TEST_CASE("Wire : refus d'un tampon inconnu dans les capacités") CHECK_FALSE(decode_capabilities(nlohmann::json::array()).has_value()); } +TEST_CASE("Wire : playerInput des capacités, facultatif au décodage") +{ + const nlohmann::json without = parse_text( + R"({"engineName": "godot", "engineVersion": "4.7", "buffers": [], "windowed": true, "headless": false})"); + const auto decoded = decode_capabilities(without); + REQUIRE(decoded.has_value()); + CHECK_FALSE(decoded->player_input); + + EngineCapabilities caps; + caps.engine_name = "godot"; + caps.player_input = true; + const auto round_trip = decode_capabilities(encode_capabilities(caps)); + REQUIRE(round_trip.has_value()); + CHECK(round_trip->player_input); + + nlohmann::json bad = encode_capabilities(caps); + bad["playerInput"] = 1; + CHECK_FALSE(decode_capabilities(bad).has_value()); +} + +TEST_CASE("Wire : render/input fait l'aller-retour, bornes comprises") +{ + PlayerInputMessage message; + message.input = PlayerInput{0xFFFFFFFFu, axis_max, -axis_max}; + message.captured_at_us = 0x123456789ULL; + const nlohmann::json encoded = encode_player_input(message); + CHECK(encoded["capturedAtUs"] == "0000000123456789"); + std::string error; + const auto decoded = decode_player_input(encoded, &error); + REQUIRE_MESSAGE(decoded.has_value(), error); + CHECK(decoded->input == message.input); + CHECK(decoded->captured_at_us == message.captured_at_us); +} + +TEST_CASE("Wire : refus d'un render/input hors bornes ou mal typé") +{ + const auto payload = [](nlohmann::json buttons, nlohmann::json x, nlohmann::json y) { + return nlohmann::json{{"buttons", std::move(buttons)}, {"moveX", std::move(x)}, {"moveY", std::move(y)}, + {"capturedAtUs", "0"}}; + }; + std::string error; + CHECK(decode_player_input(payload(0, 0, 0)).has_value()); + CHECK_FALSE(decode_player_input(payload(-1, 0, 0), &error).has_value()); + CHECK(error.find("buttons") != std::string::npos); + CHECK_FALSE(decode_player_input(payload(0x100000000LL, 0, 0)).has_value()); + CHECK_FALSE(decode_player_input(payload(std::numeric_limits::max(), 0, 0)).has_value()); + CHECK_FALSE(decode_player_input(payload(0, 32768, 0)).has_value()); + CHECK_FALSE(decode_player_input(payload(0, 0, -32768)).has_value()); + CHECK_FALSE(decode_player_input(payload(0, 0.5, 0)).has_value()); + CHECK_FALSE(decode_player_input(payload("1", 0, 0)).has_value()); + + nlohmann::json no_time = payload(0, 0, 0); + no_time.erase("capturedAtUs"); + CHECK_FALSE(decode_player_input(no_time).has_value()); + no_time["capturedAtUs"] = "zz"; + CHECK_FALSE(decode_player_input(no_time).has_value()); + CHECK_FALSE(decode_player_input(nlohmann::json::array()).has_value()); +} + TEST_CASE("Wire : un delta décodé s'applique à un SceneState sans erreur") { SceneDeltaMessage message; diff --git a/README.md b/README.md index 81a78ee..9953bf8 100644 --- a/README.md +++ b/README.md @@ -32,7 +32,7 @@ Phase 0 : fondations. Le chemin de bout en bout existe sous une forme minimale : - le Core simule de façon déterministe un micro-mode 2D de démonstration (SIMULÉ), enregistre et rejoue les sessions, et sert le protocole Studio - Core 0.3 ([spécification](docs/specs/protocole-studio-core-0.2.md)) ; - Studio (`sequa-studio` et l'interface web) démarre ou rejoint le Core, recharge le Core recompilé par rejeu et pilote le rendu depuis son panneau Rendu ; -- le Core lance `sequa-render`, qui affiche la scène simulée dans Godot (hébergé par LibGodot) ou l'écrit en images avec l'adaptateur de débogage ([protocole Core - rendu 0.1](docs/specs/protocole-core-rendu-0.1.md)). +- le Core lance `sequa-render`, qui affiche la scène simulée dans Godot (hébergé par LibGodot) ou l'écrit en images avec l'adaptateur de débogage ([protocole Core - rendu 0.2](docs/specs/protocole-core-rendu-0.1.md)) ; on joue dans la fenêtre Godot, les entrées traduites par l'adaptateur sont enregistrées par le Core ([ADR 0011](docs/adr/0011-entrees-du-joueur-captees-par-l-adaptateur.md)). Pas encore de rendu neuronal ni de carte de confiance. Seul Windows a été construit et testé ([ADR 0004](docs/adr/0004-multiplateforme-et-verification-locale.md)). diff --git a/core/include/sequa/core/server/protocol.hpp b/core/include/sequa/core/server/protocol.hpp index fdf02c1..9127768 100644 --- a/core/include/sequa/core/server/protocol.hpp +++ b/core/include/sequa/core/server/protocol.hpp @@ -21,7 +21,8 @@ namespace sequa::core::server { inline constexpr int envelope_version = 1; inline constexpr std::string_view protocol_version = "0.3.0"; // Protocole Core - rendu (docs/specs/protocole-core-rendu-0.1.md), même enveloppe. -inline constexpr std::string_view render_protocol_version = "0.1.0"; +// 0.2 : render/input, entrées du joueur captées par l'adaptateur (ADR 0011). +inline constexpr std::string_view render_protocol_version = "0.2.0"; inline constexpr std::string_view render_client_name = "sequa-render"; // Même règle que Studio : même majeure et, tant que la majeure vaut 0 (API instable diff --git a/core/include/sequa/core/server/runtime.hpp b/core/include/sequa/core/server/runtime.hpp index d8a9cbd..1cf5f7f 100644 --- a/core/include/sequa/core/server/runtime.hpp +++ b/core/include/sequa/core/server/runtime.hpp @@ -73,6 +73,7 @@ public: void render_frames(ConnectionId id, const RenderFrames& frames) override; void render_failed(ConnectionId id, const std::string& code, const std::string& message) override; void render_closed(ConnectionId id) override; + void render_input(ConnectionId id, const three_d::PlayerInput& input) override; // --- Rendu piloté par Studio (protocole 0.3, §9) --- // Ces méthodes sont appelées depuis les fils des connexions Studio. @@ -87,6 +88,7 @@ public: void stop_render(); [[nodiscard]] RenderHub& render_hub() noexcept { return *render_; } + [[nodiscard]] LiveSession& live_session() noexcept { return session_; } // tests (advance sans fil) private: CoreRuntimeOptions options_; diff --git a/core/include/sequa/core/server/services.hpp b/core/include/sequa/core/server/services.hpp index 05118c9..e61402c 100644 --- a/core/include/sequa/core/server/services.hpp +++ b/core/include/sequa/core/server/services.hpp @@ -2,6 +2,7 @@ #pragma once #include "sequa/3d/capabilities.hpp" +#include "sequa/3d/input.hpp" #include #include @@ -162,6 +163,9 @@ public: virtual void render_frames(ConnectionId /*id*/, const RenderFrames& /*frames*/) {} virtual void render_failed(ConnectionId /*id*/, const std::string& /*code*/, const std::string& /*message*/) {} virtual void render_closed(ConnectionId /*id*/) {} + // Entrées du joueur captées par l'adaptateur (render/input, ADR 0011) : appliquées comme + // session/input. Ignorées si `id` n'est pas le canal de rendu courant. + virtual void render_input(ConnectionId /*id*/, const three_d::PlayerInput& /*input*/) {} // --- Rendu piloté par Studio (protocole 0.3, §9). Implémentation par défaut pour // ne pas casser les faux services existants. --- diff --git a/core/src/server/protocol.cpp b/core/src/server/protocol.cpp index f36b376..bbed7f3 100644 --- a/core/src/server/protocol.cpp +++ b/core/src/server/protocol.cpp @@ -238,7 +238,8 @@ json engine_capabilities_json(const three_d::EngineCapabilities& caps) {"version", caps.engine_version}, {"buffers", std::move(buffers)}, {"windowed", caps.windowed}, - {"headless", caps.headless}}; + {"headless", caps.headless}, + {"playerInput", caps.player_input}}; } // Convertit RenderFrames en JSON (measurements). @@ -539,6 +540,15 @@ Reply ProtocolSession::on_text(std::string_view text) services_.render_closed(connection_id_); return reply; } + if (type == "render/input") { + std::string why; + const auto input = three_d::wire::decode_player_input(payload, &why); + if (!input) { + return bad_payload(why); + } + services_.render_input(connection_id_, input->input); + return reply; + } return error("unknown-type", "Type de message inconnu sur le canal de rendu : \"" + std::string(type) + "\"."); } diff --git a/core/src/server/render_hub.cpp b/core/src/server/render_hub.cpp index 1822d69..641189c 100644 --- a/core/src/server/render_hub.cpp +++ b/core/src/server/render_hub.cpp @@ -51,6 +51,7 @@ std::optional RenderHub::attach(ConnectionId id, PushFn push) connection_ = id; push_ = std::move(push); ready_ = false; + input_held_ = false; status_.connected = true; status_.ready = false; status_.capabilities.reset(); @@ -96,17 +97,29 @@ void RenderHub::closed(ConnectionId id) } } -void RenderHub::disconnected(ConnectionId id) +bool RenderHub::disconnected(ConnectionId id) { const std::lock_guard lock(mutex_); if (connection_ != id) { - return; + return false; } connection_.reset(); push_ = nullptr; ready_ = false; status_.connected = false; status_.ready = false; + return std::exchange(input_held_, false); +} + +bool RenderHub::accept_input(ConnectionId id, const three_d::PlayerInput& input) +{ + const std::lock_guard lock(mutex_); + if (connection_ != id) { + return false; + } + input_held_ = !input.is_neutral(); + ++status_.inputs_received; + return true; } void RenderHub::on_world(const sim::WorldState& world, std::uint64_t tick) diff --git a/core/src/server/render_hub.hpp b/core/src/server/render_hub.hpp index 4ec2dc2..50d41b6 100644 --- a/core/src/server/render_hub.hpp +++ b/core/src/server/render_hub.hpp @@ -25,6 +25,7 @@ public: std::optional frames; std::uint64_t world_version = 0; // dernière version envoyée std::uint64_t deltas_sent = 0; + std::uint64_t inputs_received = 0; // render/input acceptés (ADR 0011) std::string last_failure; }; @@ -37,7 +38,13 @@ public: void frames(ConnectionId id, const RenderFrames& frames); void failed(ConnectionId id, const std::string& code, const std::string& message); void closed(ConnectionId id); - void disconnected(ConnectionId id); + // Vrai si le rendu qui part maintenait une entrée non neutre : l'appelant la remet au + // neutre (ADR 0011 : pas de joueur qui court tout seul après la perte du rendu). + [[nodiscard]] bool disconnected(ConnectionId id); + + // Vrai si `id` est le canal de rendu courant : l'appelant applique alors l'entrée à la + // session. Mémorise si l'entrée maintenue par le rendu est neutre. + [[nodiscard]] bool accept_input(ConnectionId id, const three_d::PlayerInput& input); // Appelé sous le verrou de la session. void on_world(const sim::WorldState& world, std::uint64_t tick); @@ -54,6 +61,7 @@ private: PushFn push_; bool ready_ = false; bool send_full_ = false; + bool input_held_ = false; // dernière entrée du rendu non neutre std::uint64_t world_version_ = 0; // compteur du Core, jamais remis à zéro (spec §5) Status status_; }; diff --git a/core/src/server/runtime.cpp b/core/src/server/runtime.cpp index 987ac93..cb02576 100644 --- a/core/src/server/runtime.cpp +++ b/core/src/server/runtime.cpp @@ -147,6 +147,13 @@ void CoreRuntime::render_closed(ConnectionId id) render_->closed(id); } +void CoreRuntime::render_input(ConnectionId id, const three_d::PlayerInput& input) +{ + if (render_->accept_input(id, input)) { + (void)session_.set_input(input.buttons, input.move_x, input.move_y); + } +} + CoreRuntime::ExitReason CoreRuntime::exit_reason() const { const std::lock_guard lock(mutex_); @@ -371,7 +378,10 @@ void CoreRuntime::on_client_connected() void CoreRuntime::on_client_disconnected(ConnectionId id) { - render_->disconnected(id); // avant que la socket ne disparaisse (fonction d'envoi oubliée) + // Avant que la socket ne disparaisse (fonction d'envoi oubliée). + if (render_->disconnected(id)) { + (void)session_.set_input(0, 0, 0); // entrée maintenue par le rendu perdu (ADR 0011) + } const std::lock_guard lock(mutex_); if (clients_ > 0) { --clients_; diff --git a/core/tests/server/test_protocol.cpp b/core/tests/server/test_protocol.cpp index 9ecca3f..3458c34 100644 --- a/core/tests/server/test_protocol.cpp +++ b/core/tests/server/test_protocol.cpp @@ -488,6 +488,7 @@ TEST_CASE("Protocole : render/status running avec tous les champs") CHECK(payload["engine"]["buffers"] == json::array({"color"})); CHECK(payload["engine"]["windowed"] == true); CHECK(payload["engine"]["headless"] == false); + CHECK(payload["engine"]["playerInput"] == false); CHECK(payload["width"] == 1280); CHECK(payload["height"] == 720); CHECK(payload["measurements"]["framesPerSecond"] == 180); diff --git a/core/tests/server/test_render_channel.cpp b/core/tests/server/test_render_channel.cpp index f2f6a19..e73f86c 100644 --- a/core/tests/server/test_render_channel.cpp +++ b/core/tests/server/test_render_channel.cpp @@ -7,6 +7,7 @@ #include "sequa/core/server/live_session.hpp" #include "sequa/core/server/protocol.hpp" #include "sequa/core/server/runtime.hpp" +#include "sequa/core/sim/input.hpp" #include "sequa/3d/scene.hpp" #include "sequa/3d/wire.hpp" @@ -14,6 +15,7 @@ #include #include +#include #include #include #include @@ -144,6 +146,148 @@ TEST_CASE("Canal de rendu : un studio ne peut pas devenir rendu sans fonction d' CHECK(refused.close); } +namespace { + +std::string input_message(const nlohmann::json& payload) +{ + return json{{"v", 1}, {"type", "render/input"}, {"payload", payload}}.dump(); +} + +std::string input_message(std::uint32_t buttons, int move_x, int move_y) +{ + three_d::wire::PlayerInputMessage message; + message.input = three_d::PlayerInput{buttons, static_cast(move_x), static_cast(move_y)}; + message.captured_at_us = 1; + return input_message(three_d::wire::encode_player_input(message)); +} + +// Session de reference sans rendu : memes entrees par session/input. +LiveSession reference_session() +{ + LiveSession::Options options; + options.real_time = false; + return LiveSession(options); +} + +} // namespace + +TEST_CASE("Canal de rendu : render/input s'applique comme session/input et s'enregistre") +{ + CoreRuntime runtime(offline()); + Outbox outbox; + ProtocolSession render(runtime, 7, outbox.push()); + REQUIRE(json::parse(render.on_text(render_hello).messages.front())["type"] == "welcome"); + REQUIRE(render.on_text(ready_message()).messages.empty()); + + CHECK(render.on_text(input_message(sequa::core::sim::button::jump, 32767, 0)).messages.empty()); + (void)runtime.live_session().advance(20); + CHECK(render.on_text(input_message(0, -12000, 0)).messages.empty()); + (void)runtime.live_session().advance(20); + + LiveSession reference = reference_session(); + (void)reference.set_input(sequa::core::sim::button::jump, 32767, 0); + (void)reference.advance(20); + (void)reference.set_input(0, -12000, 0); + (void)reference.advance(20); + + const SessionStateView got = runtime.session_status(); + CHECK(got.tick == 40); + CHECK(got.input_count > 0); + CHECK(got.state_hash == reference.status().state_hash); + CHECK(runtime.render_hub().status().inputs_received == 2); + + // Le rejeu de l'enregistrement exporte retrouve le meme etat. + const SessionExportData exported = runtime.session_export(); + LiveSession replayed = reference_session(); + const auto imported = replayed.import_session(exported.recording, false); + REQUIRE(imported.has_value()); + CHECK(imported->state.state_hash == got.state_hash); +} + +TEST_CASE("Canal de rendu : entree remise au neutre quand le rendu disparait") +{ + CoreRuntime runtime(offline()); + Outbox outbox; + runtime.on_client_connected(); + ProtocolSession render(runtime, 7, outbox.push()); + REQUIRE(json::parse(render.on_text(render_hello).messages.front())["type"] == "welcome"); + REQUIRE(render.on_text(ready_message()).messages.empty()); + + REQUIRE(render.on_text(input_message(0, 32767, 0)).messages.empty()); // le joueur court + (void)runtime.live_session().advance(10); + runtime.on_client_disconnected(7); + (void)runtime.live_session().advance(30); + + LiveSession reference = reference_session(); + (void)reference.set_input(0, 32767, 0); + (void)reference.advance(10); + (void)reference.set_input(0, 0, 0); + (void)reference.advance(30); + CHECK(runtime.session_status().state_hash == reference.status().state_hash); +} + +TEST_CASE("Canal de rendu : entree neutre du rendu, l'entree de Studio n'est pas ecrasee a la deconnexion") +{ + CoreRuntime runtime(offline()); + Outbox outbox; + runtime.on_client_connected(); + ProtocolSession render(runtime, 7, outbox.push()); + REQUIRE(json::parse(render.on_text(render_hello).messages.front())["type"] == "welcome"); + REQUIRE(render.on_text(input_message(0, 0, 0)).messages.empty()); + (void)runtime.session_input(0, 20000, 0); // Studio + runtime.on_client_disconnected(7); + (void)runtime.live_session().advance(10); + + LiveSession reference = reference_session(); + (void)reference.set_input(0, 20000, 0); + (void)reference.advance(10); + CHECK(runtime.session_status().state_hash == reference.status().state_hash); +} + +TEST_CASE("Canal de rendu : render/input invalide refuse, entree inchangee") +{ + CoreRuntime runtime(offline()); + Outbox outbox; + ProtocolSession render(runtime, 7, outbox.push()); + REQUIRE(json::parse(render.on_text(render_hello).messages.front())["type"] == "welcome"); + + const Reply refused = render.on_text( + input_message(json{{"buttons", 0}, {"moveX", 40000}, {"moveY", 0}, {"capturedAtUs", "0"}})); + REQUIRE(refused.messages.size() == 1); + const json error = json::parse(refused.messages.front()); + CHECK(error["type"] == "error"); + CHECK(error["payload"]["code"] == "invalid-payload"); + CHECK_FALSE(refused.close); + CHECK(runtime.render_hub().status().inputs_received == 0); + (void)runtime.live_session().advance(5); + CHECK(runtime.session_status().input_count == 0); +} + +TEST_CASE("Canal de rendu : render/input refuse sur une connexion Studio") +{ + CoreRuntime runtime(offline()); + ProtocolSession studio(runtime, 1); + REQUIRE(json::parse(studio.on_text(encode_hello(protocol_version, "sequa-studio")).messages.front())["type"] == + "welcome"); + const Reply refused = studio.on_text(input_message(0, 100, 0)); + REQUIRE(refused.messages.size() == 1); + CHECK(json::parse(refused.messages.front())["type"] == "error"); + CHECK(runtime.render_hub().status().inputs_received == 0); +} + +TEST_CASE("Canal de rendu : entree d'un ancien canal ignoree") +{ + RenderHub hub(sequa::core::sim::ArenaParams{}); + Outbox outbox; + REQUIRE(hub.attach(1, outbox.push()).has_value()); + CHECK(hub.accept_input(1, three_d::PlayerInput{0, 5, 0})); + CHECK(hub.disconnected(1)); // entree non neutre maintenue + REQUIRE(hub.attach(2, outbox.push()).has_value()); + CHECK_FALSE(hub.accept_input(1, three_d::PlayerInput{0, 5, 0})); + CHECK_FALSE(hub.disconnected(1)); + CHECK_FALSE(hub.disconnected(2)); // le nouveau canal n'a rien maintenu +} + TEST_CASE("Canal de rendu : un delta applicable par tick qui change la scene, versions croissantes") { LiveSession::Options options; @@ -181,7 +325,7 @@ TEST_CASE("Canal de rendu : un delta applicable par tick qui change la scene, ve } CHECK(hub.status().world_version == mirror.world_version()); - hub.disconnected(1); + CHECK_FALSE(hub.disconnected(1)); // aucune entree recue du rendu (void)session.advance(10); CHECK(outbox.take().empty()); // plus rien apres la deconnexion } diff --git a/docs/adr/0011-entrees-du-joueur-captees-par-l-adaptateur.md b/docs/adr/0011-entrees-du-joueur-captees-par-l-adaptateur.md new file mode 100644 index 0000000..342b704 --- /dev/null +++ b/docs/adr/0011-entrees-du-joueur-captees-par-l-adaptateur.md @@ -0,0 +1,66 @@ +# ADR 0011 : Entrées du joueur captées par l'adaptateur, traduction en actions dans l'adaptateur + +- Statut : Proposée +- Date : 2026-11-07 +- Décideur : Ange-Marie MAURIN - A2M Systèmes +- Références : docs/SEQUA_Engine_resume_complet_v2.md §4.2, §5.3, §7.0, §7.0.1, §7.2, §10.1, §14, §15 (décisions 1, 3, 6) ; ADR 0007, ADR 0008, ADR 0009 ; spécification [docs/specs/protocole-core-rendu-0.1.md](../specs/protocole-core-rendu-0.1.md) (version 0.2 du protocole) + +## Contexte + +Le Core sait recevoir des entrées (`session/input`, protocole Studio - Core 0.2), les applique au tick suivant et les enregistre : le rejeu est exact (§5.3). Mais aucune touche n'y est reliée, à part dans les tests. Pour éprouver la simulation dans la fenêtre du moteur (et bientôt la voie générative lente, étape 4 bis), il faut que le joueur puisse agir **depuis la fenêtre où il regarde le jeu**. L'ADR 0009 laisse ce chemin ouvert. + +Contraintes : + +- le moteur ne fait qu'afficher : il ne doit pas déplacer lui-même les objets, sinon ces mouvements échappent à l'enregistrement (décisions 1 et 3) ; +- SEQUA 3D est la seule porte vers le moteur, et rien n'y reprend un concept propre à un moteur (§7.0.1) ; +- l'auteur doit pouvoir écrire sa propre correspondance avancée (manette, combinaisons, double appui, zones mortes) sans recompiler SEQUA ; +- la simulation ne reçoit que des valeurs entières (§10.1). + +## Options envisagées + +### Où traduire les touches en actions + +1. **Dans le Core** : le moteur envoie les touches brutes (codes de touches, axes de manette), le Core les traduit. Une seule table pour tous les moteurs, mais il faut définir un vocabulaire universel de touches et d'appareils, et l'auteur ne peut pas réutiliser les outils de son moteur. +2. **Dans l'adaptateur** : l'adaptateur traduit les appareils en **actions SEQUA** (boutons et axes quantifiés) et ne transmet que ces actions. Chaque moteur offre déjà un système de correspondance (InputMap dans Godot, Enhanced Input dans Unreal), que l'auteur sait utiliser. Ce qui part vers le Core est le même pour tous les moteurs. + +### Ce qui est enregistré + +Dans les deux cas, le Core enregistre ce qu'il reçoit au tick où il l'applique. Avec l'option 2, ce sont les actions : le rejeu ne dépend pas de la correspondance, qui peut changer sans invalider les enregistrements. + +## Décision + +**Option 2 : l'adaptateur capte les appareils et les traduit en actions ; le Core ne reçoit et n'enregistre que des actions.** + +- **Contrat SEQUA 3D** : type `three_d::PlayerInput` `{ buttons: uint32, move_x: int16, move_y: int16 }` (axes dans [-32767, 32767], même forme que `sim::InputFrame`), méthode `I3DEngineAdapter::poll_input()` appelée après chaque image, capacité `EngineCapabilities::player_input`. La signification des bits appartient au mode de jeu du Core (aujourd'hui `sim::button::jump`, bit 0). Version d'interface des adaptateurs : 2. +- **Protocole Core - rendu 0.2** : message `render/input` du rendu vers le Core, envoyé **seulement quand les actions changent**. Le Core l'applique comme `session/input` (prochain tick exécuté, entrée maintenue, la dernière reçue gagne, quelle que soit sa source). À la déconnexion du rendu, une entrée encore maintenue par lui est remise au neutre (pas de joueur qui court tout seul après un plantage). +- **Focus** : un adaptateur ne capte que lorsque sa fenêtre a le focus ; sans focus, il renvoie l'entrée neutre. +- **Adaptateur Godot, deux niveaux de correspondance** : + 1. *Par défaut* : actions de l'InputMap du projet Godot (`project.godot`, section `[input]`), modifiables dans l'éditeur Godot ou à la main : `sequa_move_left`, `sequa_move_right`, `sequa_move_down`, `sequa_move_up` (axes, zones mortes de Godot), `sequa_jump` (bit 0) et `sequa_button_1` à `sequa_button_31` (bits suivants, facultatifs). Le projet fourni les relie au clavier (flèches, ZQSD en position physique, espace) et à la manette. + 2. *Avancé* : si la scène principale contient un nœud `SequaInput` doté d'une méthode `sequa_player_input()`, l'adaptateur l'appelle à chaque image à la place du niveau 1. Elle renvoie `{ "buttons": int, "move_x": float, "move_y": float }` (axes dans [-1, 1]) ; l'auteur y écrit ce qu'il veut en GDScript (combinaisons, double appui, appareils particuliers). Un résultat invalide donne l'entrée neutre et un message dans le journal du rendu. + Un autre projet Godot se désigne déjà par `--adapter-options {"project": ...}`. +- Ce script ne produit **que des actions** : il ne touche pas aux objets de la scène SEQUA (synchronisation à sens unique, §7.0). +- **Adaptateur de débogage** : entrées scriptées par numéro d'image (`--adapter-options {"input": [...]}`), pour tester le chemin complet sans moteur ni clavier. + +## Conséquences + +### Positives + +- On joue dans la fenêtre du jeu ; Studio n'a pas besoin du focus. +- L'auteur règle sa correspondance avec les outils de son moteur, et peut aller jusqu'au script, sans recompiler SEQUA. +- Le Core reste indépendant du moteur et des appareils ; les enregistrements restent valables si la correspondance change. +- Unreal se branchera de la même façon (Enhanced Input traduit en `PlayerInput`). + +### Négatives et coûts + +- Chaque adaptateur réécrit sa traduction ; une correspondance écrite pour Godot ne sert pas telle quelle à Unreal. +- La correspondance n'est pas enregistrée : on rejoue les actions, pas les touches. Pour analyser le ressenti d'un appareil (§4.2), il faudra un enregistrement à part des entrées brutes. +- Un script de correspondance mal écrit peut ralentir l'image : il tourne dans la boucle du moteur. +- Deux sources d'entrées (Studio et rendu) se partagent l'entrée maintenue : la dernière gagne, sans arbitrage. +- Changement d'interface des adaptateurs (version 2) et du protocole Core - rendu (0.2) : rendu, Core et adaptateurs doivent être reconstruits ensemble (c'est déjà le cas avec le build unifié, ADR 0010). + +### Questions ouvertes + +- Latence entre la touche et l'affichage (§14) : `render/input` porte l'instant de capture ; la mesure de bout en bout viendra avec la lecture des tampons (étape 5). +- Plusieurs joueurs et plusieurs appareils : un seul joueur pour l'instant. +- Arbitrage entre Studio et le rendu quand les deux envoient des entrées : à décider si cela gêne. +- Souris et visée (axes relatifs) : à ajouter quand un mode de jeu en aura besoin. diff --git a/docs/adr/README.md b/docs/adr/README.md index 58cba83..710e97c 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -22,6 +22,7 @@ Une ADR (*Architecture Decision Record*) consigne **une** décision structurante | [0008](0008-processus-de-rendu-et-bibliotheques-chargees.md) | Processus de rendu séparé du Core, moteur et inférence en bibliothèques chargées | Acceptée | | [0009](0009-processus-de-rendu-lance-par-le-core.md) | Processus de rendu lancé par le Core, protocole Core ↔ rendu, adaptateurs en bibliothèques chargées | Acceptée | | [0010](0010-arborescence-des-binaires-et-build-unifie.md) | Arborescence des binaires (développement et installation), build unifié | Acceptée | +| [0011](0011-entrees-du-joueur-captees-par-l-adaptateur.md) | Entrées du joueur captées par l'adaptateur, traduction en actions dans l'adaptateur | Proposée | ## Décisions ouvertes (docs, §17) diff --git a/docs/specs/protocole-core-rendu-0.1.md b/docs/specs/protocole-core-rendu-0.1.md index e597b1b..0479d1f 100644 --- a/docs/specs/protocole-core-rendu-0.1.md +++ b/docs/specs/protocole-core-rendu-0.1.md @@ -1,6 +1,7 @@ -# Spécification : protocole Core - rendu 0.1 et `sequa-render` +# Spécification : protocole Core - rendu 0.2 et `sequa-render` -- Statut : **brouillon** (étape 4). Décisions : [ADR 0009](../adr/0009-processus-de-rendu-lance-par-le-core.md). +- Statut : **brouillon** (étape 4). Décisions : [ADR 0009](../adr/0009-processus-de-rendu-lance-par-le-core.md) ; entrées du joueur (0.2) : [ADR 0011](../adr/0011-entrees-du-joueur-captees-par-l-adaptateur.md). +- Historique : 0.1 (scène, mesures) ; **0.2** : message `render/input`, capacité `playerInput`, interface des adaptateurs en version 2. Le fichier garde son nom d'origine. - Le format reste du **JSON provisoire** (ADR 0005) en attendant l'ADR du schéma. Enveloppe, entiers 64 bits en hexadécimal et règles d'erreur : comme le [protocole Studio - Core 0.2](protocole-studio-core-0.2.md) §2 et §3. - Toute ambiguïté se règle en modifiant CE document. @@ -44,14 +45,14 @@ sequa::three_d::I3DEngineAdapter* sequa_create_engine_adapter(std::uint32_t inte void sequa_destroy_engine_adapter(sequa::three_d::I3DEngineAdapter* adapter); ``` -`interface_version` = `sequa::three_d::adapter_interface_version` (entier, 1 au départ), incrémenté à chaque changement de `I3DEngineAdapter` ou des types du contrat. Le rendu détruit l'adaptateur, **décharge sa bibliothèque**, puis seulement ensuite tout ce que l'adaptateur aurait chargé lui-même (le moteur, déchargé par l'adaptateur à sa destruction). +`interface_version` = `sequa::three_d::adapter_interface_version` (entier, 1 au départ, 2 depuis `poll_input`), incrémenté à chaque changement de `I3DEngineAdapter` ou des types du contrat. Le rendu détruit l'adaptateur, **décharge sa bibliothèque**, puis seulement ensuite tout ce que l'adaptateur aurait chargé lui-même (le moteur, déchargé par l'adaptateur à sa destruction). ## 4. Poignée de main 1. Connexion avec le jeton (protocole 0.2 §3). -2. Rendu → Core : `hello` `{ protocolVersion: "0.1.0", client: "sequa-render" }`. Le Core reconnaît le canal de rendu à `client` et applique la version de CE protocole (compatibilité : même majeure et, en 0.x, même mineure). +2. Rendu → Core : `hello` `{ protocolVersion: "0.2.0", client: "sequa-render" }`. Le Core reconnaît le canal de rendu à `client` et applique la version de CE protocole (compatibilité : même majeure et, en 0.x, même mineure). 3. Core → rendu : `welcome` `{ protocolVersion, coreVersion, instanceId, renderId }` (`renderId` : identifiant de ce lancement, 32 hex). -4. Rendu → Core : `render/ready` `{ capabilities: { engineName, engineVersion, buffers: [noms §5.6], windowed, headless }, width, height }` après `start()` réussi de l'adaptateur. En cas d'échec : `render/failed` `{ code, message }` puis fermeture et code de sortie 1. +4. Rendu → Core : `render/ready` `{ capabilities: { engineName, engineVersion, buffers: [noms §5.6], windowed, headless, playerInput }, width, height }` (`playerInput` : le moteur capte les entrées du joueur, facultatif, faux par défaut) après `start()` réussi de l'adaptateur. En cas d'échec : `render/failed` `{ code, message }` puis fermeture et code de sortie 1. 5. Core → rendu : `scene/delta` avec `full: true` (scène complète), puis les deltas suivants. Un seul canal de rendu à la fois : une deuxième connexion `sequa-render` reçoit `error` `{ code: "render-exists" }` et est fermée. @@ -88,9 +89,19 @@ Commandes (champ `op`) ; identifiants d'objets en hexadécimal, nombres en JSON | `render/frames` | `{ frames: int, lastWorldVersion: hex, cpuMsMedian, cpuMsP95, latencyUsMedian, latencyUsP95, applyUsMedian, applyUsP95 }` : envoyé toutes les secondes. `latencyUs` = réception du delta − `sentAtUs` (transport seul : c'est le critère de l'ADR 0009) ; `applyUs` = application du delta − `sentAtUs` (attente de la prochaine image comprise) | | `render/failed` | `{ code, message }` : l'adaptateur a échoué ; fermeture, code de sortie 1 | | `render/closed` | `{}` : l'utilisateur a fermé la fenêtre ; fermeture, code de sortie 4 | +| `render/input` | `{ buttons: int [0, 2^32-1], moveX: int, moveY: int ([-32767, 32767]), capturedAtUs: hex }` : entrées du joueur déjà traduites en actions par l'adaptateur (§5.1) | Une erreur d'application d'un delta (version qui recule, id inconnu) est une erreur de protocole : `render/failed` `{ code: "invalid-delta" }`. +### 5.1 Entrées du joueur (0.2, ADR 0011) + +- Après chaque image, si l'adaptateur déclare `playerInput`, le rendu appelle `poll_input()` et envoie `render/input` **seulement si les actions ont changé** depuis le dernier envoi (départ : entrée neutre, rien n'est envoyé tant que le joueur ne fait rien). +- La traduction des appareils (clavier, manette…) en actions appartient à l'adaptateur. Hors focus, l'adaptateur renvoie l'entrée neutre. +- Le Core applique `render/input` comme `session/input` (protocole Studio - Core §4) : prochain tick exécuté, entrée maintenue, la dernière reçue gagne, qu'elle vienne de Studio ou du rendu. Elle est enregistrée : le rejeu est exact. +- Payload invalide : `error` `{ code: "invalid-payload" }`, entrée inchangée, connexion conservée. `render/input` reçu sur une connexion Studio : `error` `unknown-type`. +- Déconnexion du rendu alors que sa dernière entrée n'était pas neutre : le Core remet l'entrée au neutre. +- `capturedAtUs` : horloge monotone du rendu à la lecture (comme `sentAtUs`), pour la mesure future de la latence entre la touche et l'affichage. + ## 6. Ajouts au protocole Studio - Core (0.3) | Requête | Payload envoyé | Réponse | @@ -114,6 +125,6 @@ Une erreur d'application d'un delta (version qui recule, id inconnu) est une err - Le rendu reçoit sur le fil du client, empile, et applique **tous** les deltas reçus avant chaque image, dans l'ordre, puis appelle `render_frame()`. Pas d'interpolation en 0.1 (ADR 0009). - Si le rendu prend du retard, les deltas s'accumulent mais aucun n'est sauté : la scène reste exacte. -## 9. Hors 0.1 +## 9. Hors 0.2 -Tampons de rendu (étape 5), entrées du joueur captées par la fenêtre du moteur, mesures de VRAM, plusieurs rendus simultanés, interpolation. +Tampons de rendu (étape 5), mesures de VRAM, plusieurs rendus simultanés, interpolation, plusieurs joueurs, souris et visée. diff --git a/docs/specs/protocole-studio-core-0.2.md b/docs/specs/protocole-studio-core-0.2.md index 778f9ca..80cd6be 100644 --- a/docs/specs/protocole-studio-core-0.2.md +++ b/docs/specs/protocole-studio-core-0.2.md @@ -157,7 +157,7 @@ Bornes : `width` et `height` dans [64, 7680] (défaut 1280 × 720), `fps` dans [ "adapter": "godot", // absent si stopped "pid": 1234, // absent si aucun processus "restarts": 0, - "engine": { "name": "godot", "version": "4.7.2-stable", "buffers": ["color", "depth"], "windowed": true, "headless": false }, + "engine": { "name": "godot", "version": "4.7.2-stable", "buffers": ["color", "depth"], "windowed": true, "headless": false, "playerInput": true }, "width": 1280, "height": 720, // absents tant que le rendu n'est pas prêt "measurements": { "framesPerSecond": 180, "cpuMsP95": 6.1, "latencyUsP95": 340, "applyUsP95": 5900 }, "worldVersion": "00000000000001d5", // dernière version de scène envoyée diff --git a/engines/debug/adapter/README.md b/engines/debug/adapter/README.md index a52ba11..8dcc0b8 100644 --- a/engines/debug/adapter/README.md +++ b/engines/debug/adapter/README.md @@ -22,6 +22,20 @@ adapter.render_frame(request); // dessine et écrit une image ; adapter.last_ima - `sequa::debug::PpmSequenceSink` : une image par fichier `frame_NNNNNN.ppm` (binaire P6). - `sequa::debug::FrameSink` : interface pour d'autres destinations. +## Entrées scriptées + +Sans clavier ni fenêtre, l'adaptateur « joue » un script d'entrées par numéro d'image +([ADR 0011](../../../docs/adr/0011-entrees-du-joueur-captees-par-l-adaptateur.md)) : le chemin +complet des entrées (adaptateur → rendu → Core) se teste sans moteur. Réglage de +`--adapter-options` (ou `--render-options` du Core) : + +```json +{ "input": [{ "frame": 20, "moveX": 32767 }, { "frame": 60, "buttons": 1, "moveX": 32767 }, { "frame": 100 }] } +``` + +À partir de l'image `frame`, l'entrée reste active jusqu'à la suivante ; champs absents = 0 +(`{ "frame": 100 }` relâche tout). Sans script, l'adaptateur ne déclare pas de capture d'entrées. + ## ffmpeg facultatif `sequa::debug::FfmpegSink` lance `ffmpeg` en sous-processus (`_popen`/`popen`) pour écrire diff --git a/engines/debug/adapter/include/sequa/debug/debug_adapter.hpp b/engines/debug/adapter/include/sequa/debug/debug_adapter.hpp index 6b3dfea..7c13458 100644 --- a/engines/debug/adapter/include/sequa/debug/debug_adapter.hpp +++ b/engines/debug/adapter/include/sequa/debug/debug_adapter.hpp @@ -5,20 +5,34 @@ #include "sequa/debug/frame_sink.hpp" #include "sequa/debug/raster.hpp" +#include #include +#include namespace sequa::debug { +// Entrée scriptée : à partir de l'image `frame` (FrameRequest::frame_index), l'adaptateur +// renvoie `input` jusqu'à l'entrée suivante (ADR 0011). +struct ScriptedInput { + std::uint64_t frame = 0; + three_d::PlayerInput input; +}; + // Adaptateur SEQUA 3D « de débogage » : outil de débogage et de mode sans interface // (docs §7.1 : deuxième adaptateur, test d'isolation léger), pas un rendu produit. // // Il rastérise la scène en vue de côté 2D (voire raster.hpp) sans GPU ni moteur, et écrit // l'image dans le FrameSink fourni (images PPM ou vidéo ffmpeg externe ; un sink nul // n'écrit rien, l'image reste consultable via last_image()). +// +// Sans clavier ni fenêtre, il « joue » un script d'entrées : le chemin complet des entrées +// (adaptateur → rendu → Core) se teste sans moteur. Sans script, il ne déclare pas de +// capture d'entrées. class DebugEngineAdapter final : public three_d::I3DEngineAdapter { public: - // sink peut être nul : aucune écriture ; view définit la vue de côté. - DebugEngineAdapter(std::unique_ptr sink, ViewParams view = {}); + // sink peut être nul : aucune écriture ; view définit la vue de côté ; script est trié + // par image (ordre stable pour une même image : la dernière gagne). + DebugEngineAdapter(std::unique_ptr sink, ViewParams view = {}, std::vector script = {}); [[nodiscard]] three_d::EngineCapabilities capabilities() const override; @@ -28,6 +42,8 @@ public: [[nodiscard]] std::optional apply(const three_d::SceneDelta& delta) override; [[nodiscard]] three_d::FrameOutcome render_frame(const three_d::FrameRequest& request) override; + // Entrée du script pour la dernière image rendue (neutre avant la première entrée). + [[nodiscard]] std::optional poll_input() override; // Dernière image rastérisée, à lire pour les tests et le journal. [[nodiscard]] const Image& last_image() const noexcept { return last_image_; } @@ -35,6 +51,8 @@ public: private: std::unique_ptr sink_; ViewParams view_; + std::vector script_; + std::optional last_frame_; // dernière image rendue depuis start() three_d::SceneState scene_; bool running_ = false; std::uint32_t width_ = 0; // taille mémorisée par start() diff --git a/engines/debug/adapter/src/debug_adapter.cpp b/engines/debug/adapter/src/debug_adapter.cpp index 03d505c..a9c1815 100644 --- a/engines/debug/adapter/src/debug_adapter.cpp +++ b/engines/debug/adapter/src/debug_adapter.cpp @@ -1,6 +1,7 @@ // SPDX-License-Identifier: MPL-2.0 #include "sequa/debug/debug_adapter.hpp" +#include #include #include @@ -10,9 +11,12 @@ using three_d::BufferSet; using three_d::Error; using three_d::FrameBuffer; -DebugEngineAdapter::DebugEngineAdapter(std::unique_ptr sink, ViewParams view) - : sink_(std::move(sink)), view_(view) +DebugEngineAdapter::DebugEngineAdapter(std::unique_ptr sink, ViewParams view, + std::vector script) + : sink_(std::move(sink)), view_(view), script_(std::move(script)) { + std::stable_sort(script_.begin(), script_.end(), + [](const ScriptedInput& a, const ScriptedInput& b) { return a.frame < b.frame; }); } three_d::EngineCapabilities DebugEngineAdapter::capabilities() const @@ -25,9 +29,27 @@ three_d::EngineCapabilities DebugEngineAdapter::capabilities() const capabilities.windowed = false; capabilities.headless = true; // max_width/max_height laissés à 0 : aucune limite de taille. + capabilities.player_input = !script_.empty(); return capabilities; } +std::optional DebugEngineAdapter::poll_input() +{ + if (!running_ || script_.empty()) { + return std::nullopt; + } + three_d::PlayerInput current; + if (last_frame_) { + for (const ScriptedInput& entry : script_) { + if (entry.frame > *last_frame_) { + break; + } + current = entry.input; + } + } + return current; +} + std::optional DebugEngineAdapter::start(const three_d::EngineConfig& config) { if (running_) { @@ -44,6 +66,7 @@ std::optional DebugEngineAdapter::start(const three_d::EngineConfig& conf width_ = config.width; height_ = config.height; scene_.clear(); // après start(), la scène du moteur est vide (l'appelant pousse un snapshot) + last_frame_.reset(); running_ = true; return std::nullopt; } @@ -87,6 +110,7 @@ three_d::FrameOutcome DebugEngineAdapter::render_frame(const three_d::FrameReque return *std::move(error); } } + last_frame_ = request.frame_index; three_d::FrameResult result; result.frame_index = request.frame_index; result.world_version = scene_.world_version(); diff --git a/engines/debug/adapter/src/entry_point.cpp b/engines/debug/adapter/src/entry_point.cpp index e98a6da..8261801 100644 --- a/engines/debug/adapter/src/entry_point.cpp +++ b/engines/debug/adapter/src/entry_point.cpp @@ -2,19 +2,25 @@ // Point d'entrée de la bibliothèque d'adaptateur de débogage (ADR 0009). // // Réglages (--adapter-options de sequa-render, tous facultatifs) : -// { "output": "", "fps": 10, "ffmpeg": "ffmpeg", "viewHeightM": 12 } +// { "output": "", "fps": 10, "ffmpeg": "ffmpeg", "viewHeightM": 12, +// "input": [{ "frame": 0, "buttons": 0, "moveX": 32767, "moveY": 0 }, ...] } // Sortie .mp4, .mkv, .webm ou .avi : vidéo par ffmpeg (outil externe) ; autre chemin : dossier -// d'images PPM ; pas de sortie : rien n'est écrit. +// d'images PPM ; pas de sortie : rien n'est écrit. "input" : entrées scriptées par numéro +// d'image (ADR 0011), champs autres que "frame" à 0 par défaut. #include "sequa/3d/adapter_library.hpp" #include "sequa/debug/debug_adapter.hpp" #include +#include #include +#include #include #include #include +#include +#include namespace { @@ -56,6 +62,50 @@ SEQUA_ADAPTER_EXPORT sequa::three_d::I3DEngineAdapter* sequa_create_engine_adapt view.view_height_m = static_cast(it->get()); } + // Entrées scriptées (ADR 0011) : [{ "frame": n, "buttons": b, "moveX": x, "moveY": y }, ...]. + std::vector script; + if (const auto it = options.find("input"); it != options.end()) { + if (!it->is_array()) { + write_adapter_error(error, error_size, "adaptateur debug : \"input\" doit être un tableau"); + return nullptr; + } + const auto integer_in = [](const nlohmann::json& entry, const char* key, std::int64_t low, std::int64_t high, + std::int64_t& out) { + if (!entry.contains(key)) { + out = 0; + return std::string_view(key) != "frame"; // seule l'image est obligatoire + } + const nlohmann::json& value = entry[key]; + if (!value.is_number_integer()) { + return false; + } + if (value.is_number_unsigned() && value.get() > static_cast(high)) { + return false; + } + out = value.get(); + return out >= low && out <= high; + }; + for (const nlohmann::json& entry : *it) { + std::int64_t frame = 0; + std::int64_t buttons = 0; + std::int64_t move_x = 0; + std::int64_t move_y = 0; + const std::int64_t axis = sequa::three_d::axis_max; + if (!entry.is_object() || !integer_in(entry, "frame", 0, std::numeric_limits::max(), frame) || + !integer_in(entry, "buttons", 0, 0xFFFFFFFFLL, buttons) || + !integer_in(entry, "moveX", -axis, axis, move_x) || !integer_in(entry, "moveY", -axis, axis, move_y)) { + write_adapter_error(error, error_size, + "adaptateur debug : entrée de \"input\" invalide (frame >= 0 obligatoire, " + "buttons sur 32 bits, moveX/moveY dans [-32767, 32767])"); + return nullptr; + } + script.push_back({static_cast(frame), + sequa::three_d::PlayerInput{static_cast(buttons), + static_cast(move_x), + static_cast(move_y)}}); + } + } + std::unique_ptr sink; const std::string output_text = string_option("output", ""); const std::filesystem::path output(std::u8string(output_text.begin(), output_text.end())); // UTF-8 @@ -66,7 +116,7 @@ SEQUA_ADAPTER_EXPORT sequa::three_d::I3DEngineAdapter* sequa_create_engine_adapt sink = std::make_unique(output); } } - return new (std::nothrow) sequa::debug::DebugEngineAdapter(std::move(sink), view); + return new (std::nothrow) sequa::debug::DebugEngineAdapter(std::move(sink), view, std::move(script)); } SEQUA_ADAPTER_EXPORT void sequa_destroy_engine_adapter(sequa::three_d::I3DEngineAdapter* adapter) diff --git a/engines/debug/adapter/tests/test_debug_adapter.cpp b/engines/debug/adapter/tests/test_debug_adapter.cpp index 7eb524c..7988342 100644 --- a/engines/debug/adapter/tests/test_debug_adapter.cpp +++ b/engines/debug/adapter/tests/test_debug_adapter.cpp @@ -180,3 +180,37 @@ TEST_CASE("Debug : stop puis redémarrage, la scène est vidée") REQUIRE(std::holds_alternative(outcome)); CHECK(std::get(outcome).world_version == 5); } + +TEST_CASE("Debug : entrees scriptees par numero d'image") +{ + debug::DebugEngineAdapter silent(nullptr); + CHECK_FALSE(silent.capabilities().player_input); + REQUIRE_FALSE(silent.start(headless_config())); + CHECK_FALSE(silent.poll_input().has_value()); + + // Desordre volontaire : le constructeur trie par image. + debug::DebugEngineAdapter adapter(nullptr, {}, + {{10, three_d::PlayerInput{0, -100, 0}}, + {2, three_d::PlayerInput{1, 32767, 0}}}); + CHECK(adapter.capabilities().player_input); + CHECK_FALSE(adapter.poll_input().has_value()); // arrete + REQUIRE_FALSE(adapter.start(headless_config())); + REQUIRE(adapter.poll_input().has_value()); + CHECK(adapter.poll_input()->is_neutral()); // aucune image rendue + + const auto input_after = [&](std::uint64_t frame) { + three_d::FrameRequest request; + request.frame_index = frame; + REQUIRE(std::holds_alternative(adapter.render_frame(request))); + return *adapter.poll_input(); + }; + CHECK(input_after(0).is_neutral()); + CHECK(input_after(2) == three_d::PlayerInput{1, 32767, 0}); + CHECK(input_after(9) == three_d::PlayerInput{1, 32767, 0}); + CHECK(input_after(10) == three_d::PlayerInput{0, -100, 0}); + CHECK(input_after(500) == three_d::PlayerInput{0, -100, 0}); + + adapter.stop(); + REQUIRE_FALSE(adapter.start(headless_config())); + CHECK(adapter.poll_input()->is_neutral()); // redemarrage : on repart du debut +} diff --git a/engines/godot/adapter/CMakeLists.txt b/engines/godot/adapter/CMakeLists.txt index 6e0c274..eb138b5 100644 --- a/engines/godot/adapter/CMakeLists.txt +++ b/engines/godot/adapter/CMakeLists.txt @@ -4,7 +4,7 @@ # LibGodot (scripts/build-godot.ps1) ; avec SEQUA_BUILD_GODOT_RUNTIME, godot-cpp # (../vendor/godot-cpp) est compilé ici pour piloter Godot depuis l'adaptateur (ADR 0007). -add_library(sequa_godot_adapter src/godot_adapter.cpp src/conversions.cpp) +add_library(sequa_godot_adapter src/godot_adapter.cpp src/conversions.cpp src/input_mapping.cpp) add_library(sequa::godot_adapter ALIAS sequa_godot_adapter) # Le nom « sequa_godot_adapter » revient à la bibliothèque chargée (et à son .lib d'import). set_target_properties(sequa_godot_adapter PROPERTIES OUTPUT_NAME sequa_godot_adapter_static) @@ -55,7 +55,7 @@ if(SEQUA_BUILD_GODOT_RUNTIME) # godot-cpp écrirait sinon ses .pdb dans /bin. set_target_properties(godot-cpp PROPERTIES PDB_OUTPUT_DIRECTORY "${CMAKE_CURRENT_BINARY_DIR}/godot-cpp/bin") - target_sources(sequa_godot_adapter PRIVATE src/godot_host.cpp src/godot_scene.cpp) + target_sources(sequa_godot_adapter PRIVATE src/godot_host.cpp src/godot_scene.cpp src/godot_input.cpp) target_link_libraries(sequa_godot_adapter PRIVATE godot::cpp sequa::layout) target_compile_definitions(sequa_godot_adapter PRIVATE SEQUA_GODOT_RUNTIME=1) @@ -92,7 +92,8 @@ else() endif() if(SEQUA_BUILD_TESTS) - add_executable(sequa_godot_adapter_tests tests/test_godot_adapter.cpp tests/test_conversions.cpp) + add_executable(sequa_godot_adapter_tests tests/test_godot_adapter.cpp tests/test_conversions.cpp + tests/test_input_mapping.cpp) target_link_libraries(sequa_godot_adapter_tests PRIVATE sequa::godot_adapter doctest::doctest) sequa_target_defaults(sequa_godot_adapter_tests) doctest_discover_tests(sequa_godot_adapter_tests) diff --git a/engines/godot/adapter/README.md b/engines/godot/adapter/README.md index 2fa76c8..66d2cca 100644 --- a/engines/godot/adapter/README.md +++ b/engines/godot/adapter/README.md @@ -17,7 +17,29 @@ Implémentation de `sequa::three_d::I3DEngineAdapter` (voir [3d/](../../../3d)) |---|---| | `src/godot_host.*` | LibGodot : chargement de la DLL, instance, godot-cpp en extension hôte, boucle | | `src/godot_scene.*` | Nœuds Godot qui reflètent la scène SEQUA (deltas déjà validés par `SceneState`) | +| `src/godot_input.*` | Lecture des entrées du joueur dans Godot (InputMap ou script `SequaInput`) | | `include/sequa/godot/conversions.hpp` | Conversions pures (rotation, couleur sRGB, formes), testées sans moteur | +| `include/sequa/godot/input_mapping.hpp` | Noms des actions et traduction en `PlayerInput`, testées sans moteur | +| `examples/sequa_input.gd` | Exemple de correspondance avancée (double appui pour courir) | + +### Entrées du joueur ([ADR 0011](../../../docs/adr/0011-entrees-du-joueur-captees-par-l-adaptateur.md)) + +L'adaptateur lit le clavier et la manette dans la fenêtre Godot, les traduit en **actions SEQUA** (`buttons` sur 32 bits, axes `move_x` et `move_y`) et `sequa-render` les envoie au Core, qui les applique et les enregistre (rejeu exact). Godot ne déplace rien lui-même. Sans focus, l'entrée est neutre. + +**Niveau 1, par défaut : l'InputMap du projet Godot** (`project/project.godot`, section `[input]`), modifiable dans l'éditeur Godot (Projet > Paramètres du projet > Contrôles) ou à la main : + +| Action Godot | Action SEQUA | Projet fourni | +|---|---|---| +| `sequa_move_left` / `sequa_move_right` | `move_x` (droite − gauche) | Q / D (AZERTY, position physique A / D), flèches, stick gauche, croix | +| `sequa_move_down` / `sequa_move_up` | `move_y` (haut − bas) | S / Z, flèches, stick gauche, croix | +| `sequa_jump` | bit 0 (saut) | Espace, bouton A (Croix) | +| `sequa_button_1` … `sequa_button_31` | bits 1 à 31 | non définies (facultatives) | + +Une action absente compte pour zéro. Les zones mortes et l'analogique sont ceux de Godot. + +**Niveau 2, avancé : un script.** Ajouter à la racine de la scène principale (`main.tscn`) un nœud nommé `SequaInput` avec un script qui définit `sequa_player_input()` et renvoie `{ "buttons": int, "move_x": float, "move_y": float }` (axes dans [-1, 1], clés absentes = 0). L'adaptateur l'appelle à chaque image à la place du niveau 1 et l'annonce dans le journal du rendu ; un résultat invalide donne l'entrée neutre et un message (une fois). Exemple : [`examples/sequa_input.gd`](examples/sequa_input.gd). Le script ne doit produire que des actions, jamais toucher aux objets de `SequaScene`. + +Pour utiliser son propre projet Godot sans toucher au projet fourni : `--adapter-options {"project": "C:/chemin/vers/projet"}` (le projet doit contenir le nœud `SequaScene`). ### Recherche de la bibliothèque et du projet Godot (ADR 0010) diff --git a/engines/godot/adapter/examples/sequa_input.gd b/engines/godot/adapter/examples/sequa_input.gd new file mode 100644 index 0000000..411db74 --- /dev/null +++ b/engines/godot/adapter/examples/sequa_input.gd @@ -0,0 +1,37 @@ +# SPDX-License-Identifier: MPL-2.0 +# +# Exemple de correspondance avancée des entrées (ADR 0011, niveau 2). À attacher à un nœud +# nommé « SequaInput », enfant direct de la racine de la scène principale du projet Godot +# (main.tscn). Dès qu'il existe, l'adaptateur appelle sequa_player_input() à chaque image à +# la place de l'InputMap. +# +# Ce script ne produit QUE des actions : il ne touche pas aux objets de la scène SEQUA (le +# Core reste la source de vérité et enregistre ces actions pour le rejeu). +# +# Exemple : course en double appui sur une direction, saut tant que la touche est tenue. +extends Node + +const DOUBLE_TAP_MS := 250 +const WALK := 0.5 + +var _last_tap_ms := { "left": -100000, "right": -100000 } +var _running := false + +func _process(_delta: float) -> void: + for side in ["left", "right"]: + if Input.is_action_just_pressed("sequa_move_" + side): + var now := Time.get_ticks_msec() + _running = now - _last_tap_ms[side] < DOUBLE_TAP_MS + _last_tap_ms[side] = now + +func sequa_player_input() -> Dictionary: + var axis := Input.get_axis("sequa_move_left", "sequa_move_right") + if axis == 0.0: + _running = false + var speed := 1.0 if _running else WALK + var buttons := 1 if Input.is_action_pressed("sequa_jump") else 0 # bit 0 : saut + return { + "buttons": buttons, + "move_x": axis * speed, + "move_y": Input.get_axis("sequa_move_down", "sequa_move_up"), + } diff --git a/engines/godot/adapter/include/sequa/godot/godot_adapter.hpp b/engines/godot/adapter/include/sequa/godot/godot_adapter.hpp index 2b180e8..d34ee0a 100644 --- a/engines/godot/adapter/include/sequa/godot/godot_adapter.hpp +++ b/engines/godot/adapter/include/sequa/godot/godot_adapter.hpp @@ -49,6 +49,8 @@ public: [[nodiscard]] std::optional apply(const three_d::SceneDelta& delta) override; [[nodiscard]] three_d::FrameOutcome render_frame(const three_d::FrameRequest& request) override; + // Correspondance des entrées : sequa/godot/input_mapping.hpp (ADR 0011). + [[nodiscard]] std::optional poll_input() override; private: struct Runtime; // moteur hébergé et nœuds de la scène (vide sans SEQUA_GODOT_RUNTIME) diff --git a/engines/godot/adapter/include/sequa/godot/input_mapping.hpp b/engines/godot/adapter/include/sequa/godot/input_mapping.hpp new file mode 100644 index 0000000..c73eb58 --- /dev/null +++ b/engines/godot/adapter/include/sequa/godot/input_mapping.hpp @@ -0,0 +1,52 @@ +// SPDX-License-Identifier: MPL-2.0 +#pragma once + +#include "sequa/3d/input.hpp" + +#include +#include +#include + +namespace sequa::godot { + +// Correspondance entre les appareils et les actions SEQUA, propre à l'adaptateur Godot +// (ADR 0011). Deux niveaux : +// +// 1. Par défaut : actions de l'InputMap du projet Godot (project.godot, section [input]), +// réglables dans l'éditeur Godot ou à la main. Une action absente compte pour zéro. +// 2. Avancé : un nœud `SequaInput` à la racine de la scène principale, doté d'une méthode +// `sequa_player_input()` qui renvoie { "buttons": int, "move_x": float, "move_y": float } +// (axes dans [-1, 1], clés absentes = 0). Elle remplace le niveau 1. +// +// Seule la traduction en PlayerInput est ici ; la lecture de Godot est dans godot_input.cpp. + +inline constexpr const char* action_move_left = "sequa_move_left"; +inline constexpr const char* action_move_right = "sequa_move_right"; +inline constexpr const char* action_move_down = "sequa_move_down"; +inline constexpr const char* action_move_up = "sequa_move_up"; +inline constexpr const char* action_jump = "sequa_jump"; // bit 0 +inline constexpr std::uint32_t action_button_count = 32; // sequa_button_1 … sequa_button_31 : bits 1 à 31 + +inline constexpr const char* input_node_name = "SequaInput"; +inline constexpr const char* input_method_name = "sequa_player_input"; + +// Nom de l'action Godot du bit `bit` : sequa_jump pour 0, sequa_button_ ensuite. +[[nodiscard]] std::string button_action_name(std::uint32_t bit); + +// Niveau 1 : forces des actions (Input.get_action_strength, dans [0, 1]) et masque des +// boutons pressés. Axe horizontal = droite - gauche, vertical = haut - bas. +struct ActionStrengths { + double left = 0; + double right = 0; + double down = 0; + double up = 0; + std::uint32_t buttons = 0; +}; +[[nodiscard]] three_d::PlayerInput map_actions(const ActionStrengths& strengths) noexcept; + +// Niveau 2 : champs déjà extraits du Dictionary renvoyé par le script. nullopt si buttons +// sort de [0, 2^32-1] ou si un axe n'est pas fini ; les axes au-delà de [-1, 1] sont bornés. +[[nodiscard]] std::optional map_script_result(std::int64_t buttons, double move_x, + double move_y) noexcept; + +} // namespace sequa::godot diff --git a/engines/godot/adapter/project/project.godot b/engines/godot/adapter/project/project.godot index 453d921..f892312 100644 --- a/engines/godot/adapter/project/project.godot +++ b/engines/godot/adapter/project/project.godot @@ -8,6 +8,51 @@ config_version=5 config/name="SEQUA" run/main_scene="res://main.tscn" +[input] + +; Correspondance par défaut des entrées du joueur (ADR 0011, include/sequa/godot/input_mapping.hpp) : +; touches en position physique (ZQSD sur AZERTY, WASD sur QWERTY), flèches, espace, manette. +; Actions facultatives en plus : sequa_button_1 à sequa_button_31 (bits 1 à 31). + +sequa_move_left={ +"deadzone": 0.2, +"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":65,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null) +, Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":4194319,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null) +, Object(InputEventJoypadMotion,"resource_local_to_scene":false,"resource_name":"","device":-1,"axis":0,"axis_value":-1.0,"script":null) +, Object(InputEventJoypadButton,"resource_local_to_scene":false,"resource_name":"","device":-1,"button_index":13,"pressure":0.0,"pressed":false,"script":null) +] +} +sequa_move_right={ +"deadzone": 0.2, +"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":68,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null) +, Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":4194321,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null) +, Object(InputEventJoypadMotion,"resource_local_to_scene":false,"resource_name":"","device":-1,"axis":0,"axis_value":1.0,"script":null) +, Object(InputEventJoypadButton,"resource_local_to_scene":false,"resource_name":"","device":-1,"button_index":14,"pressure":0.0,"pressed":false,"script":null) +] +} +sequa_move_down={ +"deadzone": 0.2, +"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":83,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null) +, Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":4194322,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null) +, Object(InputEventJoypadMotion,"resource_local_to_scene":false,"resource_name":"","device":-1,"axis":1,"axis_value":1.0,"script":null) +, Object(InputEventJoypadButton,"resource_local_to_scene":false,"resource_name":"","device":-1,"button_index":12,"pressure":0.0,"pressed":false,"script":null) +] +} +sequa_move_up={ +"deadzone": 0.2, +"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":87,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null) +, Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":4194320,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null) +, Object(InputEventJoypadMotion,"resource_local_to_scene":false,"resource_name":"","device":-1,"axis":1,"axis_value":-1.0,"script":null) +, Object(InputEventJoypadButton,"resource_local_to_scene":false,"resource_name":"","device":-1,"button_index":11,"pressure":0.0,"pressed":false,"script":null) +] +} +sequa_jump={ +"deadzone": 0.5, +"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":32,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null) +, Object(InputEventJoypadButton,"resource_local_to_scene":false,"resource_name":"","device":-1,"button_index":0,"pressure":0.0,"pressed":false,"script":null) +] +} + [rendering] renderer/rendering_method="forward_plus" diff --git a/engines/godot/adapter/src/godot_adapter.cpp b/engines/godot/adapter/src/godot_adapter.cpp index d19838f..ce6471c 100644 --- a/engines/godot/adapter/src/godot_adapter.cpp +++ b/engines/godot/adapter/src/godot_adapter.cpp @@ -6,6 +6,7 @@ #if SEQUA_GODOT_RUNTIME #include "godot_host.hpp" +#include "godot_input.hpp" #include "godot_scene.hpp" #endif @@ -17,9 +18,10 @@ using three_d::FrameBuffer; #if SEQUA_GODOT_RUNTIME struct GodotEngineAdapter::Runtime { - // Ordre de destruction : les nœuds de la scène avant le moteur. + // Ordre de destruction : entrées et nœuds de la scène avant le moteur. std::unique_ptr host; std::unique_ptr scene; + std::unique_ptr input; }; #else struct GodotEngineAdapter::Runtime {}; @@ -34,6 +36,7 @@ three_d::EngineCapabilities godot_capabilities() FrameBuffer::motion_vectors}; caps.windowed = true; caps.headless = false; // --headless de Godot coupe le rendu : vrai headless à faire dans le fork (ADR 0007) + caps.player_input = true; // InputMap du projet ou script SequaInput (ADR 0011) return caps; } @@ -64,6 +67,7 @@ std::optional GodotEngineAdapter::start(const three_d::EngineConfig& conf auto runtime = std::make_unique(); runtime->host = std::move(std::get>(outcome)); runtime->scene = std::make_unique(runtime->host->scene_root()); + runtime->input = std::make_unique(runtime->host->scene_root()); runtime_ = std::move(runtime); scene_.clear(); running_ = true; @@ -127,4 +131,16 @@ three_d::FrameOutcome GodotEngineAdapter::render_frame(const three_d::FrameReque #endif } +std::optional GodotEngineAdapter::poll_input() +{ + if (!running_) { + return std::nullopt; + } +#if SEQUA_GODOT_RUNTIME + return runtime_->input->read(); +#else + return std::nullopt; +#endif +} + } // namespace sequa::godot diff --git a/engines/godot/adapter/src/godot_input.cpp b/engines/godot/adapter/src/godot_input.cpp new file mode 100644 index 0000000..988934e --- /dev/null +++ b/engines/godot/adapter/src/godot_input.cpp @@ -0,0 +1,143 @@ +// SPDX-License-Identifier: MPL-2.0 +#include "godot_input.hpp" + +#include "sequa/godot/input_mapping.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include + +namespace sequa::godot { +namespace { + +// Valeur numérique d'une clé du Dictionary renvoyé par le script : absente = 0, entier ou +// flottant accepté (entier seulement si `integer_only`), autre type = invalide. +template +std::optional number_field(const ::godot::Dictionary& result, const char* key, bool integer_only) +{ + const ::godot::String name(key); + if (!result.has(name)) { + return T{0}; + } + const ::godot::Variant value = result[name]; + if (value.get_type() == ::godot::Variant::INT) { + return static_cast(static_cast(value)); + } + if (!integer_only && value.get_type() == ::godot::Variant::FLOAT) { + return static_cast(static_cast(value)); + } + return std::nullopt; +} + +} // namespace + +GodotInput::GodotInput(::godot::Node3D* scene_root) : scene_root_(scene_root) +{ + ::godot::InputMap* map = ::godot::InputMap::get_singleton(); + // Les actions absentes ne sont jamais interrogées : Godot signalerait une erreur à + // chaque image (action inexistante). + const auto bind = [map](Action& action, const std::string& name) { + action.name = ::godot::StringName(name.c_str()); + action.exists = map->has_action(action.name); + }; + bind(left_, action_move_left); + bind(right_, action_move_right); + bind(down_, action_move_down); + bind(up_, action_move_up); + for (std::uint32_t bit = 0; bit < action_button_count; ++bit) { + bind(buttons_[bit], button_action_name(bit)); + } + + ::godot::Node* scene = scene_root_->get_tree()->get_current_scene(); + ::godot::Node* node = scene != nullptr ? scene->get_node_or_null(::godot::NodePath(input_node_name)) : nullptr; + if (node != nullptr) { + if (node->has_method(input_method_name)) { + script_node_ = ::godot::ObjectID(node->get_instance_id()); + std::fprintf(stderr, "adaptateur Godot : correspondance des entrées par le script %s.%s()\n", + input_node_name, input_method_name); + } else { + std::fprintf(stderr, "adaptateur Godot : nœud %s sans méthode %s(), InputMap utilisée\n", + input_node_name, input_method_name); + } + std::fflush(stderr); + } +} + +three_d::PlayerInput GodotInput::read() +{ + if (!scene_root_->get_window()->has_focus()) { + return {}; + } + return script_node_.is_valid() ? read_script() : read_actions(); +} + +three_d::PlayerInput GodotInput::read_actions() const +{ + const ::godot::Input* input = ::godot::Input::get_singleton(); + const auto strength = [input](const Action& action) { + return action.exists ? static_cast(input->get_action_strength(action.name)) : 0.0; + }; + ActionStrengths strengths; + strengths.left = strength(left_); + strengths.right = strength(right_); + strengths.down = strength(down_); + strengths.up = strength(up_); + for (std::uint32_t bit = 0; bit < action_button_count; ++bit) { + if (buttons_[bit].exists && input->is_action_pressed(buttons_[bit].name)) { + strengths.buttons |= 1u << bit; + } + } + return map_actions(strengths); +} + +three_d::PlayerInput GodotInput::read_script() +{ + ::godot::Object* node = ::godot::ObjectDB::get_instance(static_cast(script_node_)); + if (node == nullptr) { + report_script_error("nœud libéré"); + return {}; + } + const ::godot::Variant result = node->call(::godot::StringName(input_method_name)); + if (result.get_type() != ::godot::Variant::DICTIONARY) { + report_script_error("Dictionary attendu"); + return {}; + } + const ::godot::Dictionary fields = result; + const auto buttons = number_field(fields, "buttons", true); + const auto move_x = number_field(fields, "move_x", false); + const auto move_y = number_field(fields, "move_y", false); + if (!buttons || !move_x || !move_y) { + report_script_error("buttons : int, move_x et move_y : float attendus"); + return {}; + } + const std::optional mapped = map_script_result(*buttons, *move_x, *move_y); + if (!mapped) { + report_script_error("buttons hors de [0, 2^32-1] ou axe non fini"); + return {}; + } + return *mapped; +} + +void GodotInput::report_script_error(const char* reason) +{ + // Une fois seulement : le script est appelé à chaque image. + if (!script_error_reported_) { + script_error_reported_ = true; + std::fprintf(stderr, "adaptateur Godot : résultat invalide de %s.%s() (%s), entrée neutre\n", + input_node_name, input_method_name, reason); + std::fflush(stderr); + } +} + +} // namespace sequa::godot diff --git a/engines/godot/adapter/src/godot_input.hpp b/engines/godot/adapter/src/godot_input.hpp new file mode 100644 index 0000000..1208eab --- /dev/null +++ b/engines/godot/adapter/src/godot_input.hpp @@ -0,0 +1,47 @@ +// SPDX-License-Identifier: MPL-2.0 +#pragma once + +#include "sequa/3d/input.hpp" + +#include +#include + +#include +#include + +namespace godot { +class Node3D; +} // namespace godot + +namespace sequa::godot { + +// Lecture des entrées du joueur dans Godot et traduction en actions SEQUA (ADR 0011, +// niveaux décrits dans input_mapping.hpp). À appeler après GodotInstance::iteration(), qui +// a traité les événements de la fenêtre. +class GodotInput { +public: + // scene_root : nœud SequaScene ; le nœud SequaInput est cherché à la racine de la scène + // principale, une fois, au démarrage. + explicit GodotInput(::godot::Node3D* scene_root); + + // Entrée neutre si la fenêtre n'a pas le focus. + [[nodiscard]] three_d::PlayerInput read(); + +private: + [[nodiscard]] three_d::PlayerInput read_actions() const; + [[nodiscard]] three_d::PlayerInput read_script(); + void report_script_error(const char* reason); + + ::godot::Node3D* scene_root_ = nullptr; + ::godot::ObjectID script_node_; // nœud SequaInput (identifiant : le script peut le libérer) + bool script_error_reported_ = false; + + struct Action { + ::godot::StringName name; + bool exists = false; + }; + Action left_, right_, down_, up_; + std::array buttons_{}; +}; + +} // namespace sequa::godot diff --git a/engines/godot/adapter/src/input_mapping.cpp b/engines/godot/adapter/src/input_mapping.cpp new file mode 100644 index 0000000..bcb5cc9 --- /dev/null +++ b/engines/godot/adapter/src/input_mapping.cpp @@ -0,0 +1,34 @@ +// SPDX-License-Identifier: MPL-2.0 +#include "sequa/godot/input_mapping.hpp" + +#include + +namespace sequa::godot { + +std::string button_action_name(std::uint32_t bit) +{ + return bit == 0 ? std::string(action_jump) : "sequa_button_" + std::to_string(bit); +} + +three_d::PlayerInput map_actions(const ActionStrengths& strengths) noexcept +{ + three_d::PlayerInput input; + input.buttons = strengths.buttons; + input.move_x = three_d::quantize_axis(strengths.right - strengths.left); + input.move_y = three_d::quantize_axis(strengths.up - strengths.down); + return input; +} + +std::optional map_script_result(std::int64_t buttons, double move_x, double move_y) noexcept +{ + if (buttons < 0 || buttons > 0xFFFFFFFFLL || !std::isfinite(move_x) || !std::isfinite(move_y)) { + return std::nullopt; + } + three_d::PlayerInput input; + input.buttons = static_cast(buttons); + input.move_x = three_d::quantize_axis(move_x); + input.move_y = three_d::quantize_axis(move_y); + return input; +} + +} // namespace sequa::godot diff --git a/engines/godot/adapter/tests/test_godot_adapter.cpp b/engines/godot/adapter/tests/test_godot_adapter.cpp index a9feccc..e3380a6 100644 --- a/engines/godot/adapter/tests/test_godot_adapter.cpp +++ b/engines/godot/adapter/tests/test_godot_adapter.cpp @@ -16,6 +16,13 @@ TEST_CASE("GodotEngineAdapter : capacites declarees sans demarrer le moteur") // Forward+ n'a pas de G-buffer classique (docs §7.2) : pas d'albedo ni d'identifiants. CHECK_FALSE(caps.buffers.contains(three_d::FrameBuffer::albedo)); CHECK_FALSE(caps.buffers.contains(three_d::FrameBuffer::object_ids)); + CHECK(caps.player_input); // ADR 0011 +} + +TEST_CASE("GodotEngineAdapter : aucune entree tant que le moteur n'est pas demarre") +{ + godot::GodotEngineAdapter adapter; + CHECK_FALSE(adapter.poll_input().has_value()); } TEST_CASE("GodotEngineAdapter : construit sans hebergement, start() non implemente") diff --git a/engines/godot/adapter/tests/test_input_mapping.cpp b/engines/godot/adapter/tests/test_input_mapping.cpp new file mode 100644 index 0000000..f64d6a9 --- /dev/null +++ b/engines/godot/adapter/tests/test_input_mapping.cpp @@ -0,0 +1,48 @@ +// SPDX-License-Identifier: MPL-2.0 +#include "sequa/godot/input_mapping.hpp" + +#include + +#include + +using namespace sequa::godot; +using sequa::three_d::axis_max; +using sequa::three_d::PlayerInput; + +TEST_CASE("Godot : noms des actions de boutons") +{ + CHECK(button_action_name(0) == "sequa_jump"); + CHECK(button_action_name(1) == "sequa_button_1"); + CHECK(button_action_name(31) == "sequa_button_31"); +} + +TEST_CASE("Godot : niveau 1, forces des actions vers PlayerInput") +{ + CHECK(map_actions({}).is_neutral()); + + ActionStrengths right; + right.right = 1.0; + CHECK(map_actions(right) == PlayerInput{0, axis_max, 0}); + + ActionStrengths both; // gauche et droite ensemble s'annulent + both.left = 1.0; + both.right = 1.0; + CHECK(map_actions(both).move_x == 0); + + ActionStrengths analog; // manette a mi-course vers le bas, saut presse + analog.down = 0.5; + analog.buttons = 1; + CHECK(map_actions(analog) == PlayerInput{1, 0, -16384}); +} + +TEST_CASE("Godot : niveau 2, resultat du script") +{ + CHECK(map_script_result(0, 0.0, 0.0) == PlayerInput{}); + CHECK(map_script_result(0xFFFFFFFFLL, -1.0, 1.0) == PlayerInput{0xFFFFFFFFu, -axis_max, axis_max}); + CHECK(map_script_result(5, 2.0, -7.0) == PlayerInput{5, axis_max, -axis_max}); // axes bornes + + CHECK_FALSE(map_script_result(-1, 0.0, 0.0).has_value()); + CHECK_FALSE(map_script_result(0x100000000LL, 0.0, 0.0).has_value()); + CHECK_FALSE(map_script_result(0, std::numeric_limits::quiet_NaN(), 0.0).has_value()); + CHECK_FALSE(map_script_result(0, 0.0, std::numeric_limits::infinity()).has_value()); +} diff --git a/render/README.md b/render/README.md index a615c21..da409f2 100644 --- a/render/README.md +++ b/render/README.md @@ -6,7 +6,7 @@ Spécification : [docs/specs/protocole-core-rendu-0.1.md](../docs/specs/protocol | Fichier | Rôle | |---|---| -| `src/main.cpp` | Options, boucle : deltas reçus → `apply()` → `render_frame()`, mesures toutes les secondes | +| `src/main.cpp` | Options, boucle : deltas reçus → `apply()` → `render_frame()` → `poll_input()` (`render/input` si les entrées changent), mesures toutes les secondes | | `src/core_link.*` | Client WebSocket : poignée de main, deltas poussés par le Core empilés pour la boucle | | `src/adapter_library.*` | Chargement de la bibliothèque d'adaptateur, point d'entrée C versionné | @@ -18,8 +18,13 @@ Spécification : [docs/specs/protocole-core-rendu-0.1.md](../docs/specs/protocol # Sans fenêtre : vue de côté en vidéo à 10 images/s (ffmpeg facultatif, sinon images PPM) .\bin\windows-x64\debug\core\sequa-core.exe --render debug --render-headless --render-fps 10 ` --render-options '{\"output\": \"build/render-debug.mp4\"}' +# Entrées scriptées (sans clavier) : le joueur court à droite à partir de l'image 20, saute à l'image 60 +.\bin\windows-x64\debug\core\sequa-core.exe --render debug --render-headless --render-fps 30 ` + --render-options '{\"input\": [{\"frame\": 20, \"moveX\": 32767}, {\"frame\": 60, \"buttons\": 1}]}' ``` +Avec Godot, on joue dans la fenêtre (clavier ou manette, voir [engines/godot/adapter](../engines/godot/adapter/README.md#entrées-du-joueur-adr-0011)). + ## État **PROVISOIRE** : JSON, pas d'interpolation entre deux ticks, pas de reconnexion (une connexion perdue arrête le rendu après le délai de grâce, le Core le relance), pas encore de tampons de rendu (étape 5). diff --git a/render/src/core_link.hpp b/render/src/core_link.hpp index c57e4e0..f6c2fde 100644 --- a/render/src/core_link.hpp +++ b/render/src/core_link.hpp @@ -17,7 +17,7 @@ namespace sequa::render { -inline constexpr std::string_view protocol_version = "0.1.0"; +inline constexpr std::string_view protocol_version = "0.2.0"; // 0.2 : render/input (ADR 0011) inline constexpr std::string_view client_name = "sequa-render"; // Événements reçus du Core, empilés par le fil du client et consommés par la boucle de rendu. diff --git a/render/src/main.cpp b/render/src/main.cpp index fff34aa..6c8013b 100644 --- a/render/src/main.cpp +++ b/render/src/main.cpp @@ -158,6 +158,9 @@ int run(const Options& options) {"height", options.height}}); three_d::SceneState mirror; + const bool forward_input = adapter.capabilities().player_input; + // Départ neutre : un rendu qui démarre n'écrase pas une entrée tenue par Studio. + three_d::PlayerInput last_input; std::vector latency_us; // fin du tick -> delta reçu (transport) std::vector apply_us; // fin du tick -> delta appliqué (attente de l'image comprise) std::vector cpu_ms; @@ -239,6 +242,16 @@ int run(const Options& options) cpu_ms.push_back(std::get(outcome).measurements.cpu_ms); ++frames_since_report; + // Entrées du joueur (ADR 0011) : envoyées seulement quand elles changent, le Core + // maintenant la dernière reçue. + if (forward_input) { + if (const std::optional input = adapter.poll_input(); + input && !(*input == last_input)) { + link.send("render/input", three_d::wire::encode_player_input({*input, render::monotonic_us()})); + last_input = *input; + } + } + if (Clock::now() - last_report >= std::chrono::seconds(1)) { const Percentiles cpu = take_percentiles(cpu_ms); const Percentiles latency = take_percentiles(latency_us); diff --git a/studio/ui/src/model/render.ts b/studio/ui/src/model/render.ts index 68917fe..75c9441 100644 --- a/studio/ui/src/model/render.ts +++ b/studio/ui/src/model/render.ts @@ -20,6 +20,8 @@ export interface RenderEngine { readonly buffers: readonly string[]; readonly windowed: boolean; readonly headless: boolean; + /** Le moteur capte les entrées du joueur dans sa fenêtre (ADR 0011) ; absent : inconnu. */ + readonly playerInput?: boolean; } /** Mesures de la dernière seconde, remontées par le rendu. */ diff --git a/studio/ui/src/protocol/messages.ts b/studio/ui/src/protocol/messages.ts index de2037d..1369c58 100644 --- a/studio/ui/src/protocol/messages.ts +++ b/studio/ui/src/protocol/messages.ts @@ -258,7 +258,7 @@ function decodeEngine(value: unknown): RenderEngine | null { return null; } - const { name, version, buffers, windowed, headless } = value; + const { name, version, buffers, windowed, headless, playerInput } = value; if (!isNonEmptyString(name) || !isNonEmptyString(version)) { return null; @@ -272,7 +272,11 @@ function decodeEngine(value: unknown): RenderEngine | null { return null; } - return { name, version, buffers, windowed, headless }; + if (playerInput !== undefined && typeof playerInput !== 'boolean') { + return null; + } + + return { name, version, buffers, windowed, headless, ...(playerInput === undefined ? {} : { playerInput }) }; } /** Mesures de la dernière seconde remontées par « render/frames » (§9). */ diff --git a/studio/ui/src/ui/render-panel.ts b/studio/ui/src/ui/render-panel.ts index ee553dc..37309d2 100644 --- a/studio/ui/src/ui/render-panel.ts +++ b/studio/ui/src/ui/render-panel.ts @@ -33,6 +33,7 @@ const ADAPTER_LABELS: Record = { const FACT_TERMS = [ 'Moteur', 'Fenêtre', + 'Entrées du joueur', 'Taille', 'Images par seconde', 'Latence transport', @@ -118,6 +119,14 @@ function windowLabel(engine: RenderEngine | undefined): string { return engine.headless ? 'Sans fenêtre' : NO_VALUE; } +function playerInputLabel(engine: RenderEngine | undefined): string { + if (engine?.playerInput === undefined) { + return NO_VALUE; + } + + return engine.playerInput ? 'Captées par le moteur' : 'Non captées'; +} + /** * Modèle du panneau : pur, sans DOM, testable dans un environnement node. * Les drapeaux suivent le contrat : start exige un Core connecté, aucune @@ -169,6 +178,7 @@ export function renderPanelModel(state: StudioState): RenderPanelModel { value: engine === undefined ? NO_VALUE : `${engine.name} ${engine.version}`, }, { term: 'Fenêtre', value: windowLabel(engine) }, + { term: 'Entrées du joueur', value: playerInputLabel(engine) }, { term: 'Taille', value: width === undefined || height === undefined ? NO_VALUE : `${width} × ${height}`, diff --git a/studio/ui/tests/messages.test.ts b/studio/ui/tests/messages.test.ts index fd785ca..9a88e8f 100644 --- a/studio/ui/tests/messages.test.ts +++ b/studio/ui/tests/messages.test.ts @@ -274,6 +274,19 @@ describe('decode', () => { } }); + it('décode playerInput du moteur, facultatif (ADR 0011)', () => { + const engine = { name: 'godot', version: '4.7.2', buffers: ['color'], windowed: true, headless: false }; + const decodeEngine = (value: object) => { + const result = decode(JSON.stringify({ v: 1, id: 'r', type: 'render/status', payload: { ...makeStatus(), engine: value } })); + expect(result.ok).toBe(true); + return result.ok && result.message.type === 'render/status' && 'state' in result.message.payload + ? (result.message.payload as RenderStatus).engine + : undefined; + }; + expect(decodeEngine({ ...engine, playerInput: true })?.playerInput).toBe(true); + expect(decodeEngine(engine)?.playerInput).toBeUndefined(); + }); + it('refuse un state inconnu', () => { expectError( JSON.stringify({ @@ -340,6 +353,17 @@ describe('decode', () => { }), 'invalid-payload', ); + expectError( + JSON.stringify({ + v: 1, + type: 'render/start', + payload: { + ...makeStatus(), + engine: { name: 'test', version: '1', buffers: ['color'], windowed: true, headless: false, playerInput: 1 }, + }, + }), + 'invalid-payload', + ); }); it('refuse un adapter invalide dans une réponse', () => { diff --git a/studio/ui/tests/render-panel.test.ts b/studio/ui/tests/render-panel.test.ts index 11272be..fa4eb58 100644 --- a/studio/ui/tests/render-panel.test.ts +++ b/studio/ui/tests/render-panel.test.ts @@ -191,6 +191,16 @@ describe('renderPanelModel — formatage des faits', () => { expect(model.facts.find(f => f.term === 'Fenêtre')?.value).toBe('Sans fenêtre'); }); + it('entrées du joueur : captées, non captées ou inconnues (ADR 0011)', () => { + const label = (engine: ReturnType) => + renderPanelModel( + createBaseState({ render: createRenderView({ status: createStatus({ engine }), pending: false }) }), + ).facts.find(f => f.term === 'Entrées du joueur')?.value; + expect(label(createEngine({ playerInput: true }))).toBe('Captées par le moteur'); + expect(label(createEngine({ playerInput: false }))).toBe('Non captées'); + expect(label(createEngine())).toBe(NO_VALUE); + }); + it('taille formatée « 1280 × 720 »', () => { const state = createBaseState({ render: createRenderView({ status: createStatus({ width: 1280, height: 720 }), pending: false }),