diff --git a/.gitignore b/.gitignore index e3261c38..4b2ac312 100644 --- a/.gitignore +++ b/.gitignore @@ -27,6 +27,11 @@ data/ # qui les convertit en static/characters/animals/ (ça, c'est committé). assets/characters/ +# Même règle que ci-dessus, pour les images de fond CraftPix — voir +# scripts/generate_background_manifest.py, qui les convertit en +# static/backgrounds/ (ça, c'est committé). +assets/background/ + # OS / éditeurs .DS_Store Thumbs.db diff --git a/core/sprite_gate.py b/core/sprite_gate.py index 280e58e6..de8e05b1 100644 --- a/core/sprite_gate.py +++ b/core/sprite_gate.py @@ -14,3 +14,13 @@ import screens def forbid_admin_only_character(forge_character): if forge_character in screens.ADMIN_ONLY_CHARACTER_SLUGS and g.current_user["role"] != "admin": abort(403) + + +# Même règle que forbid_admin_only_character ci-dessus, pour la +# bibliothèque d'images de fond CraftPix (screens.BACKGROUND_LIBRARY, +# voir screens/labels/background_library.py) — TOUJOURS admin-only +# aujourd'hui (un seul pack, tous sous licence CraftPix), contrairement +# aux personnages Forge/Kenney (domaine public, jamais gatés). +def forbid_admin_only_background(background_slug): + if background_slug in screens.ADMIN_ONLY_BACKGROUND_SLUGS and g.current_user["role"] != "admin": + abort(403) diff --git a/publish/build_scorm_package.py b/publish/build_scorm_package.py index 9ec97f09..0b0b99d5 100644 --- a/publish/build_scorm_package.py +++ b/publish/build_scorm_package.py @@ -23,7 +23,10 @@ _BASE_DIR = os.path.dirname(_PUBLISH_DIR) # Sous-ensemble de static/ réellement nécessaire au mode jouable (voir # templates/play.html) — jamais les assets propres à l'éditeur seul # (icônes de l'interface de l'éditeur mises à part, déjà dans icons/). -_STATIC_ITEMS = ["style.css", "csrf_fetch.js", "icons", "branding", "vendor/fonts", "vendor/animate.min.css", "js/play", "characters"] +_STATIC_ITEMS = [ + "style.css", "csrf_fetch.js", "icons", "branding", "vendor/fonts", "vendor/animate.min.css", + "js/play", "characters", "backgrounds", +] def _copy_characters(static_dir, used_forge_characters): @@ -82,6 +85,12 @@ def _copy_static_assets(staging_dir, slug): _copy_characters(static_dir, used_forge_characters) continue src = os.path.join(_BASE_DIR, "static", item) + if not os.path.exists(src): + # "backgrounds" (voir screens/labels/background_library.py) est + # optionnel — absent tant que + # scripts/generate_background_manifest.py n'a jamais été + # lancé sur ce poste, jamais une erreur. + continue dst = os.path.join(static_dir, item) os.makedirs(os.path.dirname(dst), exist_ok=True) if os.path.isdir(src): diff --git a/routes/scenes/scene_edit_view.py b/routes/scenes/scene_edit_view.py index 30bb8e53..6f238392 100644 --- a/routes/scenes/scene_edit_view.py +++ b/routes/scenes/scene_edit_view.py @@ -29,8 +29,9 @@ def render_scene_edit(slug, screen_id, game): personnage_data = screens.resolve_personnage_data(selected_obj) personnage_commands = screens.resolve_personnage_commands(selected_obj) + _DEFAULT_OBJECT_LABELS = {"personnage": "Personnage", "fond": "Fond"} object_labels = { - o["id"]: (o.get("name") or ("Personnage" if o["kind"] == "personnage" else "Décor")) + o["id"]: (o.get("name") or _DEFAULT_OBJECT_LABELS.get(o["kind"], "Décor")) for o in objects } element_animations_map = { @@ -54,6 +55,9 @@ def render_scene_edit(slug, screen_id, game): # "Bibliothèque de sprites animaux CraftPix"). sprite_library = screens.SPRITE_LIBRARY if g.current_user["role"] == "admin" else screens.PUBLIC_SPRITE_LIBRARY sprite_library_families = screens.sprite_gallery_families(sprite_library) + # Images de fond CraftPix (screens.BACKGROUND_LIBRARY) : même règle + # d'accès que les sprites animaux — réservées au rôle admin. + background_library = screens.BACKGROUND_LIBRARY if g.current_user["role"] == "admin" else {} return render_template( "scene_edit.html", @@ -62,6 +66,7 @@ def render_scene_edit(slug, screen_id, game): personnage_commands=personnage_commands or screens.DEFAULT_PERSONNAGE_COMMANDS, sprite_library=sprite_library, sprite_library_families=sprite_library_families, sprite_library_json=json.dumps(sprite_library), + background_library=background_library, element_animations_map_json=json.dumps(element_animations_map), element_orientation_values=screens.ELEMENT_ORIENTATION_VALUES, element_orientation_labels_json=json.dumps(screens.ELEMENT_ORIENTATION_LABELS), diff --git a/routes/scenes/scene_object_add.py b/routes/scenes/scene_object_add.py index 4786ea93..e20131e9 100644 --- a/routes/scenes/scene_object_add.py +++ b/routes/scenes/scene_object_add.py @@ -3,7 +3,7 @@ from flask import request, redirect, url_for import screens from core.flask_app import app -from core.sprite_gate import forbid_admin_only_character +from core.sprite_gate import forbid_admin_only_character, forbid_admin_only_background @app.route("/game//screens//scene-objects/add", methods=["POST"]) @@ -13,9 +13,14 @@ def scene_object_add(slug, screen_id): add_scene_object.py).""" kind = request.form.get("kind", "personnage") forge_character = request.form.get("forge_character") + background_slug = request.form.get("background_slug") if forge_character: forbid_admin_only_character(forge_character) - new_id = screens.add_scene_object(slug, screen_id, kind=kind, forge_character=forge_character) + if background_slug: + forbid_admin_only_background(background_slug) + new_id = screens.add_scene_object( + slug, screen_id, kind=kind, forge_character=forge_character, background_slug=background_slug, + ) # Même route que l'éditeur document (/game//screens//edit, # endpoint "screen_edit") — c'est CE dispatcher qui bascule vers la # vue de scène selon game_type, voir routes/screens/screen_edit.py. diff --git a/screens/__init__.py b/screens/__init__.py index 4b043dce..fc7867e6 100644 --- a/screens/__init__.py +++ b/screens/__init__.py @@ -67,6 +67,11 @@ from .labels.animal_sprite_library import ADMIN_SPRITE_LIBRARY SPRITE_LIBRARY = {**PUBLIC_SPRITE_LIBRARY, **ADMIN_SPRITE_LIBRARY} ADMIN_ONLY_CHARACTER_SLUGS = set(ADMIN_SPRITE_LIBRARY) from .labels.sprite_gallery import sprite_gallery_families +from .labels.background_library import BACKGROUND_LIBRARY +# Un seul pack aujourd'hui, licence CraftPix — tout BACKGROUND_LIBRARY est +# admin-only (voir core/sprite_gate.py::forbid_admin_only_background), +# jamais de version "publique" comme pour les personnages Kenney. +ADMIN_ONLY_BACKGROUND_SLUGS = set(BACKGROUND_LIBRARY) from .rendering.list_used_forge_characters import list_used_forge_characters from .flow.constants import ( diff --git a/screens/labels/background_library.py b/screens/labels/background_library.py new file mode 100644 index 00000000..ced6744e --- /dev/null +++ b/screens/labels/background_library.py @@ -0,0 +1,52 @@ +import json +import os + +# Bibliothèque d'images de fond CraftPix — RÉSERVÉE au rôle admin (voir +# core/sprite_gate.py, routes/scenes/scene_object_add.py), même règle que +# la bibliothèque de sprites animaux (screens/labels/animal_sprite_library.py) : +# licence CraftPix, interdiction de rendre ces images utilisables par un +# compte "user". Générée une fois par +# scripts/generate_background_manifest.py depuis assets/background// +# (pack source, non versionné) vers static/backgrounds// +# .png, avec un manifest JSON portant les dimensions RÉELLES +# (largeur/hauteur) de chaque image — utilisées comme taille par défaut de +# l'objet de scène "fond" posé (voir screens/scenes/add_scene_object.py), +# modifiable ensuite comme n'importe quel objet (panneau "Position & taille"). +_MANIFEST_PATH = os.path.join( + os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))), + "static", "backgrounds", "manifest.json", +) + + +def _load_manifest(): + try: + with open(_MANIFEST_PATH, encoding="utf-8") as f: + return json.load(f) + except FileNotFoundError: + # Poste de dev sans les images de fond copiées (script pas encore + # lancé), ou paquet exporté pour un jeu qui n'utilise aucun fond + # CraftPix (voir publish/build_scorm_package.py, à étendre pour + # copier sélectivement static/backgrounds/ le jour où un export + # utilise ce widget) : aucun fond disponible, pas une erreur. + return {} + + +def _build_library(): + manifest = _load_manifest() + library = {} + for pack_slug, pack in manifest.items(): + pack_label = pack["label"] + for image_name, dims in pack["images"].items(): + slug = f"{pack_slug}-{image_name}" + library[slug] = { + "label": f"{pack_label} — {image_name.replace('_', ' ')}", + "url": f"/static/backgrounds/{pack_slug}/{image_name}.png", + "width": dims["width"], + "height": dims["height"], + "pack": pack_slug, + "pack_label": pack_label, + } + return library + + +BACKGROUND_LIBRARY = _build_library() diff --git a/screens/scenes/add_scene_object.py b/screens/scenes/add_scene_object.py index a80dae60..335a4ab9 100644 --- a/screens/scenes/add_scene_object.py +++ b/screens/scenes/add_scene_object.py @@ -5,20 +5,27 @@ import db from .ensure_scene_schema import ensure_scene_schema _DEFAULT_PERSONNAGE_DATA = {"source": "forge", "forge_character": "male-adventurer"} -_KINDS = {"personnage", "decor"} +_KINDS = {"personnage", "decor", "fond"} -def add_scene_object(slug, scene_id, kind="personnage", forge_character=None): +def add_scene_object(slug, scene_id, kind="personnage", forge_character=None, background_slug=None): """Pose un nouvel objet sur une scène (voir ensure_scene_schema.py). kind="personnage" : attributes._personnage_data, MÊME structure/mêmes fonctions resolve_personnage_* que le widget "personnage" de l'éditeur document (screens/rendering/personnage_data.py) — un objet de scène "personnage" partage donc directement la bibliothèque Forge et le système d'animation déjà écrits. kind="decor" : une simple image fixe - (attributes.src), pas d'animation.""" + (attributes.src), pas d'animation. kind="fond" : même image fixe que + "decor" (render_scene_object.py ne les distingue pas au rendu), mais + posée en dessous de tout le reste (z_index très négatif, voir + static/style.css .sceneBackground) et à la taille RÉELLE de l'image + choisie (voir screens/labels/background_library.py) par défaut — une + image plus grande que la scène devient alors le "monde" que la caméra + suit en défilant (voir static/js/play/personnage-controller.js).""" ensure_scene_schema(slug) kind = kind if kind in _KINDS else "personnage" attributes = {} + width = height = None if kind == "personnage": data = dict(_DEFAULT_PERSONNAGE_DATA) if forge_character: @@ -27,15 +34,30 @@ def add_scene_object(slug, scene_id, kind="personnage", forge_character=None): if forge_character in PUBLIC_SPRITE_LIBRARY or forge_character in ADMIN_SPRITE_LIBRARY: data["forge_character"] = forge_character attributes["_personnage_data"] = json.dumps(data) + elif kind == "fond" and background_slug: + from ..labels.background_library import BACKGROUND_LIBRARY + background = BACKGROUND_LIBRARY.get(background_slug) + if background: + attributes["src"] = background["url"] + width, height = background["width"], background["height"] conn = db.connect(slug) - max_z = conn.execute( - "SELECT MAX(z_index) AS m FROM _scene_objects WHERE scene_id = ?", (scene_id,) - ).fetchone()["m"] - conn.execute( - """INSERT INTO _scene_objects (scene_id, kind, attributes, z_index) - VALUES (?, ?, ?, ?)""", - (scene_id, kind, json.dumps(attributes), (max_z or 0) + 1), - ) + if kind == "fond": + z_index = -1000 + else: + max_z = conn.execute( + "SELECT MAX(z_index) AS m FROM _scene_objects WHERE scene_id = ?", (scene_id,) + ).fetchone()["m"] + z_index = (max_z or 0) + 1 + cols = ["scene_id", "kind", "attributes", "z_index"] + values = [scene_id, kind, json.dumps(attributes), z_index] + if width is not None: + # x=0/y=0 (jamais le défaut 100/100, voir ensure_scene_schema.py) : + # un fond commence au coin haut-gauche de la scène/du monde, pas + # décalé arbitrairement comme un objet ordinaire. + cols += ["width", "height", "x", "y"] + values += [width, height, 0, 0] + placeholders = ", ".join(["?"] * len(values)) + conn.execute(f"INSERT INTO _scene_objects ({', '.join(cols)}) VALUES ({placeholders})", values) object_id = conn.execute("SELECT last_insert_rowid() AS id").fetchone()["id"] conn.commit() conn.close() diff --git a/screens/scenes/render_scene_object.py b/screens/scenes/render_scene_object.py index 5fa13253..1784cedc 100644 --- a/screens/scenes/render_scene_object.py +++ b/screens/scenes/render_scene_object.py @@ -46,5 +46,12 @@ def render_scene_object(obj): f'data-anim-target="{obj["id"]}" data-personnage="1" style="{style}"{src_attr}>') src = (obj.get("attributes") or {}).get("src", "") src_attr = f' src="{html_lib.escape(src)}"' if src else "" - return (f'') diff --git a/scripts/generate_background_manifest.py b/scripts/generate_background_manifest.py new file mode 100644 index 00000000..5088f9fe --- /dev/null +++ b/scripts/generate_background_manifest.py @@ -0,0 +1,80 @@ +"""Script à usage unique (PAS exécuté au runtime du moteur) : copie les +images de fond CraftPix depuis assets/background// (pack source, non +versionné dans static/) vers static/backgrounds//.png, et +écrit static/backgrounds/manifest.json (dimensions RÉELLES en pixels de +chaque image, lues dans l'en-tête PNG — nécessaires pour proposer une +taille par défaut cohérente à la pose de l'objet de scène "fond", voir +screens/scenes/add_scene_object.py). Réutilisé une fois par moi via +`python scripts/generate_background_manifest.py`, jamais importé par le +moteur. + +Accès réservé au rôle admin (voir screens/labels/background_library.py, +routes/scenes/scene_edit_view.py) : licence CraftPix, ne redistribue pas +ces images à des comptes "user" — même règle que la bibliothèque de +sprites animaux (screens/labels/animal_sprite_library.py).""" +import json +import os +import shutil +import struct + +_BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +_SRC_ROOT = os.path.join(_BASE_DIR, "assets", "background") +_DST_ROOT = os.path.join(_BASE_DIR, "static", "backgrounds") + +# Nom de dossier source -> (slug interne court, libellé FR). Seule +# l'image COMBINÉE de premier niveau (game_background_N.png) est reprise +# pour l'instant — les calques séparés (layers/) permettraient un effet de +# parallaxe, hors scope de ce premier jet. +_PACKS = { + "craftpix-net-208967-flying-island-battle-backgrounds": ("flying_island", "Île volante"), +} + + +def _png_dimensions(path): + """Largeur/hauteur RÉELLES lues dans l'en-tête PNG (chunk IHDR, 8 + octets de signature + 4 octets de taille + 4 octets de type "IHDR" + avant les 2 x 4 octets de dimensions, big-endian) — évite une + dépendance à Pillow (absente de ce projet) pour un besoin aussi + ponctuel.""" + with open(path, "rb") as f: + header = f.read(24) + if len(header) < 24 or header[:8] != b"\x89PNG\r\n\x1a\n" or header[12:16] != b"IHDR": + raise ValueError(f"pas un PNG valide : {path}") + width, height = struct.unpack(">II", header[16:24]) + return width, height + + +def main(): + manifest = {} + for pack_folder, (pack_slug, pack_label) in sorted(_PACKS.items()): + pack_dir = os.path.join(_SRC_ROOT, pack_folder, "PNG") + if not os.path.isdir(pack_dir): + print(f"SKIP (introuvable) : {pack_folder}") + continue + images = {} + for name in sorted(os.listdir(pack_dir)): + src_dir = os.path.join(pack_dir, name) + src_file = os.path.join(src_dir, f"{name}.png") + if not os.path.isfile(src_file): + continue + dst_dir = os.path.join(_DST_ROOT, pack_slug) + os.makedirs(dst_dir, exist_ok=True) + dst_file = os.path.join(dst_dir, f"{name}.png") + shutil.copy2(src_file, dst_file) + width, height = _png_dimensions(dst_file) + images[name] = {"width": width, "height": height} + if images: + manifest[pack_slug] = {"label": pack_label, "images": images} + print(f"{pack_slug} ({pack_label}) : {len(images)} images copiées") + else: + print(f"SKIP (aucune image) : {pack_folder}") + + os.makedirs(_DST_ROOT, exist_ok=True) + manifest_path = os.path.join(_DST_ROOT, "manifest.json") + with open(manifest_path, "w", encoding="utf-8") as f: + json.dump(manifest, f, ensure_ascii=False, indent=2, sort_keys=True) + print(f"\nManifest écrit : {manifest_path}") + + +if __name__ == "__main__": + main() diff --git a/static/backgrounds/flying_island/game_background_1.png b/static/backgrounds/flying_island/game_background_1.png new file mode 100644 index 00000000..127dcc5d Binary files /dev/null and b/static/backgrounds/flying_island/game_background_1.png differ diff --git a/static/backgrounds/flying_island/game_background_2.png b/static/backgrounds/flying_island/game_background_2.png new file mode 100644 index 00000000..31a40a45 Binary files /dev/null and b/static/backgrounds/flying_island/game_background_2.png differ diff --git a/static/backgrounds/flying_island/game_background_3.png b/static/backgrounds/flying_island/game_background_3.png new file mode 100644 index 00000000..adda969c Binary files /dev/null and b/static/backgrounds/flying_island/game_background_3.png differ diff --git a/static/backgrounds/flying_island/game_background_4.png b/static/backgrounds/flying_island/game_background_4.png new file mode 100644 index 00000000..d1d8076c Binary files /dev/null and b/static/backgrounds/flying_island/game_background_4.png differ diff --git a/static/backgrounds/manifest.json b/static/backgrounds/manifest.json new file mode 100644 index 00000000..8300119e --- /dev/null +++ b/static/backgrounds/manifest.json @@ -0,0 +1,23 @@ +{ + "flying_island": { + "images": { + "game_background_1": { + "height": 2160, + "width": 3840 + }, + "game_background_2": { + "height": 2160, + "width": 3840 + }, + "game_background_3": { + "height": 2160, + "width": 3840 + }, + "game_background_4": { + "height": 2160, + "width": 3840 + } + }, + "label": "Île volante" + } +} \ No newline at end of file diff --git a/static/js/play/__tests__/actions.test.js b/static/js/play/__tests__/actions.test.js index 32a9e5b9..48c9d22a 100644 --- a/static/js/play/__tests__/actions.test.js +++ b/static/js/play/__tests__/actions.test.js @@ -113,6 +113,25 @@ test('clampSceneObjectPosition — borne au bord gauche/haut (jamais négatif)', }); }); +// world_width/world_height (voir personnage-controller.js:: +// forgeUpdateSceneWorldSize) : demandé par l'utilisateur — un fond plus +// grand que la scène doit laisser le personnage se déplacer sur TOUT le +// fond, pas seulement le petit cadre visible de la scène d'origine. +test('clampSceneObjectPosition — borne au MONDE (world_width/height) quand un fond XXL l\'a agrandi', () => { + withScreensData({ id: 1, scene_width: 200, scene_height: 100, world_width: 2000, world_height: 1000 }, () => { + const el = { style: { width: '40px', height: '40px' } }; + assert.equal(clampSceneObjectPosition(el, 'left', 500), 500); // pas encore au bord du MONDE + assert.equal(clampSceneObjectPosition(el, 'left', 5000), 1960); // 2000 - 40 + }); +}); + +test('clampSceneObjectPosition — sans world_width/height (aucun fond), retombe sur scene_width/height', () => { + withScreensData({ id: 1, scene_width: 200, scene_height: 100 }, () => { + const el = { style: { width: '40px', height: '40px' } }; + assert.equal(clampSceneObjectPosition(el, 'left', 500), 160); // identique au tout premier test ci-dessus + }); +}); + test('applyObjectProperty — pos_x_relatif reste dans les limites de la scène après plusieurs pas', () => { withScreensData({ id: 1, scene_width: 100, scene_height: 100 }, () => { const el = { style: { left: '90px', top: '0px', width: '20px', height: '20px' } }; diff --git a/static/js/play/__tests__/personnage-controller.test.js b/static/js/play/__tests__/personnage-controller.test.js index 3b5b84dd..40edf822 100644 --- a/static/js/play/__tests__/personnage-controller.test.js +++ b/static/js/play/__tests__/personnage-controller.test.js @@ -16,7 +16,7 @@ Object.assign(global, actionsMod); const { forgePersonnageAnimExists, forgePersonnageMovementAnim, forgePersonnageIdleAnim, forgePersonnageActionAnim, - forgeRunPersonnageControllerTick, + forgeRunPersonnageControllerTick, forgeUpdateSceneWorldSize, forgeUpdateSceneCamera, } = require('../personnage-controller.js'); function fakeSceneEl() { @@ -33,7 +33,11 @@ function fakeSceneEl() { function withPersonnageObject(obj, elByObjectId, fn) { const previousDoc = global.document; - global.document = { querySelector: sel => elByObjectId[sel.match(/"(\d+)"/)[1]] }; + // forgeRunPersonnageControllerTick appelle aussi forgeUpdateSceneCamera + // (cherche ".sceneWorld", jamais présente dans elByObjectId ici) — un + // sélecteur qui ne matche aucun id numérique renvoie undefined plutôt + // que de lever, comme un vrai document.querySelector sans résultat. + global.document = { querySelector: sel => { const m = sel.match(/"(\d+)"/); return m ? elByObjectId[m[1]] : undefined; } }; global.screensData = [{ id: 1, scene_width: 500, scene_height: 500 }]; global.window.currentScreenId = 1; global.gameData = { personnage_animations: { [obj.id]: { walk: ['/w0.png'], idle: ['/i0.png'] } } }; @@ -137,3 +141,84 @@ test('forgePersonnageActionAnim — "interagir" prioritaire, sinon la touche sup test('forgePersonnageActionAnim — commandes sans "extra" (personnage posé avant l\'ajout du réglage)', () => { assert.equal(forgePersonnageActionAnim({ interagir: 'e' }, 'j'), null); }); + +// Caméra qui suit le personnage derrière un fond plus grand que la scène +// (voir screens/scenes/add_scene_object.py, kind="fond") — demandé par +// l'utilisateur : "si l'image de fond est plus grande que la scène, la +// caméra devra défiler en suivant le joueur". +function fakeWorldEl() { + return { style: {} }; +} + +test('forgeUpdateSceneWorldSize — le monde reste la scène tant qu\'aucun fond ne dépasse (comportement inchangé)', () => { + const screenData = { id: 1, scene_width: 500, scene_height: 500, elements: [{ kind: 'decor', x: 10, y: 10, width: 50, height: 50 }] }; + const world = forgeUpdateSceneWorldSize(screenData); + assert.deepEqual(world, { width: 500, height: 500 }); + assert.equal(screenData.world_width, 500); + assert.equal(screenData.world_height, 500); +}); + +test('forgeUpdateSceneWorldSize — un fond plus grand agrandit le monde jusqu\'à ses propres bords', () => { + const screenData = { id: 1, scene_width: 500, scene_height: 500, elements: [{ kind: 'fond', x: 0, y: 0, width: 3840, height: 2160 }] }; + const world = forgeUpdateSceneWorldSize(screenData); + assert.deepEqual(world, { width: 3840, height: 2160 }); +}); + +test('forgeUpdateSceneCamera — sans fond XXL, transform vidé (aucun effet, comportement d\'avant)', () => { + const worldEl = fakeWorldEl(); + const previousDoc = global.document; + global.document = { querySelector: () => worldEl }; + const screenData = { id: 1, scene_width: 500, scene_height: 500, elements: [] }; + try { + forgeUpdateSceneCamera(screenData); + assert.equal(worldEl.style.width, '500px'); + assert.equal(worldEl.style.height, '500px'); + assert.equal(worldEl.style.transform, ''); + } finally { global.document = previousDoc; } +}); + +test('forgeUpdateSceneCamera — centre le monde sur le personnage, borné aux bords du monde', () => { + const worldEl = fakeWorldEl(); + const playerEl = { style: { left: '1900px', top: '1000px', width: '40px', height: '60px' } }; + const previousDoc = global.document; + global.document = { + querySelector: sel => (sel.indexOf('sceneWorld') !== -1 ? worldEl : playerEl), + }; + const screenData = { + id: 1, scene_width: 500, scene_height: 500, + elements: [ + { kind: 'fond', x: 0, y: 0, width: 3840, height: 2160 }, + { id: 42, kind: 'personnage' }, + ], + }; + try { + forgeUpdateSceneCamera(screenData); + assert.equal(worldEl.style.width, '3840px'); + assert.equal(worldEl.style.height, '2160px'); + // Centre du joueur : 1900+20=1920, 1000+30=1030 -> décalage caméra + // 1920-250=1670, 1030-250=780 (aucune des deux bornes atteinte ici). + assert.equal(worldEl.style.transform, 'translate(-1670px, -780px)'); + } finally { global.document = previousDoc; } +}); + +test('forgeUpdateSceneCamera — ne montre jamais au-delà des bords du monde (bornage)', () => { + const worldEl = fakeWorldEl(); + const playerEl = { style: { left: '3820px', top: '0px', width: '20px', height: '20px' } }; // tout au bord droit + const previousDoc = global.document; + global.document = { + querySelector: sel => (sel.indexOf('sceneWorld') !== -1 ? worldEl : playerEl), + }; + const screenData = { + id: 1, scene_width: 500, scene_height: 500, + elements: [ + { kind: 'fond', x: 0, y: 0, width: 3840, height: 2160 }, + { id: 42, kind: 'personnage' }, + ], + }; + try { + forgeUpdateSceneCamera(screenData); + // max offsetX = 3840 - 500 = 3340 ; jamais plus, même si le joueur est + // encore plus près du bord (pas de bande vide visible au-delà du monde). + assert.equal(worldEl.style.transform, 'translate(-3340px, 0px)'); + } finally { global.document = previousDoc; } +}); diff --git a/static/js/play/actions.js b/static/js/play/actions.js index 7d45e089..88928f77 100644 --- a/static/js/play/actions.js +++ b/static/js/play/actions.js @@ -124,18 +124,25 @@ function applyElementProperty(targetEl, property, value) { targetEl.style[spec.css] = value + spec.suffix; } -// Bloque un objet de scène dans les limites de LA scène affichée (bug -// signalé par l'utilisateur : un déplacement continu au clavier — voir +// Bloque un objet de scène dans les limites du MONDE affiché (bug signalé +// par l'utilisateur : un déplacement continu au clavier — voir // triggers.js/"touche_maintenue" — pouvait le faire sortir du cadre sans -// rien pour l'en empêcher). scene_width/scene_height viennent de -// screensData (voir full_game_payload.py, _screens.scene_width/height) ; -// sans écran trouvé (jamais censé arriver pour un objet de scène), la -// valeur n'est pas bornée plutôt que de risquer un blocage à 0. +// rien pour l'en empêcher). world_width/world_height (voir +// personnage-controller.js::forgeUpdateSceneWorldSize, appelée à chaque +// tick AVANT tout déplacement) valent scene_width/scene_height tant +// qu'aucun objet kind="fond" plus grand que la scène n'est posé — un jeu +// sans fond XXL borne donc exactement comme avant. scene_width/height +// viennent de screensData (voir full_game_payload.py, +// _screens.scene_width/height) ; sans écran trouvé (jamais censé arriver +// pour un objet de scène), la valeur n'est pas bornée plutôt que de +// risquer un blocage à 0. function clampSceneObjectPosition(targetEl, css, rawValue) { const screenData = screensData.find(function(s){ return s.id === window.currentScreenId; }); if (!screenData || screenData.scene_width == null) return rawValue; const size = parseFloat(targetEl.style[css === 'left' ? 'width' : 'height']) || 0; - const max = (css === 'left' ? screenData.scene_width : screenData.scene_height) - size; + const boundWidth = screenData.world_width != null ? screenData.world_width : screenData.scene_width; + const boundHeight = screenData.world_height != null ? screenData.world_height : screenData.scene_height; + const max = (css === 'left' ? boundWidth : boundHeight) - size; return Math.max(0, Math.min(max, rawValue)); } diff --git a/static/js/play/personnage-controller.js b/static/js/play/personnage-controller.js index 183d6945..9b619693 100644 --- a/static/js/play/personnage-controller.js +++ b/static/js/play/personnage-controller.js @@ -14,6 +14,12 @@ var activePersonnageControllerInterval = null; var activePersonnageInteractListener = null; +// Un seul rythme pour TOUTES les poses (marche, idle, interagir, touches +// supplémentaires) — les désynchroniser (ex. idle plus lent que walk) +// n'a aucune justification et se voit immédiatement au jeu (bug signalé +// par l'utilisateur). +var FORGE_PERSONNAGE_FPS = 8; + function forgePersonnageAnimExists(elementId, animName) { var byAnim = (gameData.personnage_animations || {})[elementId]; return !!(byAnim && byAnim[animName] && byAnim[animName].length); @@ -33,6 +39,53 @@ function forgePersonnageIdleAnim(elementId) { return forgePersonnageAnimExists(elementId, 'idle') ? 'idle' : null; } +// Taille du "monde" (voir static/style.css .sceneWorld) : la scène elle- +// même par défaut, agrandie si un objet kind="fond" (voir +// add_scene_object.py) dépasse ses bords — un jeu sans fond XXL garde +// donc EXACTEMENT son comportement d'avant (monde == scène). Mémorisée +// directement sur screenData (déjà l'objet partagé avec screensData, +// voir static/js/play/actions.js) pour que clampSceneObjectPosition +// borne un déplacement au monde entier, pas seulement au petit cadre +// visible de la scène. +function forgeUpdateSceneWorldSize(screenData) { + var width = screenData.scene_width, height = screenData.scene_height; + (screenData.elements || []).forEach(function (o) { + if (o.kind !== 'fond') return; + width = Math.max(width, (o.x || 0) + (o.width || 0)); + height = Math.max(height, (o.y || 0) + (o.height || 0)); + }); + screenData.world_width = width; + screenData.world_height = height; + return { width: width, height: height }; +} + +// Caméra qui suit le personnage (le premier objet kind="personnage" +// trouvé — aucune notion de "joueur" distincte n'existe ailleurs dans le +// moteur 2D, voir personnage-controller.js dans son ensemble) : centre le +// monde sur lui, borné pour ne jamais montrer au-delà de ses bords (même +// principe que clampSceneObjectPosition, actions.js, mais pour la caméra +// plutôt qu'un objet). Sans effet (transform vidé) tant que le monde ne +// dépasse pas la scène — c'est ce qui garde le comportement identique à +// avant pour tout jeu sans fond plus grand que sa scène. +function forgeUpdateSceneCamera(screenData) { + var worldEl = document.querySelector('.playScreen[data-screen-id="' + screenData.id + '"] .sceneWorld'); + if (!worldEl) return; + var world = forgeUpdateSceneWorldSize(screenData); + worldEl.style.width = world.width + 'px'; + worldEl.style.height = world.height + 'px'; + if (world.width <= screenData.scene_width && world.height <= screenData.scene_height) { + worldEl.style.transform = ''; + return; + } + var player = (screenData.elements || []).find(function (o) { return o.kind === 'personnage'; }); + 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; + var offsetX = Math.min(Math.max(px - screenData.scene_width / 2, 0), Math.max(0, world.width - screenData.scene_width)); + var offsetY = Math.min(Math.max(py - screenData.scene_height / 2, 0), Math.max(0, world.height - screenData.scene_height)); + worldEl.style.transform = 'translate(' + (-offsetX) + 'px, ' + (-offsetY) + 'px)'; +} + function forgeStopPersonnageControllers() { if (activePersonnageControllerInterval) { clearInterval(activePersonnageControllerInterval); @@ -47,6 +100,7 @@ function forgeStopPersonnageControllers() { // 50ms (20 fois/seconde) — même cadence que runScreenHeldKeyTriggers // (triggers.js), pour un déplacement perçu comme continu. function forgeRunPersonnageControllerTick(screenData) { + forgeUpdateSceneCamera(screenData); (screenData.elements || []).forEach(function (obj) { if (obj.kind !== 'personnage' || !obj.personnage_commandes) return; var cmd = obj.personnage_commandes; @@ -63,9 +117,13 @@ function forgeRunPersonnageControllerTick(screenData) { if (heldKeys.has(cmd.bas)) dy += cmd.vitesse; } + // Même fps pour idle et walk (bug signalé par l'utilisateur : idle + // tournait à 4 i/s contre 8 pour walk, perçue comme "les autres + // animations sont lentes à côté de la marche" — aucune raison réelle + // de les désynchroniser). if (dx === 0 && dy === 0) { var idleAnim = forgePersonnageIdleAnim(obj.id); - if (idleAnim) runSpriteAnimation(targetEl, resolveSpriteFrames(obj.id, { animation: idleAnim, fps: 4, loop: true })); + if (idleAnim) runSpriteAnimation(targetEl, resolveSpriteFrames(obj.id, { animation: idleAnim, fps: FORGE_PERSONNAGE_FPS, loop: true })); return; } if (dx !== 0) { @@ -74,7 +132,7 @@ function forgeRunPersonnageControllerTick(screenData) { } if (dy !== 0) applyObjectProperty(targetEl, 'pos_y_relatif', dy); var moveAnim = forgePersonnageMovementAnim(obj.id); - if (moveAnim) runSpriteAnimation(targetEl, resolveSpriteFrames(obj.id, { animation: moveAnim, fps: 8, loop: true })); + if (moveAnim) runSpriteAnimation(targetEl, resolveSpriteFrames(obj.id, { animation: moveAnim, fps: FORGE_PERSONNAGE_FPS, loop: true })); }); } @@ -100,7 +158,7 @@ function forgeStartPersonnageInteractListener(screenData) { if (!animName || !forgePersonnageAnimExists(obj.id, animName)) return; var targetEl = document.querySelector('[data-object-id="' + obj.id + '"]'); if (!targetEl) return; - runSpriteAnimation(targetEl, resolveSpriteFrames(obj.id, { animation: animName, fps: 8, loop: false })); + runSpriteAnimation(targetEl, resolveSpriteFrames(obj.id, { animation: animName, fps: FORGE_PERSONNAGE_FPS, loop: false })); }); }; window.addEventListener('keydown', activePersonnageInteractListener); @@ -112,7 +170,15 @@ function forgeStartPersonnageInteractListener(screenData) { function forgeStartPersonnageControllers(screenData) { forgeStopPersonnageControllers(); if (!screenData || screenData.kind !== 'jeu_2d') return; - var hasPersonnage = (screenData.elements || []).some(function (o) { return o.kind === 'personnage'; }); + var elements = screenData.elements || []; + var hasPersonnage = elements.some(function (o) { return o.kind === 'personnage'; }); + 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 + // défaut. Un fond seul (sans personnage) n'a besoin que de CET appel + // ponctuel — rien à suivre en continu ; un personnage, lui, a besoin du + // tick régulier pour le déplacement continu au clavier. + if (hasFond) forgeUpdateSceneCamera(screenData); if (!hasPersonnage) return; activePersonnageControllerInterval = setInterval(function () { forgeRunPersonnageControllerTick(screenData); }, 50); forgeStartPersonnageInteractListener(screenData); @@ -125,6 +191,6 @@ if (typeof module !== 'undefined' && module.exports) { module.exports = { forgePersonnageAnimExists, forgePersonnageMovementAnim, forgePersonnageIdleAnim, forgePersonnageActionAnim, forgeRunPersonnageControllerTick, forgeStartPersonnageControllers, forgeStopPersonnageControllers, - forgeStartPersonnageInteractListener, + forgeStartPersonnageInteractListener, forgeUpdateSceneWorldSize, forgeUpdateSceneCamera, }; } diff --git a/static/js/scenes/scene-editor.js b/static/js/scenes/scene-editor.js index 5eb15faa..ec2e1bdd 100644 --- a/static/js/scenes/scene-editor.js +++ b/static/js/scenes/scene-editor.js @@ -8,12 +8,13 @@ // animation-timeline.js, personnage-panel.js, personnage-preview.js) est // réutilisée TELLE QUELLE, chargée telle quelle par scene_edit.html. -function addSceneObject(kind, forgeCharacter) { +function addSceneObject(kind, forgeCharacter, backgroundSlug) { var form = document.createElement('form'); form.method = 'post'; form.action = "/game/" + GAME_SLUG + "/screens/" + SCREEN_ID + "/scene-objects/add"; var html = ''; if (forgeCharacter) html += ''; + if (backgroundSlug) html += ''; form.innerHTML = html; document.body.appendChild(form); form.requestSubmit(); diff --git a/static/style.css b/static/style.css index c715f1d9..97000ff7 100644 --- a/static/style.css +++ b/static/style.css @@ -612,6 +612,24 @@ body.builderBody > main.content{ flex:1 1 auto; min-height:0; overflow:hidden; d remplirait quand même sa boîte (width/height:100%) mais DÉFORMÉE. */ .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; } + +/* Caméra qui suit le personnage (voir static/js/play/personnage- + controller.js) : .playScreen.playScene est désormais le VIEWPORT (taille + fixe scene_width x scene_height, jamais plus grand — overflow:hidden + coupe tout ce qui dépasse), .sceneWorld le "monde" à l'intérieur, dont + la taille par défaut (100%) reste identique au viewport tant qu'aucun + fond plus grand que la scène n'est posé — un jeu sans fond XXL se + comporte donc exactement comme avant. */ +.playScreen.playScene{ overflow:hidden; } +.sceneWorld{ position:relative; width:100%; height:100%; } + /* Surbrillance de l'élément sélectionné dans l'arborescence, posée en JS sur sa propre balise (voir applySelectionHighlight) — seul moyen de la voir sur le canevas quand cet élément est imbriqué dans un conteneur/répéteur/ diff --git a/templates/play.html b/templates/play.html index fc54cf3e..474aa735 100644 --- a/templates/play.html +++ b/templates/play.html @@ -78,7 +78,14 @@ screens/scenes/), pas de wrapper .playElement/elstyle (spécifiques au %/DOM d'un écran "document") : juste le HTML tel quel. -->
- {% for obj in s.elements %}{{ obj.rendered_html|safe }}{% endfor %} + +
+ {% for obj in s.elements %}{{ obj.rendered_html|safe }}{% endfor %} +
{% else %}
diff --git a/templates/scene_edit.html b/templates/scene_edit.html index 49656d22..1b215b8a 100644 --- a/templates/scene_edit.html +++ b/templates/scene_edit.html @@ -51,7 +51,7 @@ {% for o in objects %} {% endfor %} @@ -78,6 +78,26 @@
+ {% if background_library %} + +
+ 🏞️ Images de fond +
+
+ {% for slug_b, background in background_library.items() %} + + {% endfor %} +
+

Posée à sa taille réelle (souvent plus grande que la scène) — un seul fond par scène recommandé, la caméra le suit en défilant.

+
+
+ {% endif %} diff --git a/tests/test_background_scene_object.py b/tests/test_background_scene_object.py new file mode 100644 index 00000000..2d862334 --- /dev/null +++ b/tests/test_background_scene_object.py @@ -0,0 +1,102 @@ +"""Objet de scène kind="fond" (image de fond, voir +screens/scenes/add_scene_object.py) : posé à sa taille RÉELLE (voir +screens/labels/background_library.py), derrière tout le reste +(z_index très négatif), insensible au clic — et si plus grand que la +scène, "monde" que la caméra suit en défilant derrière le personnage +(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_background"): + 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 _a_background_slug(): + assert screens.BACKGROUND_LIBRARY, "aucune image de fond générée — lancer scripts/generate_background_manifest.py" + return next(iter(screens.BACKGROUND_LIBRARY)) + + +def test_background_library_is_built_from_the_generated_manifest(): + slug = _a_background_slug() + background = screens.BACKGROUND_LIBRARY[slug] + assert background["url"].startswith("/static/backgrounds/") + assert background["width"] > 0 and background["height"] > 0 + + +def test_adding_a_fond_object_uses_the_image_real_size_and_sits_behind_everything(client, tmp_game_slug_cleanup): + slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup) + background_slug = _a_background_slug() + background = screens.BACKGROUND_LIBRARY[background_slug] + + resp = client.post( + f"/game/{slug}/screens/{screen_id}/scene-objects/add", + data={"kind": "fond", "background_slug": background_slug}, + follow_redirects=False, + ) + assert resp.status_code == 302, resp.data + obj_id = int(re.search(r"selected=(\d+)", resp.headers["Location"]).group(1)) + + obj = screens.get_scene_object(slug, obj_id) + assert obj["kind"] == "fond" + assert obj["width"] == background["width"] + assert obj["height"] == background["height"] + assert obj["x"] == 0 and obj["y"] == 0 + assert obj["z_index"] < 0 # toujours derrière un personnage/décor ajouté normalement (z_index >= 1) + assert obj["attributes"]["src"] == background["url"] + + +def test_fond_object_renders_as_an_image_with_the_sceneBackground_class(client, tmp_game_slug_cleanup): + slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup) + background_slug = _a_background_slug() + resp = client.post( + f"/game/{slug}/screens/{screen_id}/scene-objects/add", + data={"kind": "fond", "background_slug": background_slug}, + follow_redirects=False, + ) + obj_id = int(re.search(r"selected=(\d+)", resp.headers["Location"]).group(1)) + obj = screens.get_scene_object(slug, obj_id) + html = screens.render_scene_object(obj) + assert 'class="sceneObjectSprite sceneBackground"' in html + assert f'data-object-id="{obj_id}"' in html + + +def test_fond_object_falls_back_to_default_size_without_a_known_background_slug(client, tmp_game_slug_cleanup): + """Un slug invalide/absent (URL bidouillée, script de manifeste jamais + lancé sur ce poste) : l'objet est quand même créé, à la taille par + défaut générique (128x128, voir ensure_scene_schema.py) plutôt que de + planter.""" + slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup) + resp = client.post( + f"/game/{slug}/screens/{screen_id}/scene-objects/add", + data={"kind": "fond", "background_slug": "ce-slug-nexiste-pas"}, + follow_redirects=False, + ) + assert resp.status_code == 302 + obj_id = int(re.search(r"selected=(\d+)", resp.headers["Location"]).group(1)) + obj = screens.get_scene_object(slug, obj_id) + assert obj["width"] == 128 and obj["height"] == 128 + + +def test_background_gallery_route_rejects_a_non_admin_account(user_game, user_client): + resp = user_client.post(f"/game/{user_game}/screens/new", data={"name": "Scène", "kind": "jeu_2d"}, follow_redirects=False) + screen_id = int(re.search(r"/screens/(\d+)/edit", resp.headers["Location"]).group(1)) + background_slug = _a_background_slug() + resp = user_client.post( + f"/game/{user_game}/screens/{screen_id}/scene-objects/add", + data={"kind": "fond", "background_slug": background_slug}, + follow_redirects=False, + ) + assert resp.status_code == 403 + + +def test_background_gallery_hidden_from_non_admin_scene_editor(user_game, user_client): + resp = user_client.post(f"/game/{user_game}/screens/new", data={"name": "Scène", "kind": "jeu_2d"}, follow_redirects=False) + screen_id = int(re.search(r"/screens/(\d+)/edit", resp.headers["Location"]).group(1)) + html = user_client.get(f"/game/{user_game}/screens/{screen_id}/edit").get_data(as_text=True) + assert "Images de fond" not in html