Éditeur de collision (jeu 2D) : règles trigger -> action par objet
Nouvel onglet "🧩 Collision" dans l'éditeur de scène 2D : chaque objet (hors joueur et fond) peut porter des règles "à la collision" ou "dans un périmètre (px)" déclenchant une action (quête, attaque, événement, ou interagir - qui affiche "Appuie sur [touche]" puis exécute une sous-action à l'appui, un seul niveau d'imbrication). Backend (sanitisation, route de persistance, exposition dans full_game_payload) + assistant modal en cartes empilées côté client. Ajoute le moteur d'exécution runtime (collision-rules-controller.js) : détecte l'entrée en collision/périmètre avec le joueur à chaque tick, déclenche l'action une seule fois par entrée, pur JS client sans appel serveur (fonctionne à l'identique en ligne et en export SCORM). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
dfaf85fac1
commit
ee270d0286
@@ -6,4 +6,5 @@ from . import (
|
|||||||
scene_object_commands,
|
scene_object_commands,
|
||||||
scene_object_role,
|
scene_object_role,
|
||||||
scene_object_collision,
|
scene_object_collision,
|
||||||
|
scene_object_collision_rules,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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"
|
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_blocks = screens.list_flow_blocks(slug, screen_id)
|
||||||
flow_nodes = screens.list_flow_nodes(slug, screen_id)
|
flow_nodes = screens.list_flow_nodes(slug, screen_id)
|
||||||
flow_edges = screens.list_flow_edges(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=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_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)}),
|
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,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
from flask import request, jsonify
|
||||||
|
|
||||||
|
import screens
|
||||||
|
|
||||||
|
from core.flask_app import app
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/game/<slug>/scene-objects/<int:object_id>/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})
|
||||||
@@ -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_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.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_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 .rendering.personnage_commands import resolve_personnage_commands, DEFAULT_PERSONNAGE_COMMANDS, sanitize_extra_commands
|
||||||
|
|
||||||
from .screens_repo.ensure_schema import ensure_schema
|
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_commands import set_scene_object_commands
|
||||||
from .scenes.set_scene_object_role import set_scene_object_role
|
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 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.render_scene_object import render_scene_object
|
||||||
from .scenes.flow_palette import TRIGGER_EVENTS_2D, ACTION_TYPE_LABELS_2D, OBJECT_ACTION_PROPERTIES
|
from .scenes.flow_palette import TRIGGER_EVENTS_2D, ACTION_TYPE_LABELS_2D, OBJECT_ACTION_PROPERTIES
|
||||||
from .elements.element_label import element_label
|
from .elements.element_label import element_label
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ from ..rendering.personnage_data import resolve_personnage_animations
|
|||||||
from ..rendering.personnage_commands import resolve_personnage_commands
|
from ..rendering.personnage_commands import resolve_personnage_commands
|
||||||
from ..rendering.personnage_role import resolve_personnage_role
|
from ..rendering.personnage_role import resolve_personnage_role
|
||||||
from ..rendering.collision_settings import resolve_collision_settings
|
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.list_scene_objects import list_scene_objects
|
||||||
from ..scenes.render_scene_object import render_scene_object
|
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
|
# seulement "personnage" — un décor/fond peut aussi servir
|
||||||
# d'obstacle.
|
# d'obstacle.
|
||||||
o["collision"] = resolve_collision_settings(o)
|
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})
|
payload_screens.append({**s, "elements": objects})
|
||||||
else:
|
else:
|
||||||
elements = list_elements(slug, s["id"], enforce_visibility=True, player_id=player_id)
|
elements = list_elements(slug, s["id"], enforce_visibility=True, player_id=player_id)
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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()
|
||||||
@@ -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' } }] }] }));
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -53,6 +53,10 @@ function showScreen(id) {
|
|||||||
// écran (voir static/js/play/personnage-controller.js) — n'a d'effet
|
// é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".
|
// que sur un écran "jeu_2d" contenant au moins un objet kind="personnage".
|
||||||
forgeStartPersonnageControllers(screensData.find(function(s) { return s.id === id; }));
|
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
|
// Phase 6 — musique de fond par écran (voir screen-bg-music-url dans
|
||||||
|
|||||||
@@ -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 '<span class="collisionRuleLink">' + text.replace(/</g, '<') + '</span>';
|
||||||
|
}).join('<span class="collisionRuleArrow">→</span>');
|
||||||
|
return (
|
||||||
|
'<div class="collisionRuleChain">' +
|
||||||
|
'<div class="collisionRuleChainLinks">' + linksHtml + '</div>' +
|
||||||
|
'<button type="button" class="button is-small is-danger" title="Supprimer cette règle" ' +
|
||||||
|
'onclick="deleteCollisionRule(' + objectId + ', ' + ruleIndex + ')">🗑️</button>' +
|
||||||
|
'</div>'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function collisionRuleCardHtml(obj) {
|
||||||
|
var rulesHtml = obj.rules.length
|
||||||
|
? obj.rules.map(function (rule, i) { return collisionRuleChainHtml(rule, i, obj.id); }).join('')
|
||||||
|
: '<p class="hint">Aucune règle pour l\'instant.</p>';
|
||||||
|
return (
|
||||||
|
'<div class="collisionRuleCard" id="collisionRuleCard-' + obj.id + '">' +
|
||||||
|
'<div class="collisionRuleCardHeader">' +
|
||||||
|
'<div class="collisionRuleCardThumb">' + obj.thumbnail_html + '</div>' +
|
||||||
|
'<h3 class="collisionRuleCardTitle">' + collisionRuleObjectLabel(obj).replace(/</g, '<') + '</h3>' +
|
||||||
|
'</div>' +
|
||||||
|
'<div class="collisionRuleCardRules">' + rulesHtml + '</div>' +
|
||||||
|
'<button type="button" class="button is-small primary" onclick="openCollisionRuleModal(' + obj.id + ')">+ Action</button>' +
|
||||||
|
'</div>'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderCollisionRuleCards() {
|
||||||
|
var container = document.getElementById('collisionRulesCards');
|
||||||
|
if (!container) return;
|
||||||
|
if (!COLLISION_RULE_OBJECTS.length) {
|
||||||
|
container.innerHTML = '<p class="hint">Aucun objet éligible pour l\'instant — un décor, un ennemi ou un PNJ (jamais le joueur lui-même).</p>';
|
||||||
|
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 =
|
||||||
|
'<p class="hint mb-2">Cette règle se déclenche...</p>' +
|
||||||
|
'<div class="collisionWizardChoices">' +
|
||||||
|
'<button type="button" class="button is-fullwidth mb-2" onclick="collisionWizardChooseTrigger(\'collision\')">' + COLLISION_TRIGGER_LABELS.collision + '</button>' +
|
||||||
|
'<div class="fieldRow" style="align-items:center; gap:8px;">' +
|
||||||
|
'<button type="button" class="button is-fullwidth" onclick="collisionWizardChooseTrigger(\'perimetre\')">' + COLLISION_TRIGGER_LABELS.perimetre + '</button>' +
|
||||||
|
'<input type="number" min="1" id="collisionWizardPerimetrePx" value="' + COLLISION_DEFAULT_PERIMETRE_PX + '" style="width:80px;" class="input is-small">' +
|
||||||
|
'<span class="hint">px</span>' +
|
||||||
|
'</div>' +
|
||||||
|
'</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
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 '<button type="button" class="button is-fullwidth mb-2" onclick="collisionWizardChooseAction(\'' + type + '\', ' + (nested ? 'true' : 'false') + ')">' + COLLISION_ACTION_LABELS[type] + '</button>';
|
||||||
|
}).join('');
|
||||||
|
collisionWizardBodyEl().innerHTML =
|
||||||
|
'<p class="hint mb-2">' + (nested ? 'Une fois la touche pressée...' : 'Alors...') + '</p>' +
|
||||||
|
'<div class="collisionWizardChoices">' + buttons + '</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
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 =
|
||||||
|
'<p class="hint mb-2">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) :</p>' +
|
||||||
|
'<div class="field is-grouped">' +
|
||||||
|
'<div class="control is-expanded"><input type="text" id="collisionWizardQueteId" class="input" placeholder="ex. quete-001"></div>' +
|
||||||
|
'<div class="control"><button type="button" class="button primary" onclick="collisionWizardSubmitQuete(' + (nested ? 'true' : 'false') + ')">Valider</button></div>' +
|
||||||
|
'</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (type === 'evenement') {
|
||||||
|
var eventIds = Object.keys(CUSTOM_EVENTS_MAP);
|
||||||
|
if (!eventIds.length) {
|
||||||
|
collisionWizardBodyEl().innerHTML = '<p class="hint">Aucun événement personnalisé pour l\'instant — crée-en un dans l\'onglet "📣 Événements", puis reviens ici.</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var buttons = eventIds.map(function (id) {
|
||||||
|
return '<button type="button" class="button is-fullwidth mb-2" onclick="collisionWizardFinalizeAction({type:\'evenement\', evenement_id:' + id + '}, ' + (nested ? 'true' : 'false') + ')">📣 ' + CUSTOM_EVENTS_MAP[id].name + '</button>';
|
||||||
|
}).join('');
|
||||||
|
collisionWizardBodyEl().innerHTML = '<p class="hint mb-2">Quel événement ?</p><div class="collisionWizardChoices">' + buttons + '</div>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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();
|
||||||
|
});
|
||||||
@@ -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;
|
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); }
|
.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); }
|
.childList{ display:flex; flex-direction:column; gap:6px; margin:2px 0 10px 18px; padding-left:10px; border-left:2px dashed var(--border); }
|
||||||
|
|||||||
@@ -155,6 +155,7 @@
|
|||||||
<script src="{{ asset_url('js/play/actions.js') }}"></script>
|
<script src="{{ asset_url('js/play/actions.js') }}"></script>
|
||||||
<script src="{{ asset_url('js/play/triggers.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/personnage-controller.js') }}"></script>
|
||||||
|
<script src="{{ asset_url('js/play/collision-rules-controller.js') }}"></script>
|
||||||
<script src="{{ asset_url('js/play/bindings.js') }}"></script>
|
<script src="{{ asset_url('js/play/bindings.js') }}"></script>
|
||||||
<script src="{{ asset_url('js/play/flow-engine.js') }}"></script>
|
<script src="{{ asset_url('js/play/flow-engine.js') }}"></script>
|
||||||
<script>
|
<script>
|
||||||
|
|||||||
@@ -93,6 +93,7 @@
|
|||||||
<div class="builderCanvasArea">
|
<div class="builderCanvasArea">
|
||||||
<div class="builderTabs" id="builderTabs">
|
<div class="builderTabs" id="builderTabs">
|
||||||
<button type="button" class="builderTabBtn active" data-tab="screen" onclick="switchBuilderTab('screen')">🖥️ Scène</button>
|
<button type="button" class="builderTabBtn active" data-tab="screen" onclick="switchBuilderTab('screen')">🖥️ Scène</button>
|
||||||
|
<button type="button" class="builderTabBtn" data-tab="collision-rules" onclick="switchBuilderTab('collision-rules')">🧩 Collision</button>
|
||||||
<button type="button" class="builderTabBtn" data-tab="events" onclick="switchBuilderTab('events')">📣 Événements</button>
|
<button type="button" class="builderTabBtn" data-tab="events" onclick="switchBuilderTab('events')">📣 Événements</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -125,6 +126,41 @@
|
|||||||
<p class="hint" style="text-align:center;">Glisse pour déplacer, tire le coin pour redimensionner, clique pour sélectionner. Positions en pixels (scène à taille fixe).</p>
|
<p class="hint" style="text-align:center;">Glisse pour déplacer, tire le coin pour redimensionner, clique pour sélectionner. Positions en pixels (scène à taille fixe).</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Éditeur de collision (voir screens/rendering/collision_rules.py) :
|
||||||
|
une carte par objet ÉLIGIBLE (jamais un "fond", jamais le
|
||||||
|
personnage "joueur" — ce sont toujours les AUTRES objets qui
|
||||||
|
réagissent à SA présence), listant ses règles "déclencheur ->
|
||||||
|
action" déjà posées + un bouton "+ Action" qui ouvre l'assistant
|
||||||
|
(modale plus bas). Rendu ENTIÈREMENT en JS (voir
|
||||||
|
static/js/scenes/collision-rules-editor.js) à partir de
|
||||||
|
COLLISION_RULE_OBJECTS/CUSTOM_EVENTS_MAP — jamais en Jinja, pour
|
||||||
|
pouvoir se redessiner à chaud après chaque ajout/suppression de
|
||||||
|
règle sans recharger la page. -->
|
||||||
|
<div class="builderTabPanel" id="collisionRulesTabPanel" data-tab-panel="collision-rules">
|
||||||
|
<div class="dashPanelHeader">
|
||||||
|
<h2>🧩 Collision</h2>
|
||||||
|
</div>
|
||||||
|
<p class="hint">Pour chaque objet (jamais le joueur lui-même), choisis ce qui doit se passer à son contact — la carte se construit au fil de tes choix, comme des pièces qui s'emboîtent.</p>
|
||||||
|
<div id="collisionRulesCards" class="collisionRulesCards"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Assistant "+ Action" — un seul écran affiché à la fois
|
||||||
|
(#collisionRuleModalBody, reconstruit à chaque étape), jamais un
|
||||||
|
vrai formulaire à plusieurs pages : chaque clic choisit une
|
||||||
|
option et avance directement à la suivante. -->
|
||||||
|
<div class="modal" id="collisionRuleModal">
|
||||||
|
<div class="modal-background" onclick="closeCollisionRuleModal()"></div>
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="box">
|
||||||
|
<div class="fieldRow" style="align-items:center; justify-content:space-between;">
|
||||||
|
<h2 class="title is-5" style="margin:0;">Nouvelle règle</h2>
|
||||||
|
<button type="button" class="button is-small" onclick="closeCollisionRuleModal()">✕</button>
|
||||||
|
</div>
|
||||||
|
<div id="collisionRuleModalBody"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="builderTabPanel" id="eventsTabPanel" data-tab-panel="events">
|
<div class="builderTabPanel" id="eventsTabPanel" data-tab-panel="events">
|
||||||
<div class="dashPanelHeader">
|
<div class="dashPanelHeader">
|
||||||
<h2>Événements personnalisés</h2>
|
<h2>Événements personnalisés</h2>
|
||||||
@@ -403,6 +439,10 @@
|
|||||||
var SPRITE_LIBRARY = {{ sprite_library_json|safe }};
|
var SPRITE_LIBRARY = {{ sprite_library_json|safe }};
|
||||||
var ELEMENT_ANIMATIONS_MAP = {{ element_animations_map_json|safe }};
|
var ELEMENT_ANIMATIONS_MAP = {{ element_animations_map_json|safe }};
|
||||||
var PERSONNAGE_COMMANDS_EXTRA = {{ (personnage_commands.extra if personnage_commands else [])|tojson }};
|
var PERSONNAGE_COMMANDS_EXTRA = {{ (personnage_commands.extra if personnage_commands else [])|tojson }};
|
||||||
|
var COLLISION_RULE_OBJECTS = {{ collision_rule_objects_json|safe }};
|
||||||
|
var COLLISION_ACTION_TYPES = {{ collision_action_types|tojson }};
|
||||||
|
var COLLISION_TRIGGER_TYPES = {{ collision_trigger_types|tojson }};
|
||||||
|
var COLLISION_DEFAULT_PERIMETRE_PX = {{ collision_default_perimetre_px }};
|
||||||
</script>
|
</script>
|
||||||
<script src="{{ url_for('static', filename='js/screen_edit/tree-panels.js') }}"></script>
|
<script src="{{ url_for('static', filename='js/screen_edit/tree-panels.js') }}"></script>
|
||||||
<script src="{{ url_for('static', filename='js/screen_edit/panel-init.js') }}"></script>
|
<script src="{{ url_for('static', filename='js/screen_edit/panel-init.js') }}"></script>
|
||||||
@@ -410,4 +450,5 @@
|
|||||||
<script src="{{ url_for('static', filename='js/screen_edit/builder-tabs.js') }}"></script>
|
<script src="{{ url_for('static', filename='js/screen_edit/builder-tabs.js') }}"></script>
|
||||||
<script src="{{ url_for('static', filename='js/screen_edit/personnage-preview.js') }}"></script>
|
<script src="{{ url_for('static', filename='js/screen_edit/personnage-preview.js') }}"></script>
|
||||||
<script src="{{ url_for('static', filename='js/scenes/scene-editor.js') }}"></script>
|
<script src="{{ url_for('static', filename='js/scenes/scene-editor.js') }}"></script>
|
||||||
|
<script src="{{ url_for('static', filename='js/scenes/collision-rules-editor.js') }}"></script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -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
|
||||||
Reference in New Issue
Block a user