Files
Forge-Engine/templates/scene_edit.html
T
williamandClaude Sonnet 5 449c36fd5d
Build and deploy / test-python (push) Failing after 12s
Build and deploy / test-js (push) Successful in 6s
Build and deploy / build-and-push (push) Skipped
Build and deploy / deploy (push) Skipped
Bibliothèque de sprites animaux CraftPix (1/3) : catalogue, galerie, accès admin
Premier commit d'une fonctionnalité découpée en plusieurs lots (voir le
plan "Bibliothèque de sprites animaux CraftPix") : intègre 14 familles
d'animaux (15 variantes de couleur chacune) comme personnages Forge
sélectionnables, à côté des 6 Kenney existants — réservé au rôle admin,
licence CraftPix oblige (interdiction contractuelle de rendre ces sprites
utilisables par un compte "user" via l'application).

- screens/labels/animal_sprite_library.py (nouveau) : charge un manifest
  JSON généré une fois (voir scripts/generate_animal_sprite_manifest.py,
  commit suivant) et construit ADMIN_SPRITE_LIBRARY, dans le même format
  que l'existant PUBLIC_SPRITE_LIBRARY (screens/labels/sprite_library.py,
  ex-SPRITE_LIBRARY, renommé pour distinguer les deux). screens.SPRITE_LIBRARY
  reste le catalogue FUSIONNÉ (utilisé par resolve_personnage_animations
  pour la résolution runtime, sans filtrage par rôle — voir le constat
  d'exploration : le payload de jeu et /jouer/<slug> ne vérifient déjà
  aucun rôle nulle part).
- screens/labels/sprite_gallery.py (nouveau) : sprite_gallery_families()
  groupe la galerie par famille — un animal n'apparaît qu'une fois (sa
  variante "de base"), ses 15 couleurs se choisissent depuis le panneau
  de propriétés (render_variant_gallery, templates/screen_edit.html),
  répondant à la suggestion de l'utilisateur plutôt que d'encombrer la
  galerie d'ajout de 210 tuiles quasi identiques.
- routes/screens/screen_edit.py, routes/scenes/scene_edit_view.py :
  la galerie passée au template est filtrée par rôle
  (PUBLIC_SPRITE_LIBRARY pour un compte "user", SPRITE_LIBRARY complet
  pour un admin) — même idiome que core/auth_guard.py.
- core/sprite_gate.py (nouveau) + 4 routes d'écriture (element_add,
  element_set_personnage_data, scene_object_add, scene_object_personnage_data) :
  ferme la brèche d'un POST direct qui contournerait la galerie filtrée
  (403 si un compte non-admin tente d'assigner un personnage animal).
- tests/conftest.py : nouvelles fixtures user_client/user_game (compte
  "user" non-admin avec un projet assigné) pour tester le filtrage par
  rôle de bout en bout.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-31 21:49:05 +02:00

697 lines
39 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{% extends "base.html" %}
{% block title %}{{ screen.name }} — {{ game.name }}{% endblock %}
{% block content_class %}content-builder{% endblock %}
{% block body_class %}builderBody{% endblock %}
{% block extra_head %}
<link rel="stylesheet" href="{{ url_for('static', filename='vendor/fonts/fonts.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='vendor/animate.min.css') }}">
<style>
/* render_scene_object.py positionne son <img> en absolu (left/top/width/
height en px) pour s'afficher correctement en enfant DIRECT de
.playScreen.playScene (voir templates/play.html) — dans le canevas de
CET éditeur, cette même balise est nichée dans .canvasElementInner,
lui-même déjà positionné/dimensionné par .canvasElement (voir plus
bas) : sans ce correctif, l'image se positionnait EN PLUS depuis
#canvas (son ancêtre positionné le plus proche), en double du
décalage déjà appliqué par .canvasElement. */
.canvasElementInner > img{ position:static !important; left:auto !important; top:auto !important; width:100% !important; height:100% !important; z-index:auto !important; }
</style>
{% endblock %}
{% block content %}
<!-- Fondations multi-éditeurs — éditeur de scène 2D (jeux "jeu_2d", voir
db/games/get_game_type.py). Réutilise TEL QUEL le moteur de logique de
flow partagé (mêmes JS : flow-editor.js/tabs-and-blocks.js/
animation-timeline.js) — seule la palette de nœuds et le panneau
gauche/canevas diffèrent de templates/screen_edit.html. Voir le plan
"Fondations d'une plateforme multi-éditeurs". -->
<p class="hint" style="background:var(--panel); border:1px solid var(--border); border-radius:8px; padding:10px 14px; margin:10px 0;">
🎮 Jeu 2D — scène à taille fixe ({{ screen.scene_width }}×{{ screen.scene_height }} px).
</p>
<div class="builder3" id="builder3">
{% set obj = objects|selectattr("id", "equalto", selected_id)|first if selected_id else none %}
<button type="button" id="panelLeftReopenBtn" class="button is-small floatPanelReopenBtn" onclick="openLeftPanel()" title="Rouvrir le panneau des objets">🧩 Objets</button>
<div class="floatPanel floatPanel--left" id="panelLeftFloat">
<div class="floatPanelHeader" data-drag-panel="panelLeftFloat" data-drag-key="left">
<span class="floatPanelTitle">⋮⋮ Objets de cette scène</span>
<button type="button" class="button is-small" onclick="closeLeftPanel()" title="Fermer">✕</button>
</div>
<div class="floatPanelBody">
<form method="post" action="{{ url_for('screen_rename', slug=game.slug, screen_id=screen.id) }}" class="field has-addons" style="align-items:center; margin-bottom:8px;">
<div class="control is-expanded"><input class="input is-small" type="text" name="name" value="{{ screen.name }}" required></div>
<div class="control"><button type="submit" class="button is-small">Renommer</button></div>
</form>
<a class="button is-small is-primary is-fullwidth mb-2" href="{{ url_for('game_play', slug=game.slug) }}" target="_blank">▶️ Jouer depuis le début</a>
<hr style="border-color:var(--border); margin:10px 0;">
<h2 class="subtitle is-6">Objets de cette scène</h2>
{% if objects %}
<div class="elementTree">
{% for o in objects %}
<div class="treeNode" data-element-id="{{ o.id }}">
<a class="treeNodeLabel {{ 'selected' if o.id == selected_id else '' }}" href="{{ url_for('screen_edit', slug=game.slug, screen_id=screen.id, selected=o.id) }}">
{{ '🏃' if o.kind == 'personnage' else '🖼️' }} <span class="treeLabel">{{ o.name or ('Personnage' if o.kind == 'personnage' else 'Décor') }}</span>
</a>
</div>
{% endfor %}
</div>
{% else %}
<p class="hint">Aucun objet pour l'instant.</p>
{% endif %}
<!-- Galerie de personnages Forge (VRAIES miniatures) — cliquer une
tuile pose un personnage déjà configuré, animé immédiatement (voir
personnage-preview.js, réutilisé tel quel : ne dépend que de
data-personnage, pas de la table d'origine). -->
<details class="propGroup" open>
<summary>🎭 Personnages</summary>
<div class="propGroupBody">
<div class="iconGallery personnageGallery">
{% for slug_c, character in sprite_library_families %}
<button type="button" class="iconGalleryTile" title="{{ character.label }}" onclick="addSceneObject('personnage', '{{ slug_c }}')">
<img src="{{ character.animations.idle[0] }}" alt="{{ character.label }}" class="personnageThumb">
</button>
{% endfor %}
</div>
<p class="help">Personnage Kenney (licence libre CC0) ou animal Forge, avec toutes ses animations — les variantes de couleur d'un animal se choisissent depuis le panneau de propriétés.</p>
</div>
</details>
<button type="button" class="button is-small is-fullwidth" onclick="addSceneObject('decor')">🖼️ + Ajouter un décor</button>
</div>
</div>
<div class="builderCanvasArea">
<div class="builderTabs" id="builderTabs">
<button type="button" class="builderTabBtn active" data-tab="screen" onclick="switchBuilderTab('screen')">🖥️ Scène</button>
<button type="button" class="builderTabBtn" data-tab="blocks" onclick="switchBuilderTab('blocks')">🧩 Blocs de logique</button>
<button type="button" class="builderTabBtn" data-tab="anim" onclick="switchBuilderTab('anim')">🎬 Timeline d'animation</button>
<button type="button" class="builderTabBtn" data-tab="events" onclick="switchBuilderTab('events')">📣 Événements</button>
</div>
<div class="builderTabPanel active" data-tab-panel="screen">
<div class="canvasFrame">
<div class="canvas" id="canvas" style="width:{{ screen.scene_width }}px; height:{{ screen.scene_height }}px; aspect-ratio:auto; margin:0 auto; position:relative; overflow:hidden; background:#1a1d24;">
{% for o in objects %}
<div class="canvasElement {{ 'selected' if o.id == selected_id else '' }}" id="el-{{ o.id }}"
style="position:absolute; left:{{ o.x }}px; top:{{ o.y }}px; width:{{ o.width }}px; height:{{ o.height }}px;"
onmousedown="onSceneObjectMouseDown(event, {{ o.id }})">
<div class="canvasElementInner">{{ o.rendered_html|safe }}</div>
<div class="resizeHandle" onmousedown="onSceneObjectResizeMouseDown(event, {{ o.id }})"></div>
</div>
{% endfor %}
</div>
</div>
<p class="hint" style="text-align:center;">Glisse pour déplacer, tire le coin pour redimensionner, clique pour sélectionner. Positions en pixels (scène à taille fixe).</p>
</div>
<div class="builderTabPanel" id="blocksTabPanel" data-tab-panel="blocks">
<div class="dashPanelHeader">
<h2>Blocs de logique</h2>
<button type="button" class="button is-small primary" onclick="toggleDashCreate('blocks')">+ Créer un bloc</button>
</div>
<form method="post" action="{{ url_for('flow_block_new', slug=game.slug, screen_id=screen.id) }}" class="dashCreateBar" id="dashCreate-blocks" style="display:none;">
<input type="text" name="name" placeholder="Nom du bloc (ex. Ouvrir la porte)" required autocomplete="off">
<input type="text" name="description" placeholder="Description (optionnel)">
<button type="submit" class="primary">Créer</button>
</form>
{% if blocks_view %}
{% for b in blocks_view %}
<form method="post" action="{{ url_for('flow_block_edit', slug=game.slug, screen_id=screen.id, block_id=b.id) }}" id="blockEditForm{{ b.id }}"></form>
{% endfor %}
<div class="fieldsTableWrap">
<table class="table is-fullwidth is-hoverable fieldsTable">
<thead><tr><th>Nom</th><th>Description</th><th>Objets concernés</th><th>Nœuds</th><th></th></tr></thead>
<tbody>
{% for b in blocks_view %}
<tr>
<td><strong>{{ b.name }}</strong></td>
<td>{{ b.description|truncatesentences(3) }}</td>
<td>{% if b.element_labels %}{{ b.element_labels|join(', ') }}{% else %}<span class="hint">—</span>{% endif %}</td>
<td>{{ b.node_count }}</td>
<td class="fieldsTableActions">
<button type="button" class="button is-small is-primary" onclick="openLogicBlockPanel({{ b.id }})" title="Ouvrir le graphe de ce bloc">🧩 Ouvrir</button>
<button type="button" class="button is-small" onclick="toggleBlockEditRow({{ b.id }})" title="Modifier le nom/la description">✏️</button>
<form method="post" action="{{ url_for('flow_block_delete', slug=game.slug, screen_id=screen.id, block_id=b.id) }}"
onsubmit="return confirm('Supprimer le bloc « {{ b.name }} » ? Cela supprime aussi ses {{ b.node_count }} nœud(s) et leurs connexions.');" style="display:inline;">
<button type="submit" class="button is-small is-danger" title="Supprimer">🗑️</button>
</form>
</td>
</tr>
<tr class="hidden" id="blockEditRow{{ b.id }}">
<td colspan="5">
<div class="dashCreateBar" style="display:flex;">
<input type="text" form="blockEditForm{{ b.id }}" name="name" value="{{ b.name }}" required>
<input type="text" form="blockEditForm{{ b.id }}" name="description" value="{{ b.description or '' }}" placeholder="Description">
<button type="submit" form="blockEditForm{{ b.id }}" class="primary">Enregistrer</button>
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p class="hint">Aucun bloc pour l'instant — crée le premier ci-dessus, puis ouvre-le pour poser des déclencheurs/conditions/actions.</p>
{% endif %}
</div>
<div class="floatPanel floatPanel--center hidden" id="logicBlockPanel">
<div class="floatPanelHeader" id="logicBlockPanelHeader">
<span class="floatPanelTitle">⋮⋮ <span id="logicBlockPanelTitle">Bloc</span></span>
<button type="button" class="button is-small floatPanelFullscreenBtn" title="Plein écran">⛶</button>
<button type="button" class="button is-small" onclick="closeLogicBlockPanel()">✕</button>
</div>
<div class="floatPanelBody logicBlockPanelBody">
<div class="flowToolbar">
<button type="button" class="smallBtn button is-small" onclick="openNodeForm('trigger')">➕ Déclencheur</button>
<button type="button" class="smallBtn button is-small" onclick="openNodeForm('condition')">➕ Condition</button>
<button type="button" class="smallBtn button is-small" onclick="openNodeForm('action')">➕ Action</button>
<span class="hint">Clique un point de sortie d'un nœud, puis le point d'entrée d'un autre nœud, pour les relier. Reclique une connexion pour la supprimer.</span>
</div>
<div id="nodeForm" class="nodeForm" style="display:none;">
<div class="fieldRow" style="align-items:center; justify-content:space-between;">
<strong id="nodeFormTitle">Nouveau nœud</strong>
<button type="button" class="smallBtn button is-small" onclick="closeNodeForm()">✕</button>
</div>
<div id="nodeFormTrigger" style="display:none;">
<label>Événement<select id="nf-trigger-event" onchange="toggleFlowTriggerFields()">
{% for key, label in trigger_events %}
<option value="{{ key }}">{{ label }}</option>
{% endfor %}
</select></label>
<label id="nf-trigger-element-row" style="display:none;">Objet<select id="nf-trigger-element">
{% for o in objects %}
<option value="{{ o.id }}">{{ o.name or ('Personnage' if o.kind == 'personnage' else 'Décor') }} (obj-{{ o.id }})</option>
{% endfor %}
</select></label>
<p class="hint" id="nf-trigger-repeater-hint" style="display:none;"></p>
<p class="hint" id="nf-trigger-affichage-hint" style="display:none;"></p>
<div id="nf-trigger-event-row" style="display:none;">
<label>Événement personnalisé<select id="nf-trigger-custom-event">
{% for ev in custom_events %}
<option value="{{ ev.id }}">{{ ev.name }}</option>
{% endfor %}
</select></label>
{% if not custom_events %}
<p class="hint">Aucun événement pour l'instant — <a href="javascript:void(0)" onclick="switchBuilderTab('events'); return false;">crées-en un dans l'onglet Événements</a>.</p>
{% endif %}
</div>
<div id="nf-trigger-key-row" style="display:none;">
<label>Touche à surveiller
<input type="text" id="nf-trigger-key-display" readonly placeholder="Clique ici puis appuie sur une touche" onkeydown="captureFlowTriggerKey(event)">
</label>
<input type="hidden" id="nf-trigger-key">
</div>
<div id="nf-trigger-interval-row" style="display:none;">
<label>Toutes les... millisecondes
<input type="number" id="nf-trigger-interval" min="50" step="50" value="1000" placeholder="ex. 1000">
</label>
</div>
</div>
<div id="nodeFormCondition" style="display:none;">
<label>Source<select id="nf-cond-source" onchange="onFlowConditionSourceChange()">
<option value="objet">Champ d'un objet de données</option>
<option value="variable">Variable globale</option>
<option value="collision">Collision entre deux objets de scène</option>
</select></label>
<div id="nf-cond-objet-fields" {% if not definitions %}style="display:none;"{% endif %}>
<label>Objet de données<select id="nf-cond-definition" onchange="onFlowDefinitionChange('cond')">
{% for d in definitions %}
<option value="{{ d.id }}">{{ d.name }}</option>
{% endfor %}
</select></label>
<label>Ligne concernée<select id="nf-cond-row"></select></label>
<label>Champ<select id="nf-cond-field" onchange="onFlowFieldChange()"></select></label>
</div>
{% if not definitions %}
<p class="hint" id="nf-cond-no-objet-hint">Aucun objet de données défini dans ce jeu pour l'instant — <a href="{{ url_for('game_dashboard', slug=game.slug, tab='objects') }}">crées-en un</a>.</p>
{% endif %}
<div id="nf-cond-variable-fields" style="display:none;">
<label>Variable<select id="nf-cond-variable">
<option value="">(sélectionner une variable)</option>
{% for v in global_variables %}
<option value="{{ v.name }}">{{ v.name }}</option>
{% endfor %}
</select></label>
<label>Chemin (optionnel)<input type="text" id="nf-cond-variable-chemin" placeholder="ex. .nom ou [0].valeur"></label>
{% if not global_variables %}
<p class="hint">Aucune variable globale pour l'instant — <a href="{{ url_for('game_dashboard', slug=game.slug, tab='variables') }}" target="_blank">crées-en une ↗</a></p>
{% endif %}
</div>
<div id="nf-cond-collision-fields" style="display:none;">
<label>Premier objet<select id="nf-cond-element-a">
{% for o in objects %}
<option value="{{ o.id }}">{{ o.name or ('Personnage' if o.kind == 'personnage' else 'Décor') }} (obj-{{ o.id }})</option>
{% endfor %}
</select></label>
<label>Deuxième objet<select id="nf-cond-element-b">
{% for o in objects %}
<option value="{{ o.id }}">{{ o.name or ('Personnage' if o.kind == 'personnage' else 'Décor') }} (obj-{{ o.id }})</option>
{% endfor %}
</select></label>
<p class="hint">Vrai si les deux objets se touchent (leurs rectangles se chevauchent à l'écran) — relie le port "Faux" du nœud pour "ne se touchent pas".</p>
</div>
<div id="nf-cond-operator-value-row">
<label>Condition<select id="nf-cond-operator">
{% for key, label in condition_operators %}
<option value="{{ key }}">{{ label }}</option>
{% endfor %}
</select></label>
<label>Valeur de comparaison<input type="text" id="nf-cond-value" placeholder="ex. 1, vrai, 10..."></label>
</div>
<div id="nf-cond-extra-wrap">
<label id="nf-cond-combinator-row" style="display:none;">Combiner les conditions avec
<select id="nf-cond-combinator">
<option value="et">ET (toutes les conditions doivent être vraies)</option>
<option value="ou">OU (au moins une condition doit être vraie)</option>
</select>
</label>
<div id="nf-cond-extra-clauses"></div>
<button type="button" class="smallBtn button is-small" onclick="addFlowConditionClause()">+ Ajouter une condition</button>
</div>
</div>
<div id="nodeFormAction" style="display:none;">
<label>Type d'action<select id="nf-action-type" onchange="toggleFlowActionFields()">
{% for key, label in action_type_labels.items() %}
<option value="{{ key }}">{{ label }}</option>
{% endfor %}
</select></label>
<select id="nf-action-target-screen" style="display:none;">
{% for s in all_screens %}
<option value="{{ s.id }}">{{ s.name }}</option>
{% endfor %}
</select>
<!-- Équivalent scène de "Modifier un élément" — palette réduite
(visibilité/position en pixels/orientation), voir
OBJECT_ACTION_PROPERTIES (screens/scenes/flow_palette.py). -->
<div id="nf-action-object-fields" style="display:none;">
<select id="nf-action-target-object">
{% for o in objects %}
<option value="{{ o.id }}">{{ o.name or ('Personnage' if o.kind == 'personnage' else 'Décor') }} (obj-{{ o.id }})</option>
{% endfor %}
</select>
<select id="nf-action-object-property" onchange="toggleFlowObjectPropertyValue()" style="margin-top:6px;">
{% for key, label in object_action_properties %}
<option value="{{ key }}">{{ label }}</option>
{% endfor %}
</select>
<select id="nf-action-object-value-visibility" style="margin-top:6px;">
{% for key, label in element_visibility_values %}
<option value="{{ key }}">{{ label }}</option>
{% endfor %}
</select>
<select id="nf-action-object-value-orientation" style="display:none; margin-top:6px;">
{% for key, label in element_orientation_values %}
<option value="{{ key }}">{{ label }}</option>
{% endfor %}
</select>
<input type="number" id="nf-action-object-value-number" style="display:none; margin-top:6px;" placeholder="Nouvelle valeur en pixels">
</div>
<div id="nf-action-wait-fields" style="display:none;">
<label>Délai en secondes avant de continuer
<input type="number" id="nf-action-wait-seconds" min="0" step="0.5" value="3" placeholder="ex. 3">
</label>
</div>
<div id="nf-action-data-fields" style="display:none;">
{% if not definitions %}
<p class="hint">Aucun objet de données défini dans ce jeu pour l'instant — <a href="{{ url_for('game_dashboard', slug=game.slug, tab='objects') }}">crées-en un</a>.</p>
{% else %}
<select id="nf-action-data-definition" onchange="onFlowDefinitionChange('action')">
{% for d in definitions %}
<option value="{{ d.id }}">{{ d.name }}</option>
{% endfor %}
</select>
<select id="nf-action-data-row" style="margin-top:6px;"></select>
<select id="nf-action-data-field" style="margin-top:6px;"></select>
<select id="nf-action-data-operation" style="margin-top:6px;" onchange="updateDataValuePlaceholder('nf-action-data-operation', 'nf-action-data-value')">
{% for key, label in data_operations %}
<option value="{{ key }}">{{ label }}</option>
{% endfor %}
</select>
<input type="text" id="nf-action-data-value" style="margin-top:6px;" placeholder="Valeur / montant">
{% endif %}
</div>
<div id="nf-action-sound-fields" style="display:none;">
<label>URL du son
<input type="text" id="nf-action-sound-url" placeholder="URL du fichier audio">
</label>
<div style="margin-top:6px;">
<label for="nf-action-sound-upload" style="font-size:12px;">Envoyer un fichier :</label>
<input type="file" class="nodeFormUploadInput" id="nf-action-sound-upload" data-target="nf-action-sound-url" accept="audio/*" style="font-size:12px;">
<span class="uploadStatus" style="font-size:12px; color:var(--text-dim);"></span>
</div>
</div>
<div id="nf-action-sprite-fields" style="display:none;">
<label>Personnage à animer
<select id="nf-action-sprite-target-element" onchange="onSpriteTargetElementChange()">
<option value="">— Choisir —</option>
{% for o in objects if o.kind == 'personnage' %}
<option value="{{ o.id }}">{{ o.name or 'Personnage' }} (obj-{{ o.id }})</option>
{% endfor %}
</select>
</label>
{% if not objects|selectattr('kind', 'equalto', 'personnage')|list %}
<p class="hint">Aucun personnage sur cette scène — ajoute-en un depuis la galerie "🎭 Personnages" du panneau de gauche.</p>
{% endif %}
<div id="nf-action-sprite-animation-grid" class="iconGallery" style="margin-top:6px;"></div>
<input type="hidden" id="nf-action-sprite-animation-name" value="">
<div class="fieldRow" style="margin-top:6px;">
<label style="flex:1;">Images/seconde
<input type="number" id="nf-action-sprite-fps" min="1" step="1" value="8">
</label>
<label style="flex:1; display:flex; align-items:center; gap:6px;">
<input type="checkbox" id="nf-action-sprite-loop" checked style="width:auto;"> En boucle
</label>
</div>
</div>
<div id="nf-action-variable-fields" style="display:none;">
{% if not global_variables %}
<p class="hint">Aucune variable globale pour l'instant — crées-en une depuis le tableau de bord.</p>
{% else %}
<select id="nf-action-variable-name">
{% for v in global_variables %}
<option value="{{ v.name }}">{{ v.name }}</option>
{% endfor %}
</select>
<select id="nf-action-variable-operation" style="margin-top:6px;" onchange="updateDataValuePlaceholder('nf-action-variable-operation', 'nf-action-variable-value')">
{% for key, label in data_operations %}
<option value="{{ key }}">{{ label }}</option>
{% endfor %}
</select>
<input type="text" id="nf-action-variable-value" style="margin-top:6px;" placeholder="Valeur / montant">
{% endif %}
</div>
<div id="nf-action-event-fields" style="display:none;">
{% if not custom_events %}
<p class="hint">Aucun événement pour l'instant — <a href="javascript:void(0)" onclick="switchBuilderTab('events'); return false;">crées-en un dans l'onglet Événements</a>.</p>
{% else %}
<select id="nf-action-custom-event">
{% for ev in custom_events %}
<option value="{{ ev.id }}">{{ ev.name }}</option>
{% endfor %}
</select>
{% endif %}
</div>
</div>
<div class="actionsRow"><button type="button" class="smallBtn button is-small is-primary" onclick="submitNodeForm()">+ Ajouter ce nœud</button></div>
</div>
<div class="flowCanvasWrap">
<svg id="flowEdges" class="flowEdgesSvg"></svg>
<div id="flowCanvas" class="flowCanvas"></div>
</div>
</div>
<div class="floatPanelResizeHandle" id="logicBlockPanelResizeHandle"></div>
</div>
<div class="builderTabPanel" id="animTabPanel" data-tab-panel="anim">
<div class="flowToolbar">
<label style="display:flex; align-items:center; gap:6px;">
Piste
<select id="anim-new-track-element">
{% for o in objects %}
<option value="{{ o.id }}">{{ o.name or ('Personnage' if o.kind == 'personnage' else 'Décor') }}</option>
{% endfor %}
</select>
</label>
<button type="button" class="smallBtn button is-small" onclick="addAnimTrack()">➕ Ajouter un clip</button>
</div>
<div id="animClipForm" class="nodeForm" style="display:none;">
<div class="fieldRow" style="align-items:center; justify-content:space-between;">
<strong id="animClipFormTitle">Clip d'animation</strong>
<button type="button" class="smallBtn button is-small" onclick="closeAnimClipForm()">✕</button>
</div>
<label>Type
<select id="anim-clip-kind" onchange="toggleAnimClipKind()">
<option value="animate_css">Animation Animate.css</option>
<option value="custom">Animation personnalisée — images-clés</option>
<option value="sprite">Animation de sprites</option>
</select>
</label>
<div id="anim-clip-animate-css-fields">
<label>Animation
<select id="anim-clip-animate-name">
{% for category, items in animate_css_catalog %}
<optgroup label="{{ category }}">
{% for key, label in items %}
<option value="{{ key }}">{{ label }}</option>
{% endfor %}
</optgroup>
{% endfor %}
</select>
</label>
</div>
<div id="anim-clip-custom-fields" style="display:none;">
<div id="anim-clip-keyframes"></div>
<button type="button" class="smallBtn button is-small" onclick="addAnimKeyframeRow()">+ Ajouter une image-clé</button>
</div>
<div id="anim-clip-sprite-fields" style="display:none;">
<p class="hint" id="anim-clip-sprite-no-animations" style="display:none;">Cet objet n'est pas un personnage, ou n'a encore aucune animation.</p>
<div id="anim-clip-sprite-animation-grid" class="iconGallery"></div>
<input type="hidden" id="anim-clip-sprite-animation-name" value="">
<label style="margin-top:6px; display:block;">Images/seconde
<input type="number" id="anim-clip-sprite-fps" min="1" step="1" value="8">
</label>
</div>
<div class="fieldRow">
<label style="flex:1;">Départ en secondes
<input type="number" id="anim-clip-start" min="0" step="0.1" value="0">
</label>
<label style="flex:1;">Durée en secondes
<input type="number" id="anim-clip-duration" min="0.1" step="0.1" value="1">
</label>
</div>
<div class="fieldRow">
<label style="flex:1;">Délai en secondes
<input type="number" id="anim-clip-delay" min="0" step="0.1" value="0">
</label>
<label style="flex:1;">Vitesse
<select id="anim-clip-easing">
<option value="ease">Progressive</option>
<option value="linear">Constante</option>
<option value="ease-in">Accélère</option>
<option value="ease-out">Ralentit</option>
<option value="ease-in-out">Accélère puis ralentit</option>
</select>
</label>
</div>
<label>Répétitions
<select id="anim-clip-iteration">
<option value="1">Une fois</option>
<option value="2">2 fois</option>
<option value="3">3 fois</option>
<option value="infinite">En boucle</option>
</select>
</label>
<div class="actionsRow">
<button type="button" class="smallBtn button is-small is-primary" onclick="saveAnimClipForm()">Enregistrer</button>
<button type="button" class="smallBtn button is-small" onclick="previewAnimClip()">▶ Aperçu</button>
<button type="button" class="smallBtn button is-small is-danger" onclick="deleteAnimClipForm()">🗑️ Supprimer</button>
</div>
</div>
<div class="animTimelineWrap">
<div id="animRuler" class="animRuler"></div>
<div id="animTracks" class="animTracks"></div>
</div>
</div>
<div class="builderTabPanel" id="eventsTabPanel" data-tab-panel="events">
<div class="dashPanelHeader">
<h2>Événements personnalisés</h2>
<button type="button" class="button is-small primary" onclick="toggleDashCreate('events')">+ Créer un événement</button>
</div>
<form method="post" action="{{ url_for('custom_event_new', slug=game.slug) }}" class="dashCreateBar" id="dashCreate-events" style="display:none;">
<input type="hidden" name="screen_id" value="{{ screen.id }}">
<input type="text" name="name" placeholder="NOM (ex. NEED_ACTION)" required autocomplete="off">
<input type="text" name="description" placeholder="Description (optionnel)">
<button type="submit" class="primary">Créer</button>
</form>
{% if custom_events %}
{% for ev in custom_events %}
<form method="post" action="{{ url_for('custom_event_edit', slug=game.slug, event_id=ev.id) }}" id="eventEditForm{{ ev.id }}">
<input type="hidden" name="screen_id" value="{{ screen.id }}">
</form>
{% endfor %}
<div class="fieldsTableWrap">
<table class="table is-fullwidth is-hoverable fieldsTable">
<thead><tr><th>Nom</th><th>Description</th><th>Utilisé par</th><th></th></tr></thead>
<tbody>
{% for ev in custom_events %}
<tr>
<td><code>{{ ev.name }}</code></td>
<td><input type="text" form="eventEditForm{{ ev.id }}" name="description" value="{{ ev.description or '' }}" placeholder="Description" aria-label="Description de « {{ ev.name }} »"></td>
<td>
{% set usages = custom_event_usages.get(ev.id, []) %}
{% if usages %}
{% for u in usages %}
<a href="{{ url_for('screen_edit', slug=game.slug, screen_id=u.screen_id) }}" class="linkLikeButton" title="{{ 'Écouté ici' if u.role == 'ecoute' else 'Déclenché ici' }}">{{ '🔔' if u.role == 'ecoute' else '📣' }} {{ u.screen_name }}{{ ' (modèle)' if u.is_template else '' }}</a>{{ ', ' if not loop.last else '' }}
{% endfor %}
{% else %}
<span class="hint">Inutilisé</span>
{% endif %}
</td>
<td class="fieldsTableActions">
<button type="submit" form="eventEditForm{{ ev.id }}" class="button is-small" title="Enregistrer">💾</button>
<form method="post" action="{{ url_for('custom_event_delete', slug=game.slug, event_id=ev.id) }}"
onsubmit="return confirm('Supprimer l\'événement « {{ ev.name }} » ? Cela supprime aussi tous ses écouteurs et déclencheurs, sur toutes les scènes et tous les modèles.');">
<input type="hidden" name="screen_id" value="{{ screen.id }}">
<button type="submit" class="button is-small is-danger" title="Supprimer">🗑️</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p class="hint">Aucun événement pour l'instant — crée le premier ci-dessus, puis pose un nœud "Sur un événement personnalisé" (Déclencheur) ou "Déclencher un événement" (Action) dans la Logique de la scène.</p>
{% endif %}
</div>
</div>
<!-- Panneau droit : propriétés de l'objet sélectionné. -->
<button type="button" id="propsPanelReopenBtn" class="button is-small floatPanelReopenBtn" onclick="openPropsPanel()" title="Rouvrir le panneau de propriétés">⚙️ Propriétés</button>
<div class="floatPanel floatPanel--right" id="propsPanelFloat">
<div class="floatPanelHeader" data-drag-panel="propsPanelFloat" data-drag-key="props">
<span class="floatPanelTitle">⋮⋮ Propriétés</span>
<button type="button" class="button is-small" onclick="closePropsPanel()" title="Fermer">✕</button>
</div>
<div class="floatPanelBody">
{% if not selected_id or not obj %}
<div class="hint" style="padding:20px 0;">Sélectionne un objet (sur la scène ou dans la liste) pour voir et modifier ses propriétés ici.</div>
{% else %}
<div class="fieldRow" style="align-items:center; justify-content:space-between;">
<h2 class="subtitle is-6" style="margin:0;">{{ '🏃' if obj.kind == 'personnage' else '🖼️' }} {{ obj.name or ('Personnage' if obj.kind == 'personnage' else 'Décor') }}</h2>
<form method="post" action="{{ url_for('scene_object_delete', slug=game.slug, object_id=obj.id) }}" onsubmit="return confirm('Supprimer cet objet ?');">
<button type="submit" class="button is-small is-danger">🗑️</button>
</form>
</div>
<details class="propGroup" open>
<summary>Position &amp; taille (pixels)</summary>
<div class="propGroupBody">
<div class="columns is-multiline posGrid">
<div class="column is-half">
<div class="field"><label class="label is-small">X (px)</label>
<div class="control"><input class="input is-small" type="number" id="scene-pos-x" min="0" value="{{ obj.x }}"></div>
</div>
</div>
<div class="column is-half">
<div class="field"><label class="label is-small">Y (px)</label>
<div class="control"><input class="input is-small" type="number" id="scene-pos-y" min="0" value="{{ obj.y }}"></div>
</div>
</div>
<div class="column is-half">
<div class="field"><label class="label is-small">Largeur (px)</label>
<div class="control"><input class="input is-small" type="number" id="scene-pos-w" min="1" value="{{ obj.width }}"></div>
</div>
</div>
<div class="column is-half">
<div class="field"><label class="label is-small">Hauteur (px)</label>
<div class="control"><input class="input is-small" type="number" id="scene-pos-h" min="1" value="{{ obj.height }}"></div>
</div>
</div>
</div>
</div>
</details>
{% if obj.kind == 'personnage' %}
<details class="propGroup" open>
<summary>🎭 Personnage</summary>
<div class="propGroupBody" id="personnagePanelBody" data-element-id="{{ obj.id }}">
<p class="help">Personnage Forge — clique un autre pour en changer.</p>
<div class="iconGallery personnageGallery" id="personnageGallerySwap">
{% for slug_c, character in sprite_library_families %}
<button type="button" class="iconGalleryTile {{ 'selected' if slug_c == personnage_data.forge_character else '' }}"
title="{{ character.label }}" onclick="swapSceneObjectCharacter('{{ slug_c }}')">
<img src="{{ character.animations.idle[0] }}" alt="{{ character.label }}" class="personnageThumb">
</button>
{% endfor %}
</div>
{% set current_character = sprite_library.get(personnage_data.forge_character) %}
{% if current_character and current_character.get('family') %}
<p class="help">Variantes de {{ current_character.family_label }} :</p>
<div class="iconGallery personnageGallery" id="personnageGalleryVariants">
{% for slug_c, character in sprite_library.items() if character.get('family') == current_character.family %}
<button type="button" class="iconGalleryTile {{ 'selected' if slug_c == personnage_data.forge_character else '' }}"
title="{{ character.label }}" onclick="swapSceneObjectCharacter('{{ slug_c }}')">
<img src="{{ character.animations.idle[0] }}" alt="{{ character.label }}" class="personnageThumb">
</button>
{% endfor %}
</div>
{% endif %}
</div>
</details>
{% endif %}
{% endif %}
</div>
</div>
</div>
<script>
var GAME_SLUG = "{{ game.slug }}";
var SCREEN_ID = {{ screen.id }};
var SCREEN_EDIT_URL = "{{ url_for('screen_edit', slug=game.slug, screen_id=screen.id) }}";
var SCENE_WIDTH = {{ screen.scene_width }};
var SCENE_HEIGHT = {{ screen.scene_height }};
var CURRENT_SELECTED_ID = {{ selected_id or 'null' }};
var DEFINITIONS_DATA = {{ definitions_json|safe }};
var DEFINITIONS_NAMES = {{ definitions_names_json|safe }};
var FLOW_NODES_INITIAL = {{ flow_nodes_json|safe }};
var FLOW_EDGES_INITIAL = {{ flow_edges_json|safe }};
var FLOW_BLOCKS = {{ flow_blocks_json|safe }};
var FLOW_TARGETS_OBJECTS = true;
var ELEMENTS_LABELS = {{ elements_labels_json|safe }};
var SCREENS_MAP = {{ screens_map_json|safe }};
var ACTION_TYPE_LABELS_MAP = {{ action_type_labels_json|safe }};
var ELEMENT_PROPERTY_LABELS_MAP = {{ element_action_property_labels_json|safe }};
var ELEMENT_VISIBILITY_LABELS_MAP = {{ element_visibility_labels_json|safe }};
var ELEMENT_ORIENTATION_LABELS_MAP = {{ element_orientation_labels_json|safe }};
var DATA_OPERATION_LABELS_MAP = {{ data_operation_labels_json|safe }};
var CONDITION_OPERATOR_LABELS_MAP = {{ condition_operator_labels_json|safe }};
var TRIGGER_LABELS_MAP = {};
var CUSTOM_EVENTS_MAP = {{ custom_events_map_json|safe }};
var ANIM_CLIPS = {{ animation_clips_json|safe }};
var ANIMATE_CSS_LABELS = {{ animate_css_labels_json|safe }};
var SPRITE_LIBRARY = {{ sprite_library_json|safe }};
var ELEMENT_ANIMATIONS_MAP = {{ element_animations_map_json|safe }};
</script>
<script src="{{ url_for('static', filename='js/screen_edit/tree-panels.js') }}"></script>
<script src="{{ url_for('static', filename='js/screen_edit/panel-init.js') }}"></script>
<script src="{{ url_for('static', filename='js/screen_edit/personnage-panel.js') }}"></script>
<script src="{{ url_for('static', filename='js/screen_edit/flow-editor.js') }}"></script>
<script src="{{ url_for('static', filename='js/screen_edit/tabs-and-blocks.js') }}"></script>
<script src="{{ url_for('static', filename='js/screen_edit/animation-timeline.js') }}"></script>
<script src="{{ url_for('static', filename='js/screen_edit/personnage-preview.js') }}"></script>
<script src="{{ url_for('static', filename='js/scenes/scene-editor.js') }}"></script>
{% endblock %}