From 5891c82c62af7268834a7b0bf3d034bb9a25adce Mon Sep 17 00:00:00 2001 From: william Date: Wed, 2 Sep 2026 11:50:29 +0200 Subject: [PATCH] =?UTF-8?q?Commandes=20personnage=20:=20d=C3=A9tection=20d?= =?UTF-8?q?e=20touche=20au=20clavier=20+=20touches=20d'animation=20suppl?= =?UTF-8?q?=C3=A9mentaires?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deux retours utilisateur sur le panneau "Commandes" (déplacement/ animation automatiques, voir précédent commit) : - Les champs de touche (haut/bas/gauche/droite/interagir) capturent maintenant la touche au clavier (clic puis appui — event.key, même valeur que heldKeys/triggers.js) au lieu d'être tapés à la main, source d'erreurs ("Espace" vs " ", "flèche haut" vs "ArrowUp"...). - Nouvelle section "Animations supplémentaires" : un nombre illimité de touches, chacune liée à UNE pose au choix parmi celles réellement disponibles pour ce personnage (sauter, attaquer, courir...) — pas seulement les 4 touches de déplacement + interagir. Co-Authored-By: Claude Sonnet 5 --- routes/scenes/scene_object_commands.py | 3 + screens/__init__.py | 2 +- screens/rendering/personnage_commands.py | 30 ++++++- .../__tests__/personnage-controller.test.js | 18 +++- static/js/play/personnage-controller.js | 25 ++++-- static/js/scenes/scene-editor.js | 85 ++++++++++++++++++- templates/scene_edit.html | 24 ++++-- tests/test_personnage_commands.py | 44 +++++++++- 8 files changed, 209 insertions(+), 22 deletions(-) diff --git a/routes/scenes/scene_object_commands.py b/routes/scenes/scene_object_commands.py index 6ffcf361..2c4967de 100644 --- a/routes/scenes/scene_object_commands.py +++ b/routes/scenes/scene_object_commands.py @@ -32,6 +32,9 @@ def scene_object_commands(slug, object_id): except (TypeError, ValueError): vitesse = defaults["vitesse"] commands["vitesse"] = max(1, min(20, vitesse)) + # Touches supplémentaires (sauter, attaquer...) — voir "Animations + # supplémentaires" dans le panneau Commandes, templates/scene_edit.html. + commands["extra"] = screens.sanitize_extra_commands(body.get("extra")) screens.set_scene_object_commands(slug, object_id, commands) return jsonify({"ok": True, "commands": commands}) diff --git a/screens/__init__.py b/screens/__init__.py index 4d069b7a..4b043dce 100644 --- a/screens/__init__.py +++ b/screens/__init__.py @@ -76,7 +76,7 @@ from .flow.constants import ( from .rendering.render_element_html import render_element_html from .rendering.trigger_for import _trigger_for from .rendering.personnage_data import resolve_personnage_data, resolve_personnage_animations, resolve_personnage_idle_frame -from .rendering.personnage_commands import resolve_personnage_commands, DEFAULT_PERSONNAGE_COMMANDS +from .rendering.personnage_commands import resolve_personnage_commands, DEFAULT_PERSONNAGE_COMMANDS, sanitize_extra_commands from .screens_repo.ensure_schema import ensure_schema from .screens_repo.list_screens import list_screens diff --git a/screens/rendering/personnage_commands.py b/screens/rendering/personnage_commands.py index fc06e097..1cbe44fe 100644 --- a/screens/rendering/personnage_commands.py +++ b/screens/rendering/personnage_commands.py @@ -8,11 +8,33 @@ import json # axe de déplacement (ex. un personnage qui ne se déplace que de gauche à # droite). "vitesse" est en pixels par tick (50ms, voir # personnage-controller.js — même cadence que touche_maintenue). +# "extra" : touches SUPPLÉMENTAIRES, chacune liée à une pose du personnage +# au choix (ex. "sauter" -> "jump") — au-delà des 4 touches de déplacement +# et de "interagir", qui restent les deux seules touches à usage FIXE. DEFAULT_PERSONNAGE_COMMANDS = { "haut": "z", "bas": "s", "gauche": "q", "droite": "d", - "interagir": "e", "axe": "libre", "vitesse": 4, + "interagir": "e", "axe": "libre", "vitesse": 4, "extra": [], } +# Nombre maximum de touches supplémentaires — large marge pour un vrai +# jeu (sauter, attaquer, courir...) sans laisser un panneau grossir sans +# fin par erreur de manipulation. +_MAX_EXTRA_COMMANDS = 20 + + +def sanitize_extra_commands(raw_extra): + if not isinstance(raw_extra, list): + return [] + cleaned = [] + for item in raw_extra[:_MAX_EXTRA_COMMANDS]: + if not isinstance(item, dict): + continue + touche = item.get("touche") + animation = item.get("animation") + if isinstance(touche, str) and touche and isinstance(animation, str) and animation: + cleaned.append({"touche": touche, "animation": animation}) + return cleaned + def resolve_personnage_commands(el): """Décode attributes._personnage_commandes (voir @@ -22,11 +44,15 @@ def resolve_personnage_commands(el): l'ajout de ce réglage).""" raw = (el.get("attributes") or {}).get("_personnage_commandes") commands = dict(DEFAULT_PERSONNAGE_COMMANDS) + commands["extra"] = [] # jamais l'alias de la liste DEFAULT (mutable partagée) if raw: try: parsed = json.loads(raw) except (TypeError, ValueError): parsed = None if isinstance(parsed, dict): - commands.update({k: v for k, v in parsed.items() if k in DEFAULT_PERSONNAGE_COMMANDS}) + for key in ("haut", "bas", "gauche", "droite", "interagir", "axe", "vitesse"): + if key in parsed: + commands[key] = parsed[key] + commands["extra"] = sanitize_extra_commands(parsed.get("extra")) return commands diff --git a/static/js/play/__tests__/personnage-controller.test.js b/static/js/play/__tests__/personnage-controller.test.js index 13ac010c..3b5b84dd 100644 --- a/static/js/play/__tests__/personnage-controller.test.js +++ b/static/js/play/__tests__/personnage-controller.test.js @@ -15,7 +15,7 @@ const actionsMod = require('../actions.js'); Object.assign(global, actionsMod); const { - forgePersonnageAnimExists, forgePersonnageMovementAnim, forgePersonnageIdleAnim, + forgePersonnageAnimExists, forgePersonnageMovementAnim, forgePersonnageIdleAnim, forgePersonnageActionAnim, forgeRunPersonnageControllerTick, } = require('../personnage-controller.js'); @@ -121,3 +121,19 @@ test('tick — ignore tout objet qui n\'est pas kind="personnage" ou sans comman assert.doesNotThrow(() => forgeRunPersonnageControllerTick({ elements: [decor] })); }); }); + +// Touches supplémentaires (sauter, attaquer...) — bug remonté par +// l'utilisateur : au-delà des 4 touches de déplacement et de "interagir", +// il manquait un moyen de lier une touche à N'IMPORTE QUELLE autre pose du +// personnage (voir "Animations supplémentaires", templates/scene_edit.html). +test('forgePersonnageActionAnim — "interagir" prioritaire, sinon la touche supplémentaire correspondante', () => { + const cmd = { interagir: 'e', extra: [{ touche: 'j', animation: 'jump' }, { touche: 'a', animation: 'attack' }] }; + assert.equal(forgePersonnageActionAnim(cmd, 'e'), 'interact'); + assert.equal(forgePersonnageActionAnim(cmd, 'j'), 'jump'); + assert.equal(forgePersonnageActionAnim(cmd, 'a'), 'attack'); + assert.equal(forgePersonnageActionAnim(cmd, 'x'), null); // touche non liée +}); + +test('forgePersonnageActionAnim — commandes sans "extra" (personnage posé avant l\'ajout du réglage)', () => { + assert.equal(forgePersonnageActionAnim({ interagir: 'e' }, 'j'), null); +}); diff --git a/static/js/play/personnage-controller.js b/static/js/play/personnage-controller.js index 066a11bf..183d6945 100644 --- a/static/js/play/personnage-controller.js +++ b/static/js/play/personnage-controller.js @@ -78,18 +78,29 @@ function forgeRunPersonnageControllerTick(screenData) { }); } -// Ponctuel (keydown, pas heldKeys) — contrairement au déplacement, -// "interagir" est une action instantanée (ouvrir un coffre, parler à un -// PNJ...), jamais un mouvement continu. +// Quelle pose jouer pour une touche d'ACTION (pas de déplacement) donnée — +// "interagir" (usage fixe, pose "interact") d'abord, sinon la première +// touche supplémentaire (cmd.extra, voir "Animations supplémentaires" du +// panneau Commandes) qui correspond, pour un nombre de poses illimité +// (sauter, attaquer, courir...) sans ajouter un champ fixe par pose. +function forgePersonnageActionAnim(cmd, key) { + if (cmd.interagir === key) return 'interact'; + var extraMatch = (cmd.extra || []).find(function (e) { return e.touche === key; }); + return extraMatch ? extraMatch.animation : null; +} + +// Ponctuel (keydown, pas heldKeys) — contrairement au déplacement, une +// touche d'action est une action instantanée (interagir, sauter, +// attaquer...), jamais un mouvement continu. function forgeStartPersonnageInteractListener(screenData) { activePersonnageInteractListener = function (ev) { (screenData.elements || []).forEach(function (obj) { if (obj.kind !== 'personnage' || !obj.personnage_commandes) return; - if (obj.personnage_commandes.interagir !== ev.key) return; + var animName = forgePersonnageActionAnim(obj.personnage_commandes, ev.key); + if (!animName || !forgePersonnageAnimExists(obj.id, animName)) return; var targetEl = document.querySelector('[data-object-id="' + obj.id + '"]'); if (!targetEl) return; - var interactAnim = forgePersonnageAnimExists(obj.id, 'interact') ? 'interact' : null; - if (interactAnim) runSpriteAnimation(targetEl, resolveSpriteFrames(obj.id, { animation: interactAnim, fps: 8, loop: false })); + runSpriteAnimation(targetEl, resolveSpriteFrames(obj.id, { animation: animName, fps: 8, loop: false })); }); }; window.addEventListener('keydown', activePersonnageInteractListener); @@ -112,7 +123,7 @@ function forgeStartPersonnageControllers(screenData) { // client. if (typeof module !== 'undefined' && module.exports) { module.exports = { - forgePersonnageAnimExists, forgePersonnageMovementAnim, forgePersonnageIdleAnim, + forgePersonnageAnimExists, forgePersonnageMovementAnim, forgePersonnageIdleAnim, forgePersonnageActionAnim, forgeRunPersonnageControllerTick, forgeStartPersonnageControllers, forgeStopPersonnageControllers, forgeStartPersonnageInteractListener, }; diff --git a/static/js/scenes/scene-editor.js b/static/js/scenes/scene-editor.js index ce6fdc60..5eb15faa 100644 --- a/static/js/scenes/scene-editor.js +++ b/static/js/scenes/scene-editor.js @@ -145,14 +145,23 @@ function swapSceneObjectCharacter(forgeCharacter) { } // "Commandes" (voir static/js/play/personnage-controller.js) : touches de -// déplacement/interaction + blocage d'axe d'un personnage — un seul -// formulaire, enregistré en AJAX à chaque changement de champ, même -// patron que swapSceneObjectCharacter() ci-dessus (POST JSON vers -// routes/scenes/scene_object_commands.py, aucun rechargement de page). +// déplacement/interaction + blocage d'axe + touches supplémentaires d'un +// personnage — un seul formulaire, enregistré en AJAX à chaque +// changement de champ, même patron que swapSceneObjectCharacter() +// ci-dessus (POST JSON vers routes/scenes/scene_object_commands.py, +// aucun rechargement de page). function saveSceneObjectCommands() { var body = document.getElementById('personnageCommandsBody'); if (!body) return; var objectId = parseInt(body.dataset.elementId, 10); + var extra = []; + document.querySelectorAll('#personnageCommandsExtra .personnageCommandExtraRow').forEach(function (row) { + var toucheEl = row.querySelector('.personnageKeyCapture'); + var animEl = row.querySelector('.cmdExtraAnim'); + if (toucheEl && toucheEl.value && animEl && animEl.value) { + extra.push({ touche: toucheEl.value, animation: animEl.value }); + } + }); fetch('/game/' + GAME_SLUG + '/scene-objects/' + objectId + '/commandes', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ @@ -163,15 +172,83 @@ function saveSceneObjectCommands() { interagir: document.getElementById('cmd-interagir').value, axe: document.getElementById('cmd-axe').value, vitesse: document.getElementById('cmd-vitesse').value, + extra: extra, }), }); } +// Détection de touche (bug remonté par l'utilisateur : taper le nom d'une +// touche à la main est source d'erreurs — "Espace" vs " ", "flèche haut" +// vs "ArrowUp"...) : au focus, le champ (readonly, jamais tapé) affiche un +// indice puis se remplit avec la PROCHAINE touche pressée (event.key, +// même valeur que heldKeys/bindKeyboardTriggers, triggers.js — cohérent +// avec ce que le moteur compare réellement au jeu). capture:true sur +// window pour intercepter la touche avant tout autre raccourci de +// l'éditeur (ex. Suppr qui efface l'objet sélectionné). +function bindPersonnageKeyCapture(el) { + if (!el || el.dataset.keyCaptureBound) return; + el.dataset.keyCaptureBound = '1'; + el.addEventListener('focus', function () { + var previous = el.value; + el.value = 'Appuie sur une touche...'; + el.classList.add('is-warning'); + function onKey(ev) { + ev.preventDefault(); + ev.stopPropagation(); + window.removeEventListener('keydown', onKey, true); + el.classList.remove('is-warning'); + if (ev.key === 'Escape') { el.value = previous; el.blur(); return; } + el.value = ev.key; + el.blur(); + el.dispatchEvent(new Event('change')); + } + window.addEventListener('keydown', onKey, true); + }); +} + +// Une ligne = une touche supplémentaire liée à UNE pose au choix parmi +// celles RÉELLEMENT disponibles pour CE personnage (ELEMENT_ANIMATIONS_MAP, +// déjà connu de personnage-panel.js/personnage-preview.js) — jamais un nom +// tapé à la main, qui pourrait ne correspondre à aucune pose existante. +function personnageCommandExtraRowEl(objectId, touche, animation) { + var row = document.createElement('div'); + row.className = 'field is-grouped personnageCommandExtraRow'; + var animNames = Object.keys(ELEMENT_ANIMATIONS_MAP[objectId] || {}); + var options = animNames.map(function (name) { + return ''; + }).join(''); + row.innerHTML = + '
' + + '
' + + '
'; + var keyInput = row.querySelector('.personnageKeyCapture'); + bindPersonnageKeyCapture(keyInput); + keyInput.addEventListener('change', saveSceneObjectCommands); + row.querySelector('.cmdExtraAnim').addEventListener('change', saveSceneObjectCommands); + return row; +} + +function addPersonnageCommandExtraRow() { + var body = document.getElementById('personnageCommandsBody'); + var container = document.getElementById('personnageCommandsExtra'); + if (!body || !container) return; + var objectId = parseInt(body.dataset.elementId, 10); + container.appendChild(personnageCommandExtraRowEl(objectId, '', '')); +} + (function () { var body = document.getElementById('personnageCommandsBody'); if (!body) return; + var objectId = parseInt(body.dataset.elementId, 10); + ['cmd-haut', 'cmd-bas', 'cmd-gauche', 'cmd-droite', 'cmd-interagir'].forEach(function (id) { + bindPersonnageKeyCapture(document.getElementById(id)); + }); ['cmd-haut', 'cmd-bas', 'cmd-gauche', 'cmd-droite', 'cmd-interagir', 'cmd-axe', 'cmd-vitesse'].forEach(function (id) { var el = document.getElementById(id); if (el) el.addEventListener('change', saveSceneObjectCommands); }); + var container = document.getElementById('personnageCommandsExtra'); + (window.PERSONNAGE_COMMANDS_EXTRA || []).forEach(function (e) { + container.appendChild(personnageCommandExtraRowEl(objectId, e.touche, e.animation)); + }); })(); diff --git a/templates/scene_edit.html b/templates/scene_edit.html index fd774c25..49656d22 100644 --- a/templates/scene_edit.html +++ b/templates/scene_edit.html @@ -680,21 +680,21 @@
🕹️ Commandes
-

Ce personnage se déplace et s'anime automatiquement avec ces touches, sans rien à poser dans la logique.

+

Ce personnage se déplace et s'anime automatiquement avec ces touches, sans rien à poser dans la logique. Clique un champ puis appuie sur la touche voulue.

-
+
-
+
-
+
-
+
-
+
@@ -710,6 +710,17 @@
+ +
+
+ +
{% endif %} @@ -746,6 +757,7 @@ var ANIMATE_CSS_LABELS = {{ animate_css_labels_json|safe }}; var SPRITE_LIBRARY = {{ sprite_library_json|safe }}; var ELEMENT_ANIMATIONS_MAP = {{ element_animations_map_json|safe }}; + var PERSONNAGE_COMMANDS_EXTRA = {{ (personnage_commands.extra if personnage_commands else [])|tojson }}; diff --git a/tests/test_personnage_commands.py b/tests/test_personnage_commands.py index c1604f1f..cb31c432 100644 --- a/tests/test_personnage_commands.py +++ b/tests/test_personnage_commands.py @@ -51,10 +51,52 @@ def test_commands_route_persists_remapped_keys_and_axis_lock(client, tmp_game_sl commands = screens.resolve_personnage_commands(obj) assert commands == { "haut": "ArrowUp", "bas": "ArrowDown", "gauche": "ArrowLeft", "droite": "ArrowRight", - "interagir": " ", "axe": "horizontal", "vitesse": 8, + "interagir": " ", "axe": "horizontal", "vitesse": 8, "extra": [], } +def test_commands_route_persists_extra_animation_bound_keys(client, tmp_game_slug_cleanup): + """Touches supplémentaires (sauter, attaquer...) — bug remonté par + l'utilisateur : au-delà des 4 touches de déplacement et de + "interagir", il manquait un moyen de lier une touche à N'IMPORTE + QUELLE autre pose du personnage.""" + slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup) + obj_id = _add_personnage(client, slug, screen_id) + + resp = client.post( + f"/game/{slug}/scene-objects/{obj_id}/commandes", + data='{"extra": [{"touche": "j", "animation": "jump"}, {"touche": "a", "animation": "attack"}]}', + content_type="application/json", + ) + commands = resp.get_json()["commands"] + assert commands["extra"] == [{"touche": "j", "animation": "jump"}, {"touche": "a", "animation": "attack"}] + + obj = screens.get_scene_object(slug, obj_id) + assert screens.resolve_personnage_commands(obj)["extra"] == commands["extra"] + + +def test_commands_route_drops_malformed_extra_entries(client, tmp_game_slug_cleanup): + """Une entrée sans touche/animation, un doublon de type, ou une liste + qui n'en est pas une : ignorée plutôt que de planter ou de polluer le + panneau avec une ligne inutilisable côté client.""" + slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup) + obj_id = _add_personnage(client, slug, screen_id) + + resp = client.post( + f"/game/{slug}/scene-objects/{obj_id}/commandes", + data='{"extra": [{"touche": "j", "animation": "jump"}, {"touche": ""}, {"animation": "attack"}, "pas un objet", 42]}', + content_type="application/json", + ) + assert resp.get_json()["commands"]["extra"] == [{"touche": "j", "animation": "jump"}] + + resp = client.post( + f"/game/{slug}/scene-objects/{obj_id}/commandes", + data='{"extra": "pas une liste"}', + content_type="application/json", + ) + assert resp.get_json()["commands"]["extra"] == [] + + def test_commands_route_rejects_invalid_axis_and_clamps_speed(client, tmp_game_slug_cleanup): """Un axe inconnu retombe sur "libre", une vitesse hors bornes est ramenée dans [1, 20] — jamais une valeur qui planterait le calcul