diff --git a/publish/build_package.py b/publish/build_package.py index a0c2e164..8a98ac70 100644 --- a/publish/build_package.py +++ b/publish/build_package.py @@ -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" diff --git a/routes/screens/screen_edit.py b/routes/screens/screen_edit.py index 2123c7e3..3511a215 100644 --- a/routes/screens/screen_edit.py +++ b/routes/screens/screen_edit.py @@ -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), diff --git a/screens/__init__.py b/screens/__init__.py index c66966d6..2e40a289 100644 --- a/screens/__init__.py +++ b/screens/__init__.py @@ -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, diff --git a/screens/labels/action_type_labels.py b/screens/labels/action_type_labels.py index 7200e82e..5d78197c 100644 --- a/screens/labels/action_type_labels.py +++ b/screens/labels/action_type_labels.py @@ -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", } diff --git a/screens/labels/element_action_properties.py b/screens/labels/element_action_properties.py index 36633dbe..666236e0 100644 --- a/screens/labels/element_action_properties.py +++ b/screens/labels/element_action_properties.py @@ -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) diff --git a/screens/labels/sprite_library.py b/screens/labels/sprite_library.py new file mode 100644 index 00000000..a8452e04 --- /dev/null +++ b/screens/labels/sprite_library.py @@ -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//*.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"], + }, + }, +} diff --git a/static/characters/LICENSE-kenney.txt b/static/characters/LICENSE-kenney.txt new file mode 100644 index 00000000..918d58da --- /dev/null +++ b/static/characters/LICENSE-kenney.txt @@ -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 \ No newline at end of file diff --git a/static/characters/male-adventurer/idle.png b/static/characters/male-adventurer/idle.png new file mode 100644 index 00000000..5f596a3b Binary files /dev/null and b/static/characters/male-adventurer/idle.png differ diff --git a/static/characters/male-adventurer/jump.png b/static/characters/male-adventurer/jump.png new file mode 100644 index 00000000..32a335e5 Binary files /dev/null and b/static/characters/male-adventurer/jump.png differ diff --git a/static/characters/male-adventurer/walk0.png b/static/characters/male-adventurer/walk0.png new file mode 100644 index 00000000..bbceaf0b Binary files /dev/null and b/static/characters/male-adventurer/walk0.png differ diff --git a/static/characters/male-adventurer/walk1.png b/static/characters/male-adventurer/walk1.png new file mode 100644 index 00000000..741632d9 Binary files /dev/null and b/static/characters/male-adventurer/walk1.png differ diff --git a/static/characters/male-adventurer/walk2.png b/static/characters/male-adventurer/walk2.png new file mode 100644 index 00000000..a58c92ec Binary files /dev/null and b/static/characters/male-adventurer/walk2.png differ diff --git a/static/characters/male-adventurer/walk3.png b/static/characters/male-adventurer/walk3.png new file mode 100644 index 00000000..6d58c418 Binary files /dev/null and b/static/characters/male-adventurer/walk3.png differ diff --git a/static/characters/male-adventurer/walk4.png b/static/characters/male-adventurer/walk4.png new file mode 100644 index 00000000..dd1caa72 Binary files /dev/null and b/static/characters/male-adventurer/walk4.png differ diff --git a/static/characters/male-adventurer/walk5.png b/static/characters/male-adventurer/walk5.png new file mode 100644 index 00000000..6bbea3a6 Binary files /dev/null and b/static/characters/male-adventurer/walk5.png differ diff --git a/static/characters/male-adventurer/walk6.png b/static/characters/male-adventurer/walk6.png new file mode 100644 index 00000000..aaa677b4 Binary files /dev/null and b/static/characters/male-adventurer/walk6.png differ diff --git a/static/characters/male-adventurer/walk7.png b/static/characters/male-adventurer/walk7.png new file mode 100644 index 00000000..6d58c7b5 Binary files /dev/null and b/static/characters/male-adventurer/walk7.png differ diff --git a/static/characters/zombie/idle.png b/static/characters/zombie/idle.png new file mode 100644 index 00000000..7b9ca55c Binary files /dev/null and b/static/characters/zombie/idle.png differ diff --git a/static/characters/zombie/jump.png b/static/characters/zombie/jump.png new file mode 100644 index 00000000..367951e0 Binary files /dev/null and b/static/characters/zombie/jump.png differ diff --git a/static/characters/zombie/walk0.png b/static/characters/zombie/walk0.png new file mode 100644 index 00000000..2bbaaf37 Binary files /dev/null and b/static/characters/zombie/walk0.png differ diff --git a/static/characters/zombie/walk1.png b/static/characters/zombie/walk1.png new file mode 100644 index 00000000..cb80d300 Binary files /dev/null and b/static/characters/zombie/walk1.png differ diff --git a/static/characters/zombie/walk2.png b/static/characters/zombie/walk2.png new file mode 100644 index 00000000..026ec77c Binary files /dev/null and b/static/characters/zombie/walk2.png differ diff --git a/static/characters/zombie/walk3.png b/static/characters/zombie/walk3.png new file mode 100644 index 00000000..ea24d0ea Binary files /dev/null and b/static/characters/zombie/walk3.png differ diff --git a/static/characters/zombie/walk4.png b/static/characters/zombie/walk4.png new file mode 100644 index 00000000..08b4f223 Binary files /dev/null and b/static/characters/zombie/walk4.png differ diff --git a/static/characters/zombie/walk5.png b/static/characters/zombie/walk5.png new file mode 100644 index 00000000..ab9ed64e Binary files /dev/null and b/static/characters/zombie/walk5.png differ diff --git a/static/characters/zombie/walk6.png b/static/characters/zombie/walk6.png new file mode 100644 index 00000000..a648607d Binary files /dev/null and b/static/characters/zombie/walk6.png differ diff --git a/static/characters/zombie/walk7.png b/static/characters/zombie/walk7.png new file mode 100644 index 00000000..1383724d Binary files /dev/null and b/static/characters/zombie/walk7.png differ diff --git a/static/js/play/actions.js b/static/js/play/actions.js index b4676890..f0b4ad7c 100644 --- a/static/js/play/actions.js +++ b/static/js/play/actions.js @@ -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(); +} diff --git a/static/js/play/screens.js b/static/js/play/screens.js index 66cb69c7..1d8aa7e8 100644 --- a/static/js/play/screens.js +++ b/static/js/play/screens.js @@ -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'); diff --git a/static/js/screen_edit/animation-timeline.js b/static/js/screen_edit/animation-timeline.js index 0d383370..ad69c17e 100644 --- a/static/js/screen_edit/animation-timeline.js +++ b/static/js/screen_edit/animation-timeline.js @@ -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'); diff --git a/static/js/screen_edit/flow-editor.js b/static/js/screen_edit/flow-editor.js index b59349b2..0f88348f 100644 --- a/static/js/screen_edit/flow-editor.js +++ b/static/js/screen_edit/flow-editor.js @@ -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, + }); } } diff --git a/static/js/screen_edit/panel-init.js b/static/js/screen_edit/panel-init.js index d560661f..0912b962 100644 --- a/static/js/screen_edit/panel-init.js +++ b/static/js/screen_edit/panel-init.js @@ -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']); diff --git a/templates/play.html b/templates/play.html index c5715d78..b068393c 100644 --- a/templates/play.html +++ b/templates/play.html @@ -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); } diff --git a/templates/screen_edit.html b/templates/screen_edit.html index aa9fa8c7..ff67e28c 100644 --- a/templates/screen_edit.html +++ b/templates/screen_edit.html @@ -431,6 +431,11 @@ {% endfor %} + @@ -500,6 +505,46 @@ + + + + + +