Image de fond de scène + caméra qui suit le personnage, fps d'animation cohérent
Trois retours utilisateur : - fps d'idle/interagir/touches supplémentaires alignés sur celui de la marche (8 i/s partout, était 4 pour idle — perçu comme "les autres animations sont lentes à côté de la marche"). - Nouveau menu "🏞️ Images de fond" dans "Objets de cette scène" : pose un objet kind="fond" à la taille RÉELLE de l'image choisie (pack CraftPix intégré en galerie, admin seulement — licence, voir core/sprite_gate.py), derrière tout le reste, insensible au clic. - Si ce fond dépasse la scène, elle devient le "monde" : .playScreen. playScene est désormais le viewport (overflow:hidden, taille fixe), .sceneWorld le monde à l'intérieur — la caméra centre le premier personnage trouvé, bornée pour ne jamais montrer au-delà des bords (voir personnage-controller.js::forgeUpdateSceneCamera). Le personnage peut désormais se déplacer sur tout le monde, pas seulement le petit cadre visible (clampSceneObjectPosition, actions.js). Un jeu sans fond XXL garde un comportement strictement identique à avant (monde == scène, transform vide). Bibliothèque générée une fois par scripts/generate_background_manifest.py (assets/background/, non versionné, licence CraftPix) vers static/backgrounds/ (committé), même patron que generate_animal_sprite_manifest.py. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
5891c82c62
commit
9199897784
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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/<slug>/screens/<int:screen_id>/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/<slug>/screens/<id>/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.
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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>/
|
||||
# (pack source, non versionné) vers static/backgrounds/<pack_slug>/
|
||||
# <image>.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()
|
||||
@@ -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)
|
||||
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"]
|
||||
conn.execute(
|
||||
"""INSERT INTO _scene_objects (scene_id, kind, attributes, z_index)
|
||||
VALUES (?, ?, ?, ?)""",
|
||||
(scene_id, kind, json.dumps(attributes), (max_z or 0) + 1),
|
||||
)
|
||||
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()
|
||||
|
||||
@@ -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'<img class="sceneObjectSprite" data-object-id="{obj["id"]}" data-element-id="{obj["id"]}" '
|
||||
# "fond" (voir add_scene_object.py) : même image fixe que "decor",
|
||||
# seule la classe change — .sceneBackground (static/style.css) la
|
||||
# rend insensible au clic (pointer-events:none), pour qu'elle ne
|
||||
# bloque jamais un clic destiné à un objet posé par-dessus, même
|
||||
# étendue sur tout le "monde" (voir personnage-controller.js, la
|
||||
# caméra qui la suit en défilant).
|
||||
css_class = "sceneObjectSprite sceneBackground" if obj["kind"] == "fond" else "sceneObjectSprite"
|
||||
return (f'<img class="{css_class}" data-object-id="{obj["id"]}" data-element-id="{obj["id"]}" '
|
||||
f'data-anim-target="{obj["id"]}" style="{style}"{src_attr}>')
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
"""Script à usage unique (PAS exécuté au runtime du moteur) : copie les
|
||||
images de fond CraftPix depuis assets/background/<pack>/ (pack source, non
|
||||
versionné dans static/) vers static/backgrounds/<pack_slug>/<image>.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()
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 1.5 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.0 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.3 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.0 MiB |
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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' } };
|
||||
|
||||
@@ -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; }
|
||||
});
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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 = '<input type="hidden" name="kind" value="' + kind + '">';
|
||||
if (forgeCharacter) html += '<input type="hidden" name="forge_character" value="' + forgeCharacter + '">';
|
||||
if (backgroundSlug) html += '<input type="hidden" name="background_slug" value="' + backgroundSlug + '">';
|
||||
form.innerHTML = html;
|
||||
document.body.appendChild(form);
|
||||
form.requestSubmit();
|
||||
|
||||
@@ -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/
|
||||
|
||||
@@ -78,7 +78,14 @@
|
||||
screens/scenes/), pas de wrapper .playElement/elstyle (spécifiques
|
||||
au %/DOM d'un écran "document") : juste le HTML tel quel. -->
|
||||
<div class="playScreen playScene" data-screen-id="{{ s.id }}" data-kind="jeu_2d" style="width:{{ s.scene_width }}px; height:{{ s.scene_height }}px;">
|
||||
<!-- .sceneWorld : le "monde" (voir static/style.css et
|
||||
static/js/play/personnage-controller.js) — taille par défaut 100%
|
||||
(identique au viewport ci-dessus), agrandie/décalée en JS
|
||||
uniquement si un objet kind="fond" plus grand que la scène est posé
|
||||
(caméra qui suit le personnage). -->
|
||||
<div class="sceneWorld" data-scene-width="{{ s.scene_width }}" data-scene-height="{{ s.scene_height }}">
|
||||
{% for obj in s.elements %}{{ obj.rendered_html|safe }}{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="playScreen" data-screen-id="{{ s.id }}" data-kind="document" data-aspect="{{ s.preview_aspect or '9/16' }}">
|
||||
|
||||
@@ -51,7 +51,7 @@
|
||||
{% 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 '🖼️' }} <span class="treeLabel">{{ o.name or ('Personnage' if o.kind == 'personnage' else 'Décor') }}</span>
|
||||
{{ '🏃' 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 %}
|
||||
@@ -78,6 +78,26 @@
|
||||
</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
|
||||
de l'image (voir screens/scenes/add_scene_object.py) : plus
|
||||
grande que la scène, la caméra la suit en défilant derrière le
|
||||
personnage (voir static/js/play/personnage-controller.js). -->
|
||||
<details class="propGroup">
|
||||
<summary>🏞️ Images de fond</summary>
|
||||
<div class="propGroupBody">
|
||||
<div class="iconGallery personnageGallery">
|
||||
{% for slug_b, background in background_library.items() %}
|
||||
<button type="button" class="iconGalleryTile" title="{{ background.label }}" onclick="addSceneObject('fond', null, '{{ slug_b }}')">
|
||||
<img src="{{ background.url }}" alt="{{ background.label }}" class="personnageThumb">
|
||||
</button>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<p class="help">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.</p>
|
||||
</div>
|
||||
</details>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user