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>
1308 lines
76 KiB
HTML
1308 lines
76 KiB
HTML
{% 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') }}">
|
||
{% endblock %}
|
||
{% block content %}
|
||
{% if screen.is_template %}
|
||
<p class="hint" style="background:var(--panel); border:1px solid var(--border); border-radius:8px; padding:10px 14px; margin:10px 0;">
|
||
🧩 Contenu d'un élément de jeu réutilisable.
|
||
</p>
|
||
{% endif %}
|
||
|
||
{% macro render_tree_node(el) %}
|
||
{% set kids = children_of.get(el.id, []) %}
|
||
<div class="treeNode" data-element-id="{{ el.id }}">
|
||
<div class="treeRow" draggable="true" data-parent-id="{{ el.parent_id or '' }}" data-is-container="{{ '1' if el.widget in container_widgets else '' }}">
|
||
{% if kids %}
|
||
<button type="button" class="treeToggle" data-node-id="{{ el.id }}" aria-label="Réduire / déplier">▾</button>
|
||
{% else %}
|
||
<span class="treeToggleSpacer"></span>
|
||
{% endif %}
|
||
<a class="treeRowLink {{ 'selected' if el.id == selected_id else '' }}"
|
||
href="{{ url_for('screen_edit', slug=game.slug, screen_id=screen.id, selected=el.id) }}"
|
||
data-element-id="{{ el.id }}"
|
||
oncontextmenu="return showElementContextMenu(event, {{ el.id }});">
|
||
<span class="treeIcon">{{ widgets.get(el.widget, {}).get('icon', '❔') }}</span>
|
||
<span class="treeLabel">{{ el|elabel }}</span>
|
||
</a>
|
||
</div>
|
||
{% if kids %}
|
||
<div class="treeChildren">
|
||
{% for child in kids %}
|
||
{{ render_tree_node(child) }}
|
||
{% endfor %}
|
||
</div>
|
||
{% endif %}
|
||
</div>
|
||
{% endmacro %}
|
||
|
||
<!-- Phase 8 — galerie visuelle de personnages Forge (VRAIES miniatures,
|
||
pas un emoji/icône), réutilisée deux fois : "add" pour poser un
|
||
nouveau personnage (panneau gauche), "swap" pour changer le
|
||
personnage Forge d'un élément déjà posé (panneau droit, propriétés).
|
||
Un tile par FAMILLE (sprite_library_families, voir
|
||
screens/labels/sprite_gallery.py) — un animal CraftPix (screens/
|
||
labels/animal_sprite_library.py) n'apparaît ici qu'une fois, dans sa
|
||
variante "de base" ; ses 15 variantes se choisissent depuis
|
||
render_variant_gallery ci-dessous, dans le panneau de propriétés.
|
||
Miniature = 1ère frame de l'animation "idle" du personnage (jamais un
|
||
chemin en dur : le rangement sur disque diffère entre Kenney et les
|
||
animaux, voir screens/labels/animal_sprite_library.py). -->
|
||
{% macro render_personnage_gallery(mode, gallery_id, selected_forge_character=None) %}
|
||
<div class="iconGallery personnageGallery" id="{{ gallery_id }}">
|
||
{% for char_slug, character in sprite_library_families %}
|
||
<button type="button" class="iconGalleryTile {{ 'selected' if char_slug == selected_forge_character else '' }}"
|
||
title="{{ character.label }}"
|
||
onclick="{{ 'addPersonnageElement' if mode == 'add' else 'swapPersonnageCharacter' }}('{{ char_slug }}')">
|
||
<img src="{{ character.animations.idle[0] }}" alt="{{ character.label }}" class="personnageThumb">
|
||
</button>
|
||
{% endfor %}
|
||
</div>
|
||
{% endmacro %}
|
||
|
||
<!-- Variantes de couleur/skin d'UNE famille d'animal (les 15 personnages
|
||
CraftPix qui partagent le même "family", voir screens/labels/
|
||
animal_sprite_library.py) — affichée uniquement dans le panneau de
|
||
propriétés, sous la galerie groupée ci-dessus, quand le personnage
|
||
sélectionné appartient à une famille à variantes (jamais pour un
|
||
Kenney, qui n'a pas de "family"). -->
|
||
{% macro render_variant_gallery(family, gallery_id, selected_forge_character=None) %}
|
||
<div class="iconGallery personnageGallery" id="{{ gallery_id }}">
|
||
{% for char_slug, character in sprite_library.items() if character.get('family') == family %}
|
||
<button type="button" class="iconGalleryTile {{ 'selected' if char_slug == selected_forge_character else '' }}"
|
||
title="{{ character.label }}"
|
||
onclick="swapPersonnageCharacter('{{ char_slug }}')">
|
||
<img src="{{ character.animations.idle[0] }}" alt="{{ character.label }}" class="personnageThumb">
|
||
</button>
|
||
{% endfor %}
|
||
</div>
|
||
{% endmacro %}
|
||
|
||
<div class="builder3" id="builder3">
|
||
{% set el = elements|selectattr("id", "equalto", selected_id)|first if selected_id else none %}
|
||
<!-- Panneau gauche : ajouter un élément (icônes, sans jargon technique) —
|
||
flottant, déplaçable, fermable, même mécanisme que le panneau droit
|
||
(voir floatPanel*() plus bas). -->
|
||
<button type="button" id="panelLeftReopenBtn" class="button is-small floatPanelReopenBtn" onclick="openLeftPanel()" title="Rouvrir le panneau des éléments">🧩 Éléments</button>
|
||
<div class="floatPanel floatPanel--left" id="panelLeftFloat">
|
||
<div class="floatPanelHeader" data-drag-panel="panelLeftFloat" data-drag-key="left">
|
||
<span class="floatPanelTitle">⋮⋮ Éléments de cet écran</span>
|
||
<button type="button" class="button is-small" onclick="closeLeftPanel()" title="Fermer">✕</button>
|
||
</div>
|
||
<div class="floatPanelBody">
|
||
<!-- Nom de l'écran, aperçu jouable, et format de l'aperçu (Portrait/
|
||
Paysage/Carré) : regroupés ici plutôt qu'au-dessus du canevas —
|
||
ce sont des réglages qu'on touche rarement une fois l'écran en
|
||
cours de construction, pas besoin qu'ils prennent en permanence de
|
||
la place dans la zone centrale. -->
|
||
<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>
|
||
{% if not screen.is_template %}
|
||
<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>
|
||
{% endif %}
|
||
{% set current_aspect = screen.preview_aspect or '9/16' %}
|
||
<div class="buttons has-addons are-small mb-2" style="flex-wrap:nowrap;">
|
||
<button type="button" class="button smallBtn aspectBtn {{ 'active' if current_aspect == '9/16' else '' }}" data-aspect="9/16">📱 Portrait</button>
|
||
<button type="button" class="button smallBtn aspectBtn {{ 'active' if current_aspect == '16/9' else '' }}" data-aspect="16/9">🖥️ Paysage</button>
|
||
<button type="button" class="button smallBtn aspectBtn {{ 'active' if current_aspect == '1/1' else '' }}" data-aspect="1/1">◻️ Carré</button>
|
||
</div>
|
||
<!-- Phase 6 — musique de fond de cet écran : démarrée en boucle à
|
||
l'affichage, arrêtée au changement d'écran (voir
|
||
runScreenBackgroundMusic() dans static/js/play/screens.js).
|
||
Enregistrement en AJAX au blur/changement, même patron que le
|
||
format d'aperçu ci-dessus. -->
|
||
<div class="field" style="margin-bottom:8px;">
|
||
<label class="label is-small" for="screen-bg-music-url">🎵 Musique de fond</label>
|
||
<div class="control">
|
||
<input type="text" class="input is-small" id="screen-bg-music-url" placeholder="URL du fichier audio" value="{{ screen.background_music_url or '' }}">
|
||
</div>
|
||
<div class="control" style="margin-top:4px;">
|
||
<label for="screen-bg-music-upload" style="font-size:12px;">Envoyer un fichier :</label>
|
||
<input type="file" class="screenUploadInput" id="screen-bg-music-upload" data-target="screen-bg-music-url" accept="audio/*" style="font-size:12px;">
|
||
<span class="uploadStatus" style="font-size:12px; color:var(--muted);"></span>
|
||
</div>
|
||
</div>
|
||
<hr style="border-color:var(--border); margin:10px 0;">
|
||
<h2 class="subtitle is-6">Éléments de cet écran</h2>
|
||
{% if top_level_elements %}
|
||
<div class="elementTree">
|
||
{% for el in top_level_elements %}
|
||
{{ render_tree_node(el) }}
|
||
{% endfor %}
|
||
</div>
|
||
{% else %}
|
||
<p class="hint">Aucun élément pour l'instant.</p>
|
||
{% endif %}
|
||
|
||
<details class="propGroup">
|
||
<summary>Ajouter un élément sur l'écran</summary>
|
||
<div class="propGroupBody">
|
||
<div class="widgetGrid">
|
||
{% for wkey, w in widgets.items() if wkey != 'icone' and wkey != 'personnage' %}
|
||
<form method="post" action="{{ url_for('element_add', slug=game.slug, screen_id=screen.id) }}" class="widgetBtnForm">
|
||
<input type="hidden" name="widget" value="{{ wkey }}">
|
||
<button type="submit" class="widgetBtn" title="{{ w.label }}">
|
||
<span class="widgetIcon">{{ w.icon }}</span>
|
||
<span class="widgetLabel">{{ w.label }}</span>
|
||
</button>
|
||
</form>
|
||
{% endfor %}
|
||
</div>
|
||
</div>
|
||
</details>
|
||
|
||
<!-- Galerie d'icônes — pas un widget parmi d'autres dans la grille
|
||
ci-dessus (voir "if wkey != 'icone'") : cliquer/glisser une icône ICI
|
||
pose directement un élément Icône déjà réglé sur cette icône précise,
|
||
plutôt qu'un élément générique qu'il faudrait ensuite configurer. -->
|
||
<details class="propGroup">
|
||
<summary>🔤 Icônes</summary>
|
||
<div class="propGroupBody">
|
||
<input type="text" class="input is-small" id="iconGallerySearch" placeholder="Rechercher une icône..." oninput="filterIconGallery(this.value)">
|
||
<div class="iconGallery" id="iconGallery">
|
||
{% for slug in fontawesome_icons %}
|
||
<button type="button" class="iconGalleryTile" draggable="true"
|
||
data-icon-class="{{ slug }}" data-icon-search="{{ slug }}"
|
||
title="{{ slug }}" onclick="addIconElement('{{ slug }}')">
|
||
<i class="icon-svg" style="--icon-url:url('/static/icons/{{ slug }}.svg');"></i>
|
||
</button>
|
||
{% endfor %}
|
||
</div>
|
||
<p class="help">Glisse une icône sur le canevas, ou clique dessus pour l'ajouter — sa couleur et sa taille se règlent ensuite dans ses propriétés ("Icône"). Sélection non exhaustive : d'autres icônes pourront être ajoutées à cette liste plus tard si besoin.</p>
|
||
</div>
|
||
</details>
|
||
|
||
<!-- Phase 8 — galerie de personnages Forge (widget "personnage" dédié,
|
||
voir screens/widgets/registry.py) : cliquer une tuile pose un
|
||
personnage DÉJÀ configuré (Kenney CC0, toutes ses animations),
|
||
animé immédiatement dans le canevas (voir personnage-preview.js) —
|
||
pas un élément générique à configurer ensuite dans la logique.
|
||
L'import de sprites personnalisés est retiré pour le moment. -->
|
||
<details class="propGroup">
|
||
<summary>🎭 Personnages</summary>
|
||
<div class="propGroupBody">
|
||
{{ render_personnage_gallery('add', 'personnageGalleryAdd') }}
|
||
<p class="help">Personnage Kenney prêt à l'emploi (licence libre CC0), avec toutes ses animations.</p>
|
||
</div>
|
||
</details>
|
||
|
||
<!-- Menu rétractable dédié aux éléments de jeu personnalisés (Personnage,
|
||
Outil, IA...) : chacun a désormais SA PROPRE tuile, avec son nom,
|
||
plutôt qu'une unique tuile générique "Élément de jeu" qui ajoutait
|
||
toujours le premier du catalogue sans laisser choisir lequel. -->
|
||
<details class="propGroup">
|
||
<summary>🧩 Mes éléments de jeu{{ ' (' ~ element_types|length ~ ')' if element_types else '' }}</summary>
|
||
<div class="propGroupBody">
|
||
{% if element_types %}
|
||
<div class="widgetGrid">
|
||
{% for et in element_types %}
|
||
<form method="post" action="{{ url_for('element_add', slug=game.slug, screen_id=screen.id) }}" class="widgetBtnForm">
|
||
<input type="hidden" name="widget" value="__catalogue__">
|
||
<input type="hidden" name="element_type_id" value="{{ et.id }}">
|
||
<button type="submit" class="widgetBtn" title="{{ et.name }}">
|
||
<span class="widgetIcon">{{ et.icon or '🧩' }}</span>
|
||
<span class="widgetLabel">{{ et.name }}</span>
|
||
</button>
|
||
</form>
|
||
{% endfor %}
|
||
</div>
|
||
{% else %}
|
||
<p class="hint">Pas encore d'élément de jeu personnalisé — <a href="{{ url_for('game_dashboard', slug=game.slug, tab='element_types') }}">crées-en un</a>.</p>
|
||
{% endif %}
|
||
</div>
|
||
</details>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Menu contextuel (clic droit sur un élément de l'arborescence) -->
|
||
<div id="elementContextMenu" class="contextMenu" style="display:none;">
|
||
<button type="button" onclick="contextMenuDuplicate()">📄 Dupliquer</button>
|
||
</div>
|
||
|
||
<!-- Centre : l'écran (onglet "Écran"), ou la logique de la scène / la
|
||
timeline d'animation quand leur onglet est actif — un seul onglet
|
||
visible à la fois, chacun occupant tout l'espace disponible (voir
|
||
switchBuilderTab() plus bas). Présents aussi sur un écran-modèle
|
||
(élément de jeu réutilisable, ex. "mail content") : ses enfants
|
||
gardent désormais TOUJOURS le même id, à chaque exemplaire posé sur
|
||
n'importe quelle scène (rechargés en direct depuis ce modèle à
|
||
chaque affichage, voir _render_element_type_children côté serveur —
|
||
l'ancien mécanisme qui les copiait avec un nouvel id à chaque pose,
|
||
instantiate_template_tree, a été retiré). Un déclencheur/une
|
||
animation posé ici, sur un enfant du modèle, s'exécute donc bien
|
||
partout où ce modèle est utilisé (voir findTriggerNode/
|
||
collectAnimationClips dans templates/play.html). -->
|
||
<div class="builderCanvasArea">
|
||
{% set current_aspect = screen.preview_aspect or '9/16' %}
|
||
<div class="builderTabs" id="builderTabs">
|
||
<button type="button" class="builderTabBtn active" data-tab="screen" onclick="switchBuilderTab('screen')">🖥️ Écran</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="aspect-ratio:{{ current_aspect }};">
|
||
{% for el in top_level_elements %}
|
||
<div class="canvasElement {{ 'selected' if el.id == selected_id else '' }}" id="el-{{ el.id }}" style="{{ el|elstyle }}" data-anim-target="{{ el.id }}"
|
||
onmousedown="onElementMouseDown(event, {{ el.id }})">
|
||
<div class="canvasElementInner" style="{{ el|eltransform }}">{{ el.rendered_html|safe }}</div>
|
||
<div class="resizeHandle" onmousedown="onResizeMouseDown(event, {{ el.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.</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>Éléments 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>
|
||
|
||
<!-- Modale plein écran/redimensionnable/déplaçable d'un bloc de logique
|
||
(voir openLogicBlockPanel()) — patron makeFloatPanelDraggable/
|
||
Resizable/Fullscreenable, identique à game_dashboard.html. Un seul
|
||
bloc ouvert à la fois : ce même #nodeForm/#flowCanvas est réutilisé
|
||
pour n'importe quel bloc (voir CURRENT_BLOCK_ID). -->
|
||
<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">Élément<select id="nf-trigger-element">
|
||
{% for e2 in elements %}
|
||
<option value="{{ e2.id }}">{{ e2|elabel }} (elt-{{ e2.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</option>
|
||
<option value="variable">Variable globale</option>
|
||
<option value="collision">Collision entre deux éléments</option>
|
||
</select></label>
|
||
|
||
<div id="nf-cond-objet-fields" {% if not definitions %}style="display:none;"{% endif %}>
|
||
<label>Objet<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 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>
|
||
<!-- Utile seulement si la variable choisie est de type
|
||
"objet"/"tableau" : sans chemin, c'est toute la variable
|
||
(son JSON) qui est comparée. -->
|
||
<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 élément<select id="nf-cond-element-a">
|
||
{% for e2 in elements %}
|
||
<option value="{{ e2.id }}">{{ e2|elabel }} (elt-{{ e2.id }})</option>
|
||
{% endfor %}
|
||
</select></label>
|
||
<label>Deuxième élément<select id="nf-cond-element-b">
|
||
{% for e2 in elements %}
|
||
<option value="{{ e2.id }}">{{ e2|elabel }} (elt-{{ e2.id }})</option>
|
||
{% endfor %}
|
||
</select></label>
|
||
<p class="hint">Vrai si les deux élément 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>
|
||
|
||
<div id="nf-action-element-fields" style="display:none;">
|
||
<select id="nf-action-target-element">
|
||
{% for e2 in elements %}
|
||
<option value="{{ e2.id }}">{{ e2|elabel }} (elt-{{ e2.id }})</option>
|
||
{% endfor %}
|
||
</select>
|
||
<p class="hint" id="nf-action-onglet-hint" style="display:none;"></p>
|
||
<div id="nf-action-element-property-wrap">
|
||
<select id="nf-action-element-property" onchange="toggleFlowElementPropertyValue()" style="margin-top:6px;">
|
||
{% for key, label in element_action_properties %}
|
||
<option value="{{ key }}">{{ label }}</option>
|
||
{% endfor %}
|
||
</select>
|
||
<select id="nf-action-element-value-visibility" style="margin-top:6px;">
|
||
{% for key, label in element_visibility_values %}
|
||
<option value="{{ key }}">{{ label }}</option>
|
||
{% endfor %}
|
||
</select>
|
||
<select id="nf-action-element-value-surbrillance" style="display:none; margin-top:6px;">
|
||
{% for key, label in element_surbrillance_values %}
|
||
<option value="{{ key }}">{{ label }}</option>
|
||
{% endfor %}
|
||
</select>
|
||
<select id="nf-action-element-value-desactive" style="display:none; margin-top:6px;">
|
||
{% for key, label in element_desactive_values %}
|
||
<option value="{{ key }}">{{ label }}</option>
|
||
{% endfor %}
|
||
</select>
|
||
<select id="nf-action-element-value-orientation" style="display:none; margin-top:6px;">
|
||
{% for key, label in element_orientation_values %}
|
||
<option value="{{ key }}">{{ label }}</option>
|
||
{% endfor %}
|
||
</select>
|
||
<input type="color" id="nf-action-element-value-color" style="display:none; margin-top:6px;" value="#5b8cff">
|
||
<input type="number" id="nf-action-element-value-number" style="display:none; margin-top:6px;" value="50" placeholder="Nouvelle valeur en %">
|
||
<input type="text" id="nf-action-element-value-text" style="display:none; margin-top:6px;" placeholder="Nouveau texte affiché">
|
||
|
||
<label id="nf-action-element-toggle-row" style="display:flex; align-items:center; gap:6px; margin-top:10px; font-size:12px; color:var(--text-dim);">
|
||
<input type="checkbox" id="nf-action-element-toggle" onchange="toggleFlowElementToggleMode()" style="width:auto; margin:0;">
|
||
Va-et-vient : au 1er clic, passer à cette valeur ; au 2e clic, revenir à la précédente ; etc.
|
||
</label>
|
||
<div id="nf-action-element-value2-wrap" style="display:none; margin-top:6px;">
|
||
<input type="color" id="nf-action-element-value2-color" style="display:none;" value="#ff0000">
|
||
<input type="number" id="nf-action-element-value2-number" style="display:none;" placeholder="Valeur au 2e état en %">
|
||
</div>
|
||
</div>
|
||
</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 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-add-row-fields" style="display:none;">
|
||
{% if not definitions %}
|
||
<p class="hint">Aucun objet 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-add-row-definition">
|
||
{% for d in definitions %}
|
||
<option value="{{ d.id }}">{{ d.name }}</option>
|
||
{% endfor %}
|
||
</select>
|
||
<p class="hint">Crée une ligne vide dans cet objet. Enchaîne ensuite un ou plusieurs nœuds "Modifier une donnée" ciblant "➕ Dernière ligne ajoutée" (Ligne concernée) pour renseigner ses champs.</p>
|
||
{% endif %}
|
||
</div>
|
||
|
||
<!-- Phase 6 — effet sonore ponctuel (non bouclé, joué une seule
|
||
fois), sur n'importe quel déclencheur. Indépendant de la
|
||
musique de fond par écran (voir screen_edit.html, panneau
|
||
gauche) qui, elle, boucle et suit le cycle de vie de l'écran. -->
|
||
<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>
|
||
|
||
<!-- Phase 8 — personnage animé par sprites : joue une des
|
||
animations du PERSONNAGE CIBLÉ (miniatures réelles, jamais un
|
||
menu déroulant ni une zone d'URLs en texte) — seuls les
|
||
éléments widget "personnage" sont proposés ici, leurs
|
||
animations viennent de leurs propres propriétés (galerie
|
||
Forge ou sprites importés, voir personnage-panel.js). -->
|
||
<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 e2 in elements if e2.widget == 'personnage' %}
|
||
<option value="{{ e2.id }}">{{ e2|elabel }} (elt-{{ e2.id }})</option>
|
||
{% endfor %}
|
||
</select>
|
||
</label>
|
||
{% if not elements|selectattr('widget', 'equalto', 'personnage')|list %}
|
||
<p class="hint">Aucun personnage sur cet écran — 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 "Condition de visibilité" dans les propriétés d'un élément.</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 %}
|
||
<!-- Déclencher un événement = notifier, rien d'autre : aucun
|
||
élément/ligne à choisir ici, c'est à l'ÉCOUTEUR (nœud
|
||
Déclencheur "Sur un événement personnalisé", plus bas dans
|
||
son propre graphe) de décider quoi faire, avec ses propres
|
||
réglages habituels — voir screens/custom_events/. -->
|
||
<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 e2 in elements %}
|
||
<option value="{{ e2.id }}">{{ e2|elabel }}</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>
|
||
|
||
<!-- Phase 8 — même principe que le nœud de flow "Jouer une
|
||
animation de sprites" (voir plus haut #nf-action-sprite-fields) :
|
||
miniatures réelles des SEULES animations du personnage propriétaire
|
||
de ce clip (clip.element_id, fixé à la création du clip — voir
|
||
addAnimTrack()), jamais un menu déroulant ni une zone d'URLs. -->
|
||
<div id="anim-clip-sprite-fields" style="display:none;">
|
||
<p class="hint" id="anim-clip-sprite-no-animations" style="display:none;">Cet élément n'est pas un personnage (widget "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>
|
||
|
||
<!-- Onglet Événements : entité GAME-WIDE (pas propre à cet écran, voir
|
||
db/custom_events/) mais gérée depuis ici plutôt que le tableau de
|
||
bord — pour rester au plus près de l'endroit où on câble des
|
||
écouteurs/déclencheurs (nœuds Déclencheur/Action de la Logique de
|
||
la scène ci-dessus). Présent aussi sur un écran-modèle, comme les
|
||
autres onglets. -->
|
||
<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'élément sélectionné — flottant,
|
||
déplaçable à la souris par son en-tête, fermable pour agrandir le
|
||
canevas (voir floatPanel*() plus bas). -->
|
||
<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">
|
||
<!-- Sélecteur visuel d'icône, ouvert depuis le réglage "Icône" d'un
|
||
élément Icône déjà posé (voir plus bas dans la boucle des contrôles)
|
||
— même galerie que ci-dessus, mais choisir une icône ici REMPLACE
|
||
celle de l'élément sélectionné au lieu d'en ajouter un nouveau. Une
|
||
seule instance partagée (pas besoin de la refaire par élément). -->
|
||
<div class="modal" id="iconPickerModal">
|
||
<div class="modal-background" onclick="closeIconPicker()"></div>
|
||
<div class="modal-card">
|
||
<header class="modal-card-head">
|
||
<p class="modal-card-title">Choisir une icône</p>
|
||
<button class="delete" aria-label="Fermer" onclick="closeIconPicker()"></button>
|
||
</header>
|
||
<section class="modal-card-body">
|
||
<input type="text" class="input is-small" placeholder="Rechercher une icône..." oninput="filterIconPickerModal(this.value)">
|
||
<div class="iconGallery" id="iconPickerGrid" style="max-height:50vh;">
|
||
{% for slug in fontawesome_icons %}
|
||
<button type="button" class="iconGalleryTile" data-icon-search="{{ slug }}"
|
||
title="{{ slug }}" onclick="chooseIcon('{{ slug }}')">
|
||
<i class="icon-svg" style="--icon-url:url('/static/icons/{{ slug }}.svg');"></i>
|
||
</button>
|
||
{% endfor %}
|
||
</div>
|
||
</section>
|
||
</div>
|
||
</div>
|
||
<!-- Confirmation de suppression d'un élément — remplace le confirm()
|
||
natif du navigateur pour pouvoir avertir, le cas échéant, que des
|
||
nœuds de la Logique de la scène seront aussi supprimés (voir
|
||
handleDeleteElementSubmit()/element_delete_impact.py) : le
|
||
confirm() natif ne pouvait afficher qu'un texte figé au moment du
|
||
rendu, jamais une info calculée après coup. -->
|
||
<div class="modal" id="deleteConfirmModal">
|
||
<div class="modal-background" onclick="closeDeleteConfirmModal()"></div>
|
||
<div class="modal-card">
|
||
<header class="modal-card-head">
|
||
<p class="modal-card-title">🗑️ <span id="deleteConfirmMessage">Supprimer cet élément ?</span></p>
|
||
<button class="delete" aria-label="Fermer" onclick="closeDeleteConfirmModal()"></button>
|
||
</header>
|
||
<section class="modal-card-body">
|
||
<p class="hint">Cette action est irréversible.</p>
|
||
<p class="hint" id="deleteConfirmFlowWarning" style="display:none; margin-top:10px; color:var(--danger, #f04438);"></p>
|
||
</section>
|
||
<footer class="modal-card-foot" style="justify-content:flex-end; gap:8px;">
|
||
<button type="button" class="button is-small" onclick="closeDeleteConfirmModal()">Annuler</button>
|
||
<button type="button" class="button is-small is-danger" onclick="confirmDeleteElement()">🗑️ Supprimer</button>
|
||
</footer>
|
||
</div>
|
||
</div>
|
||
|
||
{% if not selected_id %}
|
||
<div class="hint" style="padding:20px 0;">Sélectionne un élément (sur l'écran ou dans la liste) pour voir et modifier ses propriétés ici.</div>
|
||
{% else %}
|
||
{% set el = elements|selectattr("id", "equalto", selected_id)|first %}
|
||
{% if el %}
|
||
{% set w = widgets.get(el.widget, {}) %}
|
||
<div class="fieldRow" style="align-items:center; justify-content:space-between;">
|
||
<h2 class="subtitle is-6" style="margin:0;">{{ w.get('icon','❔') }} {{ el|elabel }}{% if el.name %} <span class="has-text-grey">— {{ w.get('label', el.widget) }}</span>{% endif %}</h2>
|
||
<form method="post" action="{{ url_for('element_delete', slug=game.slug, element_id=el.id) }}"
|
||
data-confirm-message="Supprimer cet élément ?" onsubmit="return handleDeleteElementSubmit(event, this);">
|
||
<button type="submit" class="button is-small is-danger">🗑️</button>
|
||
</form>
|
||
</div>
|
||
<form method="post" action="{{ url_for('element_rename', slug=game.slug, element_id=el.id) }}" class="field has-addons" style="margin:6px 0;">
|
||
<div class="control is-expanded"><input class="input" type="text" name="name" value="{{ el.name or '' }}" placeholder="Donne un nom à cet élément"></div>
|
||
<div class="control"><button type="submit" class="button is-small">Nommer</button></div>
|
||
</form>
|
||
<div class="field has-addons" style="margin:6px 0;">
|
||
<div class="control"><a class="button is-small is-static">ID</a></div>
|
||
<div class="control is-expanded"><input class="input" type="text" value="elt-{{ el.id }}" readonly style="opacity:0.7;"></div>
|
||
</div>
|
||
|
||
{% if el.widget == 'onglets' %}
|
||
{% set tabs = children_of.get(el.id, []) %}
|
||
<details class="propGroup">
|
||
<summary>🗂️ Onglets{{ ' (' ~ tabs|length ~ ')' if tabs else '' }}</summary>
|
||
<div class="propGroupBody">
|
||
{% if tabs %}
|
||
{% for tab in tabs %}
|
||
<div class="box" style="padding:10px; display:flex; flex-direction:column; gap:6px;">
|
||
<form method="post" action="{{ url_for('element_rename', slug=game.slug, element_id=tab.id) }}" class="field has-addons" style="margin:0;">
|
||
<div class="control is-expanded"><input class="input is-small" type="text" name="name" value="{{ tab.name or ('Onglet ' ~ loop.index) }}" placeholder="Nom de l'onglet"></div>
|
||
<div class="control"><button type="submit" class="button is-small">Renommer</button></div>
|
||
</form>
|
||
<div class="field has-addons" style="margin:0;">
|
||
<div class="control is-expanded"><a class="button is-small is-fullwidth" href="{{ url_for('screen_edit', slug=game.slug, screen_id=screen.id, selected=tab.id) }}">📥 Contenu de l'onglet</a></div>
|
||
<div class="control">
|
||
<form method="post" action="{{ url_for('element_delete', slug=game.slug, element_id=tab.id) }}"
|
||
data-confirm-message="Supprimer cet onglet et tout son contenu ?" onsubmit="return handleDeleteElementSubmit(event, this);">
|
||
<button type="submit" class="button is-small is-danger">🗑️</button>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
{% endfor %}
|
||
{% else %}
|
||
<p class="help">Aucun onglet pour l'instant.</p>
|
||
{% endif %}
|
||
<form method="post" action="{{ url_for('element_tab_add', slug=game.slug, element_id=el.id) }}" style="margin-top:8px;">
|
||
<button type="submit" class="button is-small is-primary is-fullwidth">➕ Ajouter un onglet</button>
|
||
</form>
|
||
</div>
|
||
</details>
|
||
{% endif %}
|
||
|
||
{% if el.widget == 'personnage' %}
|
||
<!-- Phase 8 — panneau sur-mesure (custom_panel, voir registry.py) :
|
||
pas de controls génériques, juste le personnage Forge affiché.
|
||
Sauvegarde en AJAX (personnage-panel.js), jamais via #propsForm
|
||
(générique, utilisé par tous les autres widgets). L'import de
|
||
sprites personnalisés est retiré pour le moment. -->
|
||
<details class="propGroup" open>
|
||
<summary>🎭 Personnage</summary>
|
||
<div class="propGroupBody" id="personnagePanelBody" data-element-id="{{ el.id }}">
|
||
<p class="help">Personnage Forge — clique un autre pour en changer.</p>
|
||
{{ render_personnage_gallery('swap', 'personnageGallerySwap', personnage_data.forge_character) }}
|
||
{% 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>
|
||
{{ render_variant_gallery(current_character.family, 'personnageGalleryVariants', personnage_data.forge_character) }}
|
||
{% endif %}
|
||
</div>
|
||
</details>
|
||
{% endif %}
|
||
|
||
{% set size_ctrl_keys = ['child-width', 'child-height'] %}
|
||
{% set size_ctrls = {} %}
|
||
{% for c in selected_controls if c.key in size_ctrl_keys %}{% set _ = size_ctrls.update({c.key: c}) %}{% endfor %}
|
||
|
||
<form method="post" action="{{ url_for('element_save', slug=game.slug, element_id=el.id) }}" id="propsForm" onsubmit="event.preventDefault(); return false;">
|
||
<details class="propGroup">
|
||
<summary>Position & taille</summary>
|
||
<div class="propGroupBody">
|
||
<div class="columns is-multiline posGrid">
|
||
<div class="column is-half">
|
||
<div class="field"><label class="label is-small">X (%)</label>
|
||
<div class="control"><input class="input is-small" type="number" id="pos-x" min="0" max="100" value="{{ el.x }}" {{ 'disabled' if el.parent_id else '' }}></div>
|
||
</div>
|
||
</div>
|
||
<div class="column is-half">
|
||
<div class="field"><label class="label is-small">Y (%)</label>
|
||
<div class="control"><input class="input is-small" type="number" id="pos-y" min="0" max="100" value="{{ el.y }}" {{ 'disabled' if el.parent_id else '' }}></div>
|
||
</div>
|
||
</div>
|
||
{% if el.parent_id %}
|
||
<div class="column is-half">
|
||
<div class="field"><label class="label is-small">Largeur</label>
|
||
<div class="field has-addons sizeField">
|
||
<div class="control is-expanded"><input class="input is-small" type="number" name="ctrl_child-width_value" min="0" value="{{ size_ctrls['child-width'].value.value or '' }}" placeholder="auto"></div>
|
||
<div class="control">
|
||
<div class="select is-small">
|
||
<select name="ctrl_child-width_unit">
|
||
<option value="%" {{ 'selected' if size_ctrls['child-width'].value.unit == '%' else '' }}>%</option>
|
||
<option value="px" {{ 'selected' if size_ctrls['child-width'].value.unit == 'px' else '' }}>px</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="column is-half">
|
||
<div class="field"><label class="label is-small">Hauteur</label>
|
||
<div class="field has-addons sizeField">
|
||
<div class="control is-expanded"><input class="input is-small" type="number" name="ctrl_child-height_value" min="0" value="{{ size_ctrls['child-height'].value.value or '' }}" placeholder="auto"></div>
|
||
<div class="control">
|
||
<div class="select is-small">
|
||
<select name="ctrl_child-height_unit">
|
||
<option value="%" {{ 'selected' if size_ctrls['child-height'].value.unit == '%' else '' }}>%</option>
|
||
<option value="px" {{ 'selected' if size_ctrls['child-height'].value.unit == 'px' else '' }}>px</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
{% else %}
|
||
<div class="column is-half">
|
||
<div class="field"><label class="label is-small">Largeur (%)</label>
|
||
<div class="control"><input class="input is-small" type="number" id="pos-w" min="1" max="100" value="{{ el.width }}"></div>
|
||
</div>
|
||
</div>
|
||
<div class="column is-half">
|
||
<div class="field"><label class="label is-small">Hauteur (%)</label>
|
||
<div class="control"><input class="input is-small" type="number" id="pos-h" min="1" max="100" value="{{ el.height }}"></div>
|
||
</div>
|
||
</div>
|
||
{% endif %}
|
||
</div>
|
||
{% if el.parent_id %}
|
||
<p class="help">X/Y ne s'appliquent qu'à un élément posé directement sur l'écran — celui-ci suit la disposition de son conteneur. Largeur/Hauteur : laisser vide garde la taille automatique ; "%" (relatif au conteneur) convient à la plupart des cas, "px" pour une taille précise.</p>
|
||
{% endif %}
|
||
</div>
|
||
</details>
|
||
|
||
{% set ns = namespace(last_group=None, started=False) %}
|
||
{% for c in selected_controls if c.key not in size_ctrl_keys %}
|
||
{% if c.group != ns.last_group %}
|
||
{% if ns.started %}
|
||
</div>
|
||
</details>
|
||
{% endif %}
|
||
<details class="propGroup">
|
||
<summary>{{ c.group or 'Propriétés' }}</summary>
|
||
<div class="propGroupBody">
|
||
{% set ns.last_group = c.group %}
|
||
{% set ns.started = True %}
|
||
{% endif %}
|
||
<div class="field controlRow{{ ' visCondRow' if c.key.startswith('visibilite_cond_') and c.key != 'visibilite_cond_mode' else '' }}"
|
||
{% if c.key in ('visibilite_cond_variable', 'visibilite_cond_variable_chemin') %}data-vis-cond-scope="variable"
|
||
{% elif c.key in ('visibilite_cond_definition_id', 'visibilite_cond_champ') %}data-vis-cond-scope="objet"
|
||
{% elif c.key in ('visibilite_cond_operateur', 'visibilite_cond_valeur') %}data-vis-cond-scope="any"
|
||
{% endif %}>
|
||
{% if c.type in ('checkbox', 'toggle_token') %}
|
||
<div class="control">
|
||
<label class="checkbox">
|
||
<input type="checkbox" name="ctrl_{{ c.key }}" {{ 'checked' if c.value else '' }}>
|
||
{{ c.label }}
|
||
</label>
|
||
</div>
|
||
{% else %}
|
||
<label class="label is-small">{{ c.label }}</label>
|
||
{% if c.key == 'icon_class' %}
|
||
<div class="control">
|
||
<button type="button" class="button is-fullwidth" onclick="openIconPicker()">
|
||
<span class="icon"><i id="iconPickerPreview" class="icon-svg" style="--icon-url:url('/static/icons/{{ c.value or c.default }}.svg');"></i></span>
|
||
<span>Changer l'icône</span>
|
||
</button>
|
||
<input type="hidden" name="ctrl_{{ c.key }}" id="field-{{ c.key }}" value="{{ c.value or c.default }}">
|
||
</div>
|
||
{% elif c.key.endswith('_valeur') %}
|
||
<!-- Valeur de comparaison d'un filtre — soit une valeur fixe
|
||
tapée à la main, soit une donnée d'un AUTRE objet qui change
|
||
en cours de partie (ex: le niveau courant), choisie via deux
|
||
listes déroulantes plutôt que tapée à la main sous la forme
|
||
interne attendue — voir initFilterValuePickers() et
|
||
updateFilterValueFromDynamic() plus bas, qui reconstruisent
|
||
cette syntaxe en coulisses dans le champ caché ci-dessous,
|
||
seul réellement envoyé au serveur. -->
|
||
<div class="control filterValueControl" data-current-value="{{ c.value }}">
|
||
<div class="select is-small is-fullwidth">
|
||
<select class="filterValueMode" onchange="onFilterValueModeChange(this)">
|
||
<option value="fixe">Valeur fixe</option>
|
||
<option value="dynamique">Donnée d'un autre objet (change en jeu)</option>
|
||
<option value="variable">Variable globale (change en jeu)</option>
|
||
</select>
|
||
</div>
|
||
<div class="filterValueFixe" style="margin-top:6px;">
|
||
<input class="input is-small" type="text" name="ctrl_{{ c.key }}" id="field-{{ c.key }}" value="{{ c.value }}" placeholder="{{ c.placeholder or '' }}">
|
||
</div>
|
||
<div class="filterValueDynamique" style="display:none; margin-top:6px;">
|
||
<div class="select is-small is-fullwidth">
|
||
<select class="filterValueObjSel" onchange="onFilterValueObjChange(this)">
|
||
<option value="">— objet —</option>
|
||
{% for d in definitions %}
|
||
<option value="{{ d.name }}" data-definition-id="{{ d.id }}">{{ d.name }}</option>
|
||
{% endfor %}
|
||
</select>
|
||
</div>
|
||
<div class="select is-small is-fullwidth" style="margin-top:6px;">
|
||
<select class="filterValueFieldSel" onchange="onFilterValueFieldChange(this)">
|
||
<option value="">— champ —</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
<div class="filterValueVariable" style="display:none; margin-top:6px;">
|
||
<div class="select is-small is-fullwidth">
|
||
<select class="filterValueVarSel" onchange="onFilterValueVarChange(this)">
|
||
<option value="">— variable —</option>
|
||
{% for v in global_variables %}
|
||
<option value="{{ v.name }}">{{ v.name }}</option>
|
||
{% endfor %}
|
||
</select>
|
||
</div>
|
||
<!-- Utile seulement si la variable choisie est de type
|
||
"objet"/"tableau" : sans chemin, c'est toute la variable
|
||
(son JSON) qui est utilisée comme valeur de comparaison. -->
|
||
<input class="input is-small filterValueVarPath" type="text" placeholder="Chemin optionnel (ex. .nom ou [0].valeur)" style="margin-top:6px;" oninput="updateFilterValueFromVariable(this.closest('.filterValueControl'))">
|
||
{% if not global_variables %}
|
||
<p class="help">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>
|
||
{% elif c.type in ('text', 'url') %}
|
||
<div class="control">
|
||
<input class="input {{ 'uploadTextField' if c.allow_upload else '' }}" type="text" name="ctrl_{{ c.key }}" value="{{ c.value }}" placeholder="{{ c.placeholder or '' }}" id="field-{{ c.key }}">
|
||
</div>
|
||
{% if c.allow_upload %}
|
||
<div class="control uploadRow">
|
||
<input type="file" class="uploadInput" accept="{{ c.upload_accept }}" data-target="field-{{ c.key }}">
|
||
<span class="help uploadStatus"></span>
|
||
</div>
|
||
<p class="help">Colle une adresse (URL) ci-dessus, ou choisis un fichier sur ton ordinateur — il sera hébergé automatiquement avec ce jeu.</p>
|
||
{% endif %}
|
||
{% if c.field_options %}
|
||
<div class="control fieldRow" style="margin-top:6px;">
|
||
<div class="select is-small is-expanded"><select id="fieldPicker-{{ c.key }}">
|
||
<option value="">— champ de l'objet lié —</option>
|
||
{% for fname, flabel in c.field_options %}
|
||
<option value="{{ fname }}">{{ flabel }}</option>
|
||
{% endfor %}
|
||
</select></div>
|
||
<button type="button" class="button is-small" onclick="insertFieldAtCursor('field-{{ c.key }}', 'fieldPicker-{{ c.key }}')">+ Ajouter</button>
|
||
</div>
|
||
{% endif %}
|
||
{% elif c.type == 'textarea' %}
|
||
<div class="control"><textarea class="textarea" name="ctrl_{{ c.key }}" id="field-{{ c.key }}" rows="4" placeholder="{{ c.placeholder or '' }}">{{ c.value }}</textarea></div>
|
||
{% if c.field_options %}
|
||
<div class="control fieldRow" style="margin-top:6px;">
|
||
<div class="select is-small is-expanded"><select id="fieldPicker-{{ c.key }}">
|
||
<option value="">— champ de l'objet lié —</option>
|
||
{% for fname, flabel in c.field_options %}
|
||
<option value="{{ fname }}">{{ flabel }}</option>
|
||
{% endfor %}
|
||
</select></div>
|
||
<button type="button" class="button is-small" onclick="insertFieldAtCursor('field-{{ c.key }}', 'fieldPicker-{{ c.key }}')">+ Ajouter</button>
|
||
</div>
|
||
<p class="help">Insère "{{champ}}" à l'emplacement du curseur — affichera la valeur de ce champ, sur la ligne trouvée par "Donnée liée" ci-dessous.</p>
|
||
{% endif %}
|
||
{% elif c.type == 'color' %}
|
||
<div class="control"><input class="input" type="color" name="ctrl_{{ c.key }}" value="{{ c.value or c.default }}"></div>
|
||
{% elif c.type == 'slider' %}
|
||
<div class="control sliderRow">
|
||
<input type="range" name="ctrl_{{ c.key }}" min="{{ c.min }}" max="{{ c.max }}" step="{{ c.step }}" value="{{ c.value }}"
|
||
oninput="this.nextElementSibling.textContent = this.value + '{{ c.unit }}'">
|
||
<span class="sliderValue tag">{{ c.value }}{{ c.unit }}</span>
|
||
</div>
|
||
{% elif c.type == 'scale' %}
|
||
<div class="control sliderRow">
|
||
<input type="range" name="ctrl_{{ c.key }}" min="{{ c.min }}" max="{{ c.max }}" step="{{ c.step }}" value="{{ c.value }}"
|
||
oninput="this.nextElementSibling.textContent = 'x' + this.value">
|
||
<span class="sliderValue tag">x{{ c.value }}</span>
|
||
</div>
|
||
{% elif c.type == 'clause_list' %}
|
||
<!-- Liste de conditions ILLIMITÉE (champ/opérateur/valeur),
|
||
combinées par ET/OU — voir c_clause_list.py. Chaque bloc
|
||
reprend EXACTEMENT la présentation d'avant (un "Champ" et un
|
||
"...est" clairement étiquetés, et le même sélecteur de valeur
|
||
fixe/dynamique/variable — jamais la syntaxe "{{ }}" brute à
|
||
lire/taper), simplement répété autant de fois que
|
||
nécessaire. Chaque ligne existante est rendue ici (repli
|
||
côté serveur, notamment après un rechargement de page) ;
|
||
"+ Ajouter une condition" clone le <template> ci-dessous en
|
||
JS pour en ajouter d'autres à la volée (voir addClauseRow()
|
||
plus bas) — initFilterValuePickers() (déjà utilisée par
|
||
l'ancien réglage à 2 filtres fixes, entièrement scopée par
|
||
classe CSS via .closest('.filterValueControl'), jamais par
|
||
id) fonctionne donc ici aussi tel quel, avec un nombre
|
||
quelconque de blocs "valeur" sur la même page. -->
|
||
<div class="clauseListControl" data-clause-key="{{ c.key }}">
|
||
<div class="clauseListWrap" id="clauseListWrap-{{ c.key }}">
|
||
{% for clause in c.value.clauses %}
|
||
{% include "partials/clause_row.html" with context %}
|
||
{% endfor %}
|
||
</div>
|
||
<button type="button" class="button primary is-fullwidth" style="margin-top:10px;" onclick="addClauseRow('{{ c.key }}')">+ Ajouter une condition</button>
|
||
<div class="field clauseCombinatorRow" id="clauseCombinatorRow-{{ c.key }}" style="margin-top:12px; {{ '' if c.value.clauses|length > 1 else 'display:none;' }}">
|
||
<label class="label is-small">Combiner ces conditions avec</label>
|
||
<div class="control">
|
||
<div class="select is-small is-fullwidth">
|
||
<select name="ctrl_{{ c.key }}_combinateur">
|
||
<option value="et" {{ 'selected' if c.value.combinateur != 'ou' else '' }}>ET — toutes les conditions doivent être vraies</option>
|
||
<option value="ou" {{ 'selected' if c.value.combinateur == 'ou' else '' }}>OU — au moins une condition suffit</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<p class="help">Aucune condition = la ligne la plus récente de cet objet.</p>
|
||
</div>
|
||
{% set clause = {"champ": "", "operateur": "egal", "valeur": ""} %}
|
||
<template id="clauseRowTemplate-{{ c.key }}">
|
||
{% include "partials/clause_row.html" with context %}
|
||
</template>
|
||
{% elif c.type == 'shadow' %}
|
||
<div class="control">
|
||
<div class="fieldRow">
|
||
<div class="formField" style="flex:1;"><label class="label is-small">Décalage X</label><input class="input is-small" type="number" name="ctrl_{{ c.key }}_x" value="{{ c.value.x }}"></div>
|
||
<div class="formField" style="flex:1;"><label class="label is-small">Décalage Y</label><input class="input is-small" type="number" name="ctrl_{{ c.key }}_y" value="{{ c.value.y }}"></div>
|
||
</div>
|
||
<div class="fieldRow" style="margin-top:6px;">
|
||
<div class="formField" style="flex:1;"><label class="label is-small">Flou</label><input class="input is-small" type="number" min="0" name="ctrl_{{ c.key }}_blur" value="{{ c.value.blur }}"></div>
|
||
<div class="formField" style="flex:1;"><label class="label is-small">Étendue</label><input class="input is-small" type="number" min="0" name="ctrl_{{ c.key }}_spread" value="{{ c.value.spread }}"></div>
|
||
</div>
|
||
<div class="fieldRow" style="margin-top:6px; align-items:center;">
|
||
<input class="input" type="color" style="flex:0 0 60px; padding:2px;" name="ctrl_{{ c.key }}_color" value="{{ c.value.color }}">
|
||
<div class="sliderRow" style="flex:1;">
|
||
<input type="range" name="ctrl_{{ c.key }}_opacity" min="0" max="1" step="0.05" value="{{ c.value.opacity }}"
|
||
oninput="this.nextElementSibling.textContent = Math.round(this.value * 100) + '%'">
|
||
<span class="sliderValue tag">{{ (c.value.opacity * 100)|round|int }}%</span>
|
||
</div>
|
||
</div>
|
||
<p class="help">Décalages, flou et étendue tous à 0 = pas d'ombre.</p>
|
||
</div>
|
||
{% elif c.type == 'align' %}
|
||
<div class="control">
|
||
<div class="buttons has-addons alignGroup">
|
||
{% for val, icon in [('left','⬅️'),('center','⬛'),('right','➡️'),('justify','☰')] %}
|
||
<label class="button is-small alignOption {{ 'is-primary active' if c.value == val else '' }}">
|
||
<input type="radio" name="ctrl_{{ c.key }}" value="{{ val }}" {{ 'checked' if c.value == val else '' }} onchange="syncAlign(this)" style="display:none;">
|
||
{{ icon }}
|
||
</label>
|
||
{% endfor %}
|
||
</div>
|
||
</div>
|
||
{% elif c.type in ('select', 'preset') %}
|
||
<div class="control">
|
||
<div class="select is-fullwidth">
|
||
<select name="ctrl_{{ c.key }}" id="field-{{ c.key }}" {% if c.key == 'visibilite_cond_mode' %}onchange="toggleVisCondFields(this)"{% endif %}>
|
||
{% for opt in c.options %}
|
||
<option value="{{ opt[0] }}" {{ 'selected' if c.value == opt[0] else '' }}>{{ opt[1] }}</option>
|
||
{% endfor %}
|
||
</select>
|
||
</div>
|
||
</div>
|
||
{% if c.key == 'visibilite_cond_variable' %}
|
||
<p class="help">Pas la variable qu'il te faut ? <a href="{{ url_for('game_dashboard', slug=game.slug, tab='variables') }}" target="_blank">Gérer les variables du jeu ↗</a></p>
|
||
{% endif %}
|
||
{% endif %}
|
||
{% endif %}
|
||
</div>
|
||
{% endfor %}
|
||
{% if ns.started %}
|
||
</div>
|
||
</details>
|
||
{% endif %}
|
||
<div class="actionsRow"><span class="hint autosaveStatus" id="autosaveStatus">Enregistré automatiquement</span></div>
|
||
</form>
|
||
|
||
{% endif %}
|
||
{% endif %}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
|
||
<script>
|
||
// Données injectées par Jinja (côté serveur) — tout le reste de l'éditeur
|
||
// vit maintenant dans des fichiers statiques sous static/js/screen_edit/
|
||
// (voir plan de modularisation : un fichier = une responsabilité, aussi
|
||
// pour le JS — même patron déjà en place côté moteur de jeu, voir
|
||
// static/js/play/), qui ne peuvent pas contenir de code Jinja puisqu'ils
|
||
// ne passent jamais par le moteur de templates.
|
||
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 ELEMENT_ADD_URL = "{{ url_for('element_add', slug=game.slug, screen_id=screen.id) }}";
|
||
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 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_SURBRILLANCE_LABELS_MAP = {{ element_surbrillance_labels_json|safe }};
|
||
var ELEMENT_DESACTIVE_LABELS_MAP = {{ element_desactive_labels_json|safe }};
|
||
var ELEMENT_ORIENTATION_LABELS_MAP = {{ element_orientation_labels_json|safe }};
|
||
var SPRITE_LIBRARY = {{ sprite_library_json|safe }};
|
||
var ELEMENT_ANIMATIONS_MAP = {{ element_animations_map_json|safe }};
|
||
var DATA_OPERATION_LABELS_MAP = {{ data_operation_labels_json|safe }};
|
||
var CONDITION_OPERATOR_LABELS_MAP = {{ condition_operator_labels_json|safe }};
|
||
var TRIGGER_LABELS_MAP = {{ trigger_labels_json|safe }};
|
||
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 }};
|
||
</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>
|
||
// Onglet initial : ?tab=... (utilisé par les routes de CRUD des
|
||
// événements/blocs, pour revenir directement sur le bon onglet après
|
||
// création/modification/suppression plutôt que de laisser deviner où
|
||
// cliquer — même patron que game_dashboard.html). ?block=<id> ouvre en
|
||
// plus directement le bloc concerné (même idiome que ?edit=&subtab= dans
|
||
// game_dashboard.html). Reste inline (pas dans un fichier statique) :
|
||
// dépend directement de request.args, données Jinja propres à cette
|
||
// requête, et doit s'exécuter APRÈS les fichiers ci-dessus (switchBuilderTab/
|
||
// openLogicBlockPanel doivent déjà être définis).
|
||
(function () {
|
||
var requestedTab = {{ (request.args.get('tab') or '')|tojson }};
|
||
if (requestedTab) switchBuilderTab(requestedTab);
|
||
var requestedBlock = {{ request.args.get('block', type=int)|tojson }};
|
||
if (requestedBlock) openLogicBlockPanel(requestedBlock);
|
||
})();
|
||
</script>
|
||
{% endblock %}
|