Répond au manque signalé par l'utilisateur : le déclencheur "clavier"
existant (keydown) ne se déclenche qu'UNE FOIS par appui, insuffisant
pour "maintenir une touche fait avancer/sauter le personnage en continu".
- Deux nouveaux déclencheurs (screens/flow/constants.py,
screens/scenes/flow_palette.py) : "Tant qu'une touche est maintenue"
(se répète ~20 fois/seconde tant que la touche reste enfoncée,
runScreenHeldKeyTriggers() dans triggers.js — même patron PAR ÉCRAN que
"minuteur", arrêté au changement d'écran) et "Au relâchement d'une
touche" (un seul déclenchement, scan global comme "clavier"). Combinés
à l'action existante "Modifier un objet de scène → Déplacer de... px
(relatif)", ça permet un vrai déplacement continu.
- preventDefault() sur toute touche que le jeu écoute réellement
(isGameKey(), triggers.js) : Espace/Flèches font défiler la page par
défaut, et Espace réactive en plus le bouton actuellement focus (souvent
le bouton "Jouer" qui garde le focus après l'ouverture de l'aperçu) —
ça pouvait donner l'impression qu'une touche du jeu ne faisait rien.
- Le personnage pouvait sortir du cadre de la scène en se déplaçant :
applyObjectProperty()/clampSceneObjectPosition() (static/js/play/actions.js)
bornent maintenant toute position (absolue ou relative) à
[0, scene_width/height − la taille de l'objet].
- Vitesse d'animation par défaut adaptée au nombre d'images : la valeur
fixe (8 i/s) venait d'un formulaire pensé pour les cycles Kenney (8
images) — un cycle CraftPix (walk=30 images) au même 8 i/s prenait
~4 secondes, "très lent". Le choix d'une animation dans la galerie
calcule maintenant une vitesse par défaut proportionnelle à son nombre
d'images (flow-editor.js, animation-timeline.js).
- Priorité d'animation (bug : "je ne peux pas me déplacer et sauter") :
un déclencheur de déplacement (touche maintenue) redemande "marche" à
chaque tick, écrasant aussitôt une animation ponctuelle ("sauter")
démarrée entre-temps avant qu'elle ait pu s'afficher. runSpriteAnimation()
(actions.js) laisse maintenant une animation NON BOUCLÉE en cours
(même à une seule frame, ex. une pose Kenney figée) aller jusqu'au bout
avant qu'une autre demande puisse l'interrompre.
Nouveaux tests : static/js/play/__tests__/{actions,triggers}.test.js
(idempotence + priorité d'animation, bornage aux limites de la scène,
isGameKey) ; tests/test_scene_edit_view.py (persistance d'un nœud
"touche_maintenue").
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
405 lines
18 KiB
JavaScript
405 lines
18 KiB
JavaScript
// ---------- Timeline d'animation : piste par élément, clips Animate.css ou
|
|
// personnalisés (images-clés), glissés/redimensionnés à la souris sur un
|
|
// axe du temps en secondes depuis l'affichage de l'écran. Même principe de
|
|
// panneau rétractable/redimensionnable que "Logique de la scène"
|
|
// ci-dessus, positionné juste en dessous. ----------
|
|
|
|
// ANIM_CLIPS/ANIMATE_CSS_LABELS/GAME_SLUG : voir le petit <script> inline
|
|
// en tête de screen_edit.html (données Jinja).
|
|
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; }
|
|
|
|
function animClipLabel(clip) {
|
|
if (clip.kind === 'custom') return '🎨 Personnalisée';
|
|
if (clip.kind === 'sprite') return '🏃 Sprite';
|
|
return '🎞️ ' + (ANIMATE_CSS_LABELS[clip.animate_name] || clip.animate_name);
|
|
}
|
|
|
|
function renderAnimTimeline() {
|
|
var lanesWrap = document.getElementById('animTracks');
|
|
var ticksWrap = document.getElementById('animRuler');
|
|
if (!lanesWrap || !ticksWrap) return;
|
|
lanesWrap.innerHTML = '';
|
|
ticksWrap.innerHTML = '';
|
|
|
|
var maxTime = ANIM_CLIPS.reduce(function(m, c) { return Math.max(m, (c.start_time || 0) + (c.duration || 1)); }, 5);
|
|
var totalSeconds = Math.ceil(maxTime) + 2;
|
|
var totalWidth = totalSeconds * ANIM_PX_PER_SEC;
|
|
|
|
var spacer = document.createElement('div');
|
|
spacer.className = 'animRulerSpacer';
|
|
ticksWrap.appendChild(spacer);
|
|
var ticksLane = document.createElement('div');
|
|
ticksLane.className = 'animRulerTicksWrap';
|
|
ticksLane.style.width = totalWidth + 'px';
|
|
for (var s = 0; s <= totalSeconds; s++) {
|
|
var tick = document.createElement('div');
|
|
tick.className = 'animRulerTick';
|
|
tick.style.left = (s * ANIM_PX_PER_SEC) + 'px';
|
|
tick.textContent = s + 's';
|
|
ticksLane.appendChild(tick);
|
|
}
|
|
ticksWrap.appendChild(ticksLane);
|
|
|
|
var elementIds = [];
|
|
ANIM_CLIPS.forEach(function(c) { if (elementIds.indexOf(c.element_id) === -1) elementIds.push(c.element_id); });
|
|
|
|
if (!elementIds.length) {
|
|
var empty = document.createElement('p');
|
|
empty.className = 'hint';
|
|
empty.style.padding = '10px 0';
|
|
empty.textContent = "Aucun clip pour l'instant — choisis un élément ci-dessus et clique « Ajouter un clip ».";
|
|
lanesWrap.appendChild(empty);
|
|
return;
|
|
}
|
|
|
|
elementIds.forEach(function(elId) {
|
|
var track = document.createElement('div');
|
|
track.className = 'animTrack';
|
|
|
|
var label = document.createElement('div');
|
|
label.className = 'animTrackLabel';
|
|
label.textContent = ELEMENTS_LABELS[elId] || ('elt-' + elId);
|
|
track.appendChild(label);
|
|
|
|
var lane = document.createElement('div');
|
|
lane.className = 'animTrackLane';
|
|
lane.style.width = totalWidth + 'px';
|
|
|
|
ANIM_CLIPS.filter(function(c) { return c.element_id === elId; }).forEach(function(clip) {
|
|
var block = document.createElement('div');
|
|
block.className = 'animClip';
|
|
block.style.left = ((clip.start_time || 0) * ANIM_PX_PER_SEC) + 'px';
|
|
block.style.width = Math.max(24, (clip.duration || 1) * ANIM_PX_PER_SEC) + 'px';
|
|
block.textContent = animClipLabel(clip);
|
|
block.dataset.clipId = clip.id;
|
|
block.addEventListener('mousedown', function(e) { onAnimClipMouseDown(e, clip.id); });
|
|
var handle = document.createElement('div');
|
|
handle.className = 'animClipResizeHandle';
|
|
handle.addEventListener('mousedown', function(e) { onAnimClipResizeMouseDown(e, clip.id); });
|
|
block.appendChild(handle);
|
|
lane.appendChild(block);
|
|
});
|
|
|
|
track.appendChild(lane);
|
|
lanesWrap.appendChild(track);
|
|
});
|
|
}
|
|
|
|
function onAnimClipMouseDown(e, clipId) {
|
|
if (e.target.classList.contains('animClipResizeHandle')) return;
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
var clip = ANIM_CLIPS.find(function(c) { return c.id === clipId; });
|
|
var block = document.querySelector('.animClip[data-clip-id="' + clipId + '"]');
|
|
var startX = e.clientX;
|
|
var startLeft = parseFloat(block.style.left);
|
|
var moved = false;
|
|
function onMove(ev) {
|
|
var dx = ev.clientX - startX;
|
|
if (Math.abs(dx) > 3) moved = true;
|
|
block.style.left = Math.max(0, startLeft + dx) + 'px';
|
|
}
|
|
function onUp() {
|
|
document.removeEventListener('mousemove', onMove);
|
|
document.removeEventListener('mouseup', onUp);
|
|
if (moved) {
|
|
var newStart = Math.round((parseFloat(block.style.left) / ANIM_PX_PER_SEC) * 10) / 10;
|
|
clip.start_time = newStart;
|
|
updateAnimClip(clipId, { start_time: newStart });
|
|
} else {
|
|
openAnimClipForm(clip);
|
|
}
|
|
}
|
|
document.addEventListener('mousemove', onMove);
|
|
document.addEventListener('mouseup', onUp);
|
|
}
|
|
|
|
function onAnimClipResizeMouseDown(e, clipId) {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
var clip = ANIM_CLIPS.find(function(c) { return c.id === clipId; });
|
|
var block = document.querySelector('.animClip[data-clip-id="' + clipId + '"]');
|
|
var startX = e.clientX;
|
|
var startWidth = parseFloat(block.style.width);
|
|
function onMove(ev) {
|
|
var dx = ev.clientX - startX;
|
|
block.style.width = Math.max(10, startWidth + dx) + 'px';
|
|
}
|
|
function onUp() {
|
|
document.removeEventListener('mousemove', onMove);
|
|
document.removeEventListener('mouseup', onUp);
|
|
var newDuration = Math.round((parseFloat(block.style.width) / ANIM_PX_PER_SEC) * 10) / 10;
|
|
clip.duration = newDuration;
|
|
updateAnimClip(clipId, { duration: newDuration });
|
|
}
|
|
document.addEventListener('mousemove', onMove);
|
|
document.addEventListener('mouseup', onUp);
|
|
}
|
|
|
|
function updateAnimClip(clipId, fields) {
|
|
return fetch(animUrl('/animations/clips/' + clipId + '/update'), {
|
|
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(fields)
|
|
}).then(function(r) { return r.json(); }).then(function(updated) {
|
|
var idx = ANIM_CLIPS.findIndex(function(c) { return c.id === updated.id; });
|
|
if (idx > -1) ANIM_CLIPS[idx] = updated;
|
|
return updated;
|
|
});
|
|
}
|
|
|
|
function addAnimTrack() {
|
|
var elId = parseInt(document.getElementById('anim-new-track-element').value, 10);
|
|
fetch(animUrl('/screens/' + ANIM_SCREEN_ID + '/animations/clips/add'), {
|
|
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ element_id: elId, kind: 'animate_css', animate_name: 'fadeIn', start_time: 0, duration: 1 }),
|
|
}).then(function(r) { return r.json(); }).then(function(clip) {
|
|
ANIM_CLIPS.push(clip);
|
|
renderAnimTimeline();
|
|
openAnimClipForm(clip);
|
|
});
|
|
}
|
|
|
|
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';
|
|
if (kind === 'sprite') populateAnimClipSpriteGrid();
|
|
}
|
|
|
|
// Phase 8 — mirror de onSpriteTargetElementChange() (flow-editor.js), mais
|
|
// scopé au personnage PROPRIÉTAIRE de ce clip (clip.element_id, fixé à la
|
|
// création — voir addAnimTrack()) : pas de sélecteur d'élément séparé ici,
|
|
// contrairement au nœud de flow (une action peut viser n'importe quel
|
|
// personnage, un clip appartient à un seul élément dès sa création).
|
|
function populateAnimClipSpriteGrid(preselectedName) {
|
|
var grid = document.getElementById('anim-clip-sprite-animation-grid');
|
|
var nameField = document.getElementById('anim-clip-sprite-animation-name');
|
|
var noAnimHint = document.getElementById('anim-clip-sprite-no-animations');
|
|
grid.innerHTML = '';
|
|
var clip = ANIM_CLIPS.find(function(c) { return c.id === animCurrentClipId; });
|
|
var animations = clip ? (ELEMENT_ANIMATIONS_MAP[clip.element_id] || {}) : {};
|
|
var hasAnimations = Object.keys(animations).length > 0;
|
|
noAnimHint.style.display = hasAnimations ? 'none' : 'block';
|
|
Object.keys(animations).forEach(function(animName) {
|
|
var frames = animations[animName];
|
|
if (!frames.length) return;
|
|
var tile = document.createElement('button');
|
|
tile.type = 'button';
|
|
tile.className = 'iconGalleryTile' + (animName === preselectedName ? ' selected' : '');
|
|
tile.title = animName;
|
|
tile.innerHTML = '<img src="' + frames[0] + '" alt="" class="personnageThumb">';
|
|
tile.onclick = function() {
|
|
grid.querySelectorAll('.iconGalleryTile').forEach(function(t) { t.classList.remove('selected'); });
|
|
tile.classList.add('selected');
|
|
nameField.value = animName;
|
|
// Vitesse par défaut proportionnelle au nombre d'images — voir le
|
|
// même correctif dans onSpriteTargetElementChange() (flow-editor.js).
|
|
var fpsField = document.getElementById('anim-clip-sprite-fps');
|
|
if (fpsField) fpsField.value = Math.max(8, Math.min(24, frames.length));
|
|
};
|
|
grid.appendChild(tile);
|
|
});
|
|
if (preselectedName && animations[preselectedName]) nameField.value = preselectedName;
|
|
}
|
|
|
|
function addAnimKeyframeRow(percent, cssText) {
|
|
var wrap = document.getElementById('anim-clip-keyframes');
|
|
var row = document.createElement('div');
|
|
row.className = 'fieldRow animKeyframeRow';
|
|
var percentInput = document.createElement('input');
|
|
percentInput.type = 'number'; percentInput.min = '0'; percentInput.max = '100'; percentInput.step = '1';
|
|
percentInput.className = 'anim-kf-percent'; percentInput.style.flex = '0 0 70px';
|
|
percentInput.value = percent != null ? percent : 0;
|
|
var cssInput = document.createElement('textarea');
|
|
cssInput.className = 'anim-kf-css'; cssInput.style.flex = '1'; cssInput.rows = 2;
|
|
cssInput.placeholder = 'ex. opacity: 0; transform: scale(0.5);';
|
|
cssInput.value = cssText || '';
|
|
var removeBtn = document.createElement('button');
|
|
removeBtn.type = 'button'; removeBtn.className = 'smallBtn danger';
|
|
removeBtn.textContent = '✕';
|
|
removeBtn.onclick = function() { row.remove(); };
|
|
row.appendChild(percentInput);
|
|
row.appendChild(cssInput);
|
|
row.appendChild(removeBtn);
|
|
wrap.appendChild(row);
|
|
}
|
|
|
|
function collectAnimKeyframes() {
|
|
var rows = document.querySelectorAll('#anim-clip-keyframes .animKeyframeRow');
|
|
var keyframes = [];
|
|
rows.forEach(function(row) {
|
|
var percent = parseFloat(row.querySelector('.anim-kf-percent').value) || 0;
|
|
var cssText = row.querySelector('.anim-kf-css').value;
|
|
var props = {};
|
|
cssText.split(';').forEach(function(decl) {
|
|
var parts = decl.split(':');
|
|
if (parts.length === 2) {
|
|
var prop = parts[0].trim(), val = parts[1].trim();
|
|
if (prop && val) props[prop] = val;
|
|
}
|
|
});
|
|
keyframes.push({ percent: percent, props: props });
|
|
});
|
|
return keyframes;
|
|
}
|
|
|
|
function openAnimClipForm(clip) {
|
|
animCurrentClipId = clip.id;
|
|
document.getElementById('animClipForm').style.display = 'block';
|
|
document.getElementById('animClipFormTitle').textContent = 'Clip — ' + (ELEMENTS_LABELS[clip.element_id] || ('elt-' + clip.element_id));
|
|
document.getElementById('anim-clip-kind').value = clip.kind || 'animate_css';
|
|
document.getElementById('anim-clip-animate-name').value = clip.animate_name || 'fadeIn';
|
|
document.getElementById('anim-clip-start').value = clip.start_time || 0;
|
|
document.getElementById('anim-clip-duration').value = clip.duration || 1;
|
|
document.getElementById('anim-clip-delay').value = clip.delay || 0;
|
|
document.getElementById('anim-clip-easing').value = clip.easing || 'ease';
|
|
document.getElementById('anim-clip-iteration').value = clip.iteration_count || '1';
|
|
document.getElementById('anim-clip-keyframes').innerHTML = '';
|
|
var keyframes = [];
|
|
if (clip.custom_keyframes) {
|
|
try { keyframes = JSON.parse(clip.custom_keyframes); } catch (e) { /* ignore */ }
|
|
}
|
|
if (!keyframes.length) keyframes = [{ percent: 0, props: { opacity: '0' } }, { percent: 100, props: { opacity: '1' } }];
|
|
keyframes.forEach(function(k) {
|
|
var cssText = Object.keys(k.props || {}).map(function(p) { return p + ': ' + k.props[p] + ';'; }).join(' ');
|
|
addAnimKeyframeRow(k.percent, cssText);
|
|
});
|
|
// Phase 8 — clip.custom_keyframes stocke {animation, fps} en JSON pour un
|
|
// clip kind="sprite" (pas une liste d'images-clés CSS, voir ci-dessus) :
|
|
// le NOM de l'animation, pas ses frames — résolues à l'exécution depuis
|
|
// le personnage ACTUELLEMENT assigné (voir resolveSpriteFrames() dans
|
|
// static/js/play/actions.js), jamais figées ici.
|
|
var spriteData = {};
|
|
if (clip.kind === 'sprite' && clip.custom_keyframes) {
|
|
try { spriteData = JSON.parse(clip.custom_keyframes); } catch (e) { /* ignore */ }
|
|
}
|
|
document.getElementById('anim-clip-sprite-fps').value = spriteData.fps || 8;
|
|
toggleAnimClipKind();
|
|
if (clip.kind === 'sprite') populateAnimClipSpriteGrid(spriteData.animation);
|
|
document.querySelectorAll('.animClip').forEach(function(b) {
|
|
b.classList.toggle('selected', parseInt(b.dataset.clipId, 10) === clip.id);
|
|
});
|
|
}
|
|
|
|
function closeAnimClipForm() {
|
|
animCurrentClipId = null;
|
|
document.getElementById('animClipForm').style.display = 'none';
|
|
document.querySelectorAll('.animClip').forEach(function(b) { b.classList.remove('selected'); });
|
|
}
|
|
|
|
function saveAnimClipForm() {
|
|
if (!animCurrentClipId) return Promise.resolve();
|
|
var kind = document.getElementById('anim-clip-kind').value;
|
|
var fields = {
|
|
kind: kind,
|
|
animate_name: document.getElementById('anim-clip-animate-name').value,
|
|
start_time: parseFloat(document.getElementById('anim-clip-start').value) || 0,
|
|
duration: parseFloat(document.getElementById('anim-clip-duration').value) || 1,
|
|
delay: parseFloat(document.getElementById('anim-clip-delay').value) || 0,
|
|
easing: document.getElementById('anim-clip-easing').value,
|
|
iteration_count: document.getElementById('anim-clip-iteration').value,
|
|
};
|
|
if (kind === 'custom') fields.custom_keyframes = collectAnimKeyframes();
|
|
if (kind === 'sprite') {
|
|
// Phase 8 — stocke le NOM de l'animation choisie (miniature cliquée,
|
|
// voir populateAnimClipSpriteGrid()), PAS ses frames — résolues à
|
|
// l'exécution depuis le personnage ACTUELLEMENT assigné (voir
|
|
// resolveSpriteFrames() dans static/js/play/actions.js), pour que
|
|
// changer de personnage Forge affecte aussi les clips déjà posés dans
|
|
// la Timeline. La boucle reste portée par iteration_count
|
|
// (1/2/3/infinite, ci-dessus).
|
|
fields.custom_keyframes = {
|
|
animation: document.getElementById('anim-clip-sprite-animation-name').value,
|
|
fps: parseInt(document.getElementById('anim-clip-sprite-fps').value, 10) || 8,
|
|
};
|
|
}
|
|
return updateAnimClip(animCurrentClipId, fields).then(function() { renderAnimTimeline(); });
|
|
}
|
|
|
|
function deleteAnimClipForm() {
|
|
if (!animCurrentClipId) return;
|
|
if (!confirm('Supprimer ce clip ?')) return;
|
|
var id = animCurrentClipId;
|
|
fetch(animUrl('/animations/clips/' + id + '/delete'), { method: 'POST' }).then(function() {
|
|
ANIM_CLIPS = ANIM_CLIPS.filter(function(c) { return c.id !== id; });
|
|
closeAnimClipForm();
|
|
renderAnimTimeline();
|
|
});
|
|
}
|
|
|
|
function previewAnimClip() {
|
|
if (!animCurrentClipId) return;
|
|
saveAnimClipForm().then(function() {
|
|
var clip = ANIM_CLIPS.find(function(c) { return c.id === animCurrentClipId; });
|
|
if (!clip) return;
|
|
var target = document.querySelector('#canvas [data-anim-target="' + clip.element_id + '"]');
|
|
if (!target) return;
|
|
var duration = clip.duration || 1, delay = clip.delay || 0;
|
|
var iterationCount = clip.iteration_count || '1';
|
|
if (clip.kind === 'custom') {
|
|
var keyframes = [];
|
|
try { keyframes = JSON.parse(clip.custom_keyframes || '[]'); } catch (e) { /* ignore */ }
|
|
var steps = keyframes.map(function(k) {
|
|
var props = Object.keys(k.props || {}).map(function(p) { return p + ':' + k.props[p] + ';'; }).join(' ');
|
|
return k.percent + '% { ' + props + ' }';
|
|
}).join(' ');
|
|
var styleTag = document.getElementById('animEditorPreviewStyle');
|
|
if (!styleTag) {
|
|
styleTag = document.createElement('style');
|
|
styleTag.id = 'animEditorPreviewStyle';
|
|
document.head.appendChild(styleTag);
|
|
}
|
|
styleTag.textContent = '@keyframes animEditorPreview { ' + steps + ' }';
|
|
target.style.animation = 'animEditorPreview ' + duration + 's ' + (clip.easing || 'ease') + ' ' + delay + 's ' + iterationCount;
|
|
target.addEventListener('animationend', function handler() {
|
|
target.style.animation = '';
|
|
target.removeEventListener('animationend', handler);
|
|
});
|
|
} else if (clip.kind === 'sprite') {
|
|
var spriteData = {};
|
|
try { spriteData = JSON.parse(clip.custom_keyframes || '{}'); } catch (e) { /* ignore */ }
|
|
// Phase 8 — résout le NOM de l'animation depuis les animations
|
|
// ACTUELLES du personnage (même logique que resolveSpriteFrames()
|
|
// côté jeu, mais ELEMENT_ANIMATIONS_MAP n'existe que dans l'éditeur).
|
|
var ownerAnimations = ELEMENT_ANIMATIONS_MAP[clip.element_id] || {};
|
|
var frames = ownerAnimations[spriteData.animation] || [];
|
|
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');
|
|
target.style.setProperty('--animate-delay', delay + 's');
|
|
target.style.animationIterationCount = iterationCount;
|
|
target.classList.add('animate__animated', animClass);
|
|
target.addEventListener('animationend', function handler() {
|
|
target.classList.remove('animate__animated', animClass);
|
|
target.removeEventListener('animationend', handler);
|
|
});
|
|
}
|
|
});
|
|
}
|
|
|
|
// Panneau timeline absent sur un écran-modèle (élément de jeu), comme le
|
|
// panneau de logique — voir le commentaire Jinja au-dessus de .builderTabs.
|
|
var HAS_ANIM_PANEL = !!document.getElementById('animTabPanel');
|
|
if (HAS_ANIM_PANEL) { renderAnimTimeline(); }
|