Dev #10

Merged
w-vandal merged 22 commits from dev into main 2026-09-02 19:17:50 +00:00
14 changed files with 539 additions and 0 deletions
Showing only changes of commit 74f7ca396f - Show all commits
+1
View File
@@ -3,4 +3,5 @@ from . import (
scene_object_geometry,
scene_object_delete,
scene_object_personnage_data,
scene_object_commands,
)
+3
View File
@@ -23,9 +23,11 @@ def render_scene_edit(slug, screen_id, game):
o["rendered_html"] = screens.render_scene_object(o)
selected_id = request.args.get("selected", type=int)
personnage_data = None
personnage_commands = None
selected_obj = next((o for o in objects if o["id"] == selected_id), None) if selected_id else None
if selected_obj and selected_obj["kind"] == "personnage":
personnage_data = screens.resolve_personnage_data(selected_obj)
personnage_commands = screens.resolve_personnage_commands(selected_obj)
object_labels = {
o["id"]: (o.get("name") or ("Personnage" if o["kind"] == "personnage" else "Décor"))
@@ -57,6 +59,7 @@ def render_scene_edit(slug, screen_id, game):
"scene_edit.html",
game=game, screen=screen, all_screens=all_screens,
objects=objects, selected_id=selected_id, personnage_data=personnage_data,
personnage_commands=personnage_commands or screens.DEFAULT_PERSONNAGE_COMMANDS,
sprite_library=sprite_library, sprite_library_families=sprite_library_families,
sprite_library_json=json.dumps(sprite_library),
element_animations_map_json=json.dumps(element_animations_map),
+37
View File
@@ -0,0 +1,37 @@
from flask import request, jsonify
import screens
from core.flask_app import app
@app.route("/game/<slug>/scene-objects/<int:object_id>/commandes", methods=["POST"])
def scene_object_commands(slug, object_id):
"""Touches de déplacement/interaction + blocage d'axe d'un personnage
(voir templates/scene_edit.html, section "Commandes" du panneau
Personnage, et static/js/play/personnage-controller.js pour leur
usage au jeu) — mirror de scene_object_personnage_data.py, un
attribut JSON distinct."""
body = request.get_json(silent=True) or {}
obj = screens.get_scene_object(slug, object_id)
if not obj:
return jsonify({"error": "objet introuvable"}), 404
defaults = screens.DEFAULT_PERSONNAGE_COMMANDS
commands = {}
for key in ("haut", "bas", "gauche", "droite", "interagir"):
# Jamais de .strip() ici : " " (Espace) est une valeur ev.key
# parfaitement valide côté client (voir personnage-controller.js)
# pour "interagir" — un strip() la réduirait à tort à "" et la
# ferait retomber sur "e" par défaut.
value = body.get(key)
commands[key] = value if isinstance(value, str) and value else defaults[key]
commands["axe"] = body.get("axe") if body.get("axe") in ("libre", "horizontal", "vertical") else "libre"
try:
vitesse = float(body.get("vitesse", defaults["vitesse"]))
except (TypeError, ValueError):
vitesse = defaults["vitesse"]
commands["vitesse"] = max(1, min(20, vitesse))
screens.set_scene_object_commands(slug, object_id, commands)
return jsonify({"ok": True, "commands": commands})
+2
View File
@@ -76,6 +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 .screens_repo.ensure_schema import ensure_schema
from .screens_repo.list_screens import list_screens
@@ -109,6 +110,7 @@ from .scenes.get_scene_object import get_scene_object
from .scenes.update_scene_object_geometry import update_scene_object_geometry
from .scenes.delete_scene_object import delete_scene_object
from .scenes.set_scene_object_personnage_data import set_scene_object_personnage_data
from .scenes.set_scene_object_commands import set_scene_object_commands
from .scenes.render_scene_object import render_scene_object
from .scenes.flow_palette import TRIGGER_EVENTS_2D, ACTION_TYPE_LABELS_2D, OBJECT_ACTION_PROPERTIES
from .elements.element_label import element_label
+8
View File
@@ -8,6 +8,7 @@ from ..animations.list_animation_clips import list_animation_clips
from ..element_types.list_element_types import list_element_types
from ..element_types.load_template_tree import _load_template_tree
from ..rendering.personnage_data import resolve_personnage_animations
from ..rendering.personnage_commands import resolve_personnage_commands
from ..scenes.list_scene_objects import list_scene_objects
from ..scenes.render_scene_object import render_scene_object
@@ -44,6 +45,13 @@ def full_game_payload(slug, player_id=db.PLAYER_SHARED):
objects = list_scene_objects(slug, s["id"])
for o in objects:
o["rendered_html"] = render_scene_object(o)
# Déplacement/animation automatiques (voir
# static/js/play/personnage-controller.js) : chaque
# personnage porte ses touches déjà résolues (défauts
# comblés) pour que le runtime client n'ait aucune logique
# de repli à dupliquer.
if o["kind"] == "personnage":
o["personnage_commandes"] = resolve_personnage_commands(o)
payload_screens.append({**s, "elements": objects})
else:
elements = list_elements(slug, s["id"], enforce_visibility=True, player_id=player_id)
+32
View File
@@ -0,0 +1,32 @@
import json
# ZQSD (convention AZERTY, comme le reste de l'éditeur) + E pour interagir
# — un objet de scène kind="personnage" se déplace et s'anime TOUT SEUL
# avec ces touches dès qu'il est posé sur la scène (voir
# static/js/play/personnage-controller.js), sans le moindre nœud de flow
# à poser. "axe" ("libre"/"horizontal"/"vertical") permet de bloquer un
# 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).
DEFAULT_PERSONNAGE_COMMANDS = {
"haut": "z", "bas": "s", "gauche": "q", "droite": "d",
"interagir": "e", "axe": "libre", "vitesse": 4,
}
def resolve_personnage_commands(el):
"""Décode attributes._personnage_commandes (voir
screens/scenes/set_scene_object_commands.py) en dict Python, jamais
None — un attribut absent/corrompu retombe sur les touches par défaut
ci-dessus (comportement inchangé pour tout personnage déjà posé avant
l'ajout de ce réglage)."""
raw = (el.get("attributes") or {}).get("_personnage_commandes")
commands = dict(DEFAULT_PERSONNAGE_COMMANDS)
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})
return commands
@@ -0,0 +1,26 @@
import json
import db
from .ensure_scene_schema import ensure_scene_schema
def set_scene_object_commands(slug, object_id, commands):
"""Enregistre les touches de déplacement/interaction et le blocage
d'axe d'un objet de scène kind="personnage" (voir
screens/rendering/personnage_commands.py pour les valeurs par défaut,
et static/js/play/personnage-controller.js pour leur usage au jeu) —
même patron que set_scene_object_personnage_data.py, mais un attribut
JSON DISTINCT (_personnage_commandes) pour ne jamais entrer en
conflit avec _personnage_data (choix du personnage/de ses animations)."""
ensure_scene_schema(slug)
conn = db.connect(slug)
row = conn.execute("SELECT attributes FROM _scene_objects WHERE id = ?", (object_id,)).fetchone()
if not row:
conn.close()
return
attributes = json.loads(row["attributes"] or "{}")
attributes["_personnage_commandes"] = json.dumps(commands)
conn.execute("UPDATE _scene_objects SET attributes = ? WHERE id = ?", (json.dumps(attributes), object_id))
conn.commit()
conn.close()
@@ -0,0 +1,123 @@
// Déplacement/animation automatiques d'un personnage (voir
// static/js/play/personnage-controller.js) : ZQSD + E par défaut, sans
// le moindre nœud de flow à poser — réutilise applyObjectProperty/
// clampSceneObjectPosition/runSpriteAnimation/resolveSpriteFrames
// (actions.js), heldKeys (triggers.js).
const test = require('node:test');
const assert = require('node:assert/strict');
global.window = global.window || {};
global.gameData = global.gameData || {};
global.screensData = [];
global.heldKeys = new Set();
const actionsMod = require('../actions.js');
Object.assign(global, actionsMod);
const {
forgePersonnageAnimExists, forgePersonnageMovementAnim, forgePersonnageIdleAnim,
forgeRunPersonnageControllerTick,
} = require('../personnage-controller.js');
function fakeSceneEl() {
const classes = new Set();
return {
style: { left: '100px', top: '100px', width: '20px', height: '20px' },
classList: {
add: c => classes.add(c), remove: c => classes.delete(c),
toggle: c => (classes.has(c) ? classes.delete(c) : classes.add(c)),
contains: c => classes.has(c),
},
};
}
function withPersonnageObject(obj, elByObjectId, fn) {
const previousDoc = global.document;
global.document = { querySelector: sel => elByObjectId[sel.match(/"(\d+)"/)[1]] };
global.screensData = [{ id: 1, scene_width: 500, scene_height: 500 }];
global.window.currentScreenId = 1;
global.gameData = { personnage_animations: { [obj.id]: { walk: ['/w0.png'], idle: ['/i0.png'] } } };
try { fn(); } finally { global.document = previousDoc; stopAllSpriteAnimations(); }
}
const { stopAllSpriteAnimations } = actionsMod;
test('forgePersonnageMovementAnim/forgePersonnageIdleAnim — repli silencieux si la pose est absente', () => {
global.gameData = { personnage_animations: { 5: { idle: ['/i0.png'] } } };
assert.equal(forgePersonnageMovementAnim(5), null); // pas de "walk" pour ce personnage
assert.equal(forgePersonnageIdleAnim(5), 'idle');
assert.equal(forgePersonnageAnimExists(5, 'interact'), false);
});
test('tick — touche "droite" enfoncée : déplace vers la droite et retire le miroir', () => {
const obj = { id: 10, kind: 'personnage', personnage_commandes: { haut: 'z', bas: 's', gauche: 'q', droite: 'd', interagir: 'e', axe: 'libre', vitesse: 4 } };
const el = fakeSceneEl();
withPersonnageObject(obj, { 10: el }, () => {
global.heldKeys = new Set(['d']);
forgeRunPersonnageControllerTick({ elements: [obj] });
assert.equal(el.style.left, '104px');
assert.equal(el.classList.contains('forgeFlipped'), false);
});
});
test('tick — touche "gauche" enfoncée : déplace vers la gauche et applique le miroir', () => {
const obj = { id: 11, kind: 'personnage', personnage_commandes: { haut: 'z', bas: 's', gauche: 'q', droite: 'd', interagir: 'e', axe: 'libre', vitesse: 4 } };
const el = fakeSceneEl();
withPersonnageObject(obj, { 11: el }, () => {
global.heldKeys = new Set(['q']);
forgeRunPersonnageControllerTick({ elements: [obj] });
assert.equal(el.style.left, '96px');
assert.equal(el.classList.contains('forgeFlipped'), true);
});
});
test('tick — axe "horizontal" : ignore haut/bas même si la touche est enfoncée', () => {
const obj = { id: 12, kind: 'personnage', personnage_commandes: { haut: 'z', bas: 's', gauche: 'q', droite: 'd', interagir: 'e', axe: 'horizontal', vitesse: 4 } };
const el = fakeSceneEl();
withPersonnageObject(obj, { 12: el }, () => {
global.heldKeys = new Set(['z']); // "haut", bloqué par l'axe
forgeRunPersonnageControllerTick({ elements: [obj] });
assert.equal(el.style.top, '100px'); // inchangé
});
});
test('tick — axe "vertical" : ignore gauche/droite même si la touche est enfoncée', () => {
const obj = { id: 13, kind: 'personnage', personnage_commandes: { haut: 'z', bas: 's', gauche: 'q', droite: 'd', interagir: 'e', axe: 'vertical', vitesse: 4 } };
const el = fakeSceneEl();
withPersonnageObject(obj, { 13: el }, () => {
global.heldKeys = new Set(['d']); // "droite", bloqué par l'axe
forgeRunPersonnageControllerTick({ elements: [obj] });
assert.equal(el.style.left, '100px'); // inchangé
});
});
test('tick — bornage aux limites de la scène (ne sort jamais du cadre)', () => {
const obj = { id: 14, kind: 'personnage', personnage_commandes: { haut: 'z', bas: 's', gauche: 'q', droite: 'd', interagir: 'e', axe: 'libre', vitesse: 50 } };
const el = fakeSceneEl();
el.style.left = '490px'; // 500 (scène) - 20 (largeur) = 480 max
withPersonnageObject(obj, { 14: el }, () => {
global.heldKeys = new Set(['d']);
forgeRunPersonnageControllerTick({ elements: [obj] });
assert.equal(el.style.left, '480px');
});
});
test('tick — aucune touche enfoncée : ne bouge pas (l\'appel reste sans effet)', () => {
const obj = { id: 15, kind: 'personnage', personnage_commandes: { haut: 'z', bas: 's', gauche: 'q', droite: 'd', interagir: 'e', axe: 'libre', vitesse: 4 } };
const el = fakeSceneEl();
withPersonnageObject(obj, { 15: el }, () => {
global.heldKeys = new Set();
forgeRunPersonnageControllerTick({ elements: [obj] });
assert.equal(el.style.left, '100px');
assert.equal(el.style.top, '100px');
});
});
test('tick — ignore tout objet qui n\'est pas kind="personnage" ou sans commandes résolues', () => {
const decor = { id: 16, kind: 'decor' };
withPersonnageObject(decor, {}, () => {
global.heldKeys = new Set(['d']);
// Ne doit pas lever (aucun élément DOM trouvé pour cet objet, mais on
// ne devrait même pas tenter de le chercher vu qu'il n'est pas un personnage).
assert.doesNotThrow(() => forgeRunPersonnageControllerTick({ elements: [decor] }));
});
});
+119
View File
@@ -0,0 +1,119 @@
// ---------- Personnage jouable — déplacement/animation automatiques ----------
// N'importe quel objet de scène kind="personnage" se déplace et s'anime
// TOUT SEUL avec ses touches (ZQSD + E par défaut — voir "Commandes" du
// panneau de propriétés, templates/scene_edit.html) dès qu'il est posé sur
// la scène, sans le moindre nœud de flow à poser — contrairement au reste
// du moteur, entièrement piloté par le graphe de logique. Réutilise
// heldKeys (déjà tenu à jour par bindHeldKeyTriggers, triggers.js) pour
// savoir quelles touches sont enfoncées, et
// applyObjectProperty/clampSceneObjectPosition/runSpriteAnimation/
// resolveSpriteFrames (actions.js) pour le déplacement/l'animation — même
// mécanique que "Modifier un objet de scène"/"Jouer une animation",
// jamais dupliquée.
var activePersonnageControllerInterval = null;
var activePersonnageInteractListener = null;
function forgePersonnageAnimExists(elementId, animName) {
var byAnim = (gameData.personnage_animations || {})[elementId];
return !!(byAnim && byAnim[animName] && byAnim[animName].length);
}
// "walk" est la SEULE animation de déplacement du catalogue Forge/CraftPix
// (voir screens/labels/sprite_library.py — aucune variante directionnelle
// walk_haut/walk_bas...) : le sens se voit via l'inversion miroir
// (orientation, déjà existante — Phase 7), pas une frame différente.
// Absente (personnage sans cette pose) : le déplacement continue sans
// animation plutôt que de planter, comme le reste du moteur.
function forgePersonnageMovementAnim(elementId) {
return forgePersonnageAnimExists(elementId, 'walk') ? 'walk' : null;
}
function forgePersonnageIdleAnim(elementId) {
return forgePersonnageAnimExists(elementId, 'idle') ? 'idle' : null;
}
function forgeStopPersonnageControllers() {
if (activePersonnageControllerInterval) {
clearInterval(activePersonnageControllerInterval);
activePersonnageControllerInterval = null;
}
if (activePersonnageInteractListener) {
window.removeEventListener('keydown', activePersonnageInteractListener);
activePersonnageInteractListener = null;
}
}
// 50ms (20 fois/seconde) — même cadence que runScreenHeldKeyTriggers
// (triggers.js), pour un déplacement perçu comme continu.
function forgeRunPersonnageControllerTick(screenData) {
(screenData.elements || []).forEach(function (obj) {
if (obj.kind !== 'personnage' || !obj.personnage_commandes) return;
var cmd = obj.personnage_commandes;
var targetEl = document.querySelector('[data-object-id="' + obj.id + '"]');
if (!targetEl) return;
var dx = 0, dy = 0;
if (cmd.axe !== 'vertical') {
if (heldKeys.has(cmd.gauche)) dx -= cmd.vitesse;
if (heldKeys.has(cmd.droite)) dx += cmd.vitesse;
}
if (cmd.axe !== 'horizontal') {
if (heldKeys.has(cmd.haut)) dy -= cmd.vitesse;
if (heldKeys.has(cmd.bas)) dy += cmd.vitesse;
}
if (dx === 0 && dy === 0) {
var idleAnim = forgePersonnageIdleAnim(obj.id);
if (idleAnim) runSpriteAnimation(targetEl, resolveSpriteFrames(obj.id, { animation: idleAnim, fps: 4, loop: true }));
return;
}
if (dx !== 0) {
applyObjectProperty(targetEl, 'orientation', dx < 0 ? 'gauche' : 'droite');
applyObjectProperty(targetEl, 'pos_x_relatif', dx);
}
if (dy !== 0) applyObjectProperty(targetEl, 'pos_y_relatif', dy);
var moveAnim = forgePersonnageMovementAnim(obj.id);
if (moveAnim) runSpriteAnimation(targetEl, resolveSpriteFrames(obj.id, { animation: moveAnim, fps: 8, loop: true }));
});
}
// Ponctuel (keydown, pas heldKeys) — contrairement au déplacement,
// "interagir" est une action instantanée (ouvrir un coffre, parler à un
// PNJ...), 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 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 }));
});
};
window.addEventListener('keydown', activePersonnageInteractListener);
}
// Même patron PAR ÉCRAN que runScreenHeldKeyTriggers/runScreenTimerTriggers
// (triggers.js/screens.js) : n'existe que tant que l'écran est affiché,
// réinitialisé à chaque changement d'écran (voir showScreen()).
function forgeStartPersonnageControllers(screenData) {
forgeStopPersonnageControllers();
if (!screenData || screenData.kind !== 'jeu_2d') return;
var hasPersonnage = (screenData.elements || []).some(function (o) { return o.kind === 'personnage'; });
if (!hasPersonnage) return;
activePersonnageControllerInterval = setInterval(function () { forgeRunPersonnageControllerTick(screenData); }, 50);
forgeStartPersonnageInteractListener(screenData);
}
// static/js/play/__tests__/ (node:test) — pas de risque en navigateur :
// `module` n'existe pas là-bas, cette branche ne s'exécute jamais côté
// client.
if (typeof module !== 'undefined' && module.exports) {
module.exports = {
forgePersonnageAnimExists, forgePersonnageMovementAnim, forgePersonnageIdleAnim,
forgeRunPersonnageControllerTick, forgeStartPersonnageControllers, forgeStopPersonnageControllers,
forgeStartPersonnageInteractListener,
};
}
+4
View File
@@ -49,6 +49,10 @@ function showScreen(id) {
runScreenHeldKeyTriggers(id);
runAnimationTimeline(id);
runScreenBackgroundMusic(id);
// Déplacement/animation automatiques de tout personnage posé sur cet
// écran (voir static/js/play/personnage-controller.js) — n'a d'effet
// que sur un écran "jeu_2d" contenant au moins un objet kind="personnage".
forgeStartPersonnageControllers(screensData.find(function(s) { return s.id === id; }));
}
// Phase 6 — musique de fond par écran (voir screen-bg-music-url dans
+32
View File
@@ -143,3 +143,35 @@ 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).
function saveSceneObjectCommands() {
var body = document.getElementById('personnageCommandsBody');
if (!body) return;
var objectId = parseInt(body.dataset.elementId, 10);
fetch('/game/' + GAME_SLUG + '/scene-objects/' + objectId + '/commandes', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
haut: document.getElementById('cmd-haut').value,
bas: document.getElementById('cmd-bas').value,
gauche: document.getElementById('cmd-gauche').value,
droite: document.getElementById('cmd-droite').value,
interagir: document.getElementById('cmd-interagir').value,
axe: document.getElementById('cmd-axe').value,
vitesse: document.getElementById('cmd-vitesse').value,
}),
});
}
(function () {
var body = document.getElementById('personnageCommandsBody');
if (!body) return;
['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);
});
})();
+1
View File
@@ -147,6 +147,7 @@
{% endif %}
<script src="{{ asset_url('js/play/actions.js') }}"></script>
<script src="{{ asset_url('js/play/triggers.js') }}"></script>
<script src="{{ asset_url('js/play/personnage-controller.js') }}"></script>
<script src="{{ asset_url('js/play/bindings.js') }}"></script>
<script src="{{ asset_url('js/play/flow-engine.js') }}"></script>
<script>
+40
View File
@@ -672,6 +672,46 @@
{% endif %}
</div>
</details>
<!-- Déplacement/animation automatiques (voir static/js/play/
personnage-controller.js) : ce personnage se déplace et s'anime
TOUT SEUL avec ces touches dès qu'il est posé sur la scène, sans
le moindre nœud de flow à poser — ce panneau ne fait que
permettre de les remapper/bloquer un axe. -->
<details class="propGroup">
<summary>🕹️ Commandes</summary>
<div class="propGroupBody" id="personnageCommandsBody" data-element-id="{{ obj.id }}">
<p class="help">Ce personnage se déplace et s'anime automatiquement avec ces touches, sans rien à poser dans la logique.</p>
<div class="field is-grouped">
<div class="control"><label class="label is-small">Haut</label>
<input class="input is-small" id="cmd-haut" maxlength="12" value="{{ personnage_commands.haut }}"></div>
<div class="control"><label class="label is-small">Bas</label>
<input class="input is-small" id="cmd-bas" maxlength="12" value="{{ personnage_commands.bas }}"></div>
</div>
<div class="field is-grouped">
<div class="control"><label class="label is-small">Gauche</label>
<input class="input is-small" id="cmd-gauche" maxlength="12" value="{{ personnage_commands.gauche }}"></div>
<div class="control"><label class="label is-small">Droite</label>
<input class="input is-small" id="cmd-droite" maxlength="12" value="{{ personnage_commands.droite }}"></div>
</div>
<div class="field"><label class="label is-small">Interagir</label>
<div class="control"><input class="input is-small" id="cmd-interagir" maxlength="12" value="{{ personnage_commands.interagir }}"></div>
</div>
<div class="field"><label class="label is-small">Axe de déplacement</label>
<div class="control">
<div class="select is-small">
<select id="cmd-axe">
<option value="libre" {{ 'selected' if personnage_commands.axe == 'libre' else '' }}>Libre</option>
<option value="horizontal" {{ 'selected' if personnage_commands.axe == 'horizontal' else '' }}>Horizontal seulement (gauche/droite)</option>
<option value="vertical" {{ 'selected' if personnage_commands.axe == 'vertical' else '' }}>Vertical seulement (haut/bas)</option>
</select>
</div>
</div>
</div>
<div class="field"><label class="label is-small">Vitesse (px/tick)</label>
<div class="control"><input class="input is-small" type="number" id="cmd-vitesse" min="1" max="20" value="{{ personnage_commands.vitesse }}"></div>
</div>
</div>
</details>
{% endif %}
{% endif %}
</div>
+111
View File
@@ -0,0 +1,111 @@
"""Déplacement/animation automatiques d'un personnage posé sur une scène
2D (voir static/js/play/personnage-controller.js) : ZQSD + E par défaut,
sans le moindre nœud de flow à poser — le panneau "🕹️ Commandes"
(templates/scene_edit.html) permet seulement de les remapper/bloquer un
axe. Ce fichier teste la couche serveur (défauts, route de sauvegarde,
exposition dans full_game_payload) ; static/js/play/__tests__/
personnage-controller.test.js teste le comportement client (tick,
bornage, blocage d'axe)."""
import re
import screens
def _create_jeu2d_game(client, tmp_game_slug_cleanup, name="pytest_personnage_cmd"):
resp = client.post("/games/new", data={"name": name}, follow_redirects=False)
slug = tmp_game_slug_cleanup(resp.headers["Location"].rstrip("/").split("/")[-1])
screen_id = screens.create_screen(slug, "Scène 1", kind="jeu_2d")
return slug, screen_id
def _add_personnage(client, slug, screen_id, forge_character="zombie"):
resp = client.post(
f"/game/{slug}/screens/{screen_id}/scene-objects/add",
data={"kind": "personnage", "forge_character": forge_character},
follow_redirects=False,
)
assert resp.status_code == 302, resp.data
return int(re.search(r"selected=(\d+)", resp.headers["Location"]).group(1))
def test_freshly_placed_personnage_gets_default_zqsd_commands(client, tmp_game_slug_cleanup):
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
obj_id = _add_personnage(client, slug, screen_id)
obj = screens.get_scene_object(slug, obj_id)
assert screens.resolve_personnage_commands(obj) == screens.DEFAULT_PERSONNAGE_COMMANDS
def test_commands_route_persists_remapped_keys_and_axis_lock(client, tmp_game_slug_cleanup):
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='{"haut": "ArrowUp", "bas": "ArrowDown", "gauche": "ArrowLeft", "droite": "ArrowRight", "interagir": " ", "axe": "horizontal", "vitesse": 8}',
content_type="application/json",
)
assert resp.status_code == 200
assert resp.get_json()["ok"] is True
obj = screens.get_scene_object(slug, obj_id)
commands = screens.resolve_personnage_commands(obj)
assert commands == {
"haut": "ArrowUp", "bas": "ArrowDown", "gauche": "ArrowLeft", "droite": "ArrowRight",
"interagir": " ", "axe": "horizontal", "vitesse": 8,
}
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
d'un déplacement côté client (voir personnage-controller.js)."""
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='{"axe": "diagonal", "vitesse": 999}',
content_type="application/json",
)
commands = resp.get_json()["commands"]
assert commands["axe"] == "libre"
assert commands["vitesse"] == 20
def test_commands_route_falls_back_to_defaults_for_blank_or_missing_keys(client, tmp_game_slug_cleanup):
""""" (chaîne vide)/absent retombe sur le défaut — mais PAS " "
(Espace), une valeur ev.key parfaitement valide côté client pour
"interagir" (voir la note dans routes/scenes/scene_object_commands.py)."""
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='{"haut": "", "interagir": " "}',
content_type="application/json",
)
commands = resp.get_json()["commands"]
assert commands["haut"] == screens.DEFAULT_PERSONNAGE_COMMANDS["haut"]
assert commands["bas"] == screens.DEFAULT_PERSONNAGE_COMMANDS["bas"] # absent du body
assert commands["interagir"] == " "
def test_commands_route_404s_for_missing_object(client, game):
resp = client.post(
f"/game/{game}/scene-objects/999999/commandes",
data="{}", content_type="application/json",
)
assert resp.status_code == 404
def test_full_game_payload_exposes_resolved_commands_for_personnage_objects(client, tmp_game_slug_cleanup):
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
_add_personnage(client, slug, screen_id)
screens.add_scene_object(slug, screen_id, kind="decor") # jamais de commandes sur un décor
payload = screens.full_game_payload(slug)
scene_screen = next(s for s in payload["screens"] if s["id"] == screen_id)
personnage_obj = next(o for o in scene_screen["elements"] if o["kind"] == "personnage")
decor_obj = next(o for o in scene_screen["elements"] if o["kind"] == "decor")
assert personnage_obj["personnage_commandes"] == screens.DEFAULT_PERSONNAGE_COMMANDS
assert "personnage_commandes" not in decor_obj