Phase 7 : personnage animé par sprites (poses/images successives)
Ajoute une nouvelle action de flow "jouer_animation_sprite" qui joue une
séquence de PNG sur un élément image (cycle en boucle type marche, ou
une fois type saut) — réutilise target_element_id (déjà whitelisté) et
data_value en JSON {frames, fps, loop}, exactement le patron déjà établi
par "jouer_son" (Phase 6) et "alea" (Phase 2) : aucune nouvelle colonne,
aucune migration de schéma.
Ajoute une propriété d'élément "orientation" (Modifier un élément) pour
retourner un personnage en miroir (gauche/droite/bascule) sans nécessiter
une 2e feuille de sprites "vue de dos" — même patron que "surbrillance"/
"désactivé".
Étend aussi la Timeline d'animation existante (kind="sprite", aux côtés
de "animate_css"/"custom") pour qu'un personnage puisse animer tout seul
dès l'affichage de l'écran (ex. idle en boucle perpétuelle), pas
seulement en réaction à un événement — réutilise la colonne générique
custom_keyframes (JSON) et le réglage iteration_count déjà là pour la
boucle, aucune migration non plus. Les deux entrées (action de flow et
clip de timeline) partagent le même moteur côté client
(runSpriteAnimation()/activeSpriteAnimations dans static/js/play/
actions.js, indexé par nœud DOM plutôt que par id d'élément pour
supporter plusieurs instances d'un même écran-modèle animées
indépendamment).
Une bibliothèque de sprites Forge (2 personnages Kenney CC0, sous-
ensemble curé idle/marche/saut copié dans static/characters/) est
proposée dans l'éditeur, mais un créateur peut tout aussi bien utiliser
ses propres sprites uploadés (même route d'upload générique que le son
de la Phase 6).
Périmètre volontairement limité à ce qui a été demandé : pas d'avatar
modulable en couches (assemblage cheveux/haut/bas par le joueur),
écarté du plan initial à la demande de l'utilisateur.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@@ -23,7 +23,7 @@ _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"]
|
||||
_STATIC_ITEMS = ["style.css", "csrf_fetch.js", "icons", "branding", "vendor/fonts", "vendor/animate.min.css", "js/play", "characters"]
|
||||
|
||||
_SLUG_PLACEHOLDER = "_SLUG_PLACEHOLDER"
|
||||
|
||||
|
||||
@@ -73,6 +73,9 @@ def screen_edit(slug, screen_id):
|
||||
element_visibility_labels=screens.ELEMENT_VISIBILITY_LABELS,
|
||||
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),
|
||||
data_operations=screens.DATA_OPERATIONS,
|
||||
data_operation_labels=screens.DATA_OPERATION_LABELS,
|
||||
definitions=definitions, definitions_json=json.dumps(definitions_data),
|
||||
@@ -86,6 +89,7 @@ def screen_edit(slug, screen_id):
|
||||
element_visibility_labels_json=json.dumps(screens.ELEMENT_VISIBILITY_LABELS),
|
||||
element_surbrillance_labels_json=json.dumps(screens.ELEMENT_SURBRILLANCE_LABELS),
|
||||
element_desactive_labels_json=json.dumps(screens.ELEMENT_DESACTIVE_LABELS),
|
||||
element_orientation_labels_json=json.dumps(screens.ELEMENT_ORIENTATION_LABELS),
|
||||
data_operation_labels_json=json.dumps(screens.DATA_OPERATION_LABELS),
|
||||
condition_operator_labels_json=json.dumps(screens.CONDITION_OPERATOR_LABELS),
|
||||
trigger_labels_json=json.dumps(screens.TRIGGER_LABELS),
|
||||
|
||||
@@ -51,9 +51,11 @@ from .labels.element_action_properties import (
|
||||
ELEMENT_ACTION_PROPERTIES, ELEMENT_ACTION_PROPERTY_LABELS,
|
||||
ELEMENT_SURBRILLANCE_VALUES, ELEMENT_SURBRILLANCE_LABELS,
|
||||
ELEMENT_DESACTIVE_VALUES, ELEMENT_DESACTIVE_LABELS,
|
||||
ELEMENT_ORIENTATION_VALUES, ELEMENT_ORIENTATION_LABELS,
|
||||
)
|
||||
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 .flow.constants import (
|
||||
TRIGGER_EVENTS, CONDITION_OPERATORS, CONDITION_OPERATOR_LABELS, CLICKED_ROW_ID, LAST_INSERTED_ROW_ID,
|
||||
|
||||
@@ -11,5 +11,6 @@ ACTION_TYPE_LABELS = {
|
||||
"declencher_evenement": "Déclencher un événement",
|
||||
"ajouter_ligne": "Ajouter une ligne à un objet",
|
||||
"jouer_son": "Jouer un son",
|
||||
"jouer_animation_sprite": "Jouer une animation de sprites",
|
||||
"rien": "Ne rien faire",
|
||||
}
|
||||
|
||||
@@ -39,6 +39,12 @@ ELEMENT_ACTION_PROPERTIES = [
|
||||
("pos_y", "Position verticale en % (absolue)"),
|
||||
("pos_x_relatif", "Déplacer horizontalement de... % (relatif)"),
|
||||
("pos_y_relatif", "Déplacer verticalement de... % (relatif)"),
|
||||
# Phase 7 (moteur) — personnage animé par sprites : quel sens regarde
|
||||
# l'élément (utile pour un personnage qui se déplace au clavier, voir
|
||||
# applyElementProperty() dans static/js/play/actions.js — retourne
|
||||
# l'élément en miroir plutôt que de nécessiter une 2e feuille de
|
||||
# sprites "vue de dos").
|
||||
("orientation", "Orientation — sens du personnage"),
|
||||
]
|
||||
ELEMENT_ACTION_PROPERTY_LABELS = dict(ELEMENT_ACTION_PROPERTIES)
|
||||
|
||||
@@ -55,3 +61,10 @@ ELEMENT_DESACTIVE_VALUES = [
|
||||
("toggle", "Basculer désactivé / réactivé"),
|
||||
]
|
||||
ELEMENT_DESACTIVE_LABELS = dict(ELEMENT_DESACTIVE_VALUES)
|
||||
|
||||
ELEMENT_ORIENTATION_VALUES = [
|
||||
("droite", "Faire face à droite"),
|
||||
("gauche", "Faire face à gauche"),
|
||||
("toggle", "Retourner (miroir)"),
|
||||
]
|
||||
ELEMENT_ORIENTATION_LABELS = dict(ELEMENT_ORIENTATION_VALUES)
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
# ---------- Bibliothèque de sprites bundlée avec Forge (Phase 7) ----------
|
||||
#
|
||||
# Sous-ensemble curé (idle/marche/saut, pas les packs complets HD/Parts/
|
||||
# Tilesheet/Vector — inutiles pour un jeu 2D web) des personnages "Toon
|
||||
# Characters" de Kenney (CC0, voir static/characters/LICENSE-kenney.txt),
|
||||
# copiés dans static/characters/<slug>/*.png. Un créateur qui veut ses
|
||||
# propres sprites n'utilise pas ce catalogue : il uploade ses images (voir
|
||||
# routes/uploads/upload_file.py) et colle leurs URLs directement dans la
|
||||
# même zone de frames — voir #nf-action-sprite-fields dans
|
||||
# screen_edit.html.
|
||||
SPRITE_LIBRARY = {
|
||||
"male-adventurer": {
|
||||
"label": "Aventurier",
|
||||
"animations": {
|
||||
"idle": ["/static/characters/male-adventurer/idle.png"],
|
||||
"marche": [f"/static/characters/male-adventurer/walk{i}.png" for i in range(8)],
|
||||
"saut": ["/static/characters/male-adventurer/jump.png"],
|
||||
},
|
||||
},
|
||||
"zombie": {
|
||||
"label": "Zombie",
|
||||
"animations": {
|
||||
"idle": ["/static/characters/zombie/idle.png"],
|
||||
"marche": [f"/static/characters/zombie/walk{i}.png" for i in range(8)],
|
||||
"saut": ["/static/characters/zombie/jump.png"],
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
|
||||
|
||||
Toon Characters
|
||||
|
||||
Created/distributed by Kenney (www.kenney.nl)
|
||||
Creation date: 26-09-2019
|
||||
|
||||
------------------------------
|
||||
|
||||
License: (Creative Commons Zero, CC0)
|
||||
http://creativecommons.org/publicdomain/zero/1.0/
|
||||
|
||||
This content is free to use in personal, educational and commercial projects.
|
||||
Support us by crediting Kenney or www.kenney.nl (this is not mandatory)
|
||||
|
||||
------------------------------
|
||||
|
||||
Donate: http://support.kenney.nl
|
||||
Request: http://request.kenney.nl
|
||||
Patreon: http://patreon.com/kenney/
|
||||
|
||||
Follow on Twitter for updates:
|
||||
http://twitter.com/KenneyNL
|
||||
|
After Width: | Height: | Size: 4.0 KiB |
|
After Width: | Height: | Size: 3.9 KiB |
|
After Width: | Height: | Size: 4.0 KiB |
|
After Width: | Height: | Size: 3.8 KiB |
|
After Width: | Height: | Size: 3.7 KiB |
|
After Width: | Height: | Size: 3.8 KiB |
|
After Width: | Height: | Size: 3.9 KiB |
|
After Width: | Height: | Size: 3.8 KiB |
|
After Width: | Height: | Size: 3.6 KiB |
|
After Width: | Height: | Size: 3.8 KiB |
|
After Width: | Height: | Size: 4.0 KiB |
|
After Width: | Height: | Size: 4.0 KiB |
|
After Width: | Height: | Size: 4.0 KiB |
|
After Width: | Height: | Size: 3.8 KiB |
|
After Width: | Height: | Size: 3.7 KiB |
|
After Width: | Height: | Size: 3.9 KiB |
|
After Width: | Height: | Size: 4.0 KiB |
|
After Width: | Height: | Size: 3.8 KiB |
|
After Width: | Height: | Size: 3.7 KiB |
|
After Width: | Height: | Size: 3.9 KiB |
@@ -76,6 +76,19 @@ function applyElementProperty(targetEl, property, value) {
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (property === 'orientation') {
|
||||
// Phase 7 — retourne le personnage en miroir (voir .forgeFlipped dans
|
||||
// play.html) plutôt que de nécessiter une 2e feuille de sprites "vue
|
||||
// de dos" pour chaque sens de déplacement.
|
||||
if (value === 'toggle') {
|
||||
targetEl.classList.toggle('forgeFlipped');
|
||||
} else if (value === 'gauche') {
|
||||
targetEl.classList.add('forgeFlipped');
|
||||
} else {
|
||||
targetEl.classList.remove('forgeFlipped');
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (property === 'pos_x_relatif' || property === 'pos_y_relatif') {
|
||||
// Phase 5 — AJOUTE la valeur à la position actuelle plutôt que de
|
||||
// l'écraser (contrairement à pos_x/pos_y, "absolus") : combiné à un
|
||||
@@ -227,7 +240,62 @@ function runActionNode(node) {
|
||||
// Fire-and-forget : ne bloque jamais la suite du graphe.
|
||||
if (node.data_value) new Audio(node.data_value).play().catch(function() {});
|
||||
return Promise.resolve();
|
||||
} else if (node.action_type === 'jouer_animation_sprite' && node.target_element_id) {
|
||||
const targetEl = document.querySelector('[data-element-id="' + node.target_element_id + '"]');
|
||||
if (targetEl) {
|
||||
let spriteData = {};
|
||||
try { spriteData = JSON.parse(node.data_value || '{}'); } catch (e) { /* data_value invalide : rien à jouer */ }
|
||||
runSpriteAnimation(targetEl, spriteData);
|
||||
}
|
||||
return Promise.resolve();
|
||||
}
|
||||
// 'rien' : aucune action volontairement.
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
// ---------- Phase 7 — animation de sprites (poses/images successives) ----------
|
||||
// Premier état actif PAR ÉLÉMENT du moteur (contrairement à
|
||||
// activeTimerTriggerIntervals dans triggers.js et currentBackgroundAudio
|
||||
// dans screens.js, qui sont par-écran/singleton) : plusieurs personnages
|
||||
// peuvent animer indépendamment en même temps. Clé = le NŒUD DOM lui-même
|
||||
// (pas element_id) : un clip posé sur un écran-modèle peut viser plusieurs
|
||||
// instances à la fois (ex. chaque ligne d'un Répéteur utilisant ce modèle),
|
||||
// toutes partageant le même element_id — les indexer par élément DOM réel
|
||||
// évite qu'une nouvelle instance n'arrête l'animation d'une autre. Alimenté
|
||||
// par deux entrées équivalentes — l'action de flow "jouer_animation_sprite"
|
||||
// ci-dessus (événementiel) ET un clip de la Timeline d'animation de kind
|
||||
// "sprite" (déclaratif, voir applyAnimationClip() dans screens.js) — les
|
||||
// deux partagent ce même moteur, aucune logique dupliquée.
|
||||
var activeSpriteAnimations = new Map();
|
||||
|
||||
function runSpriteAnimation(targetEl, spriteData) {
|
||||
var frames = spriteData.frames || [];
|
||||
var fps = spriteData.fps > 0 ? spriteData.fps : 8;
|
||||
var loop = !!spriteData.loop;
|
||||
if (activeSpriteAnimations.has(targetEl)) {
|
||||
clearInterval(activeSpriteAnimations.get(targetEl));
|
||||
activeSpriteAnimations.delete(targetEl);
|
||||
}
|
||||
if (!frames.length) return;
|
||||
var frameIndex = 0;
|
||||
targetEl.src = frames[0];
|
||||
if (frames.length === 1) return;
|
||||
var intervalId = setInterval(function() {
|
||||
frameIndex++;
|
||||
if (frameIndex >= frames.length) {
|
||||
if (!loop) { clearInterval(intervalId); activeSpriteAnimations.delete(targetEl); return; }
|
||||
frameIndex = 0;
|
||||
}
|
||||
targetEl.src = frames[frameIndex];
|
||||
}, 1000 / fps);
|
||||
activeSpriteAnimations.set(targetEl, intervalId);
|
||||
}
|
||||
|
||||
// Appelée depuis showScreen() (screens.js) à chaque changement d'écran —
|
||||
// encapsule activeSpriteAnimations ici plutôt que de le manipuler
|
||||
// directement depuis screens.js, même patron que runScreenTimerTriggers()
|
||||
// dans triggers.js (l'appelant ne connaît jamais la structure interne).
|
||||
function stopAllSpriteAnimations() {
|
||||
activeSpriteAnimations.forEach(function(intervalId) { clearInterval(intervalId); });
|
||||
activeSpriteAnimations.clear();
|
||||
}
|
||||
|
||||
@@ -38,6 +38,11 @@ function showScreen(id) {
|
||||
window.currentScreenId = id;
|
||||
applyScreenAspect(el.dataset.aspect || '9/16');
|
||||
}
|
||||
// Phase 7 — nettoie les animations de sprites de l'écran quitté (sinon
|
||||
// une boucle setInterval reste active sur un élément désormais caché,
|
||||
// mirroring le nettoyage déjà fait pour activeTimerTriggerIntervals
|
||||
// dans triggers.js).
|
||||
stopAllSpriteAnimations();
|
||||
runScreenShowTriggers(id);
|
||||
runScreenTimerTriggers(id);
|
||||
runAnimationTimeline(id);
|
||||
@@ -141,6 +146,15 @@ function applyAnimationClip(clip) {
|
||||
target.removeEventListener('animationend', handler);
|
||||
});
|
||||
}
|
||||
} else if (clip.kind === 'sprite') {
|
||||
// Phase 7 — même moteur que l'action de flow "jouer_animation_sprite"
|
||||
// (voir runSpriteAnimation() dans actions.js) : custom_keyframes
|
||||
// stocke ici {frames, fps} en JSON plutôt que des images-clés CSS
|
||||
// (réutilise la colonne générique existante, pas de nouveau champ).
|
||||
var spriteData = {};
|
||||
try { spriteData = JSON.parse(clip.custom_keyframes || '{}'); } catch (e) { /* invalide : rien à jouer */ }
|
||||
spriteData.loop = infinite;
|
||||
setTimeout(function() { runSpriteAnimation(target, spriteData); }, delay * 1000);
|
||||
} else {
|
||||
var animClass = 'animate__' + clip.animate_name;
|
||||
target.style.setProperty('--animate-duration', duration + 's');
|
||||
|
||||
@@ -9,6 +9,11 @@
|
||||
var ANIM_SCREEN_ID = SCREEN_ID;
|
||||
var ANIM_PX_PER_SEC = 60;
|
||||
var animCurrentClipId = null;
|
||||
// Phase 7 — aperçu d'un clip "sprite" dans l'éditeur (voir previewAnimClip
|
||||
// ci-dessous) : un intervalle à la fois suffit ici (contrairement au
|
||||
// moteur de jeu, static/js/play/actions.js, PAS chargé sur cette page —
|
||||
// runSpriteAnimation()/activeSpriteAnimations n'existent que côté jeu).
|
||||
var animPreviewSpriteInterval = null;
|
||||
|
||||
function animUrl(path) { return "/game/" + GAME_SLUG + path; }
|
||||
|
||||
@@ -165,6 +170,32 @@ function toggleAnimClipKind() {
|
||||
var kind = document.getElementById('anim-clip-kind').value;
|
||||
document.getElementById('anim-clip-animate-css-fields').style.display = kind === 'animate_css' ? 'block' : 'none';
|
||||
document.getElementById('anim-clip-custom-fields').style.display = kind === 'custom' ? 'block' : 'none';
|
||||
document.getElementById('anim-clip-sprite-fields').style.display = kind === 'sprite' ? 'block' : 'none';
|
||||
}
|
||||
|
||||
// Phase 7 — même mécanisme que onSpriteLibraryCharacterChange()/
|
||||
// applySpriteLibraryAnimation() dans flow-editor.js, pour le formulaire de
|
||||
// clip de Timeline (DOM distinct, pas d'état partagé entre les deux formulaires).
|
||||
function onAnimClipSpriteLibraryCharacterChange() {
|
||||
var charSel = document.getElementById('anim-clip-sprite-library-character');
|
||||
var animSel = document.getElementById('anim-clip-sprite-library-animation');
|
||||
var slug = charSel.value;
|
||||
animSel.innerHTML = '';
|
||||
if (!slug || !SPRITE_LIBRARY[slug]) { animSel.style.display = 'none'; return; }
|
||||
Object.keys(SPRITE_LIBRARY[slug].animations).forEach(function(name) {
|
||||
var opt = document.createElement('option');
|
||||
opt.value = name; opt.textContent = name;
|
||||
animSel.appendChild(opt);
|
||||
});
|
||||
animSel.style.display = 'block';
|
||||
applyAnimClipSpriteLibraryAnimation();
|
||||
}
|
||||
|
||||
function applyAnimClipSpriteLibraryAnimation() {
|
||||
var slug = document.getElementById('anim-clip-sprite-library-character').value;
|
||||
var name = document.getElementById('anim-clip-sprite-library-animation').value;
|
||||
if (!slug || !name || !SPRITE_LIBRARY[slug]) return;
|
||||
document.getElementById('anim-clip-sprite-frames').value = SPRITE_LIBRARY[slug].animations[name].join('\n');
|
||||
}
|
||||
|
||||
function addAnimKeyframeRow(percent, cssText) {
|
||||
@@ -229,6 +260,14 @@ function openAnimClipForm(clip) {
|
||||
var cssText = Object.keys(k.props || {}).map(function(p) { return p + ': ' + k.props[p] + ';'; }).join(' ');
|
||||
addAnimKeyframeRow(k.percent, cssText);
|
||||
});
|
||||
// Phase 7 — clip.custom_keyframes stocke {frames, fps} en JSON pour un
|
||||
// clip kind="sprite" (pas une liste d'images-clés CSS, voir ci-dessus).
|
||||
var spriteData = {};
|
||||
if (clip.kind === 'sprite' && clip.custom_keyframes) {
|
||||
try { spriteData = JSON.parse(clip.custom_keyframes); } catch (e) { /* ignore */ }
|
||||
}
|
||||
document.getElementById('anim-clip-sprite-frames').value = (spriteData.frames || []).join('\n');
|
||||
document.getElementById('anim-clip-sprite-fps').value = spriteData.fps || 8;
|
||||
toggleAnimClipKind();
|
||||
document.querySelectorAll('.animClip').forEach(function(b) {
|
||||
b.classList.toggle('selected', parseInt(b.dataset.clipId, 10) === clip.id);
|
||||
@@ -254,6 +293,18 @@ function saveAnimClipForm() {
|
||||
iteration_count: document.getElementById('anim-clip-iteration').value,
|
||||
};
|
||||
if (kind === 'custom') fields.custom_keyframes = collectAnimKeyframes();
|
||||
if (kind === 'sprite') {
|
||||
// Phase 7 — {frames, fps} en JSON dans custom_keyframes (colonne
|
||||
// générique déjà réutilisée par le kind "custom" ci-dessus pour ses
|
||||
// images-clés) ; la boucle est déjà portée par iteration_count
|
||||
// (1/2/3/infinite, ci-dessus) — aucun champ dédié nécessaire.
|
||||
var frames = document.getElementById('anim-clip-sprite-frames').value
|
||||
.split('\n').map(function(s) { return s.trim(); }).filter(Boolean);
|
||||
fields.custom_keyframes = {
|
||||
frames: frames,
|
||||
fps: parseInt(document.getElementById('anim-clip-sprite-fps').value, 10) || 8,
|
||||
};
|
||||
}
|
||||
return updateAnimClip(animCurrentClipId, fields).then(function() { renderAnimTimeline(); });
|
||||
}
|
||||
|
||||
@@ -296,6 +347,22 @@ function previewAnimClip() {
|
||||
target.style.animation = '';
|
||||
target.removeEventListener('animationend', handler);
|
||||
});
|
||||
} else if (clip.kind === 'sprite') {
|
||||
var spriteData = {};
|
||||
try { spriteData = JSON.parse(clip.custom_keyframes || '{}'); } catch (e) { /* ignore */ }
|
||||
var frames = spriteData.frames || [];
|
||||
var fps = spriteData.fps > 0 ? spriteData.fps : 8;
|
||||
if (animPreviewSpriteInterval) { clearInterval(animPreviewSpriteInterval); animPreviewSpriteInterval = null; }
|
||||
if (frames.length) {
|
||||
var frameIndex = 0;
|
||||
target.src = frames[0];
|
||||
if (frames.length > 1) {
|
||||
animPreviewSpriteInterval = setInterval(function() {
|
||||
frameIndex = (frameIndex + 1) % frames.length;
|
||||
target.src = frames[frameIndex];
|
||||
}, 1000 / fps);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
var animClass = 'animate__' + clip.animate_name;
|
||||
target.style.setProperty('--animate-duration', duration + 's');
|
||||
|
||||
@@ -91,6 +91,8 @@ function nodeLabel(node) {
|
||||
base += ' = ' + (ELEMENT_SURBRILLANCE_LABELS_MAP[node.element_value] || node.element_value);
|
||||
} else if (node.element_property === 'desactive') {
|
||||
base += ' = ' + (ELEMENT_DESACTIVE_LABELS_MAP[node.element_value] || node.element_value);
|
||||
} else if (node.element_property === 'orientation') {
|
||||
base += ' = ' + (ELEMENT_ORIENTATION_LABELS_MAP[node.element_value] || node.element_value);
|
||||
} else if (node.element_value && node.element_value.indexOf('||') !== -1) {
|
||||
var vparts = node.element_value.split('||');
|
||||
base += ' = ' + vparts[0] + ' ↔ ' + vparts[1] + ' (va-et-vient)';
|
||||
@@ -115,6 +117,12 @@ function nodeLabel(node) {
|
||||
base += ' → ' + (DEFINITIONS_NAMES[node.target_definition_id] || '?');
|
||||
} else if (node.action_type === 'jouer_son') {
|
||||
base += ' « ' + (node.data_value || '?') + ' »';
|
||||
} else if (node.action_type === 'jouer_animation_sprite') {
|
||||
base += ' → ' + (ELEMENTS_LABELS[node.target_element_id] || '?');
|
||||
try {
|
||||
var spriteData = JSON.parse(node.data_value || '{}');
|
||||
base += ' (' + (spriteData.frames ? spriteData.frames.length : 0) + ' images, ' + (spriteData.fps || '?') + ' i/s' + (spriteData.loop ? ', boucle' : '') + ')';
|
||||
} catch (e) { /* data_value invalide : pas de détail affiché */ }
|
||||
}
|
||||
return base;
|
||||
}
|
||||
@@ -500,8 +508,10 @@ function toggleFlowActionFields() {
|
||||
var eventFields = document.getElementById('nf-action-event-fields');
|
||||
var addRowFields = document.getElementById('nf-action-add-row-fields');
|
||||
var soundFields = document.getElementById('nf-action-sound-fields');
|
||||
var spriteFields = document.getElementById('nf-action-sprite-fields');
|
||||
var isOnglet = typeSel.value === 'activer_onglet';
|
||||
soundFields.style.display = typeSel.value === 'jouer_son' ? 'block' : 'none';
|
||||
spriteFields.style.display = typeSel.value === 'jouer_animation_sprite' ? 'block' : 'none';
|
||||
screenSel.style.display = (typeSel.value === 'aller_a' || typeSel.value === 'ouvrir_ligne') ? 'block' : 'none';
|
||||
elementFields.style.display = (typeSel.value === 'modifier_element' || isOnglet) ? 'block' : 'none';
|
||||
dataFields.style.display = typeSel.value === 'modifier_donnee' ? 'block' : 'none';
|
||||
@@ -528,16 +538,44 @@ function updateDataValuePlaceholder(operationSelectId, valueInputId) {
|
||||
valueInp.placeholder = opSel.value === 'alea' ? 'min,max (ex. 1,6)' : 'Valeur / montant';
|
||||
}
|
||||
|
||||
// Phase 7 — bibliothèque de sprites Forge (SPRITE_LIBRARY, injecté par
|
||||
// screen_edit.html) : choisir un personnage puis une de ses animations
|
||||
// pré-remplit la zone de frames — un créateur qui préfère ses propres
|
||||
// sprites laisse simplement ce sélecteur sur "— Choisir —" et remplit/
|
||||
// uploade directement dans la zone de frames.
|
||||
function onSpriteLibraryCharacterChange() {
|
||||
var charSel = document.getElementById('nf-action-sprite-library-character');
|
||||
var animSel = document.getElementById('nf-action-sprite-library-animation');
|
||||
var slug = charSel.value;
|
||||
animSel.innerHTML = '';
|
||||
if (!slug || !SPRITE_LIBRARY[slug]) { animSel.style.display = 'none'; return; }
|
||||
Object.keys(SPRITE_LIBRARY[slug].animations).forEach(function(name) {
|
||||
var opt = document.createElement('option');
|
||||
opt.value = name; opt.textContent = name;
|
||||
animSel.appendChild(opt);
|
||||
});
|
||||
animSel.style.display = 'block';
|
||||
applySpriteLibraryAnimation();
|
||||
}
|
||||
|
||||
function applySpriteLibraryAnimation() {
|
||||
var slug = document.getElementById('nf-action-sprite-library-character').value;
|
||||
var name = document.getElementById('nf-action-sprite-library-animation').value;
|
||||
if (!slug || !name || !SPRITE_LIBRARY[slug]) return;
|
||||
document.getElementById('nf-action-sprite-frames').value = SPRITE_LIBRARY[slug].animations[name].join('\n');
|
||||
}
|
||||
|
||||
function toggleFlowElementPropertyValue() {
|
||||
var propSel = document.getElementById('nf-action-element-property');
|
||||
var visSel = document.getElementById('nf-action-element-value-visibility');
|
||||
var surbrillanceSel = document.getElementById('nf-action-element-value-surbrillance');
|
||||
var desactiveSel = document.getElementById('nf-action-element-value-desactive');
|
||||
var orientationSel = document.getElementById('nf-action-element-value-orientation');
|
||||
var colorInp = document.getElementById('nf-action-element-value-color');
|
||||
var numInp = document.getElementById('nf-action-element-value-number');
|
||||
var textInp = document.getElementById('nf-action-element-value-text');
|
||||
var toggleRow = document.getElementById('nf-action-element-toggle-row');
|
||||
[visSel, surbrillanceSel, desactiveSel, colorInp, numInp, textInp].forEach(function(el){ el.style.display = 'none'; });
|
||||
[visSel, surbrillanceSel, desactiveSel, orientationSel, colorInp, numInp, textInp].forEach(function(el){ el.style.display = 'none'; });
|
||||
if (propSel.value === 'visibilite') {
|
||||
visSel.style.display = 'block';
|
||||
toggleRow.style.display = 'none';
|
||||
@@ -550,6 +588,10 @@ function toggleFlowElementPropertyValue() {
|
||||
desactiveSel.style.display = 'block';
|
||||
toggleRow.style.display = 'none';
|
||||
document.getElementById('nf-action-element-toggle').checked = false;
|
||||
} else if (propSel.value === 'orientation') {
|
||||
orientationSel.style.display = 'block';
|
||||
toggleRow.style.display = 'none';
|
||||
document.getElementById('nf-action-element-toggle').checked = false;
|
||||
} else if (propSel.value === 'contenu') {
|
||||
textInp.style.display = 'block';
|
||||
toggleRow.style.display = 'none';
|
||||
@@ -646,13 +688,15 @@ function submitNodeForm() {
|
||||
var prop = document.getElementById('nf-action-element-property').value;
|
||||
payload.element_property = prop;
|
||||
var isColorProp = prop === 'bg_color' || prop === 'border_color';
|
||||
var toggleOn = prop !== 'visibilite' && prop !== 'surbrillance' && prop !== 'desactive' && document.getElementById('nf-action-element-toggle').checked;
|
||||
var toggleOn = prop !== 'visibilite' && prop !== 'surbrillance' && prop !== 'desactive' && prop !== 'orientation' && document.getElementById('nf-action-element-toggle').checked;
|
||||
if (prop === 'visibilite') {
|
||||
payload.element_value = document.getElementById('nf-action-element-value-visibility').value;
|
||||
} else if (prop === 'surbrillance') {
|
||||
payload.element_value = document.getElementById('nf-action-element-value-surbrillance').value;
|
||||
} else if (prop === 'desactive') {
|
||||
payload.element_value = document.getElementById('nf-action-element-value-desactive').value;
|
||||
} else if (prop === 'orientation') {
|
||||
payload.element_value = document.getElementById('nf-action-element-value-orientation').value;
|
||||
} else if (prop === 'contenu') {
|
||||
payload.element_value = document.getElementById('nf-action-element-value-text').value;
|
||||
} else {
|
||||
@@ -695,6 +739,17 @@ function submitNodeForm() {
|
||||
// sur le nœud action, comme pour "attendre") plutôt qu'une nouvelle
|
||||
// colonne dédiée.
|
||||
payload.data_value = document.getElementById('nf-action-sound-url').value;
|
||||
} else if (actionType === 'jouer_animation_sprite') {
|
||||
// Phase 7 — réutilise target_element_id (déjà whitelisté) +
|
||||
// data_value en JSON {frames, fps, loop}, même patron que jouer_son.
|
||||
payload.target_element_id = parseInt(document.getElementById('nf-action-sprite-target-element').value, 10);
|
||||
var frames = document.getElementById('nf-action-sprite-frames').value
|
||||
.split('\n').map(function(s) { return s.trim(); }).filter(Boolean);
|
||||
payload.data_value = JSON.stringify({
|
||||
frames: frames,
|
||||
fps: parseInt(document.getElementById('nf-action-sprite-fps').value, 10) || 8,
|
||||
loop: document.getElementById('nf-action-sprite-loop').checked,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -70,6 +70,55 @@ function initBuilderPanel() {
|
||||
});
|
||||
});
|
||||
|
||||
// Phase 7 — sprites propres au créateur : contrairement aux autres
|
||||
// champs d'upload (qui REMPLACENT la valeur d'un champ texte unique),
|
||||
// chaque fichier envoyé ici AJOUTE une ligne à la zone de frames — un
|
||||
// personnage animé a besoin de plusieurs images, pas d'une seule.
|
||||
var spriteUploadInput = document.getElementById('nf-action-sprite-upload');
|
||||
if (spriteUploadInput) {
|
||||
spriteUploadInput.addEventListener('change', function () {
|
||||
if (!spriteUploadInput.files.length) return;
|
||||
var status = document.getElementById('nf-action-sprite-upload-status');
|
||||
var framesField = document.getElementById('nf-action-sprite-frames');
|
||||
status.textContent = 'Envoi en cours...';
|
||||
var data = new FormData();
|
||||
data.append('file', spriteUploadInput.files[0]);
|
||||
fetch("/game/" + GAME_SLUG + "/upload", { method: 'POST', body: data })
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (json) {
|
||||
if (json.url) {
|
||||
framesField.value = framesField.value ? (framesField.value + '\n' + json.url) : json.url;
|
||||
status.textContent = 'Fichier envoyé ✓';
|
||||
} else { status.textContent = "Échec de l'envoi."; }
|
||||
})
|
||||
.catch(function () { status.textContent = "Échec de l'envoi."; });
|
||||
});
|
||||
}
|
||||
|
||||
// Phase 7 — même mécanisme d'upload que nf-action-sprite-upload
|
||||
// ci-dessus (ajoute une ligne plutôt que de remplacer), pour le
|
||||
// formulaire de clip de la Timeline d'animation.
|
||||
var animClipSpriteUploadInput = document.getElementById('anim-clip-sprite-upload');
|
||||
if (animClipSpriteUploadInput) {
|
||||
animClipSpriteUploadInput.addEventListener('change', function () {
|
||||
if (!animClipSpriteUploadInput.files.length) return;
|
||||
var status = document.getElementById('anim-clip-sprite-upload-status');
|
||||
var framesField = document.getElementById('anim-clip-sprite-frames');
|
||||
status.textContent = 'Envoi en cours...';
|
||||
var data = new FormData();
|
||||
data.append('file', animClipSpriteUploadInput.files[0]);
|
||||
fetch("/game/" + GAME_SLUG + "/upload", { method: 'POST', body: data })
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (json) {
|
||||
if (json.url) {
|
||||
framesField.value = framesField.value ? (framesField.value + '\n' + json.url) : json.url;
|
||||
status.textContent = 'Fichier envoyé ✓';
|
||||
} else { status.textContent = "Échec de l'envoi."; }
|
||||
})
|
||||
.catch(function () { status.textContent = "Échec de l'envoi."; });
|
||||
});
|
||||
}
|
||||
|
||||
bindPropsAutosave();
|
||||
bindJaugeDefinitionSelect();
|
||||
bindDefinitionFieldSelects('field-definition_id', ['field-filtre_champ', 'field-filtre2_champ']);
|
||||
|
||||
@@ -52,6 +52,9 @@
|
||||
/* 3.5 (Confort) — verrouillage d'un élément après décision : grisé +
|
||||
inerte, posé/enlevé par une action "Modifier un élément → Désactivé". */
|
||||
.forgeDisabled{ opacity:0.45; filter:grayscale(60%); pointer-events:none; }
|
||||
/* Phase 7 — personnage animé par sprites : retourne l'élément en miroir
|
||||
plutôt que de nécessiter une 2e feuille de sprites "vue de dos". */
|
||||
.forgeFlipped{ transform: scaleX(-1); }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -431,6 +431,11 @@
|
||||
<option value="{{ key }}">{{ label }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<select id="nf-action-element-value-orientation" style="display:none; margin-top:6px;">
|
||||
{% for key, label in element_orientation_values %}
|
||||
<option value="{{ key }}">{{ label }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<input type="color" id="nf-action-element-value-color" style="display:none; margin-top:6px;" value="#5b8cff">
|
||||
<input type="number" id="nf-action-element-value-number" style="display:none; margin-top:6px;" value="50" placeholder="Nouvelle valeur en %">
|
||||
<input type="text" id="nf-action-element-value-text" style="display:none; margin-top:6px;" placeholder="Nouveau texte affiché">
|
||||
@@ -500,6 +505,46 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Phase 7 — personnage animé par sprites : joue une séquence de
|
||||
PNG (cycle en boucle type marche, ou une fois type saut) sur
|
||||
l'élément visé, depuis la bibliothèque Forge ou des sprites
|
||||
propres au créateur (même bouton d'upload que le son ci-dessus,
|
||||
mais ajoute une ligne plutôt que de remplacer le champ). -->
|
||||
<div id="nf-action-sprite-fields" style="display:none;">
|
||||
<label>Élément à animer
|
||||
<select id="nf-action-sprite-target-element">
|
||||
{% for e2 in elements %}
|
||||
<option value="{{ e2.id }}">{{ e2|elabel }} (elt-{{ e2.id }})</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
<label style="margin-top:6px; display:block;">Personnage Forge (optionnel)
|
||||
<select id="nf-action-sprite-library-character" onchange="onSpriteLibraryCharacterChange()">
|
||||
<option value="">— Choisir —</option>
|
||||
{% for slug, character in sprite_library.items() %}
|
||||
<option value="{{ slug }}">{{ character.label }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
<select id="nf-action-sprite-library-animation" style="margin-top:6px; display:none;" onchange="applySpriteLibraryAnimation()"></select>
|
||||
<label style="margin-top:10px; display:block;">Images (une URL par ligne)
|
||||
<textarea id="nf-action-sprite-frames" rows="4" style="width:100%;" placeholder="https://.../frame1.png"></textarea>
|
||||
</label>
|
||||
<div style="margin-top:6px;">
|
||||
<label for="nf-action-sprite-upload" style="font-size:12px;">Ajouter un fichier :</label>
|
||||
<input type="file" id="nf-action-sprite-upload" accept="image/*" style="font-size:12px;">
|
||||
<span id="nf-action-sprite-upload-status" class="uploadStatus" style="font-size:12px; color:var(--text-dim);"></span>
|
||||
</div>
|
||||
<div class="fieldRow" style="margin-top:6px;">
|
||||
<label style="flex:1;">Images/seconde
|
||||
<input type="number" id="nf-action-sprite-fps" min="1" step="1" value="8">
|
||||
</label>
|
||||
<label style="flex:1; display:flex; align-items:center; gap:6px;">
|
||||
<input type="checkbox" id="nf-action-sprite-loop" checked style="width:auto;"> En boucle
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="nf-action-variable-fields" style="display:none;">
|
||||
{% if not global_variables %}
|
||||
<p class="hint">Aucune variable globale pour l'instant — crées-en une depuis "Condition de visibilité" dans les propriétés d'un élément.</p>
|
||||
@@ -570,6 +615,7 @@
|
||||
<select id="anim-clip-kind" onchange="toggleAnimClipKind()">
|
||||
<option value="animate_css">Animation Animate.css</option>
|
||||
<option value="custom">Animation personnalisée — images-clés</option>
|
||||
<option value="sprite">Animation de sprites</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
@@ -592,6 +638,32 @@
|
||||
<button type="button" class="smallBtn button is-small" onclick="addAnimKeyframeRow()">+ Ajouter une image-clé</button>
|
||||
</div>
|
||||
|
||||
<!-- Phase 7 — même champs que le nœud de flow "Jouer une animation
|
||||
de sprites" (voir plus haut #nf-action-sprite-fields), formulaire
|
||||
indépendant : bibliothèque Forge ou sprites propres au créateur. -->
|
||||
<div id="anim-clip-sprite-fields" style="display:none;">
|
||||
<label>Personnage Forge (optionnel)
|
||||
<select id="anim-clip-sprite-library-character" onchange="onAnimClipSpriteLibraryCharacterChange()">
|
||||
<option value="">— Choisir —</option>
|
||||
{% for slug, character in sprite_library.items() %}
|
||||
<option value="{{ slug }}">{{ character.label }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
<select id="anim-clip-sprite-library-animation" style="margin-top:6px; display:none;" onchange="applyAnimClipSpriteLibraryAnimation()"></select>
|
||||
<label style="margin-top:10px; display:block;">Images (une URL par ligne)
|
||||
<textarea id="anim-clip-sprite-frames" rows="4" style="width:100%;" placeholder="https://.../frame1.png"></textarea>
|
||||
</label>
|
||||
<div style="margin-top:6px;">
|
||||
<label for="anim-clip-sprite-upload" style="font-size:12px;">Ajouter un fichier :</label>
|
||||
<input type="file" id="anim-clip-sprite-upload" accept="image/*" style="font-size:12px;">
|
||||
<span id="anim-clip-sprite-upload-status" class="uploadStatus" style="font-size:12px; color:var(--text-dim);"></span>
|
||||
</div>
|
||||
<label style="margin-top:6px; display:block;">Images/seconde
|
||||
<input type="number" id="anim-clip-sprite-fps" min="1" step="1" value="8">
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="fieldRow">
|
||||
<label style="flex:1;">Départ en secondes
|
||||
<input type="number" id="anim-clip-start" min="0" step="0.1" value="0">
|
||||
@@ -1144,6 +1216,8 @@
|
||||
var ELEMENT_VISIBILITY_LABELS_MAP = {{ element_visibility_labels_json|safe }};
|
||||
var ELEMENT_SURBRILLANCE_LABELS_MAP = {{ element_surbrillance_labels_json|safe }};
|
||||
var ELEMENT_DESACTIVE_LABELS_MAP = {{ element_desactive_labels_json|safe }};
|
||||
var ELEMENT_ORIENTATION_LABELS_MAP = {{ element_orientation_labels_json|safe }};
|
||||
var SPRITE_LIBRARY = {{ sprite_library_json|safe }};
|
||||
var DATA_OPERATION_LABELS_MAP = {{ data_operation_labels_json|safe }};
|
||||
var CONDITION_OPERATOR_LABELS_MAP = {{ condition_operator_labels_json|safe }};
|
||||
var TRIGGER_LABELS_MAP = {{ trigger_labels_json|safe }};
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
"""Personnage animé par sprites (poses/images successives) — Phase 7 du
|
||||
plan d'extension du moteur. Deux entrées équivalentes : une action de flow
|
||||
"jouer_animation_sprite" (événementiel) et un clip de la Timeline
|
||||
d'animation de kind "sprite" (déclaratif, voir tests/test_animations.py
|
||||
pour le patron des clips). Comme le reste du graphe de logique côté
|
||||
client, la lecture réelle de l'animation n'est pas testable sans navigateur
|
||||
(voir play_js_bundle() — on vérifie que la logique existe dans le JS livré
|
||||
et que les données persistent, pas qu'elles jouent une animation)."""
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
|
||||
from conftest import play_js_bundle, screen_edit_js_bundle
|
||||
|
||||
import screens
|
||||
|
||||
_STATIC_CHARACTERS_DIR = os.path.join(
|
||||
os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "static", "characters"
|
||||
)
|
||||
|
||||
|
||||
def _create_screen(client, slug, name="Accueil"):
|
||||
resp = client.post(f"/game/{slug}/screens/new", data={"name": name}, follow_redirects=False)
|
||||
return int(re.search(r"/screens/(\d+)/edit", resp.headers["Location"]).group(1))
|
||||
|
||||
|
||||
def _add_element(client, slug, screen_id, widget="image"):
|
||||
resp = client.post(f"/game/{slug}/screens/{screen_id}/elements/add", data={"widget": widget}, follow_redirects=False)
|
||||
return int(re.search(r"selected=(\d+)", resp.headers["Location"]).group(1))
|
||||
|
||||
|
||||
def _add_flow_node(client, slug, screen_id, **fields):
|
||||
resp = client.post(
|
||||
f"/game/{slug}/screens/{screen_id}/flow/nodes/add",
|
||||
data=json.dumps(fields),
|
||||
content_type="application/json",
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
return resp.get_json()["id"]
|
||||
|
||||
|
||||
def test_sprite_animation_action_node_persists(client, game):
|
||||
screen_id = _create_screen(client, game)
|
||||
el_id = _add_element(client, game, screen_id)
|
||||
data_value = json.dumps({"frames": ["/static/characters/zombie/walk0.png", "/static/characters/zombie/walk1.png"], "fps": 8, "loop": True})
|
||||
node_id = _add_flow_node(
|
||||
client, game, screen_id, node_type="action", action_type="jouer_animation_sprite",
|
||||
target_element_id=el_id, data_value=data_value,
|
||||
)
|
||||
node = screens.get_flow_node(game, node_id)
|
||||
assert node["action_type"] == "jouer_animation_sprite"
|
||||
assert node["target_element_id"] == el_id
|
||||
assert json.loads(node["data_value"])["fps"] == 8
|
||||
|
||||
|
||||
def test_orientation_action_node_persists(client, game):
|
||||
screen_id = _create_screen(client, game)
|
||||
el_id = _add_element(client, game, screen_id)
|
||||
node_id = _add_flow_node(
|
||||
client, game, screen_id, node_type="action", action_type="modifier_element",
|
||||
target_element_id=el_id, element_property="orientation", element_value="gauche",
|
||||
)
|
||||
node = screens.get_flow_node(game, node_id)
|
||||
assert node["element_property"] == "orientation"
|
||||
assert node["element_value"] == "gauche"
|
||||
|
||||
|
||||
def test_sprite_timeline_clip_persists(client, game):
|
||||
screen_id = _create_screen(client, game)
|
||||
el_id = _add_element(client, game, screen_id)
|
||||
sprite_data = {"frames": ["/static/characters/male-adventurer/idle.png"], "fps": 8}
|
||||
resp = client.post(
|
||||
f"/game/{game}/screens/{screen_id}/animations/clips/add",
|
||||
data=json.dumps({
|
||||
"element_id": el_id, "kind": "sprite", "custom_keyframes": sprite_data,
|
||||
"start_time": "0", "iteration_count": "infinite",
|
||||
}),
|
||||
content_type="application/json",
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
clip = resp.get_json()
|
||||
assert clip["kind"] == "sprite"
|
||||
assert clip["iteration_count"] == "infinite"
|
||||
assert json.loads(clip["custom_keyframes"])["frames"] == ["/static/characters/male-adventurer/idle.png"]
|
||||
|
||||
|
||||
def test_sprite_library_paths_exist_on_disk(client, game):
|
||||
for character in screens.SPRITE_LIBRARY.values():
|
||||
for frames in character["animations"].values():
|
||||
assert frames, "chaque animation doit avoir au moins une frame"
|
||||
for url in frames:
|
||||
assert url.startswith("/static/characters/")
|
||||
relative = url[len("/static/characters/"):]
|
||||
assert os.path.isfile(os.path.join(_STATIC_CHARACTERS_DIR, relative)), url
|
||||
|
||||
|
||||
def test_play_page_exposes_sprite_animation_runtime(client, game):
|
||||
js = play_js_bundle()
|
||||
assert "runSpriteAnimation" in js
|
||||
assert "'jouer_animation_sprite'" in js
|
||||
assert "activeSpriteAnimations" in js
|
||||
assert "stopAllSpriteAnimations" in js
|
||||
assert "forgeFlipped" in js
|
||||
|
||||
|
||||
def test_screen_edit_exposes_sprite_animation_editors(client, game):
|
||||
screen_id = _create_screen(client, game)
|
||||
html = client.get(f"/game/{game}/screens/{screen_id}/edit").get_data(as_text=True)
|
||||
assert "nf-action-sprite-fields" in html
|
||||
assert "nf-action-sprite-target-element" in html
|
||||
assert "anim-clip-sprite-fields" in html
|
||||
js = screen_edit_js_bundle()
|
||||
assert "jouer_animation_sprite" in js
|
||||
assert "onSpriteLibraryCharacterChange" in js
|
||||
assert "onAnimClipSpriteLibraryCharacterChange" in js
|
||||