Bibliothèque de sprites animaux CraftPix (1/3) : catalogue, galerie, accès admin
Premier commit d'une fonctionnalité découpée en plusieurs lots (voir le plan "Bibliothèque de sprites animaux CraftPix") : intègre 14 familles d'animaux (15 variantes de couleur chacune) comme personnages Forge sélectionnables, à côté des 6 Kenney existants — réservé au rôle admin, licence CraftPix oblige (interdiction contractuelle de rendre ces sprites utilisables par un compte "user" via l'application). - screens/labels/animal_sprite_library.py (nouveau) : charge un manifest JSON généré une fois (voir scripts/generate_animal_sprite_manifest.py, commit suivant) et construit ADMIN_SPRITE_LIBRARY, dans le même format que l'existant PUBLIC_SPRITE_LIBRARY (screens/labels/sprite_library.py, ex-SPRITE_LIBRARY, renommé pour distinguer les deux). screens.SPRITE_LIBRARY reste le catalogue FUSIONNÉ (utilisé par resolve_personnage_animations pour la résolution runtime, sans filtrage par rôle — voir le constat d'exploration : le payload de jeu et /jouer/<slug> ne vérifient déjà aucun rôle nulle part). - screens/labels/sprite_gallery.py (nouveau) : sprite_gallery_families() groupe la galerie par famille — un animal n'apparaît qu'une fois (sa variante "de base"), ses 15 couleurs se choisissent depuis le panneau de propriétés (render_variant_gallery, templates/screen_edit.html), répondant à la suggestion de l'utilisateur plutôt que d'encombrer la galerie d'ajout de 210 tuiles quasi identiques. - routes/screens/screen_edit.py, routes/scenes/scene_edit_view.py : la galerie passée au template est filtrée par rôle (PUBLIC_SPRITE_LIBRARY pour un compte "user", SPRITE_LIBRARY complet pour un admin) — même idiome que core/auth_guard.py. - core/sprite_gate.py (nouveau) + 4 routes d'écriture (element_add, element_set_personnage_data, scene_object_add, scene_object_personnage_data) : ferme la brèche d'un POST direct qui contournerait la galerie filtrée (403 si un compte non-admin tente d'assigner un personnage animal). - tests/conftest.py : nouvelles fixtures user_client/user_game (compte "user" non-admin avec un projet assigné) pour tester le filtrage par rôle de bout en bout. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
43abfc9b93
commit
449c36fd5d
@@ -0,0 +1,16 @@
|
||||
"""Garde d'accès aux sprites animaux CraftPix (screens.ADMIN_SPRITE_LIBRARY)
|
||||
— licence CraftPix, réservés au rôle admin (voir le plan "Bibliothèque de
|
||||
sprites animaux CraftPix"). La galerie de l'éditeur (routes/screens/
|
||||
screen_edit.py, routes/scenes/scene_edit_view.py) ne les propose déjà pas
|
||||
à un compte "user", mais un POST direct vers une route d'écriture
|
||||
(element_add, scene_object_add, element_set_personnage_data,
|
||||
scene_object_personnage_data) pourrait contourner ce filtrage d'UI — ce
|
||||
garde-fou ferme cette brèche au moment de l'écriture."""
|
||||
from flask import abort, g
|
||||
|
||||
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)
|
||||
@@ -3,6 +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
|
||||
|
||||
|
||||
@app.route("/game/<slug>/screens/<int:screen_id>/elements/add", methods=["POST"])
|
||||
@@ -31,5 +32,6 @@ def element_add(slug, screen_id):
|
||||
# réellement cliqué.
|
||||
forge_character = request.form.get("forge_character")
|
||||
if forge_character and forge_character in screens.SPRITE_LIBRARY:
|
||||
forbid_admin_only_character(forge_character)
|
||||
screens.set_personnage_data(slug, new_id, {"source": "forge", "forge_character": forge_character})
|
||||
return redirect(url_for("screen_edit", slug=slug, screen_id=screen_id, selected=new_id))
|
||||
|
||||
@@ -3,6 +3,7 @@ from flask import request, jsonify
|
||||
import screens
|
||||
|
||||
from core.flask_app import app
|
||||
from core.sprite_gate import forbid_admin_only_character
|
||||
|
||||
|
||||
@app.route("/game/<slug>/elements/<int:element_id>/personnage", methods=["POST"])
|
||||
@@ -16,6 +17,8 @@ def element_set_personnage_data(slug, element_id):
|
||||
el = screens.get_element(slug, element_id)
|
||||
if not el:
|
||||
return jsonify({"error": "élément introuvable"}), 404
|
||||
if body.get("source") == "forge" and body.get("forge_character"):
|
||||
forbid_admin_only_character(body["forge_character"])
|
||||
screens.set_personnage_data(slug, element_id, body)
|
||||
el = screens.get_element(slug, element_id)
|
||||
return jsonify({"ok": True, "animations": screens.resolve_personnage_animations(el)})
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import json
|
||||
|
||||
from flask import render_template, request, abort
|
||||
from flask import render_template, request, abort, g
|
||||
|
||||
import db
|
||||
import screens
|
||||
@@ -46,11 +46,18 @@ def render_scene_edit(slug, screen_id, game):
|
||||
screens_map = {s["id"]: s["name"] for s in all_screens}
|
||||
blocks_view = screens.blocks_view(flow_blocks, flow_nodes, object_labels)
|
||||
|
||||
# Sprites animaux CraftPix (screens.ADMIN_SPRITE_LIBRARY) : réservés au
|
||||
# rôle admin (licence CraftPix, voir core/sprite_gate.py et le plan
|
||||
# "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)
|
||||
|
||||
return render_template(
|
||||
"scene_edit.html",
|
||||
game=game, screen=screen, all_screens=all_screens,
|
||||
objects=objects, selected_id=selected_id, personnage_data=personnage_data,
|
||||
sprite_library=screens.SPRITE_LIBRARY, sprite_library_json=json.dumps(screens.SPRITE_LIBRARY),
|
||||
sprite_library=sprite_library, sprite_library_families=sprite_library_families,
|
||||
sprite_library_json=json.dumps(sprite_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,6 +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
|
||||
|
||||
|
||||
@app.route("/game/<slug>/screens/<int:screen_id>/scene-objects/add", methods=["POST"])
|
||||
@@ -12,6 +13,8 @@ def scene_object_add(slug, screen_id):
|
||||
add_scene_object.py)."""
|
||||
kind = request.form.get("kind", "personnage")
|
||||
forge_character = request.form.get("forge_character")
|
||||
if forge_character:
|
||||
forbid_admin_only_character(forge_character)
|
||||
new_id = screens.add_scene_object(slug, screen_id, kind=kind, forge_character=forge_character)
|
||||
# Même route que l'éditeur document (/game/<slug>/screens/<id>/edit,
|
||||
# endpoint "screen_edit") — c'est CE dispatcher qui bascule vers la
|
||||
|
||||
@@ -3,6 +3,7 @@ from flask import request, jsonify
|
||||
import screens
|
||||
|
||||
from core.flask_app import app
|
||||
from core.sprite_gate import forbid_admin_only_character
|
||||
|
||||
|
||||
@app.route("/game/<slug>/scene-objects/<int:object_id>/personnage", methods=["POST"])
|
||||
@@ -17,6 +18,8 @@ def scene_object_personnage_data(slug, object_id):
|
||||
obj = screens.get_scene_object(slug, object_id)
|
||||
if not obj:
|
||||
return jsonify({"error": "objet introuvable"}), 404
|
||||
if body.get("source") == "forge" and body.get("forge_character"):
|
||||
forbid_admin_only_character(body["forge_character"])
|
||||
screens.set_scene_object_personnage_data(slug, object_id, body)
|
||||
obj = screens.get_scene_object(slug, object_id)
|
||||
return jsonify({"ok": True, "animations": screens.resolve_personnage_animations(obj)})
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import json
|
||||
|
||||
from flask import render_template, request, abort
|
||||
from flask import render_template, request, abort, g
|
||||
|
||||
import db
|
||||
import screens
|
||||
@@ -78,6 +78,13 @@ def screen_edit(slug, screen_id):
|
||||
|
||||
blocks_view = screens.blocks_view(flow_blocks, flow_nodes, elements_labels)
|
||||
|
||||
# Sprites animaux CraftPix (screens.ADMIN_SPRITE_LIBRARY) : réservés au
|
||||
# rôle admin (licence CraftPix, voir core/sprite_gate.py et le plan
|
||||
# "Bibliothèque de sprites animaux CraftPix") — un compte "user" ne
|
||||
# doit même pas les voir dans la galerie.
|
||||
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)
|
||||
|
||||
return render_template(
|
||||
"screen_edit.html",
|
||||
game=game, screen=screen, elements=elements, top_level_elements=top_level_elements,
|
||||
@@ -93,8 +100,8 @@ def screen_edit(slug, screen_id):
|
||||
element_surbrillance_values=screens.ELEMENT_SURBRILLANCE_VALUES,
|
||||
element_desactive_values=screens.ELEMENT_DESACTIVE_VALUES,
|
||||
element_orientation_values=screens.ELEMENT_ORIENTATION_VALUES,
|
||||
sprite_library=screens.SPRITE_LIBRARY,
|
||||
sprite_library_json=json.dumps(screens.SPRITE_LIBRARY),
|
||||
sprite_library=sprite_library, sprite_library_families=sprite_library_families,
|
||||
sprite_library_json=json.dumps(sprite_library),
|
||||
personnage_data=personnage_data,
|
||||
element_animations_map_json=json.dumps(element_animations_map),
|
||||
data_operations=screens.DATA_OPERATIONS,
|
||||
|
||||
+13
-1
@@ -55,7 +55,19 @@ from .labels.element_action_properties import (
|
||||
)
|
||||
from .labels.element_visibility import ELEMENT_VISIBILITY_VALUES, ELEMENT_VISIBILITY_LABELS
|
||||
from .labels.data_operations import DATA_OPERATIONS, DATA_OPERATION_LABELS
|
||||
from .labels.sprite_library import SPRITE_LIBRARY
|
||||
from .labels.sprite_library import PUBLIC_SPRITE_LIBRARY
|
||||
from .labels.animal_sprite_library import ADMIN_SPRITE_LIBRARY
|
||||
|
||||
# SPRITE_LIBRARY (fusionné) : utilisé par resolve_personnage_animations
|
||||
# pour la résolution RUNTIME (jeu jouable, tous rôles confondus — voir le
|
||||
# plan "Bibliothèque de sprites animaux CraftPix" : aucun filtrage par
|
||||
# rôle au runtime, seulement à la galerie/l'écriture). Les routes qui
|
||||
# alimentent la galerie de l'éditeur doivent utiliser PUBLIC_SPRITE_LIBRARY
|
||||
# ou ADMIN_SPRITE_LIBRARY selon le rôle, jamais SPRITE_LIBRARY directement.
|
||||
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 .rendering.list_used_forge_characters import list_used_forge_characters
|
||||
|
||||
from .flow.constants import (
|
||||
TRIGGER_EVENTS, CONDITION_OPERATORS, CONDITION_OPERATOR_LABELS, CLICKED_ROW_ID, LAST_INSERTED_ROW_ID,
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import json
|
||||
import os
|
||||
|
||||
# Bibliothèque de sprites animaux CraftPix — RÉSERVÉE au rôle admin (voir
|
||||
# routes/screens/screen_edit.py, routes/scenes/scene_edit_view.py, et les
|
||||
# routes d'écriture screens/scene-objects "personnage"/"add"). Licence
|
||||
# CraftPix : interdiction de rendre ces sprites utilisables par un compte
|
||||
# "user" via l'application (voir le plan "Bibliothèque de sprites animaux
|
||||
# CraftPix"). Générée une fois par scripts/generate_animal_sprite_manifest.py
|
||||
# depuis assets/characters/animal_sprite/ (pack source, non versionné) vers
|
||||
# static/characters/animals/<animal>/<NN>/<pose><i>.png, avec un manifest
|
||||
# JSON décrivant le compte RÉEL de frames par pose/variante (variable selon
|
||||
# les packs — pas de table figée comme _SEQUENCE_ANIMATIONS pour Kenney).
|
||||
_MANIFEST_PATH = os.path.join(
|
||||
os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))),
|
||||
"static", "characters", "animals", "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 assets animaux copiés (script pas encore
|
||||
# lancé), ou paquet exporté pour un jeu qui n'utilise aucun
|
||||
# personnage animal (voir publish/build_package.py, copie
|
||||
# sélective) : aucun personnage animal disponible, pas une erreur.
|
||||
return {}
|
||||
|
||||
|
||||
def _slug(animal, nn):
|
||||
return f"animal-{animal}-{nn}"
|
||||
|
||||
|
||||
def _label(family_label, nn):
|
||||
n = int(nn)
|
||||
return f"{family_label} (variante {n})" if n > 1 else f"{family_label} (de base)"
|
||||
|
||||
|
||||
def _animations_for(animal, nn, poses):
|
||||
return {
|
||||
pose: [f"/static/characters/animals/{animal}/{nn}/{pose}{i}.png" for i in range(count)]
|
||||
for pose, count in poses.items()
|
||||
}
|
||||
|
||||
|
||||
def _build_library():
|
||||
manifest = _load_manifest()
|
||||
library = {}
|
||||
for animal, pack in manifest.items():
|
||||
family_label = pack["label"]
|
||||
for nn, poses in pack["variants"].items():
|
||||
library[_slug(animal, nn)] = {
|
||||
"label": _label(family_label, nn),
|
||||
"animations": _animations_for(animal, nn, poses),
|
||||
"family": animal,
|
||||
"family_label": family_label,
|
||||
}
|
||||
return library
|
||||
|
||||
|
||||
ADMIN_SPRITE_LIBRARY = _build_library()
|
||||
@@ -0,0 +1,21 @@
|
||||
def sprite_gallery_families(sprite_library):
|
||||
"""Un tile par FAMILLE pour la galerie d'ajout/de changement de
|
||||
personnage — les personnages Kenney (pas de clé "family", voir
|
||||
screens/labels/sprite_library.py) sont chacun leur propre famille ;
|
||||
les animaux CraftPix (screens/labels/animal_sprite_library.py) sont
|
||||
représentés par leur variante "de base" (dict Python : ordre
|
||||
d'insertion garanti, "01" toujours vu en premier — voir
|
||||
_build_library()). Les autres variantes se choisissent depuis le
|
||||
panneau de propriétés (voir render_variant_gallery, templates/
|
||||
screen_edit.html) plutôt que d'encombrer cette galerie de 15 tiles
|
||||
quasi identiques par animal."""
|
||||
seen_families = set()
|
||||
families = []
|
||||
for slug, character in sprite_library.items():
|
||||
family = character.get("family")
|
||||
if family:
|
||||
if family in seen_families:
|
||||
continue
|
||||
seen_families.add(family)
|
||||
families.append((slug, character))
|
||||
return families
|
||||
@@ -48,7 +48,12 @@ def _animations_for(slug):
|
||||
return animations
|
||||
|
||||
|
||||
SPRITE_LIBRARY = {
|
||||
# Bibliothèque publique — visible par tous les comptes (CC0, voir
|
||||
# LICENSE-kenney.txt). Distincte de screens.ADMIN_SPRITE_LIBRARY (sprites
|
||||
# animaux CraftPix, réservés au rôle admin — voir
|
||||
# screens/labels/animal_sprite_library.py et le plan "Bibliothèque de
|
||||
# sprites animaux CraftPix").
|
||||
PUBLIC_SPRITE_LIBRARY = {
|
||||
slug: {"label": label, "animations": _animations_for(slug)}
|
||||
for slug, label in CHARACTERS.items()
|
||||
}
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
import json
|
||||
|
||||
from ..labels.sprite_library import SPRITE_LIBRARY
|
||||
from ..labels.sprite_library import PUBLIC_SPRITE_LIBRARY
|
||||
from ..labels.animal_sprite_library import ADMIN_SPRITE_LIBRARY
|
||||
|
||||
# Résolution RUNTIME (jeu jouable) : le catalogue COMPLET, quel que soit le
|
||||
# rôle du créateur — voir le plan "Bibliothèque de sprites animaux
|
||||
# CraftPix" (aucun filtrage par rôle au runtime, seulement à la galerie de
|
||||
# l'éditeur/l'écriture, voir routes/screens/screen_edit.py et
|
||||
# routes/elements/element_add.py).
|
||||
_SPRITE_LIBRARY = {**PUBLIC_SPRITE_LIBRARY, **ADMIN_SPRITE_LIBRARY}
|
||||
|
||||
_DEFAULT_PERSONNAGE_DATA = {"source": "forge", "forge_character": "male-adventurer"}
|
||||
|
||||
@@ -26,7 +34,7 @@ def resolve_personnage_animations(el):
|
||||
data = resolve_personnage_data(el)
|
||||
if data.get("source") == "custom":
|
||||
return data.get("animations") or {}
|
||||
character = SPRITE_LIBRARY.get(data.get("forge_character"))
|
||||
character = _SPRITE_LIBRARY.get(data.get("forge_character"))
|
||||
return character["animations"] if character else {}
|
||||
|
||||
|
||||
|
||||
@@ -22,8 +22,9 @@ def add_scene_object(slug, scene_id, kind="personnage", forge_character=None):
|
||||
if kind == "personnage":
|
||||
data = dict(_DEFAULT_PERSONNAGE_DATA)
|
||||
if forge_character:
|
||||
from ..labels.sprite_library import SPRITE_LIBRARY
|
||||
if forge_character in SPRITE_LIBRARY:
|
||||
from ..labels.sprite_library import PUBLIC_SPRITE_LIBRARY
|
||||
from ..labels.animal_sprite_library import ADMIN_SPRITE_LIBRARY
|
||||
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)
|
||||
conn = db.connect(slug)
|
||||
|
||||
@@ -67,14 +67,14 @@
|
||||
<details class="propGroup" open>
|
||||
<summary>🎭 Personnages</summary>
|
||||
<div class="propGroupBody">
|
||||
<div class="iconGallery">
|
||||
{% for slug_c, character in sprite_library.items() %}
|
||||
<div class="iconGallery personnageGallery">
|
||||
{% for slug_c, character in sprite_library_families %}
|
||||
<button type="button" class="iconGalleryTile" title="{{ character.label }}" onclick="addSceneObject('personnage', '{{ slug_c }}')">
|
||||
<img src="/static/characters/{{ slug_c }}/idle.png" alt="{{ character.label }}" class="personnageThumb">
|
||||
<img src="{{ character.animations.idle[0] }}" alt="{{ character.label }}" class="personnageThumb">
|
||||
</button>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<p class="help">Personnage Kenney prêt à l'emploi (licence libre CC0), avec toutes ses animations.</p>
|
||||
<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>
|
||||
@@ -629,14 +629,26 @@
|
||||
<summary>🎭 Personnage</summary>
|
||||
<div class="propGroupBody" id="personnagePanelBody" data-element-id="{{ obj.id }}">
|
||||
<p class="help">Personnage Forge — clique un autre pour en changer.</p>
|
||||
<div class="iconGallery" id="personnageGallerySwap">
|
||||
{% for slug_c, character in sprite_library.items() %}
|
||||
<div class="iconGallery personnageGallery" id="personnageGallerySwap">
|
||||
{% for slug_c, character in sprite_library_families %}
|
||||
<button type="button" class="iconGalleryTile {{ 'selected' if slug_c == personnage_data.forge_character else '' }}"
|
||||
title="{{ character.label }}" onclick="swapSceneObjectCharacter('{{ slug_c }}')">
|
||||
<img src="/static/characters/{{ slug_c }}/idle.png" alt="{{ character.label }}" class="personnageThumb">
|
||||
<img src="{{ character.animations.idle[0] }}" alt="{{ character.label }}" class="personnageThumb">
|
||||
</button>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% set current_character = sprite_library.get(personnage_data.forge_character) %}
|
||||
{% if current_character and current_character.get('family') %}
|
||||
<p class="help">Variantes de {{ current_character.family_label }} :</p>
|
||||
<div class="iconGallery personnageGallery" id="personnageGalleryVariants">
|
||||
{% for slug_c, character in sprite_library.items() if character.get('family') == current_character.family %}
|
||||
<button type="button" class="iconGalleryTile {{ 'selected' if slug_c == personnage_data.forge_character else '' }}"
|
||||
title="{{ character.label }}" onclick="swapSceneObjectCharacter('{{ slug_c }}')">
|
||||
<img src="{{ character.animations.idle[0] }}" alt="{{ character.label }}" class="personnageThumb">
|
||||
</button>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</details>
|
||||
{% endif %}
|
||||
|
||||
@@ -43,14 +43,40 @@
|
||||
<!-- Phase 8 — galerie visuelle de personnages Forge (VRAIES miniatures,
|
||||
pas un emoji/icône), réutilisée deux fois : "add" pour poser un
|
||||
nouveau personnage (panneau gauche), "swap" pour changer le
|
||||
personnage Forge d'un élément déjà posé (panneau droit, propriétés). -->
|
||||
personnage Forge d'un élément déjà posé (panneau droit, propriétés).
|
||||
Un tile par FAMILLE (sprite_library_families, voir
|
||||
screens/labels/sprite_gallery.py) — un animal CraftPix (screens/
|
||||
labels/animal_sprite_library.py) n'apparaît ici qu'une fois, dans sa
|
||||
variante "de base" ; ses 15 variantes se choisissent depuis
|
||||
render_variant_gallery ci-dessous, dans le panneau de propriétés.
|
||||
Miniature = 1ère frame de l'animation "idle" du personnage (jamais un
|
||||
chemin en dur : le rangement sur disque diffère entre Kenney et les
|
||||
animaux, voir screens/labels/animal_sprite_library.py). -->
|
||||
{% macro render_personnage_gallery(mode, gallery_id, selected_forge_character=None) %}
|
||||
<div class="iconGallery" id="{{ gallery_id }}">
|
||||
{% for char_slug, character in sprite_library.items() %}
|
||||
<div class="iconGallery personnageGallery" id="{{ gallery_id }}">
|
||||
{% for char_slug, character in sprite_library_families %}
|
||||
<button type="button" class="iconGalleryTile {{ 'selected' if char_slug == selected_forge_character else '' }}"
|
||||
title="{{ character.label }}"
|
||||
onclick="{{ 'addPersonnageElement' if mode == 'add' else 'swapPersonnageCharacter' }}('{{ char_slug }}')">
|
||||
<img src="/static/characters/{{ char_slug }}/idle.png" alt="{{ character.label }}" class="personnageThumb">
|
||||
<img src="{{ character.animations.idle[0] }}" alt="{{ character.label }}" class="personnageThumb">
|
||||
</button>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endmacro %}
|
||||
|
||||
<!-- Variantes de couleur/skin d'UNE famille d'animal (les 15 personnages
|
||||
CraftPix qui partagent le même "family", voir screens/labels/
|
||||
animal_sprite_library.py) — affichée uniquement dans le panneau de
|
||||
propriétés, sous la galerie groupée ci-dessus, quand le personnage
|
||||
sélectionné appartient à une famille à variantes (jamais pour un
|
||||
Kenney, qui n'a pas de "family"). -->
|
||||
{% macro render_variant_gallery(family, gallery_id, selected_forge_character=None) %}
|
||||
<div class="iconGallery personnageGallery" id="{{ gallery_id }}">
|
||||
{% for char_slug, character in sprite_library.items() if character.get('family') == family %}
|
||||
<button type="button" class="iconGalleryTile {{ 'selected' if char_slug == selected_forge_character else '' }}"
|
||||
title="{{ character.label }}"
|
||||
onclick="swapPersonnageCharacter('{{ char_slug }}')">
|
||||
<img src="{{ character.animations.idle[0] }}" alt="{{ character.label }}" class="personnageThumb">
|
||||
</button>
|
||||
{% endfor %}
|
||||
</div>
|
||||
@@ -902,6 +928,11 @@
|
||||
<div class="propGroupBody" id="personnagePanelBody" data-element-id="{{ el.id }}">
|
||||
<p class="help">Personnage Forge — clique un autre pour en changer.</p>
|
||||
{{ render_personnage_gallery('swap', 'personnageGallerySwap', personnage_data.forge_character) }}
|
||||
{% set current_character = sprite_library.get(personnage_data.forge_character) %}
|
||||
{% if current_character and current_character.get('family') %}
|
||||
<p class="help">Variantes de {{ current_character.family_label }} :</p>
|
||||
{{ render_variant_gallery(current_character.family, 'personnageGalleryVariants', personnage_data.forge_character) }}
|
||||
{% endif %}
|
||||
</div>
|
||||
</details>
|
||||
{% endif %}
|
||||
|
||||
@@ -74,6 +74,46 @@ def client():
|
||||
yield c
|
||||
|
||||
|
||||
_TEST_USER_COUNTER = [0]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def user_client():
|
||||
"""Un compte "user" (jamais le premier de la session de tests, donc
|
||||
jamais admin — voir auth/create_user.py), connecté mais SANS projet
|
||||
assigné pour l'instant (voir la fixture `user_game` ci-dessous) —
|
||||
pour les tests qui vérifient qu'un rôle non-admin voit/peut moins de
|
||||
choses qu'un admin (ex. galerie de sprites animaux CraftPix, voir
|
||||
core/sprite_gate.py)."""
|
||||
_TEST_USER_COUNTER[0] += 1
|
||||
email = f"pytest-user-{_TEST_USER_COUNTER[0]}@pytest.local"
|
||||
user_id = auth.create_user(email, "Test1234!", "Test", "User")
|
||||
auth.confirm_totp(user_id)
|
||||
flask_app.config["TESTING"] = True
|
||||
with flask_app.test_client() as c:
|
||||
with c.session_transaction() as sess:
|
||||
sess["user_id"] = user_id
|
||||
yield c
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def user_game(client, user_client):
|
||||
"""Un jeu appartenant au compte `user_client` (project_slug assigné,
|
||||
voir auth.set_project_slug — même mécanisme que l'inscription réelle,
|
||||
routes/auth/register_2fa.py). Créé via le client ADMIN (`client`,
|
||||
illimité) puis rattaché, pour ne pas dépendre du parcours
|
||||
d'inscription complet dans les tests qui n'en ont pas besoin."""
|
||||
resp = client.post("/games/new", data={"name": "pytest_user_game"}, follow_redirects=False)
|
||||
assert resp.status_code == 302
|
||||
slug = resp.headers["Location"].rstrip("/").split("/")[-1]
|
||||
with user_client.session_transaction() as sess:
|
||||
auth.set_project_slug(sess["user_id"], slug)
|
||||
yield slug
|
||||
game_dir = db.game_dir(slug)
|
||||
if os.path.isdir(game_dir):
|
||||
shutil.rmtree(game_dir)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def game(client):
|
||||
"""Crée un jeu de test frais et le supprime après le test, quel que
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
"""Bibliothèque de sprites animaux CraftPix (screens.ADMIN_SPRITE_LIBRARY,
|
||||
screens/labels/animal_sprite_library.py) — réservée au rôle admin
|
||||
(licence CraftPix, voir core/sprite_gate.py et le plan "Bibliothèque de
|
||||
sprites animaux CraftPix"). Un compte "user" ne doit ni la voir dans la
|
||||
galerie de l'éditeur, ni pouvoir l'assigner par un POST direct."""
|
||||
import json
|
||||
|
||||
import screens
|
||||
|
||||
|
||||
def test_admin_sprite_library_loaded_from_manifest():
|
||||
assert len(screens.ADMIN_SPRITE_LIBRARY) > 0
|
||||
bunny = screens.ADMIN_SPRITE_LIBRARY["animal-bunny-01"]
|
||||
assert bunny["family"] == "bunny"
|
||||
assert bunny["animations"]["idle"][0] == "/static/characters/animals/bunny/01/idle0.png"
|
||||
assert len(bunny["animations"]["idle"]) == 20
|
||||
|
||||
|
||||
def test_sprite_library_is_public_plus_admin_merged():
|
||||
assert set(screens.SPRITE_LIBRARY) == set(screens.PUBLIC_SPRITE_LIBRARY) | set(screens.ADMIN_SPRITE_LIBRARY)
|
||||
assert screens.ADMIN_ONLY_CHARACTER_SLUGS == set(screens.ADMIN_SPRITE_LIBRARY)
|
||||
assert "male-adventurer" not in screens.ADMIN_ONLY_CHARACTER_SLUGS
|
||||
|
||||
|
||||
def test_sprite_gallery_families_shows_one_tile_per_animal():
|
||||
families = screens.sprite_gallery_families(screens.SPRITE_LIBRARY)
|
||||
slugs = [slug for slug, _ in families]
|
||||
assert len(slugs) == len(screens.PUBLIC_SPRITE_LIBRARY) + 14 # 6 Kenney + 14 familles animales
|
||||
assert "animal-bunny-01" in slugs
|
||||
assert "animal-bunny-02" not in slugs
|
||||
|
||||
|
||||
def test_document_editor_hides_animal_characters_from_a_user_account(user_game, user_client):
|
||||
resp = user_client.post(f"/game/{user_game}/screens/new", data={"name": "Accueil"}, follow_redirects=False)
|
||||
screen_id = resp.headers["Location"].rstrip("/").split("/")[-2]
|
||||
html = user_client.get(f"/game/{user_game}/screens/{screen_id}/edit").get_data(as_text=True)
|
||||
assert "animal-bunny-01" not in html
|
||||
assert "male-adventurer" in html
|
||||
|
||||
|
||||
def test_document_editor_shows_animal_characters_to_admin(game, client):
|
||||
resp = client.post(f"/game/{game}/screens/new", data={"name": "Accueil"}, follow_redirects=False)
|
||||
screen_id = resp.headers["Location"].rstrip("/").split("/")[-2]
|
||||
html = client.get(f"/game/{game}/screens/{screen_id}/edit").get_data(as_text=True)
|
||||
assert "animal-bunny-01" in html
|
||||
|
||||
|
||||
def _create_jeu2d_game_as(post_client, name, slug_owner_client=None):
|
||||
resp = post_client.post("/games/new", data={"name": name, "game_type": "jeu_2d"}, follow_redirects=False)
|
||||
assert resp.status_code == 302
|
||||
return resp.headers["Location"].rstrip("/").split("/")[-1]
|
||||
|
||||
|
||||
def test_scene_editor_hides_animal_characters_from_a_user_account(client, user_client, tmp_game_slug_cleanup):
|
||||
slug = tmp_game_slug_cleanup(_create_jeu2d_game_as(client, "pytest_animal_scene_user"))
|
||||
with user_client.session_transaction() as sess:
|
||||
import auth
|
||||
auth.set_project_slug(sess["user_id"], slug)
|
||||
screen_id = screens.create_screen(slug, "Scène 1")
|
||||
html = user_client.get(f"/game/{slug}/screens/{screen_id}/edit").get_data(as_text=True)
|
||||
assert "animal-bunny-01" not in html
|
||||
|
||||
|
||||
def test_scene_object_add_rejects_animal_character_for_a_user_account(client, user_client, tmp_game_slug_cleanup):
|
||||
slug = tmp_game_slug_cleanup(_create_jeu2d_game_as(client, "pytest_animal_scene_gate"))
|
||||
with user_client.session_transaction() as sess:
|
||||
import auth
|
||||
auth.set_project_slug(sess["user_id"], slug)
|
||||
screen_id = screens.create_screen(slug, "Scène 1")
|
||||
resp = user_client.post(
|
||||
f"/game/{slug}/screens/{screen_id}/scene-objects/add",
|
||||
data={"kind": "personnage", "forge_character": "animal-bunny-01"},
|
||||
)
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
def test_scene_object_add_allows_animal_character_for_admin(client, tmp_game_slug_cleanup):
|
||||
slug = tmp_game_slug_cleanup(_create_jeu2d_game_as(client, "pytest_animal_scene_admin"))
|
||||
screen_id = screens.create_screen(slug, "Scène 1")
|
||||
resp = client.post(
|
||||
f"/game/{slug}/screens/{screen_id}/scene-objects/add",
|
||||
data={"kind": "personnage", "forge_character": "animal-bunny-01"},
|
||||
follow_redirects=False,
|
||||
)
|
||||
assert resp.status_code == 302
|
||||
obj_id = int(resp.headers["Location"].rsplit("selected=", 1)[1])
|
||||
obj = screens.get_scene_object(slug, obj_id)
|
||||
assert screens.resolve_personnage_data(obj)["forge_character"] == "animal-bunny-01"
|
||||
|
||||
|
||||
def test_scene_object_personnage_swap_rejects_animal_character_for_a_user_account(client, user_client, tmp_game_slug_cleanup):
|
||||
slug = tmp_game_slug_cleanup(_create_jeu2d_game_as(client, "pytest_animal_swap_gate"))
|
||||
with user_client.session_transaction() as sess:
|
||||
import auth
|
||||
auth.set_project_slug(sess["user_id"], slug)
|
||||
screen_id = screens.create_screen(slug, "Scène 1")
|
||||
obj_id = screens.add_scene_object(slug, screen_id)
|
||||
resp = user_client.post(
|
||||
f"/game/{slug}/scene-objects/{obj_id}/personnage",
|
||||
data=json.dumps({"source": "forge", "forge_character": "animal-bunny-01"}),
|
||||
content_type="application/json",
|
||||
)
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
def test_element_add_rejects_animal_character_for_a_user_account(user_game, user_client):
|
||||
resp = user_client.post(f"/game/{user_game}/screens/new", data={"name": "Accueil"}, follow_redirects=False)
|
||||
screen_id = resp.headers["Location"].rstrip("/").split("/")[-2]
|
||||
resp = user_client.post(
|
||||
f"/game/{user_game}/screens/{screen_id}/elements/add",
|
||||
data={"widget": "personnage", "forge_character": "animal-bunny-01"},
|
||||
)
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
def test_element_set_personnage_data_rejects_animal_character_for_a_user_account(user_game, user_client):
|
||||
resp = user_client.post(f"/game/{user_game}/screens/new", data={"name": "Accueil"}, follow_redirects=False)
|
||||
screen_id = resp.headers["Location"].rstrip("/").split("/")[-2]
|
||||
resp = user_client.post(
|
||||
f"/game/{user_game}/screens/{screen_id}/elements/add", data={"widget": "personnage"}, follow_redirects=False
|
||||
)
|
||||
element_id = int(resp.headers["Location"].rsplit("selected=", 1)[1])
|
||||
resp = user_client.post(
|
||||
f"/game/{user_game}/elements/{element_id}/personnage",
|
||||
data=json.dumps({"source": "forge", "forge_character": "animal-bunny-01"}),
|
||||
content_type="application/json",
|
||||
)
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
def test_list_used_forge_characters_scans_elements_and_scene_objects(client, tmp_game_slug_cleanup):
|
||||
slug = tmp_game_slug_cleanup(_create_jeu2d_game_as(client, "pytest_used_characters"))
|
||||
screen_id = screens.create_screen(slug, "Scène 1")
|
||||
screens.add_scene_object(slug, screen_id, forge_character="animal-bunny-01")
|
||||
screens.add_scene_object(slug, screen_id, kind="decor")
|
||||
used = screens.list_used_forge_characters(slug)
|
||||
assert used == {"animal-bunny-01"}
|
||||
|
||||
|
||||
def test_build_package_only_copies_used_animal_character_folders(client, tmp_game_slug_cleanup, tmp_path):
|
||||
slug = tmp_game_slug_cleanup(_create_jeu2d_game_as(client, "pytest_publish_animals"))
|
||||
screen_id = screens.create_screen(slug, "Scène 1")
|
||||
screens.add_scene_object(slug, screen_id, forge_character="animal-bunny-01")
|
||||
|
||||
from publish import build_package
|
||||
zip_path, _name = build_package.build_game_zip(slug)
|
||||
try:
|
||||
import zipfile
|
||||
with zipfile.ZipFile(zip_path) as zf:
|
||||
names = zf.namelist()
|
||||
animal_paths = {n for n in names if "static/characters/animals/" in n and not n.endswith("manifest.json")}
|
||||
animals_used = {n.split("static/characters/animals/")[1].split("/")[0] for n in animal_paths}
|
||||
assert animals_used == {"bunny"}
|
||||
assert any("static/characters/animals/bunny/01/" in n for n in animal_paths)
|
||||
assert not any("static/characters/animals/bear/" in n for n in animal_paths)
|
||||
assert any(n.endswith("static/characters/animals/manifest.json") for n in names)
|
||||
assert any("static/characters/male-adventurer/" in n for n in names)
|
||||
finally:
|
||||
import os
|
||||
os.remove(zip_path)
|
||||
@@ -117,10 +117,16 @@ def test_resolve_personnage_idle_frame_falls_back_to_first_animation(client, gam
|
||||
|
||||
|
||||
def test_screen_edit_exposes_personnage_gallery_and_scripts(client, game):
|
||||
"""Les 6 Kenney (screens.PUBLIC_SPRITE_LIBRARY) ont chacun leur tile —
|
||||
les sprites animaux CraftPix (screens.ADMIN_SPRITE_LIBRARY), eux, sont
|
||||
groupés par famille dans cette même galerie (un seul tile par animal,
|
||||
voir screens.sprite_gallery_families et le plan "Bibliothèque de
|
||||
sprites animaux CraftPix"), pas testés ici un par un."""
|
||||
screen_id = _create_screen(client, game)
|
||||
html = client.get(f"/game/{game}/screens/{screen_id}/edit").get_data(as_text=True)
|
||||
for slug in screens.SPRITE_LIBRARY:
|
||||
for slug in screens.PUBLIC_SPRITE_LIBRARY:
|
||||
assert f"/static/characters/{slug}/idle.png" in html
|
||||
assert "/static/characters/animals/bunny/01/idle0.png" in html
|
||||
assert "personnageGallery" in html
|
||||
# L'import de sprites personnalisés est retiré pour le moment.
|
||||
assert "Sprite personnalisé" not in html
|
||||
@@ -130,11 +136,16 @@ def test_screen_edit_exposes_personnage_gallery_and_scripts(client, game):
|
||||
|
||||
|
||||
def test_sprite_library_has_all_six_characters_with_all_animations(client, game):
|
||||
assert set(screens.SPRITE_LIBRARY) == {
|
||||
"""screens.PUBLIC_SPRITE_LIBRARY (les 6 Kenney, CC0) — distinct de
|
||||
screens.SPRITE_LIBRARY, qui inclut aussi les sprites animaux CraftPix
|
||||
(screens.ADMIN_SPRITE_LIBRARY, réservés au rôle admin, voir le plan
|
||||
"Bibliothèque de sprites animaux CraftPix" et
|
||||
test_admin_sprite_library_loaded_from_manifest)."""
|
||||
assert set(screens.PUBLIC_SPRITE_LIBRARY) == {
|
||||
"male-adventurer", "female-adventurer", "male-person",
|
||||
"female-person", "robot", "zombie",
|
||||
}
|
||||
for character in screens.SPRITE_LIBRARY.values():
|
||||
for character in screens.PUBLIC_SPRITE_LIBRARY.values():
|
||||
names = set(character["animations"])
|
||||
assert {"idle", "walk", "run", "jump", "attack"} <= names
|
||||
assert len(character["animations"]["walk"]) == 8
|
||||
|
||||
Reference in New Issue
Block a user