Dev #10

Merged
w-vandal merged 22 commits from dev into main 2026-09-02 19:17:50 +00:00
19 changed files with 602 additions and 49 deletions
Showing only changes of commit bc3feb5080 - Show all commits
+2
View File
@@ -4,4 +4,6 @@ from . import (
scene_object_delete,
scene_object_personnage_data,
scene_object_commands,
scene_object_role,
scene_object_collision,
)
+11 -3
View File
@@ -24,10 +24,15 @@ def render_scene_edit(slug, screen_id, game):
selected_id = request.args.get("selected", type=int)
personnage_data = None
personnage_commands = None
personnage_role = None
collision_settings = None
selected_obj = next((o for o in objects if o["id"] == selected_id), None) if selected_id else None
if selected_obj and selected_obj["kind"] == "personnage":
personnage_data = screens.resolve_personnage_data(selected_obj)
personnage_commands = screens.resolve_personnage_commands(selected_obj)
if selected_obj:
collision_settings = screens.resolve_collision_settings(selected_obj)
if selected_obj["kind"] == "personnage":
personnage_data = screens.resolve_personnage_data(selected_obj)
personnage_commands = screens.resolve_personnage_commands(selected_obj)
personnage_role = screens.resolve_personnage_role(selected_obj)
_DEFAULT_OBJECT_LABELS = {"personnage": "Personnage", "fond": "Fond"}
object_labels = {
@@ -64,6 +69,9 @@ def render_scene_edit(slug, screen_id, game):
game=game, screen=screen, all_screens=all_screens,
objects=objects, selected_id=selected_id, personnage_data=personnage_data,
personnage_commands=personnage_commands or screens.DEFAULT_PERSONNAGE_COMMANDS,
personnage_role=personnage_role or screens.DEFAULT_PERSONNAGE_ROLE,
personnage_role_choices=screens.PERSONNAGE_ROLES, personnage_role_labels=screens.PERSONNAGE_ROLE_LABELS,
collision_settings=collision_settings or screens.DEFAULT_COLLISION_SETTINGS,
sprite_library=sprite_library, sprite_library_families=sprite_library_families,
sprite_library_json=json.dumps(sprite_library),
background_library=background_library,
+34
View File
@@ -0,0 +1,34 @@
from flask import request, jsonify
import screens
from core.flask_app import app
@app.route("/game/<slug>/scene-objects/<int:object_id>/collision", methods=["POST"])
def scene_object_collision(slug, object_id):
"""Boîte de collision d'un objet de scène (voir "🧱 Collision" dans
templates/scene_edit.html, static/js/play/conditions.js::
elementsOverlap pour son usage au jeu)."""
body = request.get_json(silent=True) or {}
obj = screens.get_scene_object(slug, object_id)
if not obj:
return jsonify({"error": "objet introuvable"}), 404
settings = {"enabled": bool(body.get("enabled", True))}
settings["shape"] = body.get("shape") if body.get("shape") in screens.COLLISION_SHAPES else "rectangle"
for key in ("width", "height"):
try:
value = body.get(key)
settings[key] = float(value) if value not in (None, "") else None
except (TypeError, ValueError):
settings[key] = None
for key in ("offset_x", "offset_y"):
try:
settings[key] = float(body.get(key) or 0)
except (TypeError, ValueError):
settings[key] = 0
screens.set_scene_object_collision(slug, object_id, settings)
obj = screens.get_scene_object(slug, object_id)
return jsonify({"ok": True, "collision": screens.resolve_collision_settings(obj)})
+22
View File
@@ -0,0 +1,22 @@
from flask import request, jsonify
import screens
from core.flask_app import app
@app.route("/game/<slug>/scene-objects/<int:object_id>/role", methods=["POST"])
def scene_object_role(slug, object_id):
"""Rôle d'un personnage (joueur/ennemie/pnj — voir "🏷️ Rôle" dans le
panneau Personnage, templates/scene_edit.html) : seul un personnage
"joueur" est déplacé/animé automatiquement au clavier (voir
static/js/play/personnage-controller.js)."""
body = request.get_json(silent=True) or {}
obj = screens.get_scene_object(slug, object_id)
if not obj:
return jsonify({"error": "objet introuvable"}), 404
role = body.get("role")
if role not in screens.PERSONNAGE_ROLES:
role = screens.DEFAULT_PERSONNAGE_ROLE
screens.set_scene_object_role(slug, object_id, role)
return jsonify({"ok": True, "role": role})
+4
View File
@@ -81,6 +81,8 @@ from .flow.constants import (
from .rendering.render_element_html import render_element_html
from .rendering.trigger_for import _trigger_for
from .rendering.personnage_data import resolve_personnage_data, resolve_personnage_animations, resolve_personnage_idle_frame
from .rendering.personnage_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.personnage_commands import resolve_personnage_commands, DEFAULT_PERSONNAGE_COMMANDS, sanitize_extra_commands
from .screens_repo.ensure_schema import ensure_schema
@@ -116,6 +118,8 @@ from .scenes.update_scene_object_geometry import update_scene_object_geometry
from .scenes.delete_scene_object import delete_scene_object
from .scenes.set_scene_object_personnage_data import set_scene_object_personnage_data
from .scenes.set_scene_object_commands import set_scene_object_commands
from .scenes.set_scene_object_role import set_scene_object_role
from .scenes.set_scene_object_collision import set_scene_object_collision
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
+12 -3
View File
@@ -9,6 +9,8 @@ from ..element_types.list_element_types import list_element_types
from ..element_types.load_template_tree import _load_template_tree
from ..rendering.personnage_data import resolve_personnage_animations
from ..rendering.personnage_commands import resolve_personnage_commands
from ..rendering.personnage_role import resolve_personnage_role
from ..rendering.collision_settings import resolve_collision_settings
from ..scenes.list_scene_objects import list_scene_objects
from ..scenes.render_scene_object import render_scene_object
@@ -47,11 +49,18 @@ def full_game_payload(slug, player_id=db.PLAYER_SHARED):
o["rendered_html"] = render_scene_object(o)
# Déplacement/animation automatiques (voir
# static/js/play/personnage-controller.js) : chaque
# personnage porte ses touches déjà résolues (défauts
# comblés) pour que le runtime client n'ait aucune logique
# de repli à dupliquer.
# personnage porte ses touches et son rôle déjà résolus
# (défauts comblés) pour que le runtime client n'ait
# aucune logique de repli à dupliquer — seul un
# personnage "joueur" est déplacé/suivi par la caméra.
if o["kind"] == "personnage":
o["personnage_commandes"] = resolve_personnage_commands(o)
o["personnage_role"] = resolve_personnage_role(o)
# Boîte de collision (voir static/js/play/conditions.js::
# elementsOverlap) : posée sur TOUT objet de scène, pas
# seulement "personnage" — un décor/fond peut aussi servir
# d'obstacle.
o["collision"] = resolve_collision_settings(o)
payload_screens.append({**s, "elements": objects})
else:
elements = list_elements(slug, s["id"], enforce_visibility=True, player_id=player_id)
+40
View File
@@ -0,0 +1,40 @@
import json
# Boîte de collision d'un objet de scène (voir screens/scenes/
# set_scene_object_collision.py) : posée AUTOMATIQUEMENT sur chaque objet
# (rectangle, taille = la boîte de l'objet lui-même, centrée dessus, sans
# décalage — comportement inchangé pour une condition de collision
# existante, voir elementsOverlap() dans static/js/play/conditions.js),
# réglable ensuite dans le panneau de propriétés : activée/désactivée,
# forme (rectangle/cercle) et taille/décalage propres, pour un sprite très
# paddé (ex. les frames CraftPix) dont la silhouette réelle est bien plus
# petite que son canevas.
DEFAULT_COLLISION_SETTINGS = {
"enabled": True, "shape": "rectangle", "width": None, "height": None,
"offset_x": 0, "offset_y": 0,
}
COLLISION_SHAPES = ["rectangle", "cercle"]
def resolve_collision_settings(obj):
"""Décode attributes._collision en dict Python, jamais None — un
attribut absent/corrompu retombe sur le défaut (boîte de collision =
la boîte entière de l'objet, comme si ce réglage n'existait pas)."""
raw = (obj.get("attributes") or {}).get("_collision")
settings = dict(DEFAULT_COLLISION_SETTINGS)
if raw:
try:
parsed = json.loads(raw)
except (TypeError, ValueError):
parsed = None
if isinstance(parsed, dict):
for key in DEFAULT_COLLISION_SETTINGS:
if key in parsed:
settings[key] = parsed[key]
if settings["shape"] not in COLLISION_SHAPES:
settings["shape"] = "rectangle"
if not settings["width"]:
settings["width"] = obj.get("width")
if not settings["height"]:
settings["height"] = obj.get("height")
return settings
+20
View File
@@ -0,0 +1,20 @@
# Rôle d'un personnage posé sur une scène 2D (voir
# screens/scenes/set_scene_object_role.py) : seul un personnage "joueur"
# est déplacé/animé automatiquement au clavier et suivi par la caméra
# (voir static/js/play/personnage-controller.js) — "ennemie"/"pnj" restent
# immobiles tant qu'aucune logique de flow ne les pilote (déplacement
# ennemi automatique/apparition, dialogue de PNJ... : hors scope de ce
# premier réglage, seulement l'étiquette qui permettra de les construire
# ensuite sans ambiguïté sur "quel objet est le joueur").
PERSONNAGE_ROLES = ["joueur", "ennemie", "pnj"]
PERSONNAGE_ROLE_LABELS = {"joueur": "Joueur", "ennemie": "Ennemi", "pnj": "PNJ"}
DEFAULT_PERSONNAGE_ROLE = "joueur"
def resolve_personnage_role(el):
"""Décode attributes._personnage_role — un attribut absent/corrompu
retombe sur "joueur" (comportement inchangé pour tout personnage posé
avant l'ajout de ce réglage : c'était déjà, de fait, le seul rôle qui
existait — tout personnage se déplaçait au clavier)."""
role = (el.get("attributes") or {}).get("_personnage_role")
return role if role in PERSONNAGE_ROLES else DEFAULT_PERSONNAGE_ROLE
@@ -0,0 +1,24 @@
import json
import db
from .ensure_scene_schema import ensure_scene_schema
def set_scene_object_collision(slug, object_id, settings):
"""Enregistre la boîte de collision (activée/désactivée, forme,
taille, décalage — voir screens/rendering/collision_settings.py) d'un
objet de scène QUELCONQUE (pas seulement "personnage" : un décor/fond
peut aussi servir d'obstacle à une condition de collision) — 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"] = json.dumps(settings)
conn.execute("UPDATE _scene_objects SET attributes = ? WHERE id = ?", (json.dumps(attributes), object_id))
conn.commit()
conn.close()
+25
View File
@@ -0,0 +1,25 @@
import json
import db
from .ensure_scene_schema import ensure_scene_schema
def set_scene_object_role(slug, object_id, role):
"""Enregistre le rôle (joueur/ennemie/pnj, voir
screens/rendering/personnage_role.py) d'un objet de scène
kind="personnage" — un attribut DISTINCT de _personnage_data (choix du
personnage) et _personnage_commandes (touches), pour ne jamais les
écraser l'un l'autre à l'enregistrement (même raison que
set_scene_object_commands.py)."""
ensure_scene_schema(slug)
conn = db.connect(slug)
row = conn.execute("SELECT attributes FROM _scene_objects WHERE id = ?", (object_id,)).fetchone()
if not row:
conn.close()
return
attributes = json.loads(row["attributes"] or "{}")
attributes["_personnage_role"] = role
conn.execute("UPDATE _scene_objects SET attributes = ? WHERE id = ?", (json.dumps(attributes), object_id))
conn.commit()
conn.close()
@@ -13,6 +13,9 @@ const {
readVariableValue,
evaluateConditionClause,
evaluateConditionNode,
forgeCollisionRectFromBox,
forgeRectanglesOverlap,
forgeCirclesOverlap,
} = require('../conditions.js');
function withGameData(data, fn) {
@@ -117,3 +120,37 @@ test('evaluateConditionNode — clauses combinées OU (une seule vraie suffit)',
assert.equal(evaluateConditionNode(node), true);
});
});
// Boîte de collision (voir screens/rendering/collision_settings.py) —
// demandé par l'utilisateur : chaque objet a automatiquement une boîte de
// collision (= sa boîte visuelle par défaut), configurable (taille,
// forme, activée/désactivée).
test('forgeCollisionRectFromBox — sans réglage, le rectangle visuel EXACT (comportement inchangé)', () => {
const box = { left: 10, top: 20, width: 40, height: 60 };
assert.deepEqual(forgeCollisionRectFromBox(box, undefined), { left: 10, top: 20, right: 50, bottom: 80, width: 40, height: 60 });
});
test('forgeCollisionRectFromBox — une boîte plus petite est centrée par défaut (offset 0,0)', () => {
const box = { left: 0, top: 0, width: 100, height: 100 };
const rect = forgeCollisionRectFromBox(box, { width: 20, height: 20, offset_x: 0, offset_y: 0 });
assert.deepEqual(rect, { left: 40, top: 40, right: 60, bottom: 60, width: 20, height: 20 });
});
test('forgeCollisionRectFromBox — un décalage déplace la boîte de collision sans toucher au rectangle visuel', () => {
const box = { left: 0, top: 0, width: 100, height: 100 };
const rect = forgeCollisionRectFromBox(box, { width: 20, height: 20, offset_x: 15, offset_y: -5 });
assert.deepEqual(rect, { left: 55, top: 35, right: 75, bottom: 55, width: 20, height: 20 });
});
test('forgeRectanglesOverlap — chevauchement/non-chevauchement', () => {
assert.equal(forgeRectanglesOverlap({ left: 0, top: 0, right: 10, bottom: 10 }, { left: 5, top: 5, right: 15, bottom: 15 }), true);
assert.equal(forgeRectanglesOverlap({ left: 0, top: 0, right: 10, bottom: 10 }, { left: 20, top: 20, right: 30, bottom: 30 }), false);
});
test('forgeCirclesOverlap — deux cercles qui se touchent/s\'évitent', () => {
const a = { left: 0, top: 0, right: 20, bottom: 20, width: 20, height: 20 }; // centre (10,10), rayon 10
const touching = { left: 15, top: 0, right: 35, bottom: 20, width: 20, height: 20 }; // centre (25,10), rayon 10, distance 15 < 20
const apart = { left: 100, top: 100, right: 120, bottom: 120, width: 20, height: 20 };
assert.equal(forgeCirclesOverlap(a, touching), true);
assert.equal(forgeCirclesOverlap(a, apart), false);
});
@@ -53,7 +53,7 @@ test('forgePersonnageMovementAnim/forgePersonnageIdleAnim — repli silencieux s
});
test('tick — touche "droite" enfoncée : déplace vers la droite et retire le miroir', () => {
const obj = { id: 10, kind: 'personnage', personnage_commandes: { haut: 'z', bas: 's', gauche: 'q', droite: 'd', interagir: 'e', axe: 'libre', vitesse: 4 } };
const obj = { id: 10, kind: 'personnage', personnage_role: 'joueur', personnage_commandes: { haut: 'z', bas: 's', gauche: 'q', droite: 'd', interagir: 'e', axe: 'libre', vitesse: 4 } };
const el = fakeSceneEl();
withPersonnageObject(obj, { 10: el }, () => {
global.heldKeys = new Set(['d']);
@@ -64,7 +64,7 @@ test('tick — touche "droite" enfoncée : déplace vers la droite et retire le
});
test('tick — touche "gauche" enfoncée : déplace vers la gauche et applique le miroir', () => {
const obj = { id: 11, kind: 'personnage', personnage_commandes: { haut: 'z', bas: 's', gauche: 'q', droite: 'd', interagir: 'e', axe: 'libre', vitesse: 4 } };
const obj = { id: 11, kind: 'personnage', personnage_role: 'joueur', personnage_commandes: { haut: 'z', bas: 's', gauche: 'q', droite: 'd', interagir: 'e', axe: 'libre', vitesse: 4 } };
const el = fakeSceneEl();
withPersonnageObject(obj, { 11: el }, () => {
global.heldKeys = new Set(['q']);
@@ -75,7 +75,7 @@ test('tick — touche "gauche" enfoncée : déplace vers la gauche et applique l
});
test('tick — axe "horizontal" : ignore haut/bas même si la touche est enfoncée', () => {
const obj = { id: 12, kind: 'personnage', personnage_commandes: { haut: 'z', bas: 's', gauche: 'q', droite: 'd', interagir: 'e', axe: 'horizontal', vitesse: 4 } };
const obj = { id: 12, kind: 'personnage', personnage_role: 'joueur', personnage_commandes: { haut: 'z', bas: 's', gauche: 'q', droite: 'd', interagir: 'e', axe: 'horizontal', vitesse: 4 } };
const el = fakeSceneEl();
withPersonnageObject(obj, { 12: el }, () => {
global.heldKeys = new Set(['z']); // "haut", bloqué par l'axe
@@ -85,7 +85,7 @@ test('tick — axe "horizontal" : ignore haut/bas même si la touche est enfonc
});
test('tick — axe "vertical" : ignore gauche/droite même si la touche est enfoncée', () => {
const obj = { id: 13, kind: 'personnage', personnage_commandes: { haut: 'z', bas: 's', gauche: 'q', droite: 'd', interagir: 'e', axe: 'vertical', vitesse: 4 } };
const obj = { id: 13, kind: 'personnage', personnage_role: 'joueur', personnage_commandes: { haut: 'z', bas: 's', gauche: 'q', droite: 'd', interagir: 'e', axe: 'vertical', vitesse: 4 } };
const el = fakeSceneEl();
withPersonnageObject(obj, { 13: el }, () => {
global.heldKeys = new Set(['d']); // "droite", bloqué par l'axe
@@ -95,7 +95,7 @@ test('tick — axe "vertical" : ignore gauche/droite même si la touche est enfo
});
test('tick — bornage aux limites de la scène (ne sort jamais du cadre)', () => {
const obj = { id: 14, kind: 'personnage', personnage_commandes: { haut: 'z', bas: 's', gauche: 'q', droite: 'd', interagir: 'e', axe: 'libre', vitesse: 50 } };
const obj = { id: 14, kind: 'personnage', personnage_role: 'joueur', personnage_commandes: { haut: 'z', bas: 's', gauche: 'q', droite: 'd', interagir: 'e', axe: 'libre', vitesse: 50 } };
const el = fakeSceneEl();
el.style.left = '490px'; // 500 (scène) - 20 (largeur) = 480 max
withPersonnageObject(obj, { 14: el }, () => {
@@ -106,7 +106,7 @@ test('tick — bornage aux limites de la scène (ne sort jamais du cadre)', () =
});
test('tick — aucune touche enfoncée : ne bouge pas (l\'appel reste sans effet)', () => {
const obj = { id: 15, kind: 'personnage', personnage_commandes: { haut: 'z', bas: 's', gauche: 'q', droite: 'd', interagir: 'e', axe: 'libre', vitesse: 4 } };
const obj = { id: 15, kind: 'personnage', personnage_role: 'joueur', personnage_commandes: { haut: 'z', bas: 's', gauche: 'q', droite: 'd', interagir: 'e', axe: 'libre', vitesse: 4 } };
const el = fakeSceneEl();
withPersonnageObject(obj, { 15: el }, () => {
global.heldKeys = new Set();
@@ -126,6 +126,20 @@ test('tick — ignore tout objet qui n\'est pas kind="personnage" ou sans comman
});
});
// Rôle (voir screens/rendering/personnage_role.py) — demandé par
// l'utilisateur : seul un personnage "joueur" doit être déplacé/animé au
// clavier, "ennemie"/"pnj" doivent rester immobiles tant qu'aucune
// logique de flow ne les pilote.
test('tick — un personnage "ennemie"/"pnj" n\'est jamais déplacé au clavier', () => {
const ennemi = { id: 17, kind: 'personnage', personnage_role: 'ennemie', personnage_commandes: { haut: 'z', bas: 's', gauche: 'q', droite: 'd', interagir: 'e', axe: 'libre', vitesse: 4 } };
const el = fakeSceneEl();
withPersonnageObject(ennemi, { 17: el }, () => {
global.heldKeys = new Set(['d']);
forgeRunPersonnageControllerTick({ elements: [ennemi] });
assert.equal(el.style.left, '100px'); // inchangé, malgré la touche "droite" enfoncée
});
});
// Touches supplémentaires (sauter, attaquer...) — bug remonté par
// l'utilisateur : au-delà des 4 touches de déplacement et de "interagir",
// il manquait un moyen de lier une touche à N'IMPORTE QUELLE autre pose du
@@ -188,7 +202,7 @@ test('forgeUpdateSceneCamera — centre le monde sur le personnage, borné aux b
id: 1, scene_width: 500, scene_height: 500,
elements: [
{ kind: 'fond', x: 0, y: 0, width: 3840, height: 2160 },
{ id: 42, kind: 'personnage' },
{ id: 42, kind: 'personnage', personnage_role: 'joueur' },
],
};
try {
@@ -212,7 +226,7 @@ test('forgeUpdateSceneCamera — ne montre jamais au-delà des bords du monde (b
id: 1, scene_width: 500, scene_height: 500,
elements: [
{ kind: 'fond', x: 0, y: 0, width: 3840, height: 2160 },
{ id: 42, kind: 'personnage' },
{ id: 42, kind: 'personnage', personnage_role: 'joueur' },
],
};
try {
+62 -10
View File
@@ -101,19 +101,68 @@ function resolveVariablePath(rawValue, path) {
// comportement historique), soit une VARIABLE GLOBALE (source
// "variable" — voir ensure_flow_schema.py pour cond_source/cond_variable/
// cond_variable_chemin).
// Phase 5 — condition de collision : chevauchement des rectangles de
// deux éléments (getBoundingClientRect, donc leur position RÉELLE à
// l'écran — reflète les déplacements posés via "Modifier un élément →
// Position", voir static/js/play/actions.js). Aucun élément trouvé (id
// invalide, élément masqué avec display:none — getBoundingClientRect
// renvoie alors une boîte de largeur/hauteur nulles) => pas de
// chevauchement plutôt que de planter.
// Boîte de collision (voir screens/rendering/collision_settings.py) :
// centrée par défaut sur le rectangle VISUEL (getBoundingClientRect),
// réduite/décalée si réglée dans le panneau de propriétés — un sprite
// très paddé (ex. les frames CraftPix) peut ainsi avoir une silhouette de
// collision bien plus petite que son canevas. `collision` absent (élément
// de l'éditeur document, ou objet de scène posé avant l'ajout de ce
// réglage) => le rectangle visuel EXACT, comportement inchangé. Fonction
// PURE (aucun accès DOM) — testée par static/js/play/__tests__/
// conditions.test.js, contrairement à elementsOverlap() ci-dessous.
function forgeCollisionRectFromBox(box, collision) {
const width = (collision && collision.width) || box.width;
const height = (collision && collision.height) || box.height;
const offsetX = (collision && collision.offset_x) || 0;
const offsetY = (collision && collision.offset_y) || 0;
const left = box.left + (box.width - width) / 2 + offsetX;
const top = box.top + (box.height - height) / 2 + offsetY;
return { left, top, right: left + width, bottom: top + height, width, height };
}
function forgeRectanglesOverlap(a, b) {
return a.left < b.right && a.right > b.left && a.top < b.bottom && a.bottom > b.top;
}
// Cercle-cercle uniquement (rayon = le plus petit des deux côtés/2) — une
// paire rectangle/cercle retombe sur un chevauchement de rectangles (repli
// volontairement approximatif plutôt qu'une vraie intersection cercle-
// rectangle, hors scope de ce premier réglage).
function forgeCirclesOverlap(a, b) {
const acx = a.left + a.width / 2, acy = a.top + a.height / 2, ar = Math.min(a.width, a.height) / 2;
const bcx = b.left + b.width / 2, bcy = b.top + b.height / 2, br = Math.min(b.width, b.height) / 2;
const dx = acx - bcx, dy = acy - bcy;
return Math.sqrt(dx * dx + dy * dy) < (ar + br);
}
// Phase 5 — condition de collision : chevauchement des BOÎTES DE
// COLLISION (voir forgeCollisionRectFromBox ci-dessus) de deux éléments,
// positionnées sur leur rectangle RÉEL à l'écran (getBoundingClientRect —
// reflète les déplacements posés via "Modifier un élément"/"Modifier un
// objet de scène", voir static/js/play/actions.js, ET la caméra qui suit
// le personnage, voir personnage-controller.js : un décalage de caméra
// est un simple transform CSS, déjà reflété par getBoundingClientRect).
// Aucun élément trouvé (id invalide, élément masqué avec display:none —
// getBoundingClientRect renvoie alors une boîte de largeur/hauteur
// nulles), ou collision explicitement désactivée => pas de chevauchement
// plutôt que de planter.
function elementsOverlap(elementIdA, elementIdB) {
const a = document.querySelector('[data-element-id="' + elementIdA + '"]');
const b = document.querySelector('[data-element-id="' + elementIdB + '"]');
if (!a || !b) return false;
const r1 = a.getBoundingClientRect(), r2 = b.getBoundingClientRect();
return r1.left < r2.right && r1.right > r2.left && r1.top < r2.bottom && r1.bottom > r2.top;
const screenData = (typeof screensData !== 'undefined' && typeof window !== 'undefined')
&& screensData.find(function (s) { return s.id === window.currentScreenId; });
const objA = screenData && (screenData.elements || []).find(function (o) { return o.id === elementIdA; });
const objB = screenData && (screenData.elements || []).find(function (o) { return o.id === elementIdB; });
if ((objA && objA.collision && objA.collision.enabled === false) || (objB && objB.collision && objB.collision.enabled === false)) {
return false;
}
const rectA = forgeCollisionRectFromBox(a.getBoundingClientRect(), objA && objA.collision);
const rectB = forgeCollisionRectFromBox(b.getBoundingClientRect(), objB && objB.collision);
const shapeA = (objA && objA.collision && objA.collision.shape) || 'rectangle';
const shapeB = (objB && objB.collision && objB.collision.shape) || 'rectangle';
if (shapeA === 'cercle' && shapeB === 'cercle') return forgeCirclesOverlap(rectA, rectB);
return forgeRectanglesOverlap(rectA, rectB);
}
function evaluateConditionClause(clause) {
@@ -156,5 +205,8 @@ function evaluateConditionNode(node) {
// require() — pas de risque en navigateur : `module` n'existe pas là-bas,
// cette branche ne s'exécute jamais côté client.
if (typeof module !== 'undefined' && module.exports) {
module.exports = { readFieldValue, compareValues, readVariableValue, resolveVariablePath, evaluateConditionClause, evaluateConditionNode };
module.exports = {
readFieldValue, compareValues, readVariableValue, resolveVariablePath, evaluateConditionClause, evaluateConditionNode,
forgeCollisionRectFromBox, forgeRectanglesOverlap, forgeCirclesOverlap,
};
}
+7 -4
View File
@@ -77,7 +77,7 @@ function forgeUpdateSceneCamera(screenData) {
worldEl.style.transform = '';
return;
}
var player = (screenData.elements || []).find(function (o) { return o.kind === 'personnage'; });
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 + '"]');
var px = playerEl ? (parseFloat(playerEl.style.left) || 0) + (parseFloat(playerEl.style.width) || 0) / 2 : world.width / 2;
var py = playerEl ? (parseFloat(playerEl.style.top) || 0) + (parseFloat(playerEl.style.height) || 0) / 2 : world.height / 2;
@@ -102,7 +102,10 @@ function forgeStopPersonnageControllers() {
function forgeRunPersonnageControllerTick(screenData) {
forgeUpdateSceneCamera(screenData);
(screenData.elements || []).forEach(function (obj) {
if (obj.kind !== 'personnage' || !obj.personnage_commandes) return;
// Seul le personnage "joueur" (voir screens/rendering/personnage_role.py)
// est déplacé/animé au clavier — "ennemie"/"pnj" restent immobiles tant
// qu'aucune logique de flow ne les pilote (voir add_scene_object.py).
if (obj.kind !== 'personnage' || obj.personnage_role !== 'joueur' || !obj.personnage_commandes) return;
var cmd = obj.personnage_commandes;
var targetEl = document.querySelector('[data-object-id="' + obj.id + '"]');
if (!targetEl) return;
@@ -153,7 +156,7 @@ function forgePersonnageActionAnim(cmd, key) {
function forgeStartPersonnageInteractListener(screenData) {
activePersonnageInteractListener = function (ev) {
(screenData.elements || []).forEach(function (obj) {
if (obj.kind !== 'personnage' || !obj.personnage_commandes) return;
if (obj.kind !== 'personnage' || obj.personnage_role !== 'joueur' || !obj.personnage_commandes) return;
var animName = forgePersonnageActionAnim(obj.personnage_commandes, ev.key);
if (!animName || !forgePersonnageAnimExists(obj.id, animName)) return;
var targetEl = document.querySelector('[data-object-id="' + obj.id + '"]');
@@ -171,7 +174,7 @@ function forgeStartPersonnageControllers(screenData) {
forgeStopPersonnageControllers();
if (!screenData || screenData.kind !== 'jeu_2d') return;
var elements = screenData.elements || [];
var hasPersonnage = elements.some(function (o) { return o.kind === 'personnage'; });
var hasPersonnage = elements.some(function (o) { return o.kind === 'personnage' && o.personnage_role === 'joueur'; });
var hasFond = elements.some(function (o) { return o.kind === 'fond'; });
// Appliqué tout de suite (avant le premier tick de l'intervalle, 50ms
// plus tard) pour éviter un flash d'une frame à la taille/caméra par
+35
View File
@@ -145,6 +145,41 @@ function swapSceneObjectCharacter(forgeCharacter) {
});
}
// Rôle (voir screens/rendering/personnage_role.py) : seul un personnage
// "Joueur" est déplacé/animé automatiquement au clavier — même patron AJAX
// que swapSceneObjectCharacter() ci-dessus (POST JSON, aucun rechargement
// de page), sur routes/scenes/scene_object_role.py.
function saveSceneObjectRole(role) {
var body = document.getElementById('personnagePanelBody');
if (!body) return;
var objectId = parseInt(body.dataset.elementId, 10);
fetch('/game/' + GAME_SLUG + '/scene-objects/' + objectId + '/role', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ role: role }),
});
}
// Boîte de collision (voir screens/rendering/collision_settings.py,
// static/js/play/conditions.js::elementsOverlap) : disponible pour TOUT
// objet de scène (pas seulement "personnage") — même patron AJAX que les
// fonctions ci-dessus, sur routes/scenes/scene_object_collision.py.
function saveSceneObjectCollision() {
var body = document.getElementById('collisionPanelBody');
if (!body) return;
var objectId = parseInt(body.dataset.elementId, 10);
fetch('/game/' + GAME_SLUG + '/scene-objects/' + objectId + '/collision', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
enabled: document.getElementById('collision-enabled').checked,
shape: document.getElementById('collision-shape').value,
width: document.getElementById('collision-width').value,
height: document.getElementById('collision-height').value,
offset_x: document.getElementById('collision-offset-x').value,
offset_y: document.getElementById('collision-offset-y').value,
}),
});
}
// "Commandes" (voir static/js/play/personnage-controller.js) : touches de
// déplacement/interaction + blocage d'axe + touches supplémentaires d'un
// personnage — un seul formulaire, enregistré en AJAX à chaque
+11 -6
View File
@@ -613,12 +613,17 @@ body.builderBody > main.content{ flex:1 1 auto; min-height:0; overflow:hidden; d
.sceneObjectSprite{ object-fit:contain; }
/* Objet de scène kind="fond" (image de fond, voir add_scene_object.py) :
insensible au clic (peut couvrir tout le "monde", ne doit jamais
intercepter un clic destiné à un objet posé par-dessus) et étirée pour
remplir exactement sa boîte (jamais "contain"/lettrboxée comme
.sceneObjectSprite ci-dessus : un fond redimensionné doit combler tout
l'espace, pas laisser des bandes vides). */
.sceneBackground{ pointer-events:none; object-fit:fill; }
étirée pour remplir exactement sa boîte (jamais "contain"/lettrboxée
comme .sceneObjectSprite ci-dessus : un fond redimensionné doit combler
tout l'espace, pas laisser des bandes vides). Insensible au clic
SEULEMENT au jeu (.playScreen .sceneBackground, peut couvrir tout le
"monde", ne doit jamais intercepter un clic destiné à un objet posé
par-dessus) — JAMAIS dans l'éditeur (.canvasElement, scene_edit.html) :
sans arborescence "Objets de cette scène" pour le sélectionner
autrement, un fond y resterait impossible à re-sélectionner/déplacer
au clic si on le rendait, lui aussi, insensible au clic. */
.sceneBackground{ object-fit:fill; }
.playScreen .sceneBackground{ pointer-events:none; }
/* Caméra qui suit le personnage (voir static/js/play/personnage-
controller.js) : .playScreen.playScene est désormais le VIEWPORT (taille
+62 -15
View File
@@ -45,20 +45,10 @@
<a class="button is-small is-primary is-fullwidth mb-2" href="{{ url_for('game_play', slug=game.slug) }}" target="_blank">▶️ Jouer depuis le début</a>
<hr style="border-color:var(--border); margin:10px 0;">
<h2 class="subtitle is-6">Objets de cette scène</h2>
{% if objects %}
<div class="elementTree">
{% for o in objects %}
<div class="treeNode" data-element-id="{{ o.id }}">
<a class="treeNodeLabel {{ 'selected' if o.id == selected_id else '' }}" href="{{ url_for('screen_edit', slug=game.slug, screen_id=screen.id, selected=o.id) }}">
{{ '🏃' if o.kind == 'personnage' else ('🏞️' if o.kind == 'fond' else '🖼️') }} <span class="treeLabel">{{ o.name or ('Personnage' if o.kind == 'personnage' else ('Fond' if o.kind == 'fond' else 'Décor')) }}</span>
</a>
</div>
{% endfor %}
</div>
{% else %}
<p class="hint">Aucun objet pour l'instant.</p>
{% endif %}
<!-- "Objets de cette scène" (arborescence des objets déjà posés)
retirée à la demande de l'utilisateur — un objet posé reste
sélectionnable en cliquant directement dessus sur le canevas
(voir onSceneObjectMouseDown, static/js/scenes/scene-editor.js). -->
<!-- Galerie de personnages Forge (VRAIES miniatures) — cliquer une
tuile pose un personnage déjà configuré, animé immédiatement (voir
@@ -77,7 +67,6 @@
<p class="help">Personnage Kenney (licence libre CC0) ou animal Forge, avec toutes ses animations — les variantes de couleur d'un animal se choisissent depuis le panneau de propriétés.</p>
</div>
</details>
<button type="button" class="button is-small is-fullwidth" onclick="addSceneObject('decor')">🖼️ + Ajouter un décor</button>
{% if background_library %}
<!-- Images de fond CraftPix (screens.BACKGROUND_LIBRARY, admin
seulement — voir core/sprite_gate.py). Posée à la taille RÉELLE
@@ -665,10 +654,68 @@
</div>
</details>
<!-- Boîte de collision (voir screens/rendering/collision_settings.py,
static/js/play/conditions.js::elementsOverlap) : posée AUTOMATIQUEMENT
sur tout objet (= sa boîte visuelle par défaut, tant que rien
n'est changé ici) — désactivable, taille/forme/décalage réglables
pour un sprite très paddé dont la silhouette réelle est plus
petite que son canevas. -->
<details class="propGroup">
<summary>🧱 Collision</summary>
<div class="propGroupBody" id="collisionPanelBody" data-element-id="{{ obj.id }}">
<div class="field">
<div class="control">
<label class="checkbox">
<input type="checkbox" id="collision-enabled" {{ 'checked' if collision_settings.enabled else '' }} onchange="saveSceneObjectCollision()">
Boîte de collision activée
</label>
</div>
</div>
<div class="field"><label class="label is-small">Forme</label>
<div class="control">
<div class="select is-small">
<select id="collision-shape" onchange="saveSceneObjectCollision()">
<option value="rectangle" {{ 'selected' if collision_settings.shape == 'rectangle' else '' }}>Rectangle</option>
<option value="cercle" {{ 'selected' if collision_settings.shape == 'cercle' else '' }}>Cercle</option>
</select>
</div>
</div>
</div>
<div class="field is-grouped">
<div class="control"><label class="label is-small">Largeur (px)</label>
<input class="input is-small" type="number" min="1" id="collision-width" value="{{ collision_settings.width }}" onchange="saveSceneObjectCollision()"></div>
<div class="control"><label class="label is-small">Hauteur (px)</label>
<input class="input is-small" type="number" min="1" id="collision-height" value="{{ collision_settings.height }}" onchange="saveSceneObjectCollision()"></div>
</div>
<div class="field is-grouped">
<div class="control"><label class="label is-small">Décalage X (px)</label>
<input class="input is-small" type="number" id="collision-offset-x" value="{{ collision_settings.offset_x }}" onchange="saveSceneObjectCollision()"></div>
<div class="control"><label class="label is-small">Décalage Y (px)</label>
<input class="input is-small" type="number" id="collision-offset-y" value="{{ collision_settings.offset_y }}" onchange="saveSceneObjectCollision()"></div>
</div>
<p class="help">Taille/décalage réglés par rapport au centre de l'objet — laisse la taille par défaut (celle de l'objet) si son image n'a pas de marge inutile.</p>
</div>
</details>
{% if obj.kind == 'personnage' %}
<details class="propGroup" open>
<summary>🎭 Personnage</summary>
<div class="propGroupBody" id="personnagePanelBody" data-element-id="{{ obj.id }}">
<!-- Rôle (voir screens/rendering/personnage_role.py) : seul un
personnage "Joueur" est déplacé/animé automatiquement au
clavier et suivi par la caméra — "Ennemi"/"PNJ" restent
immobiles tant qu'aucune logique de flow ne les pilote. -->
<div class="field"><label class="label is-small">🏷️ Rôle</label>
<div class="control">
<div class="select is-small">
<select id="personnage-role" onchange="saveSceneObjectRole(this.value)">
{% for value in personnage_role_choices %}
<option value="{{ value }}" {{ 'selected' if value == personnage_role else '' }}>{{ personnage_role_labels[value] }}</option>
{% endfor %}
</select>
</div>
</div>
</div>
<p class="help">Personnage Forge — clique un autre pour en changer.</p>
<div class="iconGallery personnageGallery" id="personnageGallerySwap">
{% for slug_c, character in sprite_library_families %}
+107
View File
@@ -0,0 +1,107 @@
"""Boîte de collision d'un objet de scène (voir
screens/rendering/collision_settings.py, static/js/play/conditions.js::
elementsOverlap pour son usage au jeu, testé côté client dans
static/js/play/__tests__/conditions.test.js) : posée AUTOMATIQUEMENT sur
CHAQUE objet (= sa propre boîte, tant que rien n'est réglé), activable/
désactivable, taille/forme/décalage configurables."""
import re
import screens
def _create_jeu2d_game(client, tmp_game_slug_cleanup, name="pytest_collision"):
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_decor(client, slug, screen_id):
resp = client.post(f"/game/{slug}/screens/{screen_id}/scene-objects/add", data={"kind": "decor"}, follow_redirects=False)
assert resp.status_code == 302, resp.data
return int(re.search(r"selected=(\d+)", resp.headers["Location"]).group(1))
def test_freshly_placed_object_defaults_to_its_own_box_as_collision(client, tmp_game_slug_cleanup):
"""Sans réglage explicite, la boîte de collision EST la boîte de
l'objet (comportement inchangé pour tout objet déjà posé — la
collision se comportait déjà comme ça, implicitement, avant l'ajout
de ce réglage)."""
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
obj_id = _add_decor(client, slug, screen_id)
obj = screens.get_scene_object(slug, obj_id)
collision = screens.resolve_collision_settings(obj)
assert collision["enabled"] is True
assert collision["shape"] == "rectangle"
assert collision["width"] == obj["width"]
assert collision["height"] == obj["height"]
assert collision["offset_x"] == 0 and collision["offset_y"] == 0
def test_collision_route_persists_a_custom_box(client, tmp_game_slug_cleanup):
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
obj_id = _add_decor(client, slug, screen_id)
resp = client.post(
f"/game/{slug}/scene-objects/{obj_id}/collision",
data='{"enabled": true, "shape": "cercle", "width": 30, "height": 30, "offset_x": 5, "offset_y": -5}',
content_type="application/json",
)
assert resp.status_code == 200
assert resp.get_json()["collision"] == {
"enabled": True, "shape": "cercle", "width": 30.0, "height": 30.0, "offset_x": 5.0, "offset_y": -5.0,
}
obj = screens.get_scene_object(slug, obj_id)
assert screens.resolve_collision_settings(obj)["shape"] == "cercle"
def test_collision_route_can_disable_the_box_entirely(client, tmp_game_slug_cleanup):
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
obj_id = _add_decor(client, slug, screen_id)
resp = client.post(
f"/game/{slug}/scene-objects/{obj_id}/collision",
data='{"enabled": false}', content_type="application/json",
)
assert resp.get_json()["collision"]["enabled"] is False
def test_collision_route_rejects_an_unknown_shape_and_falls_back_to_rectangle(client, tmp_game_slug_cleanup):
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
obj_id = _add_decor(client, slug, screen_id)
resp = client.post(
f"/game/{slug}/scene-objects/{obj_id}/collision",
data='{"shape": "triangle"}', content_type="application/json",
)
assert resp.get_json()["collision"]["shape"] == "rectangle"
def test_collision_route_ignores_invalid_size_values(client, tmp_game_slug_cleanup):
"""Une largeur/hauteur invalide (texte, vide) ne plante pas — retombe
sur la taille de l'objet, comme si rien n'avait été réglé."""
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
obj_id = _add_decor(client, slug, screen_id)
resp = client.post(
f"/game/{slug}/scene-objects/{obj_id}/collision",
data='{"width": "pas-un-nombre", "height": ""}', content_type="application/json",
)
obj = screens.get_scene_object(slug, obj_id)
collision = resp.get_json()["collision"]
assert collision["width"] == obj["width"]
assert collision["height"] == obj["height"]
def test_collision_route_404s_for_missing_object(client, game):
resp = client.post(f"/game/{game}/scene-objects/999999/collision", data="{}", content_type="application/json")
assert resp.status_code == 404
def test_full_game_payload_exposes_collision_settings_for_every_scene_object_kind(client, tmp_game_slug_cleanup):
"""Pas seulement "personnage" — un décor/fond peut aussi servir
d'obstacle à une condition de collision."""
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
obj_id = _add_decor(client, slug, screen_id)
payload = screens.full_game_payload(slug)
scene_screen = next(s for s in payload["screens"] if s["id"] == screen_id)
obj_payload = next(o for o in scene_screen["elements"] if o["id"] == obj_id)
assert obj_payload["collision"]["enabled"] is True
+65
View File
@@ -0,0 +1,65 @@
"""Rôle d'un personnage posé sur une scène 2D (joueur/ennemie/pnj, voir
screens/rendering/personnage_role.py) : seul un personnage "joueur" est
déplacé/animé automatiquement au clavier et suivi par la caméra (voir
static/js/play/personnage-controller.js, testé côté client dans
static/js/play/__tests__/personnage-controller.test.js)."""
import re
import screens
def _create_jeu2d_game(client, tmp_game_slug_cleanup, name="pytest_role"):
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):
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
return int(re.search(r"selected=(\d+)", resp.headers["Location"]).group(1))
def test_freshly_placed_personnage_defaults_to_the_joueur_role(client, tmp_game_slug_cleanup):
"""Comportement inchangé pour un personnage déjà posé avant l'ajout de
ce réglage : c'était déjà, de fait, le seul rôle qui existait."""
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
obj_id = _add_personnage(client, slug, screen_id)
obj = screens.get_scene_object(slug, obj_id)
assert screens.resolve_personnage_role(obj) == "joueur"
def test_role_route_persists_a_valid_role(client, tmp_game_slug_cleanup):
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
obj_id = _add_personnage(client, slug, screen_id)
resp = client.post(f"/game/{slug}/scene-objects/{obj_id}/role", data='{"role": "pnj"}', content_type="application/json")
assert resp.status_code == 200
assert resp.get_json() == {"ok": True, "role": "pnj"}
obj = screens.get_scene_object(slug, obj_id)
assert screens.resolve_personnage_role(obj) == "pnj"
def test_role_route_rejects_an_unknown_role(client, tmp_game_slug_cleanup):
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
obj_id = _add_personnage(client, slug, screen_id)
resp = client.post(f"/game/{slug}/scene-objects/{obj_id}/role", data='{"role": "boss-final"}', content_type="application/json")
assert resp.get_json()["role"] == "joueur" # retombe sur le défaut plutôt que d'enregistrer n'importe quoi
def test_role_route_404s_for_missing_object(client, game):
resp = client.post(f"/game/{game}/scene-objects/999999/role", data='{"role": "ennemie"}', content_type="application/json")
assert resp.status_code == 404
def test_full_game_payload_exposes_the_resolved_role_for_personnage_objects(client, tmp_game_slug_cleanup):
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
obj_id = _add_personnage(client, slug, screen_id)
client.post(f"/game/{slug}/scene-objects/{obj_id}/role", data='{"role": "ennemie"}', content_type="application/json")
payload = screens.full_game_payload(slug)
scene_screen = next(s for s in payload["screens"] if s["id"] == screen_id)
obj_payload = next(o for o in scene_screen["elements"] if o["id"] == obj_id)
assert obj_payload["personnage_role"] == "ennemie"