Phase 7 : personnage animé par sprites (poses/images successives)
Build and deploy / test-python (push) Successful in 1m26s
Build and deploy / test-js (push) Successful in 6s
Build and deploy / build-and-push (push) Skipped
Build and deploy / deploy (push) Skipped

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>
This commit is contained in:
william
2026-08-30 19:18:07 +02:00
co-authored by Claude Sonnet 5
parent 76331f9285
commit d2c2f20b65
35 changed files with 519 additions and 3 deletions
+1 -1
View File
@@ -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"
+4
View File
@@ -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),
+2
View File
@@ -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,
+1
View File
@@ -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)
+28
View File
@@ -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"],
},
},
}
+23
View File
@@ -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
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

+68
View File
@@ -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();
}
+14
View File
@@ -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');
+57 -2
View File
@@ -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,
});
}
}
+49
View File
@@ -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']);
+3
View File
@@ -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>
+74
View File
@@ -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 }};
+115
View File
@@ -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