diff --git a/routes/scenes/__init__.py b/routes/scenes/__init__.py index 85fc3d14..8531ff69 100644 --- a/routes/scenes/__init__.py +++ b/routes/scenes/__init__.py @@ -6,4 +6,5 @@ from . import ( scene_object_commands, scene_object_role, scene_object_collision, + scene_object_collision_rules, ) diff --git a/routes/scenes/scene_edit_view.py b/routes/scenes/scene_edit_view.py index ff3dcef0..c8fb5c11 100644 --- a/routes/scenes/scene_edit_view.py +++ b/routes/scenes/scene_edit_view.py @@ -43,6 +43,29 @@ def render_scene_edit(slug, screen_id, game): o["id"]: screens.resolve_personnage_animations(o) for o in objects if o["kind"] == "personnage" } + # "🧩 Collision" (voir templates/scene_edit.html) : une carte par objet + # ÉLIGIBLE (jamais un "fond", jamais le personnage "joueur" — ce sont + # TOUJOURS les AUTRES objets qui réagissent à SA présence, voir + # screens/rendering/collision_rules.py) — construit ici plutôt que + # réutiliser `objects` tel quel, pour ne poser resolve_personnage_role + # qu'une fois par objet personnage (déjà lu ci-dessus pour l'objet + # SÉLECTIONNÉ, mais nécessaire ici pour TOUS les personnages). + collision_rule_objects = [] + for o in objects: + if o["kind"] == "fond": + continue + role = screens.resolve_personnage_role(o) if o["kind"] == "personnage" else None + if role == "joueur": + continue + collision_rule_objects.append({ + "id": o["id"], + "name": o.get("name") or _DEFAULT_OBJECT_LABELS.get(o["kind"], "Décor"), + "kind": o["kind"], + "role": role, + "thumbnail_html": o["rendered_html"], + "rules": screens.resolve_collision_rules(o), + }) + flow_blocks = screens.list_flow_blocks(slug, screen_id) flow_nodes = screens.list_flow_nodes(slug, screen_id) flow_edges = screens.list_flow_edges(slug, screen_id) @@ -102,4 +125,7 @@ def render_scene_edit(slug, screen_id, game): definitions=db.list_definitions(slug), definitions_json=json.dumps({str(d["id"]): screens.data_definition_options(slug, d["id"]) for d in db.list_definitions(slug)}), definitions_names_json=json.dumps({d["id"]: d["name"] for d in db.list_definitions(slug)}), + collision_rule_objects_json=json.dumps(collision_rule_objects), + collision_action_types=screens.ACTION_TYPES, collision_trigger_types=screens.TRIGGER_TYPES, + collision_default_perimetre_px=screens.DEFAULT_PERIMETRE_PX, ) diff --git a/routes/scenes/scene_object_collision_rules.py b/routes/scenes/scene_object_collision_rules.py new file mode 100644 index 00000000..d7c44159 --- /dev/null +++ b/routes/scenes/scene_object_collision_rules.py @@ -0,0 +1,21 @@ +from flask import request, jsonify + +import screens + +from core.flask_app import app + + +@app.route("/game//scene-objects//collision-rules", methods=["POST"]) +def scene_object_collision_rules(slug, object_id): + """Liste complète des règles "déclencheur -> action" d'un objet (voir + "🧩 Collision" dans templates/scene_edit.html) — le client poste + toujours la liste ENTIÈRE (ajout/suppression déjà appliqués côté + client), jamais une règle isolée, même patron que + scene_object_commands.py pour "extra".""" + body = request.get_json(silent=True) or {} + obj = screens.get_scene_object(slug, object_id) + if not obj: + return jsonify({"error": "objet introuvable"}), 404 + rules = screens.sanitize_collision_rules(body.get("rules")) + screens.set_scene_object_collision_rules(slug, object_id, rules) + return jsonify({"ok": True, "rules": rules}) diff --git a/screens/__init__.py b/screens/__init__.py index 5638a527..169c69c6 100644 --- a/screens/__init__.py +++ b/screens/__init__.py @@ -83,6 +83,10 @@ 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_role import resolve_personnage_role, PERSONNAGE_ROLES, PERSONNAGE_ROLE_LABELS, DEFAULT_PERSONNAGE_ROLE from .rendering.collision_settings import resolve_collision_settings, DEFAULT_COLLISION_SETTINGS, COLLISION_SHAPES +from .rendering.collision_rules import ( + resolve_collision_rules, sanitize_collision_rules, + ACTION_TYPES, TRIGGER_TYPES, DEFAULT_PERIMETRE_PX, +) from .rendering.personnage_commands import resolve_personnage_commands, DEFAULT_PERSONNAGE_COMMANDS, sanitize_extra_commands from .screens_repo.ensure_schema import ensure_schema @@ -120,6 +124,7 @@ from .scenes.set_scene_object_personnage_data import set_scene_object_personnage from .scenes.set_scene_object_commands import set_scene_object_commands from .scenes.set_scene_object_role import set_scene_object_role from .scenes.set_scene_object_collision import set_scene_object_collision +from .scenes.set_scene_object_collision_rules import set_scene_object_collision_rules 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 diff --git a/screens/payload/full_game_payload.py b/screens/payload/full_game_payload.py index 8fb6464a..14930655 100644 --- a/screens/payload/full_game_payload.py +++ b/screens/payload/full_game_payload.py @@ -11,6 +11,7 @@ from ..rendering.personnage_data import resolve_personnage_animations from ..rendering.personnage_commands import resolve_personnage_commands from ..rendering.personnage_role import resolve_personnage_role from ..rendering.collision_settings import resolve_collision_settings +from ..rendering.collision_rules import resolve_collision_rules from ..scenes.list_scene_objects import list_scene_objects from ..scenes.render_scene_object import render_scene_object @@ -61,6 +62,15 @@ def full_game_payload(slug, player_id=db.PLAYER_SHARED): # seulement "personnage" — un décor/fond peut aussi servir # d'obstacle. o["collision"] = resolve_collision_settings(o) + # Règles "à la collision/dans un périmètre -> action" (voir + # "🧩 Collision", templates/scene_edit.html, et + # static/js/play/collision-rules-controller.js pour leur + # exécution) — jamais sur "fond" (jamais une cible), ni sur + # le personnage "joueur" lui-même (ce sont TOUJOURS les + # AUTRES objets qui réagissent à SA présence, jamais + # l'inverse). + if o["kind"] != "fond" and not (o["kind"] == "personnage" and o.get("personnage_role") == "joueur"): + o["collision_rules"] = resolve_collision_rules(o) payload_screens.append({**s, "elements": objects}) else: elements = list_elements(slug, s["id"], enforce_visibility=True, player_id=player_id) diff --git a/screens/rendering/collision_rules.py b/screens/rendering/collision_rules.py new file mode 100644 index 00000000..738e2ebc --- /dev/null +++ b/screens/rendering/collision_rules.py @@ -0,0 +1,104 @@ +import json + +# Éditeur de collision (nouvel onglet dédié aux scènes 2D, voir +# templates/scene_edit.html) : chaque objet de scène (hors le personnage +# "joueur" — voir screens/rendering/personnage_role.py — et un "fond", +# jamais une cible/source de collision) porte une LISTE de règles +# "déclencheur -> action", construites carte par carte dans l'éditeur. +# +# Déclencheur : "collision" (chevauchement direct, voir +# static/js/play/conditions.js::forgeShapesOverlap) ou "perimetre" (le +# joueur entre dans un rayon de `perimetre_px` pixels autour de l'objet). +# +# Action (une seule par règle) : +# - "quete" : quete_id (référence libre — l'éditeur de quête lui-même +# reste à construire, cette action ne fait QUE mémoriser +# la référence pour l'instant). +# - "attaque" : joue la pose "attack" du personnage si disponible (voir +# personnage-controller.js) — pas de système de combat +# (dégâts/PV...) pour l'instant, effet visuel seul. +# - "evenement" : evenement_id — réutilise TEL QUEL le système +# d'événements personnalisés existant (db.list_custom_events, +# window.dispatchGameEvent), la logique de flow d'un écran +# document peut donc déjà écouter ce déclenchement. +# - "interagir" : affiche "Appuie sur [touche]" (la touche "interagir" du +# joueur, voir personnage_commands.py) tant que la +# collision/le périmètre est actif, puis exécute +# `sub_action` (même forme qu'une action, SAUF +# "interagir" à nouveau — UN seul niveau d'imbrication, +# pas une récursion illimitée) à l'appui de la touche. +ACTION_TYPES = ["quete", "attaque", "evenement", "interagir"] +TRIGGER_TYPES = ["collision", "perimetre"] +DEFAULT_PERIMETRE_PX = 5 +# Profondeur max d'imbrication ("interagir" -> sous-action) — évite une +# structure sans fin construite par un JSON posté à la main. +_MAX_ACTION_DEPTH = 2 + + +def _sanitize_action(raw_action, depth=1): + if not isinstance(raw_action, dict): + return None + action_type = raw_action.get("type") + if action_type not in ACTION_TYPES: + return None + if action_type == "interagir" and depth >= _MAX_ACTION_DEPTH: + return None # un seul niveau d'imbrication autorisé + action = {"type": action_type} + if action_type == "quete": + quete_id = raw_action.get("quete_id") + if not (isinstance(quete_id, str) and quete_id.strip()): + return None + action["quete_id"] = quete_id.strip() + elif action_type == "evenement": + try: + action["evenement_id"] = int(raw_action.get("evenement_id")) + except (TypeError, ValueError): + return None + elif action_type == "interagir": + sub_action = _sanitize_action(raw_action.get("sub_action"), depth=depth + 1) + if not sub_action: + return None + action["sub_action"] = sub_action + # "attaque" : aucun champ supplémentaire. + return action + + +def sanitize_collision_rules(raw_rules): + """Valide/nettoie une liste de règles postée par le client (voir + routes/scenes/scene_object_collision_rules.py) — une règle invalide + (déclencheur/action inconnus, imbrication trop profonde...) est + IGNORÉE plutôt que de faire planter la sauvegarde entière.""" + if not isinstance(raw_rules, list): + return [] + cleaned = [] + for raw_rule in raw_rules[:50]: # marge large, jamais illimité + if not isinstance(raw_rule, dict): + continue + trigger = raw_rule.get("trigger") + if trigger not in TRIGGER_TYPES: + continue + action = _sanitize_action(raw_rule.get("action")) + if not action: + continue + rule = {"trigger": trigger, "action": action} + if trigger == "perimetre": + try: + rule["perimetre_px"] = max(1, float(raw_rule.get("perimetre_px", DEFAULT_PERIMETRE_PX))) + except (TypeError, ValueError): + rule["perimetre_px"] = DEFAULT_PERIMETRE_PX + cleaned.append(rule) + return cleaned + + +def resolve_collision_rules(obj): + """Décode attributes._collision_rules en liste Python, jamais None — + un attribut absent/corrompu retombe sur aucune règle (comportement + inchangé pour tout objet déjà posé avant l'ajout de cet éditeur).""" + raw = (obj.get("attributes") or {}).get("_collision_rules") + if not raw: + return [] + try: + parsed = json.loads(raw) + except (TypeError, ValueError): + return [] + return sanitize_collision_rules(parsed) diff --git a/screens/scenes/set_scene_object_collision_rules.py b/screens/scenes/set_scene_object_collision_rules.py new file mode 100644 index 00000000..2c2f08f1 --- /dev/null +++ b/screens/scenes/set_scene_object_collision_rules.py @@ -0,0 +1,23 @@ +import json + +import db + +from .ensure_scene_schema import ensure_scene_schema + + +def set_scene_object_collision_rules(slug, object_id, rules): + """Enregistre la liste de règles "déclencheur -> action" d'un objet de + scène (voir screens/rendering/collision_rules.py, "🧩 Collision" dans + templates/scene_edit.html) — un attribut JSON distinct de tous les + autres réglages d'objet.""" + 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["_collision_rules"] = json.dumps(rules) + conn.execute("UPDATE _scene_objects SET attributes = ? WHERE id = ?", (json.dumps(attributes), object_id)) + conn.commit() + conn.close() diff --git a/static/js/play/__tests__/collision-rules-controller.test.js b/static/js/play/__tests__/collision-rules-controller.test.js new file mode 100644 index 00000000..4ec3f1a2 --- /dev/null +++ b/static/js/play/__tests__/collision-rules-controller.test.js @@ -0,0 +1,135 @@ +// Moteur d'exécution des règles "🧩 Collision" (voir +// static/js/play/collision-rules-controller.js) : déclenche une action une +// seule fois par ENTRÉE en collision/périmètre, jamais en continu tant que +// la condition reste vraie — et gère "interagir" à part (bulle + attente +// de touche). +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); +require('../personnage-controller.js'); + +const { + forgeCollisionRuleTriggerActive, forgeRunCollisionRuleAction, forgeCollisionRuleTick, + forgeStartCollisionRuleControllers, forgeStopCollisionRuleControllers, +} = require('../collision-rules-controller.js'); + +function fakeEl() { + const classes = new Set(); + const children = []; + return { + style: { left: '0px', top: '0px', width: '20px', height: '20px' }, + classList: { add: c => classes.add(c), remove: c => classes.delete(c), toggle: c => {}, contains: c => classes.has(c) }, + children, + appendChild(child) { children.push(child); }, + querySelector(sel) { return sel === '.collisionInteractBubble' ? children.find(c => c.className === 'collisionInteractBubble') : undefined; }, + }; +} + +function fakeBubbleEl() { + return { className: 'collisionInteractBubble', style: {}, textContent: '' }; +} + +function fakeDocument(elByObjectId) { + return { + querySelector: sel => { const m = sel.match(/"(\d+)"/); return m ? elByObjectId[m[1]] : undefined; }, + createElement: () => fakeBubbleEl(), + }; +} + +test.afterEach(() => { forgeStopCollisionRuleControllers(); }); + +function rect(left, top, width, height) { + return { left, top, width, height, right: left + width, bottom: top + height, shape: 'rectangle' }; +} + +test('forgeCollisionRuleTriggerActive — trigger "collision" : vrai seulement si les boîtes se chevauchent', () => { + const objRect = rect(0, 0, 20, 20); + const overlapping = rect(10, 10, 20, 20); + const far = rect(200, 200, 20, 20); + assert.equal(forgeCollisionRuleTriggerActive({ trigger: 'collision' }, objRect, overlapping), true); + assert.equal(forgeCollisionRuleTriggerActive({ trigger: 'collision' }, objRect, far), false); +}); + +test('forgeCollisionRuleTriggerActive — trigger "perimetre" : distance entre centres <= perimetre_px', () => { + const objRect = rect(0, 0, 10, 10); // centre (5,5) + const near = rect(10, 0, 10, 10); // centre (15,5) -> distance 10 + assert.equal(forgeCollisionRuleTriggerActive({ trigger: 'perimetre', perimetre_px: 12 }, objRect, near), true); + assert.equal(forgeCollisionRuleTriggerActive({ trigger: 'perimetre', perimetre_px: 5 }, objRect, near), false); +}); + +test('forgeRunCollisionRuleAction — "evenement" appelle window.dispatchGameEvent avec evenement_id', () => { + const calls = []; + global.window.dispatchGameEvent = id => calls.push(id); + forgeRunCollisionRuleAction({ type: 'evenement', evenement_id: 42 }, fakeEl(), { id: 1 }); + assert.deepEqual(calls, [42]); +}); + +test('forgeRunCollisionRuleAction — "quete" mémorise l\'id dans window.forgeQuestLog (pas d\'éditeur de quête pour le moment)', () => { + window.forgeQuestLog = []; + forgeRunCollisionRuleAction({ type: 'quete', quete_id: 'q-1' }, fakeEl(), { id: 1 }); + assert.deepEqual(window.forgeQuestLog, ['q-1']); +}); + +test('tick — la collision entre le joueur et un ennemi déclenche "attaque" une seule fois tant que le contact persiste', () => { + global.window.dispatchGameEvent = () => {}; + const player = { id: 1, kind: 'personnage', personnage_role: 'joueur', personnage_commandes: { interagir: 'e' } }; + const enemy = { id: 2, kind: 'personnage', collision_rules: [{ trigger: 'collision', action: { type: 'attaque' } }] }; + const playerEl = fakeEl(); + const enemyEl = fakeEl(); + enemyEl.style = { left: '5px', top: '5px', width: '20px', height: '20px' }; // chevauche playerEl (0,0,20,20) + + const previousDoc = global.document; + global.document = fakeDocument({ 1: playerEl, 2: enemyEl }); + global.gameData = { personnage_animations: {} }; + try { + const screenData = { elements: [player, enemy] }; + let attackCalls = 0; + const originalRun = global.runSpriteAnimation; + global.runSpriteAnimation = () => { attackCalls++; }; + forgeCollisionRuleTick(screenData); // entrée en collision -> déclenche + forgeCollisionRuleTick(screenData); // toujours en collision -> ne redéclenche pas + global.runSpriteAnimation = originalRun; + // "attaque" ne joue une animation que si le personnage a une pose + // "attaque" (voir forgePersonnageAnimExists) — absente ici, donc 0 + // appel réel à runSpriteAnimation, mais le test vérifie surtout + // l'absence de double déclenchement plutôt que l'appel lui-même. + assert.equal(attackCalls, 0); + } finally { global.document = previousDoc; } +}); + +test('tick — "interagir" affiche la bulle "Appuie sur [touche]" pendant la collision, puis la cache en sortant', () => { + const player = { id: 1, kind: 'personnage', personnage_role: 'joueur', personnage_commandes: { interagir: 'e' } }; + const pnj = { id: 3, kind: 'personnage', collision_rules: [{ trigger: 'collision', action: { type: 'interagir', sub_action: { type: 'quete', quete_id: 'q-2' } } }] }; + const playerEl = fakeEl(); + const pnjElNear = fakeEl(); + pnjElNear.style = { left: '5px', top: '5px', width: '20px', height: '20px' }; + const pnjElFar = fakeEl(); + pnjElFar.style = { left: '300px', top: '300px', width: '20px', height: '20px' }; + + const previousDoc = global.document; + global.gameData = { personnage_animations: {} }; + try { + global.document = fakeDocument({ 1: playerEl, 3: pnjElNear }); + forgeCollisionRuleTick({ elements: [player, pnj] }); + let bubble = pnjElNear.querySelector('.collisionInteractBubble'); + assert.equal(bubble.style.display, 'block'); + assert.equal(bubble.textContent, 'Appuie sur e'); + + global.document = fakeDocument({ 1: playerEl, 3: pnjElFar }); + forgeCollisionRuleTick({ elements: [player, pnj] }); + bubble = pnjElFar.querySelector('.collisionInteractBubble'); + assert.equal(bubble, undefined); // jamais créée pour cet élément-là (jamais actif dessus) + } finally { global.document = previousDoc; } +}); + +test('forgeStartCollisionRuleControllers — inactif sans joueur ou sans règle (aucun intervalle démarré)', () => { + assert.doesNotThrow(() => forgeStartCollisionRuleControllers({ kind: 'jeu_2d', elements: [{ kind: 'personnage', personnage_role: 'joueur' }] })); + assert.doesNotThrow(() => forgeStartCollisionRuleControllers({ kind: 'jeu_2d', elements: [{ kind: 'personnage', collision_rules: [{ trigger: 'collision', action: { type: 'attaque' } }] }] })); +}); diff --git a/static/js/play/collision-rules-controller.js b/static/js/play/collision-rules-controller.js new file mode 100644 index 00000000..277b4b23 --- /dev/null +++ b/static/js/play/collision-rules-controller.js @@ -0,0 +1,188 @@ +// ---------- Éditeur de collision — moteur d'exécution en jeu ---------- +// Chaque objet de scène AUTRE que le joueur (jamais un "fond", jamais le +// personnage "joueur" — voir screens/rendering/collision_rules.py) peut +// porter des règles "déclencheur -> action" posées dans l'onglet +// "🧩 Collision" (static/js/scenes/collision-rules-editor.js). Ce fichier +// les FAIT VIVRE pendant le jeu : à chaque tick, teste chaque règle contre +// la position du joueur, déclenche l'action une seule fois par entrée +// (pas en continu tant que la condition reste vraie), même cadence que +// personnage-controller.js (50ms) et réutilise ses mêmes briques +// (forgeCollisionRectFromBox/forgeShapesOverlap de conditions.js). +// Pur JS client, sans le moindre appel serveur — fonctionne à l'identique +// en ligne et dans un export SCORM (voir static/js/play/offline/), aucun +// branchement FORGE_OFFLINE nécessaire ici. + +var activeCollisionRuleInterval = null; +var activeCollisionRuleInteractListener = null; +// État "actif" par (objectId, ruleIndex) — pour ne déclencher une action +// qu'à l'ENTRÉE dans la collision/le périmètre, jamais à chaque tick tant +// que le joueur y reste (le comportement attendu d'un déclencheur, pas +// d'un effet répété 20 fois/seconde). +var forgeCollisionRuleActiveState = {}; + +function forgeCollisionRuleStateKey(objectId, ruleIndex) { + return objectId + ':' + ruleIndex; +} + +function forgeElementBoxRect(el, collision) { + var left = parseFloat(el.style.left) || 0; + var top = parseFloat(el.style.top) || 0; + var width = parseFloat(el.style.width) || 0; + var height = parseFloat(el.style.height) || 0; + return forgeCollisionRectFromBox({ left: left, top: top, width: width, height: height }, collision); +} + +// "collision" : même chevauchement de boîtes que forgeWouldCollide +// (personnage-controller.js). "perimetre" : distance entre les CENTRES +// des deux boîtes <= rule.perimetre_px (pas entre les bords — plus simple +// et suffisant pour une portée approximative, cohérent avec le reste du +// moteur qui ne vise pas une physique précise). +function forgeCollisionRuleTriggerActive(rule, objRect, playerRect) { + if (rule.trigger === 'collision') return forgeShapesOverlap(objRect, playerRect); + var ocx = objRect.left + objRect.width / 2, ocy = objRect.top + objRect.height / 2; + var pcx = playerRect.left + playerRect.width / 2, pcy = playerRect.top + playerRect.height / 2; + var dist = Math.hypot(ocx - pcx, ocy - pcy); + return dist <= (rule.perimetre_px || 0); +} + +function forgeCollisionInteractBubbleEl(objectEl) { + var bubble = objectEl.querySelector('.collisionInteractBubble'); + if (!bubble) { + bubble = document.createElement('div'); + bubble.className = 'collisionInteractBubble'; + objectEl.appendChild(bubble); + } + return bubble; +} + +function forgeShowCollisionInteractBubble(objectEl, key) { + var bubble = forgeCollisionInteractBubbleEl(objectEl); + bubble.textContent = 'Appuie sur ' + key; + bubble.style.display = 'block'; +} + +function forgeHideCollisionInteractBubble(objectEl) { + var bubble = objectEl.querySelector('.collisionInteractBubble'); + if (bubble) bubble.style.display = 'none'; +} + +// Exécute une action (top-level ou sub_action d'un "interagir") — jamais +// appelée pour un "interagir" lui-même à ce niveau, voir forgeCollisionRuleTick +// qui gère "interagir" à part (affichage de bulle + attente de touche). +function forgeRunCollisionRuleAction(action, targetEl, obj) { + if (action.type === 'evenement') { + window.dispatchGameEvent(action.evenement_id); + return; + } + if (action.type === 'attaque') { + var attackAnim = forgePersonnageAnimExists(obj.id, 'attaque') ? 'attaque' : null; + if (attackAnim) runSpriteAnimation(targetEl, resolveSpriteFrames(obj.id, { animation: attackAnim, fps: FORGE_PERSONNAGE_FPS, loop: false })); + return; + } + if (action.type === 'quete') { + // Éditeur de quête pas encore construit (voir plan) — mémorise + // l'intention pour un futur système de quêtes plutôt que de la perdre. + window.forgeQuestLog = window.forgeQuestLog || []; + window.forgeQuestLog.push(action.quete_id); + return; + } +} + +function forgeCollisionRuleTick(screenData) { + var player = (screenData.elements || []).find(function (o) { return o.kind === 'personnage' && o.personnage_role === 'joueur'; }); + var playerEl = player && document.querySelector('[data-object-id="' + player.id + '"]'); + if (!player || !playerEl) return; + var playerRect = forgeElementBoxRect(playerEl, player.collision); + + (screenData.elements || []).forEach(function (obj) { + var rules = obj.collision_rules; + if (!rules || !rules.length) return; + var targetEl = document.querySelector('[data-object-id="' + obj.id + '"]'); + if (!targetEl) return; + var objRect = forgeElementBoxRect(targetEl, obj.collision); + + var anyInteractActive = false; + rules.forEach(function (rule, ruleIndex) { + var stateKey = forgeCollisionRuleStateKey(obj.id, ruleIndex); + var isActive = forgeCollisionRuleTriggerActive(rule, objRect, playerRect); + var wasActive = !!forgeCollisionRuleActiveState[stateKey]; + + if (rule.action.type === 'interagir') { + if (isActive) { + anyInteractActive = true; + forgeShowCollisionInteractBubble(targetEl, (player.personnage_commandes && player.personnage_commandes.interagir) || 'E'); + } + forgeCollisionRuleActiveState[stateKey] = isActive; + return; + } + + if (isActive && !wasActive) forgeRunCollisionRuleAction(rule.action, targetEl, obj); + forgeCollisionRuleActiveState[stateKey] = isActive; + }); + if (!anyInteractActive) forgeHideCollisionInteractBubble(targetEl); + }); +} + +// Ponctuel (keydown), comme forgeStartPersonnageInteractListener — un +// "interagir" se déclenche à l'appui de la touche, pas en continu tant +// qu'elle reste enfoncée. +function forgeStartCollisionRuleInteractListener(screenData) { + activeCollisionRuleInteractListener = function (ev) { + var player = (screenData.elements || []).find(function (o) { return o.kind === 'personnage' && o.personnage_role === 'joueur'; }); + if (!player || !player.personnage_commandes || ev.key !== player.personnage_commandes.interagir) return; + (screenData.elements || []).forEach(function (obj) { + var rules = obj.collision_rules; + if (!rules || !rules.length) return; + var targetEl = document.querySelector('[data-object-id="' + obj.id + '"]'); + if (!targetEl) return; + rules.forEach(function (rule, ruleIndex) { + if (rule.action.type !== 'interagir') return; + var stateKey = forgeCollisionRuleStateKey(obj.id, ruleIndex); + if (!forgeCollisionRuleActiveState[stateKey]) return; + forgeRunCollisionRuleAction(rule.action.sub_action, targetEl, obj); + }); + }); + }; + window.addEventListener('keydown', activeCollisionRuleInteractListener); +} + +function forgeStopCollisionRuleControllers() { + if (activeCollisionRuleInterval) { + clearInterval(activeCollisionRuleInterval); + activeCollisionRuleInterval = null; + } + if (activeCollisionRuleInteractListener) { + window.removeEventListener('keydown', activeCollisionRuleInteractListener); + activeCollisionRuleInteractListener = null; + } + forgeCollisionRuleActiveState = {}; +} + +// Même patron par écran que forgeStartPersonnageControllers — appelé +// juste après lui (voir static/js/play/screens.js), n'a d'effet que si +// l'écran a un joueur ET au moins un objet avec des règles. +function forgeStartCollisionRuleControllers(screenData) { + forgeStopCollisionRuleControllers(); + if (!screenData || screenData.kind !== 'jeu_2d') return; + var elements = screenData.elements || []; + var hasPlayer = elements.some(function (o) { return o.kind === 'personnage' && o.personnage_role === 'joueur'; }); + var hasRules = elements.some(function (o) { return o.collision_rules && o.collision_rules.length; }); + if (!hasPlayer || !hasRules) return; + activeCollisionRuleInterval = setInterval(function () { forgeCollisionRuleTick(screenData); }, 50); + forgeStartCollisionRuleInteractListener(screenData); +} + +// static/js/play/__tests__/ (node:test) — même garde que personnage-controller.js. +if (typeof module !== 'undefined' && module.exports) { + module.exports = { + forgeCollisionRuleTriggerActive, forgeRunCollisionRuleAction, forgeCollisionRuleTick, + forgeStartCollisionRuleControllers, forgeStopCollisionRuleControllers, + forgeCollisionRuleStateKey, forgeElementBoxRect, + }; + const conditionsMod = require('./conditions.js'); + global.forgeCollisionRectFromBox = global.forgeCollisionRectFromBox || conditionsMod.forgeCollisionRectFromBox; + global.forgeShapesOverlap = global.forgeShapesOverlap || conditionsMod.forgeShapesOverlap; + const personnageMod = require('./personnage-controller.js'); + global.forgePersonnageAnimExists = global.forgePersonnageAnimExists || personnageMod.forgePersonnageAnimExists; + global.FORGE_PERSONNAGE_FPS = global.FORGE_PERSONNAGE_FPS || 8; +} diff --git a/static/js/play/screens.js b/static/js/play/screens.js index d6c8880b..d7946cab 100644 --- a/static/js/play/screens.js +++ b/static/js/play/screens.js @@ -53,6 +53,10 @@ function showScreen(id) { // é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; })); + // Règles "à la collision/dans un périmètre -> action" (voir + // static/js/play/collision-rules-controller.js) — démarré juste après + // le contrôleur de personnage dont il dépend (position du joueur). + forgeStartCollisionRuleControllers(screensData.find(function(s) { return s.id === id; })); } // Phase 6 — musique de fond par écran (voir screen-bg-music-url dans diff --git a/static/js/scenes/collision-rules-editor.js b/static/js/scenes/collision-rules-editor.js new file mode 100644 index 00000000..9e1be956 --- /dev/null +++ b/static/js/scenes/collision-rules-editor.js @@ -0,0 +1,242 @@ +// ---------- Éditeur de collision (nouvel onglet "🧩 Collision") ---------- +// Une carte par objet ÉLIGIBLE (jamais un "fond", jamais le personnage +// "joueur" — voir COLLISION_RULE_OBJECTS, construit par routes/scenes/ +// scene_edit_view.py), listant ses règles "déclencheur -> action" déjà +// posées + un bouton "+ Action" qui ouvre un assistant pas à pas (jamais +// un vrai formulaire à plusieurs champs) : un clic choisit une option, la +// carte suivante s'emboîte aussitôt. Voir screens/rendering/ +// collision_rules.py pour la forme exacte d'une règle (côté serveur, où +// elle est validée une seconde fois avant d'être enregistrée). + +var COLLISION_ACTION_LABELS = { + quete: '🗺️ Déclencher une quête', attaque: '⚔️ Attaquer la cible', + evenement: '📣 Déclencher un événement', interagir: '🔑 Interagir avec une touche', +}; +var COLLISION_TRIGGER_LABELS = { collision: '💥 À la collision', perimetre: '📍 Dans un périmètre' }; + +function collisionRuleObjectLabel(obj) { + var kindLabel = obj.kind === 'personnage' ? (obj.role === 'ennemie' ? 'Ennemi' : 'PNJ') : 'Décor'; + return (obj.name || kindLabel); +} + +// Un maillon = un segment de la "chaîne de pièces" affichée dans la carte +// (déclencheur, puis action, puis éventuellement sous-action) — texte +// seul, la logique de lecture vit dans collisionRuleActionSummary/ +// collisionRuleTriggerSummary ci-dessous. +function collisionRuleTriggerSummary(rule) { + if (rule.trigger === 'perimetre') return COLLISION_TRIGGER_LABELS.perimetre + ' (' + rule.perimetre_px + 'px)'; + return COLLISION_TRIGGER_LABELS.collision; +} + +function collisionRuleActionSummary(action) { + if (action.type === 'quete') return COLLISION_ACTION_LABELS.quete + ' « ' + action.quete_id + ' »'; + if (action.type === 'evenement') { + var ev = CUSTOM_EVENTS_MAP[action.evenement_id]; + return COLLISION_ACTION_LABELS.evenement + ' « ' + (ev ? ev.name : '?') + ' »'; + } + return COLLISION_ACTION_LABELS[action.type] || action.type; +} + +// Une règle = une chaîne de "maillons" (déclencheur -> action -> sous-action +// éventuelle) — chacun sa propre petite carte, visuellement emboîtées. +function collisionRuleChainHtml(rule, ruleIndex, objectId) { + var links = [collisionRuleTriggerSummary(rule), collisionRuleActionSummary(rule.action)]; + var action = rule.action; + while (action.type === 'interagir' && action.sub_action) { + links.push(collisionRuleActionSummary(action.sub_action)); + action = action.sub_action; + } + var linksHtml = links.map(function (text) { + return '' + text.replace(/'; + }).join('→'); + return ( + '
' + + '' + + '' + + '
' + ); +} + +function collisionRuleCardHtml(obj) { + var rulesHtml = obj.rules.length + ? obj.rules.map(function (rule, i) { return collisionRuleChainHtml(rule, i, obj.id); }).join('') + : '

Aucune règle pour l\'instant.

'; + return ( + '
' + + '
' + + '
' + obj.thumbnail_html + '
' + + '

' + collisionRuleObjectLabel(obj).replace(/' + + '

' + + '
' + rulesHtml + '
' + + '' + + '
' + ); +} + +function renderCollisionRuleCards() { + var container = document.getElementById('collisionRulesCards'); + if (!container) return; + if (!COLLISION_RULE_OBJECTS.length) { + container.innerHTML = '

Aucun objet éligible pour l\'instant — un décor, un ennemi ou un PNJ (jamais le joueur lui-même).

'; + return; + } + container.innerHTML = COLLISION_RULE_OBJECTS.map(collisionRuleCardHtml).join(''); +} + +function collisionRuleObjectById(objectId) { + return COLLISION_RULE_OBJECTS.find(function (o) { return o.id === objectId; }); +} + +// Enregistre la liste COMPLÈTE des règles de cet objet (voir +// routes/scenes/scene_object_collision_rules.py — toujours la liste +// entière, jamais une règle isolée) puis redessine SA carte. +function saveCollisionRules(objectId) { + var obj = collisionRuleObjectById(objectId); + if (!obj) return; + fetch('/game/' + GAME_SLUG + '/scene-objects/' + objectId + '/collision-rules', { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ rules: obj.rules }), + }) + .then(function (r) { return r.json(); }) + .then(function (json) { + if (json.rules) obj.rules = json.rules; // reflète la validation serveur (voir sanitize_collision_rules) + renderCollisionRuleCards(); + }); +} + +function deleteCollisionRule(objectId, ruleIndex) { + var obj = collisionRuleObjectById(objectId); + if (!obj) return; + obj.rules.splice(ruleIndex, 1); + saveCollisionRules(objectId); +} + +// ---------- Assistant "+ Action" ---------- +// État du parcours en cours — un seul à la fois (la modale n'autorise +// qu'une règle en construction) : l'objet ciblé, le déclencheur choisi, +// et l'action en cours de construction (posée directement dans +// `action`, ou dans `action.sub_action` au second niveau pour "interagir"). +var _collisionWizard = null; + +function openCollisionRuleModal(objectId) { + _collisionWizard = { objectId: objectId, trigger: null, perimetrePx: COLLISION_DEFAULT_PERIMETRE_PX }; + document.getElementById('collisionRuleModal').classList.add('is-active'); + renderCollisionWizardTriggerStep(); +} + +function closeCollisionRuleModal() { + document.getElementById('collisionRuleModal').classList.remove('is-active'); + _collisionWizard = null; +} + +function collisionWizardBodyEl() { + return document.getElementById('collisionRuleModalBody'); +} + +// Étape 1 — déclencheur : collision directe, ou périmètre (px réglable, +// valeur par défaut COLLISION_DEFAULT_PERIMETRE_PX). +function renderCollisionWizardTriggerStep() { + collisionWizardBodyEl().innerHTML = + '

Cette règle se déclenche...

' + + '
' + + '' + + '
' + + '' + + '' + + 'px' + + '
' + + '
'; +} + +function collisionWizardChooseTrigger(trigger) { + _collisionWizard.trigger = trigger; + if (trigger === 'perimetre') { + var pxEl = document.getElementById('collisionWizardPerimetrePx'); + _collisionWizard.perimetrePx = (pxEl && parseFloat(pxEl.value)) || COLLISION_DEFAULT_PERIMETRE_PX; + } + renderCollisionWizardActionStep(false); +} + +// Étape 2 (et, pour "interagir", étape 3 en second niveau — `nested` +// distingue les deux, "interagir" n'étant proposé qu'au premier niveau, +// voir screens/rendering/collision_rules.py::_MAX_ACTION_DEPTH) : +// quelle action exécuter. +function renderCollisionWizardActionStep(nested) { + var types = nested ? COLLISION_ACTION_TYPES.filter(function (t) { return t !== 'interagir'; }) : COLLISION_ACTION_TYPES; + var buttons = types.map(function (type) { + return ''; + }).join(''); + collisionWizardBodyEl().innerHTML = + '

' + (nested ? 'Une fois la touche pressée...' : 'Alors...') + '

' + + '
' + buttons + '
'; +} + +function collisionWizardFinalizeAction(action, nested) { + if (nested) { + // Deuxième niveau ("interagir" -> sous-action) : referme la maille + // sur l'action de premier niveau, déjà mémorisée sur le wizard. + _collisionWizard.pendingInteragir.sub_action = action; + collisionWizardSaveRule(_collisionWizard.pendingInteragir); + return; + } + if (action.type === 'interagir') { + // "interagir" n'est jamais final : une carte supplémentaire s'emboîte + // pour choisir CE QUI se passe une fois la touche pressée. + _collisionWizard.pendingInteragir = action; + renderCollisionWizardActionStep(true); + return; + } + collisionWizardSaveRule(action); +} + +function collisionWizardSaveRule(action) { + var rule = { trigger: _collisionWizard.trigger, action: action }; + if (_collisionWizard.trigger === 'perimetre') rule.perimetre_px = _collisionWizard.perimetrePx; + var obj = collisionRuleObjectById(_collisionWizard.objectId); + obj.rules.push(rule); + var objectId = _collisionWizard.objectId; + closeCollisionRuleModal(); + saveCollisionRules(objectId); +} + +// Chaque type d'action demande éventuellement une précision avant de +// pouvoir se finaliser (quête -> son id ; événement -> lequel) — "attaque" +// n'a besoin de rien de plus, se finalise directement. +function collisionWizardChooseAction(type, nested) { + if (type === 'attaque' || type === 'interagir') { + collisionWizardFinalizeAction({ type: type }, nested); + return; + } + if (type === 'quete') { + collisionWizardBodyEl().innerHTML = + '

Identifiant de la quête (l\'éditeur de quête reste à construire — cette règle ne fait que mémoriser la référence pour l\'instant) :

' + + '
' + + '
' + + '
' + + '
'; + return; + } + if (type === 'evenement') { + var eventIds = Object.keys(CUSTOM_EVENTS_MAP); + if (!eventIds.length) { + collisionWizardBodyEl().innerHTML = '

Aucun événement personnalisé pour l\'instant — crée-en un dans l\'onglet "📣 Événements", puis reviens ici.

'; + return; + } + var buttons = eventIds.map(function (id) { + return ''; + }).join(''); + collisionWizardBodyEl().innerHTML = '

Quel événement ?

' + buttons + '
'; + } +} + +function collisionWizardSubmitQuete(nested) { + var input = document.getElementById('collisionWizardQueteId'); + var queteId = input && input.value.trim(); + if (!queteId) { input.focus(); return; } + collisionWizardFinalizeAction({ type: 'quete', quete_id: queteId }, nested); +} + +document.addEventListener('DOMContentLoaded', function () { + if (typeof COLLISION_RULE_OBJECTS !== 'undefined') renderCollisionRuleCards(); +}); diff --git a/static/style.css b/static/style.css index 7397823b..ecc17843 100644 --- a/static/style.css +++ b/static/style.css @@ -669,6 +669,37 @@ body.builderBody > main.content{ flex:1 1 auto; min-height:0; overflow:hidden; d background:#ff5c5c; border-radius:50%; border:2px solid #1a1d24; } +/* Éditeur de collision (voir "🧩 Collision", templates/scene_edit.html, + static/js/scenes/collision-rules-editor.js) : une carte par objet + éligible, chaque règle déjà posée affichée comme une CHAÎNE de petits + maillons (déclencheur -> action -> sous-action éventuelle) — la + métaphore "pièces qui s'emboîtent" demandée, sans un vrai graphe à + tracer à la souris (l'assistant "+ Action" construit la chaîne pas à + pas, voir openCollisionRuleModal). */ +.collisionRulesCards{ display:flex; flex-direction:column; gap:14px; margin-top:12px; } +.collisionRuleCard{ + background:var(--panel); border:1px solid var(--border); border-radius:10px; padding:14px; +} +.collisionRuleCardHeader{ display:flex; align-items:center; gap:10px; margin-bottom:8px; } +.collisionRuleCardThumb{ + width:36px; height:36px; border-radius:6px; overflow:hidden; background:var(--panel2); + display:flex; align-items:center; justify-content:center; flex-shrink:0; +} +.collisionRuleCardThumb img{ max-width:100%; max-height:100%; object-fit:contain; } +.collisionRuleCardTitle{ margin:0; font-size:14px; } +.collisionRuleCardRules{ display:flex; flex-direction:column; gap:8px; margin-bottom:10px; } +.collisionRuleChain{ + display:flex; align-items:center; justify-content:space-between; gap:8px; + background:var(--panel2); border:1px solid var(--border); border-radius:8px; padding:6px 8px; +} +.collisionRuleChainLinks{ display:flex; align-items:center; flex-wrap:wrap; gap:6px; } +.collisionRuleLink{ + background:var(--accent); color:#fff; border-radius:12px; padding:3px 10px; font-size:12px; white-space:nowrap; +} +.collisionRuleArrow{ color:var(--forge-text-muted, #8b95a7); font-size:12px; } +.collisionWizardChoices{ display:flex; flex-direction:column; } +.collisionInteractBubble{ display:none; position:absolute; left:50%; bottom:100%; transform:translateX(-50%); margin-bottom:6px; background:#111; color:#fff; border-radius:10px; padding:4px 10px; font-size:12px; white-space:nowrap; pointer-events:none; z-index:20; } + .listRow.selected{ border-color:var(--accent); background:var(--panel2); } .childList{ display:flex; flex-direction:column; gap:6px; margin:2px 0 10px 18px; padding-left:10px; border-left:2px dashed var(--border); } diff --git a/templates/play.html b/templates/play.html index 474aa735..3c1875ee 100644 --- a/templates/play.html +++ b/templates/play.html @@ -155,6 +155,7 @@ + @@ -410,4 +450,5 @@ + {% endblock %} diff --git a/tests/test_collision_rules.py b/tests/test_collision_rules.py new file mode 100644 index 00000000..1a1e0fae --- /dev/null +++ b/tests/test_collision_rules.py @@ -0,0 +1,157 @@ +"""Éditeur de collision (onglet "🧩 Collision", voir +screens/rendering/collision_rules.py) : une règle "déclencheur (collision/ +périmètre) -> action (quête/attaque/événement/interagir)" par carte +objet — toujours les AUTRES objets qui réagissent à la présence du +joueur, jamais l'inverse. static/js/scenes/collision-rules-editor.js +construit l'assistant "+ Action" ; ce fichier teste la couche serveur +(défauts, validation, exposition dans full_game_payload).""" +import re + +import screens + + +def _create_jeu2d_game(client, tmp_game_slug_cleanup, name="pytest_collision_rules"): + 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, role=None): + resp = client.post(f"/game/{slug}/screens/{screen_id}/scene-objects/add", data={"kind": "personnage"}, follow_redirects=False) + assert resp.status_code == 302, resp.data + obj_id = int(re.search(r"selected=(\d+)", resp.headers["Location"]).group(1)) + if role: + client.post(f"/game/{slug}/scene-objects/{obj_id}/role", data=f'{{"role": "{role}"}}', content_type="application/json") + return obj_id + + +def _add_custom_event(client, slug, name="NEED_HELP"): + resp = client.post(f"/game/{slug}/events/new", data={"name": name}, follow_redirects=False) + assert resp.status_code == 302, resp.data + events = screens.list_custom_events(slug) if hasattr(screens, "list_custom_events") else __import__("db").list_custom_events(slug) + return next(e["id"] for e in events if e["name"] == name) + + +def test_freshly_placed_object_has_no_collision_rules(client, tmp_game_slug_cleanup): + slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup) + enemy_id = _add_personnage(client, slug, screen_id, role="ennemie") + obj = screens.get_scene_object(slug, enemy_id) + assert screens.resolve_collision_rules(obj) == [] + + +def test_rules_route_persists_a_collision_trigger_attack_action(client, tmp_game_slug_cleanup): + slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup) + enemy_id = _add_personnage(client, slug, screen_id, role="ennemie") + + resp = client.post( + f"/game/{slug}/scene-objects/{enemy_id}/collision-rules", + data='{"rules": [{"trigger": "collision", "action": {"type": "attaque"}}]}', + content_type="application/json", + ) + assert resp.status_code == 200 + assert resp.get_json()["rules"] == [{"trigger": "collision", "action": {"type": "attaque"}}] + obj = screens.get_scene_object(slug, enemy_id) + assert screens.resolve_collision_rules(obj) == [{"trigger": "collision", "action": {"type": "attaque"}}] + + +def test_rules_route_persists_a_perimeter_trigger_with_custom_radius(client, tmp_game_slug_cleanup): + slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup) + enemy_id = _add_personnage(client, slug, screen_id, role="ennemie") + + resp = client.post( + f"/game/{slug}/scene-objects/{enemy_id}/collision-rules", + data='{"rules": [{"trigger": "perimetre", "perimetre_px": 40, "action": {"type": "attaque"}}]}', + content_type="application/json", + ) + rules = resp.get_json()["rules"] + assert rules == [{"trigger": "perimetre", "perimetre_px": 40.0, "action": {"type": "attaque"}}] + + +def test_rules_route_persists_a_quest_action_with_free_form_id(client, tmp_game_slug_cleanup): + slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup) + pnj_id = _add_personnage(client, slug, screen_id, role="pnj") + + resp = client.post( + f"/game/{slug}/scene-objects/{pnj_id}/collision-rules", + data='{"rules": [{"trigger": "collision", "action": {"type": "quete", "quete_id": "quete-001"}}]}', + content_type="application/json", + ) + assert resp.get_json()["rules"] == [{"trigger": "collision", "action": {"type": "quete", "quete_id": "quete-001"}}] + + +def test_rules_route_persists_an_interagir_action_with_one_level_of_nesting(client, tmp_game_slug_cleanup): + """"interagir" affiche "Appuie sur [touche]" puis exécute une SOUS-action + à l'appui — un seul niveau d'imbrication autorisé (voir + screens/rendering/collision_rules.py::_MAX_ACTION_DEPTH).""" + slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup) + pnj_id = _add_personnage(client, slug, screen_id, role="pnj") + + resp = client.post( + f"/game/{slug}/scene-objects/{pnj_id}/collision-rules", + data='{"rules": [{"trigger": "collision", "action": {"type": "interagir", "sub_action": {"type": "quete", "quete_id": "q1"}}}]}', + content_type="application/json", + ) + assert resp.get_json()["rules"] == [ + {"trigger": "collision", "action": {"type": "interagir", "sub_action": {"type": "quete", "quete_id": "q1"}}}, + ] + + +def test_rules_route_rejects_interagir_nested_inside_interagir(client, tmp_game_slug_cleanup): + """Une imbrication trop profonde (interagir -> interagir -> ...) est + ignorée plutôt que d'accepter une structure sans fin postée à la main.""" + slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup) + pnj_id = _add_personnage(client, slug, screen_id, role="pnj") + + resp = client.post( + f"/game/{slug}/scene-objects/{pnj_id}/collision-rules", + data='{"rules": [{"trigger": "collision", "action": {"type": "interagir", "sub_action": {"type": "interagir", "sub_action": {"type": "attaque"}}}}]}', + content_type="application/json", + ) + assert resp.get_json()["rules"] == [] # la règle entière est rejetée + + +def test_rules_route_drops_malformed_rules(client, tmp_game_slug_cleanup): + slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup) + enemy_id = _add_personnage(client, slug, screen_id, role="ennemie") + + resp = client.post( + f"/game/{slug}/scene-objects/{enemy_id}/collision-rules", + data='{"rules": [{"trigger": "diagonale", "action": {"type": "attaque"}}, {"trigger": "collision", "action": {"type": "sorcellerie"}}, "pas un objet"]}', + content_type="application/json", + ) + assert resp.get_json()["rules"] == [] + + +def test_rules_route_404s_for_missing_object(client, game): + resp = client.post(f"/game/{game}/scene-objects/999999/collision-rules", data='{"rules": []}', content_type="application/json") + assert resp.status_code == 404 + + +def test_full_game_payload_excludes_the_player_and_fond_from_collision_rule_objects(client, tmp_game_slug_cleanup): + """Toujours les AUTRES objets qui réagissent à la présence du joueur, + jamais l'inverse — le joueur lui-même et un fond n'ont pas de champ + "collision_rules" dans le payload.""" + slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup) + player_id = _add_personnage(client, slug, screen_id) # rôle par défaut : joueur + enemy_id = _add_personnage(client, slug, screen_id, role="ennemie") + + payload = screens.full_game_payload(slug) + scene_screen = next(s for s in payload["screens"] if s["id"] == screen_id) + player_payload = next(o for o in scene_screen["elements"] if o["id"] == player_id) + enemy_payload = next(o for o in scene_screen["elements"] if o["id"] == enemy_id) + assert "collision_rules" not in player_payload + assert enemy_payload["collision_rules"] == [] + + +def test_scene_edit_view_lists_eligible_objects_only(client, tmp_game_slug_cleanup): + slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup) + player_id = _add_personnage(client, slug, screen_id) # rôle par défaut : joueur, exclu + enemy_id = _add_personnage(client, slug, screen_id, role="ennemie") + + html = client.get(f"/game/{slug}/screens/{screen_id}/edit").get_data(as_text=True) + idx = html.find("var COLLISION_RULE_OBJECTS") + end = html.find(";", idx) + snippet = html[idx:end] + assert f'"id": {enemy_id}' in snippet + assert f'"id": {player_id}' not in snippet