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
|
# Sous-ensemble de static/ réellement nécessaire au mode jouable (voir
|
||||||
# templates/play.html) — jamais les assets propres à l'éditeur seul
|
# templates/play.html) — jamais les assets propres à l'éditeur seul
|
||||||
# (icônes de l'interface de l'éditeur mises à part, déjà dans icons/).
|
# (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"
|
_SLUG_PLACEHOLDER = "_SLUG_PLACEHOLDER"
|
||||||
|
|
||||||
|
|||||||
@@ -73,6 +73,9 @@ def screen_edit(slug, screen_id):
|
|||||||
element_visibility_labels=screens.ELEMENT_VISIBILITY_LABELS,
|
element_visibility_labels=screens.ELEMENT_VISIBILITY_LABELS,
|
||||||
element_surbrillance_values=screens.ELEMENT_SURBRILLANCE_VALUES,
|
element_surbrillance_values=screens.ELEMENT_SURBRILLANCE_VALUES,
|
||||||
element_desactive_values=screens.ELEMENT_DESACTIVE_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_operations=screens.DATA_OPERATIONS,
|
||||||
data_operation_labels=screens.DATA_OPERATION_LABELS,
|
data_operation_labels=screens.DATA_OPERATION_LABELS,
|
||||||
definitions=definitions, definitions_json=json.dumps(definitions_data),
|
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_visibility_labels_json=json.dumps(screens.ELEMENT_VISIBILITY_LABELS),
|
||||||
element_surbrillance_labels_json=json.dumps(screens.ELEMENT_SURBRILLANCE_LABELS),
|
element_surbrillance_labels_json=json.dumps(screens.ELEMENT_SURBRILLANCE_LABELS),
|
||||||
element_desactive_labels_json=json.dumps(screens.ELEMENT_DESACTIVE_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),
|
data_operation_labels_json=json.dumps(screens.DATA_OPERATION_LABELS),
|
||||||
condition_operator_labels_json=json.dumps(screens.CONDITION_OPERATOR_LABELS),
|
condition_operator_labels_json=json.dumps(screens.CONDITION_OPERATOR_LABELS),
|
||||||
trigger_labels_json=json.dumps(screens.TRIGGER_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_ACTION_PROPERTIES, ELEMENT_ACTION_PROPERTY_LABELS,
|
||||||
ELEMENT_SURBRILLANCE_VALUES, ELEMENT_SURBRILLANCE_LABELS,
|
ELEMENT_SURBRILLANCE_VALUES, ELEMENT_SURBRILLANCE_LABELS,
|
||||||
ELEMENT_DESACTIVE_VALUES, ELEMENT_DESACTIVE_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.element_visibility import ELEMENT_VISIBILITY_VALUES, ELEMENT_VISIBILITY_LABELS
|
||||||
from .labels.data_operations import DATA_OPERATIONS, DATA_OPERATION_LABELS
|
from .labels.data_operations import DATA_OPERATIONS, DATA_OPERATION_LABELS
|
||||||
|
from .labels.sprite_library import SPRITE_LIBRARY
|
||||||
|
|
||||||
from .flow.constants import (
|
from .flow.constants import (
|
||||||
TRIGGER_EVENTS, CONDITION_OPERATORS, CONDITION_OPERATOR_LABELS, CLICKED_ROW_ID, LAST_INSERTED_ROW_ID,
|
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",
|
"declencher_evenement": "Déclencher un événement",
|
||||||
"ajouter_ligne": "Ajouter une ligne à un objet",
|
"ajouter_ligne": "Ajouter une ligne à un objet",
|
||||||
"jouer_son": "Jouer un son",
|
"jouer_son": "Jouer un son",
|
||||||
|
"jouer_animation_sprite": "Jouer une animation de sprites",
|
||||||
"rien": "Ne rien faire",
|
"rien": "Ne rien faire",
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,6 +39,12 @@ ELEMENT_ACTION_PROPERTIES = [
|
|||||||
("pos_y", "Position verticale en % (absolue)"),
|
("pos_y", "Position verticale en % (absolue)"),
|
||||||
("pos_x_relatif", "Déplacer horizontalement de... % (relatif)"),
|
("pos_x_relatif", "Déplacer horizontalement de... % (relatif)"),
|
||||||
("pos_y_relatif", "Déplacer verticalement 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)
|
ELEMENT_ACTION_PROPERTY_LABELS = dict(ELEMENT_ACTION_PROPERTIES)
|
||||||
|
|
||||||
@@ -55,3 +61,10 @@ ELEMENT_DESACTIVE_VALUES = [
|
|||||||
("toggle", "Basculer désactivé / réactivé"),
|
("toggle", "Basculer désactivé / réactivé"),
|
||||||
]
|
]
|
||||||
ELEMENT_DESACTIVE_LABELS = dict(ELEMENT_DESACTIVE_VALUES)
|
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;
|
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') {
|
if (property === 'pos_x_relatif' || property === 'pos_y_relatif') {
|
||||||
// Phase 5 — AJOUTE la valeur à la position actuelle plutôt que de
|
// Phase 5 — AJOUTE la valeur à la position actuelle plutôt que de
|
||||||
// l'écraser (contrairement à pos_x/pos_y, "absolus") : combiné à un
|
// 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.
|
// Fire-and-forget : ne bloque jamais la suite du graphe.
|
||||||
if (node.data_value) new Audio(node.data_value).play().catch(function() {});
|
if (node.data_value) new Audio(node.data_value).play().catch(function() {});
|
||||||
return Promise.resolve();
|
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.
|
// 'rien' : aucune action volontairement.
|
||||||
return Promise.resolve();
|
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;
|
window.currentScreenId = id;
|
||||||
applyScreenAspect(el.dataset.aspect || '9/16');
|
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);
|
runScreenShowTriggers(id);
|
||||||
runScreenTimerTriggers(id);
|
runScreenTimerTriggers(id);
|
||||||
runAnimationTimeline(id);
|
runAnimationTimeline(id);
|
||||||
@@ -141,6 +146,15 @@ function applyAnimationClip(clip) {
|
|||||||
target.removeEventListener('animationend', handler);
|
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 {
|
} else {
|
||||||
var animClass = 'animate__' + clip.animate_name;
|
var animClass = 'animate__' + clip.animate_name;
|
||||||
target.style.setProperty('--animate-duration', duration + 's');
|
target.style.setProperty('--animate-duration', duration + 's');
|
||||||
|
|||||||
@@ -9,6 +9,11 @@
|
|||||||
var ANIM_SCREEN_ID = SCREEN_ID;
|
var ANIM_SCREEN_ID = SCREEN_ID;
|
||||||
var ANIM_PX_PER_SEC = 60;
|
var ANIM_PX_PER_SEC = 60;
|
||||||
var animCurrentClipId = null;
|
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; }
|
function animUrl(path) { return "/game/" + GAME_SLUG + path; }
|
||||||
|
|
||||||
@@ -165,6 +170,32 @@ function toggleAnimClipKind() {
|
|||||||
var kind = document.getElementById('anim-clip-kind').value;
|
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-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-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) {
|
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(' ');
|
var cssText = Object.keys(k.props || {}).map(function(p) { return p + ': ' + k.props[p] + ';'; }).join(' ');
|
||||||
addAnimKeyframeRow(k.percent, cssText);
|
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();
|
toggleAnimClipKind();
|
||||||
document.querySelectorAll('.animClip').forEach(function(b) {
|
document.querySelectorAll('.animClip').forEach(function(b) {
|
||||||
b.classList.toggle('selected', parseInt(b.dataset.clipId, 10) === clip.id);
|
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,
|
iteration_count: document.getElementById('anim-clip-iteration').value,
|
||||||
};
|
};
|
||||||
if (kind === 'custom') fields.custom_keyframes = collectAnimKeyframes();
|
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(); });
|
return updateAnimClip(animCurrentClipId, fields).then(function() { renderAnimTimeline(); });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -296,6 +347,22 @@ function previewAnimClip() {
|
|||||||
target.style.animation = '';
|
target.style.animation = '';
|
||||||
target.removeEventListener('animationend', handler);
|
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 {
|
} else {
|
||||||
var animClass = 'animate__' + clip.animate_name;
|
var animClass = 'animate__' + clip.animate_name;
|
||||||
target.style.setProperty('--animate-duration', duration + 's');
|
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);
|
base += ' = ' + (ELEMENT_SURBRILLANCE_LABELS_MAP[node.element_value] || node.element_value);
|
||||||
} else if (node.element_property === 'desactive') {
|
} else if (node.element_property === 'desactive') {
|
||||||
base += ' = ' + (ELEMENT_DESACTIVE_LABELS_MAP[node.element_value] || node.element_value);
|
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) {
|
} else if (node.element_value && node.element_value.indexOf('||') !== -1) {
|
||||||
var vparts = node.element_value.split('||');
|
var vparts = node.element_value.split('||');
|
||||||
base += ' = ' + vparts[0] + ' ↔ ' + vparts[1] + ' (va-et-vient)';
|
base += ' = ' + vparts[0] + ' ↔ ' + vparts[1] + ' (va-et-vient)';
|
||||||
@@ -115,6 +117,12 @@ function nodeLabel(node) {
|
|||||||
base += ' → ' + (DEFINITIONS_NAMES[node.target_definition_id] || '?');
|
base += ' → ' + (DEFINITIONS_NAMES[node.target_definition_id] || '?');
|
||||||
} else if (node.action_type === 'jouer_son') {
|
} else if (node.action_type === 'jouer_son') {
|
||||||
base += ' « ' + (node.data_value || '?') + ' »';
|
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;
|
return base;
|
||||||
}
|
}
|
||||||
@@ -500,8 +508,10 @@ function toggleFlowActionFields() {
|
|||||||
var eventFields = document.getElementById('nf-action-event-fields');
|
var eventFields = document.getElementById('nf-action-event-fields');
|
||||||
var addRowFields = document.getElementById('nf-action-add-row-fields');
|
var addRowFields = document.getElementById('nf-action-add-row-fields');
|
||||||
var soundFields = document.getElementById('nf-action-sound-fields');
|
var soundFields = document.getElementById('nf-action-sound-fields');
|
||||||
|
var spriteFields = document.getElementById('nf-action-sprite-fields');
|
||||||
var isOnglet = typeSel.value === 'activer_onglet';
|
var isOnglet = typeSel.value === 'activer_onglet';
|
||||||
soundFields.style.display = typeSel.value === 'jouer_son' ? 'block' : 'none';
|
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';
|
screenSel.style.display = (typeSel.value === 'aller_a' || typeSel.value === 'ouvrir_ligne') ? 'block' : 'none';
|
||||||
elementFields.style.display = (typeSel.value === 'modifier_element' || isOnglet) ? 'block' : 'none';
|
elementFields.style.display = (typeSel.value === 'modifier_element' || isOnglet) ? 'block' : 'none';
|
||||||
dataFields.style.display = typeSel.value === 'modifier_donnee' ? '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';
|
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() {
|
function toggleFlowElementPropertyValue() {
|
||||||
var propSel = document.getElementById('nf-action-element-property');
|
var propSel = document.getElementById('nf-action-element-property');
|
||||||
var visSel = document.getElementById('nf-action-element-value-visibility');
|
var visSel = document.getElementById('nf-action-element-value-visibility');
|
||||||
var surbrillanceSel = document.getElementById('nf-action-element-value-surbrillance');
|
var surbrillanceSel = document.getElementById('nf-action-element-value-surbrillance');
|
||||||
var desactiveSel = document.getElementById('nf-action-element-value-desactive');
|
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 colorInp = document.getElementById('nf-action-element-value-color');
|
||||||
var numInp = document.getElementById('nf-action-element-value-number');
|
var numInp = document.getElementById('nf-action-element-value-number');
|
||||||
var textInp = document.getElementById('nf-action-element-value-text');
|
var textInp = document.getElementById('nf-action-element-value-text');
|
||||||
var toggleRow = document.getElementById('nf-action-element-toggle-row');
|
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') {
|
if (propSel.value === 'visibilite') {
|
||||||
visSel.style.display = 'block';
|
visSel.style.display = 'block';
|
||||||
toggleRow.style.display = 'none';
|
toggleRow.style.display = 'none';
|
||||||
@@ -550,6 +588,10 @@ function toggleFlowElementPropertyValue() {
|
|||||||
desactiveSel.style.display = 'block';
|
desactiveSel.style.display = 'block';
|
||||||
toggleRow.style.display = 'none';
|
toggleRow.style.display = 'none';
|
||||||
document.getElementById('nf-action-element-toggle').checked = false;
|
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') {
|
} else if (propSel.value === 'contenu') {
|
||||||
textInp.style.display = 'block';
|
textInp.style.display = 'block';
|
||||||
toggleRow.style.display = 'none';
|
toggleRow.style.display = 'none';
|
||||||
@@ -646,13 +688,15 @@ function submitNodeForm() {
|
|||||||
var prop = document.getElementById('nf-action-element-property').value;
|
var prop = document.getElementById('nf-action-element-property').value;
|
||||||
payload.element_property = prop;
|
payload.element_property = prop;
|
||||||
var isColorProp = prop === 'bg_color' || prop === 'border_color';
|
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') {
|
if (prop === 'visibilite') {
|
||||||
payload.element_value = document.getElementById('nf-action-element-value-visibility').value;
|
payload.element_value = document.getElementById('nf-action-element-value-visibility').value;
|
||||||
} else if (prop === 'surbrillance') {
|
} else if (prop === 'surbrillance') {
|
||||||
payload.element_value = document.getElementById('nf-action-element-value-surbrillance').value;
|
payload.element_value = document.getElementById('nf-action-element-value-surbrillance').value;
|
||||||
} else if (prop === 'desactive') {
|
} else if (prop === 'desactive') {
|
||||||
payload.element_value = document.getElementById('nf-action-element-value-desactive').value;
|
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') {
|
} else if (prop === 'contenu') {
|
||||||
payload.element_value = document.getElementById('nf-action-element-value-text').value;
|
payload.element_value = document.getElementById('nf-action-element-value-text').value;
|
||||||
} else {
|
} else {
|
||||||
@@ -695,6 +739,17 @@ function submitNodeForm() {
|
|||||||
// sur le nœud action, comme pour "attendre") plutôt qu'une nouvelle
|
// sur le nœud action, comme pour "attendre") plutôt qu'une nouvelle
|
||||||
// colonne dédiée.
|
// colonne dédiée.
|
||||||
payload.data_value = document.getElementById('nf-action-sound-url').value;
|
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();
|
bindPropsAutosave();
|
||||||
bindJaugeDefinitionSelect();
|
bindJaugeDefinitionSelect();
|
||||||
bindDefinitionFieldSelects('field-definition_id', ['field-filtre_champ', 'field-filtre2_champ']);
|
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é +
|
/* 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é". */
|
inerte, posé/enlevé par une action "Modifier un élément → Désactivé". */
|
||||||
.forgeDisabled{ opacity:0.45; filter:grayscale(60%); pointer-events:none; }
|
.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>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@@ -431,6 +431,11 @@
|
|||||||
<option value="{{ key }}">{{ label }}</option>
|
<option value="{{ key }}">{{ label }}</option>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</select>
|
</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="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="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é">
|
<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>
|
||||||
</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;">
|
<div id="nf-action-variable-fields" style="display:none;">
|
||||||
{% if not global_variables %}
|
{% 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>
|
<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()">
|
<select id="anim-clip-kind" onchange="toggleAnimClipKind()">
|
||||||
<option value="animate_css">Animation Animate.css</option>
|
<option value="animate_css">Animation Animate.css</option>
|
||||||
<option value="custom">Animation personnalisée — images-clés</option>
|
<option value="custom">Animation personnalisée — images-clés</option>
|
||||||
|
<option value="sprite">Animation de sprites</option>
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
@@ -592,6 +638,32 @@
|
|||||||
<button type="button" class="smallBtn button is-small" onclick="addAnimKeyframeRow()">+ Ajouter une image-clé</button>
|
<button type="button" class="smallBtn button is-small" onclick="addAnimKeyframeRow()">+ Ajouter une image-clé</button>
|
||||||
</div>
|
</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">
|
<div class="fieldRow">
|
||||||
<label style="flex:1;">Départ en secondes
|
<label style="flex:1;">Départ en secondes
|
||||||
<input type="number" id="anim-clip-start" min="0" step="0.1" value="0">
|
<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_VISIBILITY_LABELS_MAP = {{ element_visibility_labels_json|safe }};
|
||||||
var ELEMENT_SURBRILLANCE_LABELS_MAP = {{ element_surbrillance_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_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 DATA_OPERATION_LABELS_MAP = {{ data_operation_labels_json|safe }};
|
||||||
var CONDITION_OPERATOR_LABELS_MAP = {{ condition_operator_labels_json|safe }};
|
var CONDITION_OPERATOR_LABELS_MAP = {{ condition_operator_labels_json|safe }};
|
||||||
var TRIGGER_LABELS_MAP = {{ trigger_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
|
||||||