1834 lines
86 KiB
HTML
1834 lines
86 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="{{ google_fonts_link }}">
|
||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/animate.css/4.1.1/animate.min.css">
|
||
{% endblock %}
|
||
{% block breadcrumb %}
|
||
<a class="crumb" href="{{ url_for('game_dashboard', slug=game.slug) }}">{{ game.name }}</a>
|
||
<span class="crumb-sep">/</span>
|
||
<a class="crumb" href="{{ url_for('screens_list', slug=game.slug) }}">Écrans</a>
|
||
<span class="crumb-sep">/</span><span class="crumb">{{ screen.name }}</span>
|
||
{% endblock %}
|
||
{% block content %}
|
||
<div class="fieldRow" style="align-items:center; justify-content:space-between;">
|
||
<form method="post" action="{{ url_for('screen_rename', slug=game.slug, screen_id=screen.id) }}" class="fieldRow" style="align-items:center; margin:0;">
|
||
<input type="text" name="name" value="{{ screen.name }}" required style="font-size:16px; font-weight:700; width:auto;">
|
||
<button type="submit" class="smallBtn">Renommer</button>
|
||
</form>
|
||
{% if not screen.is_template %}
|
||
<a class="smallBtn primary" href="{{ url_for('game_play', slug=game.slug) }}" target="_blank">▶️ Jouer depuis le début</a>
|
||
{% endif %}
|
||
</div>
|
||
|
||
{% if screen.is_template %}
|
||
<p class="hint" style="background:#1c2130; border:1px solid #2b3140; border-radius:8px; padding:10px 14px; margin:10px 0;">
|
||
🧩 Contenu d'un élément de jeu réutilisable.
|
||
</p>
|
||
{% endif %}
|
||
|
||
<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) -->
|
||
<div class="builderPanel">
|
||
{% if selected_id and el and el.widget in container_widgets %}
|
||
<p class="hint" style="background:#2a1f10; border:1px solid #6b4a12; border-radius:8px; padding:8px 12px; margin-bottom:10px;">
|
||
⚠️ Un conteneur est sélectionné. La grille ci-dessous pose l'élément directement sur l'écran, PAS à l'intérieur du conteneur — pour poser un élément dans le conteneur sélectionné, utilise plutôt <strong>« 📥 Ajouter DANS ce conteneur »</strong> dans le panneau de droite.
|
||
</p>
|
||
{% endif %}
|
||
<details class="propGroup" open>
|
||
<summary>Ajouter un élément sur l'écran</summary>
|
||
<div class="propGroupBody">
|
||
<div class="widgetGrid">
|
||
{% for wkey, w in widgets.items() %}
|
||
<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>
|
||
|
||
<!-- 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" {{ 'open' if element_types else '' }}>
|
||
<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('element_types', slug=game.slug) }}">crées-en un</a>.</p>
|
||
{% endif %}
|
||
</div>
|
||
</details>
|
||
|
||
<h2>Éléments de cet écran</h2>
|
||
{% if top_level_elements %}
|
||
<div class="list">
|
||
{% for el in top_level_elements %}
|
||
<a class="listRow {{ 'selected' if el.id == selected_id else '' }}" id="listrow-{{ el.id }}"
|
||
href="{{ url_for('screen_edit', slug=game.slug, screen_id=screen.id, selected=el.id) }}">
|
||
<div class="rowTitle">{{ widgets.get(el.widget, {}).get('icon', '❔') }} {{ el|elabel }}</div>
|
||
<div class="rowSub">{{ (el.content or '—')[:30] }}</div>
|
||
</a>
|
||
{% if el.widget in container_widgets and children_of.get(el.id) %}
|
||
<div class="childList">
|
||
{% for child in children_of.get(el.id) %}
|
||
<a class="listRow childRow {{ 'selected' if child.id == selected_id else '' }}"
|
||
href="{{ url_for('screen_edit', slug=game.slug, screen_id=screen.id, selected=child.id) }}">
|
||
<div class="rowTitle">↳ {{ widgets.get(child.widget, {}).get('icon', '❔') }} {{ child|elabel }}</div>
|
||
<div class="rowSub">{{ (child.content or '—')[:26] }}</div>
|
||
</a>
|
||
{% endfor %}
|
||
</div>
|
||
{% endif %}
|
||
{% endfor %}
|
||
</div>
|
||
{% else %}
|
||
<p class="hint">Aucun élément pour l'instant.</p>
|
||
{% endif %}
|
||
</div>
|
||
|
||
<!-- Centre : l'écran, en très grand -->
|
||
<div class="builderCanvasArea">
|
||
{% set current_aspect = screen.preview_aspect or '9/16' %}
|
||
<div class="canvasToolbar">
|
||
<span class="hint">Aperçu :</span>
|
||
<button type="button" class="smallBtn aspectBtn {{ 'active' if current_aspect == '9/16' else '' }}" data-aspect="9/16">📱 Portrait</button>
|
||
<button type="button" class="smallBtn aspectBtn {{ 'active' if current_aspect == '16/9' else '' }}" data-aspect="16/9">🖥️ Paysage</button>
|
||
<button type="button" class="smallBtn aspectBtn {{ 'active' if current_aspect == '1/1' else '' }}" data-aspect="1/1">◻️ Carré</button>
|
||
</div>
|
||
<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>
|
||
|
||
<!-- Panneau droit : propriétés de l'élément sélectionné -->
|
||
<div class="builderPanel builderPanelRight">
|
||
{% 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 style="margin:0;">{{ w.get('icon','❔') }} {{ el|elabel }}{% if el.name %} <span class="hint">— {{ w.get('label', el.widget) }}</span>{% endif %}</h2>
|
||
<form method="post" action="{{ url_for('element_delete', slug=game.slug, element_id=el.id) }}"
|
||
onsubmit="return confirm('Supprimer cet élément ?');">
|
||
<button type="submit" class="smallBtn danger">🗑️</button>
|
||
</form>
|
||
</div>
|
||
<form method="post" action="{{ url_for('element_rename', slug=game.slug, element_id=el.id) }}" class="fieldRow" style="align-items:center; margin:6px 0;">
|
||
<input type="text" name="name" value="{{ el.name or '' }}" placeholder="Donne un nom à cet élément" style="flex:1;">
|
||
<button type="submit" class="smallBtn">Nommer</button>
|
||
</form>
|
||
<div class="fieldRow" style="align-items:center; margin:6px 0;">
|
||
<label style="width:auto; margin-right:6px;">ID</label>
|
||
<input type="text" value="elt-{{ el.id }}" readonly style="flex:1; opacity:0.7;">
|
||
</div>
|
||
|
||
{% if el.widget in container_widgets %}
|
||
<details class="propGroup containerContentGroup" open>
|
||
<summary>📥 Ajouter DANS ce conteneur</summary>
|
||
<div class="propGroupBody">
|
||
{% set children = children_of.get(el.id, []) %}
|
||
{% if children %}
|
||
<div class="list">
|
||
{% for child in children %}
|
||
<a class="listRow childRow {{ 'selected' if child.id == selected_id else '' }}"
|
||
href="{{ url_for('screen_edit', slug=game.slug, screen_id=screen.id, selected=child.id) }}">
|
||
<div class="rowTitle">↳ {{ widgets.get(child.widget, {}).get('icon', '❔') }} {{ child|elabel }}</div>
|
||
<div class="rowSub">{{ (child.content or '—')[:26] }}</div>
|
||
</a>
|
||
{% endfor %}
|
||
</div>
|
||
{% else %}
|
||
<p class="hint">Aucun élément à l'intérieur pour l'instant — ajoute-en un ci-dessous.</p>
|
||
{% endif %}
|
||
<div class="widgetGrid">
|
||
{% for wkey, w in widgets.items() %}
|
||
<form method="post" action="{{ url_for('element_add_child', slug=game.slug, parent_id=el.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>
|
||
{% endif %}
|
||
|
||
<details class="propGroup" open>
|
||
<summary>Position & taille</summary>
|
||
<div class="propGroupBody">
|
||
<div class="posGrid">
|
||
<label>X (%)<input type="number" id="pos-x" min="0" max="100" value="{{ el.x }}"></label>
|
||
<label>Y (%)<input type="number" id="pos-y" min="0" max="100" value="{{ el.y }}"></label>
|
||
<label>Largeur (%)<input type="number" id="pos-w" min="1" max="100" value="{{ el.width }}"></label>
|
||
<label>Hauteur (%)<input type="number" id="pos-h" min="1" max="100" value="{{ el.height }}"></label>
|
||
</div>
|
||
</div>
|
||
</details>
|
||
|
||
<form method="post" action="{{ url_for('element_save', slug=game.slug, element_id=el.id) }}" id="propsForm" onsubmit="event.preventDefault(); return false;">
|
||
{% set ns = namespace(last_group=None, started=False) %}
|
||
{% for c in selected_controls %}
|
||
{% if c.group != ns.last_group %}
|
||
{% if ns.started %}
|
||
</div>
|
||
</details>
|
||
{% endif %}
|
||
<details class="propGroup" {{ 'open' if not ns.started else '' }}>
|
||
<summary>{{ c.group or 'Propriétés' }}</summary>
|
||
<div class="propGroupBody">
|
||
{% set ns.last_group = c.group %}
|
||
{% set ns.started = True %}
|
||
{% endif %}
|
||
<div class="controlRow">
|
||
<label>{{ c.label }}</label>
|
||
{% if c.type in ('text', 'url') %}
|
||
<input type="text" name="ctrl_{{ c.key }}" value="{{ c.value }}" placeholder="{{ c.placeholder or '' }}" class="{{ 'uploadTextField' if c.allow_upload else '' }}" id="field-{{ c.key }}">
|
||
{% if c.allow_upload %}
|
||
<div class="uploadRow">
|
||
<input type="file" class="uploadInput" accept="{{ c.upload_accept }}" data-target="field-{{ c.key }}">
|
||
<span class="hint uploadStatus"></span>
|
||
</div>
|
||
<p class="hint">Colle une adresse (URL) ci-dessus, ou choisis un fichier sur ton ordinateur — il sera hébergé automatiquement avec ce jeu.</p>
|
||
{% endif %}
|
||
{% elif c.type == 'textarea' %}
|
||
<textarea name="ctrl_{{ c.key }}" rows="4" placeholder="{{ c.placeholder or '' }}">{{ c.value }}</textarea>
|
||
{% elif c.type == 'color' %}
|
||
<input type="color" name="ctrl_{{ c.key }}" value="{{ c.value or c.default }}">
|
||
{% elif c.type == 'slider' %}
|
||
<div class="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">{{ c.value }}{{ c.unit }}</span>
|
||
</div>
|
||
{% elif c.type == 'scale' %}
|
||
<div class="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">x{{ c.value }}</span>
|
||
</div>
|
||
{% elif c.type in ('checkbox', 'toggle_token') %}
|
||
<input type="checkbox" name="ctrl_{{ c.key }}" {{ 'checked' if c.value else '' }}>
|
||
{% elif c.type == 'align' %}
|
||
<div class="alignGroup">
|
||
{% for val, icon in [('left','⬅️'),('center','⬛'),('right','➡️'),('justify','☰')] %}
|
||
<label class="alignOption {{ 'active' if c.value == val else '' }}">
|
||
<input type="radio" name="ctrl_{{ c.key }}" value="{{ val }}" {{ 'checked' if c.value == val else '' }} onchange="syncAlign(this)">
|
||
{{ icon }}
|
||
</label>
|
||
{% endfor %}
|
||
</div>
|
||
{% elif c.type in ('select', 'preset') %}
|
||
<select name="ctrl_{{ c.key }}">
|
||
{% for opt in c.options %}
|
||
<option value="{{ opt[0] }}" {{ 'selected' if c.value == opt[0] else '' }}>{{ opt[1] }}</option>
|
||
{% endfor %}
|
||
</select>
|
||
{% 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>
|
||
|
||
{% if not screen.is_template %}
|
||
<!-- Poignée de redimensionnement : visible seulement quand le panneau du bas
|
||
est ouvert, on la tire à la souris pour régler sa hauteur. -->
|
||
<div class="flowResizeHandle" id="flowResizeHandle"></div>
|
||
|
||
<!-- Panneau bas, rétractable : logique de la scène (éditeur de flow à nœuds).
|
||
Volontairement PAS un <details> natif : sa hauteur ouverte doit pouvoir
|
||
être réglée à la souris (voir flowResizeHandle), ce qu'un <details> ne
|
||
permet pas nativement. Absent pour un écran-modèle (élément de jeu) :
|
||
ses déclencheurs référenceraient des éléments dont l'id change à chaque
|
||
copie (voir instantiate_template_tree), donc une logique posée ici ne
|
||
fonctionnerait plus une fois l'élément réutilisé. -->
|
||
<div class="flowPanel" id="flowPanel">
|
||
<div class="flowPanelHeader" onclick="toggleFlowPanel()">
|
||
<span class="flowToggleIcon">▸</span> 🔀 Logique de la scène — nœuds et connexions
|
||
</div>
|
||
<div class="flowPanelBody" id="flowPanelBody">
|
||
<div class="flowToolbar">
|
||
<button type="button" class="smallBtn" onclick="openNodeForm('trigger')">➕ Déclencheur</button>
|
||
<button type="button" class="smallBtn" onclick="openNodeForm('condition')">➕ Condition</button>
|
||
<button type="button" class="smallBtn" 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" 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>
|
||
|
||
<div id="nodeFormCondition" style="display:none;">
|
||
{% if not definitions %}
|
||
<p class="hint">Aucun objet défini dans ce jeu pour l'instant — <a href="{{ url_for('object_new', slug=game.slug) }}">crées-en un</a>.</p>
|
||
{% else %}
|
||
<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>
|
||
<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 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" onclick="addFlowConditionClause()">+ Ajouter une condition</button>
|
||
</div>
|
||
{% endif %}
|
||
</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>
|
||
<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 %">
|
||
|
||
<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('object_new', slug=game.slug) }}">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;">
|
||
{% 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>
|
||
|
||
<div class="actionsRow"><button type="button" class="smallBtn 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>
|
||
|
||
<!-- Poignée de redimensionnement du panneau timeline, même principe que
|
||
flowResizeHandle ci-dessus. -->
|
||
<div class="flowResizeHandle" id="animResizeHandle"></div>
|
||
|
||
<!-- Panneau bas, rétractable, positionné sous "Logique de la scène" :
|
||
timeline d'animation. Une piste par élément animé, des clips posés sur
|
||
un axe du temps (secondes depuis l'affichage de l'écran) — glisse un
|
||
clip pour changer son départ, tire son bord droit pour changer sa
|
||
durée. Chaque clip est soit une animation de la bibliothèque
|
||
Animate.css, soit une animation personnalisée (images-clés propres à
|
||
ce jeu). -->
|
||
<div class="flowPanel" id="animPanel">
|
||
<div class="flowPanelHeader" onclick="toggleAnimPanel()">
|
||
<span class="flowToggleIcon">▸</span> 🎬 Timeline d'animation
|
||
</div>
|
||
<div class="flowPanelBody" id="animPanelBody">
|
||
<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" 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" 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>
|
||
</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" onclick="addAnimKeyframeRow()">+ Ajouter une image-clé</button>
|
||
</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 primary" onclick="saveAnimClipForm()">Enregistrer</button>
|
||
<button type="button" class="smallBtn" onclick="previewAnimClip()">▶ Aperçu</button>
|
||
<button type="button" class="smallBtn 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>
|
||
{% endif %}
|
||
|
||
<script>
|
||
function saveGeometry(id, x, y, width, height) {
|
||
return fetch("/game/{{ game.slug }}/elements/" + id + "/geometry", {
|
||
method: 'POST',
|
||
headers: {'Content-Type': 'application/json'},
|
||
body: JSON.stringify({x: x, y: y, width: width, height: height})
|
||
});
|
||
}
|
||
|
||
var SCREEN_EDIT_URL = "{{ url_for('screen_edit', slug=game.slug, screen_id=screen.id) }}";
|
||
var CURRENT_SELECTED_ID = {{ selected_id or 'null' }};
|
||
|
||
// ---------- Navigation dans l'éditeur sans AUCUN rechargement de page ----------
|
||
//
|
||
// Se déplacer dans le canevas (glisser, redimensionner, cliquer un autre
|
||
// élément) et modifier les réglages d'un élément ne doivent jamais recharger
|
||
// la page : on va chercher en arrière-plan (fetch) uniquement le fragment
|
||
// HTML dont on a besoin (le bloc #builder3 en entier pour changer de
|
||
// sélection, ou juste #canvas pour refléter un changement visuel après une
|
||
// sauvegarde automatique) et on le remplace dans le DOM courant. L'URL
|
||
// (?selected=...) reste mise à jour via l'historique (pushState), pour que
|
||
// le bouton "précédent" du navigateur continue à fonctionner normalement.
|
||
|
||
function refreshBuilderPanel(selectedId) {
|
||
var url = SCREEN_EDIT_URL + "?selected=" + selectedId;
|
||
return fetch(url, { headers: { "X-Requested-With": "pjax" } })
|
||
.then(function (r) { return r.text().then(function (html) { return { html: html, url: r.url || url }; }); })
|
||
.then(function (result) {
|
||
var doc = new DOMParser().parseFromString(result.html, "text/html");
|
||
var newBuilder = doc.getElementById("builder3");
|
||
var curBuilder = document.getElementById("builder3");
|
||
if (newBuilder && curBuilder) {
|
||
curBuilder.innerHTML = newBuilder.innerHTML;
|
||
}
|
||
CURRENT_SELECTED_ID = selectedId;
|
||
history.pushState({ pjax: true }, "", result.url);
|
||
initBuilderPanel();
|
||
})
|
||
.catch(function () {
|
||
// Échec réseau : on retombe sur une vraie navigation plutôt que de
|
||
// laisser l'éditeur dans un état incohérent.
|
||
window.location.href = url;
|
||
});
|
||
}
|
||
|
||
function refreshCanvasOnly() {
|
||
if (!CURRENT_SELECTED_ID) return Promise.resolve();
|
||
var url = SCREEN_EDIT_URL + "?selected=" + CURRENT_SELECTED_ID;
|
||
return fetch(url, { headers: { "X-Requested-With": "pjax" } })
|
||
.then(function (r) { return r.text(); })
|
||
.then(function (html) {
|
||
var doc = new DOMParser().parseFromString(html, "text/html");
|
||
var newCanvas = doc.getElementById("canvas");
|
||
var curCanvas = document.getElementById("canvas");
|
||
if (newCanvas && curCanvas) {
|
||
curCanvas.innerHTML = newCanvas.innerHTML;
|
||
}
|
||
// Aperçu du contenu dans l'arborescence de gauche (ex: texte tronqué) —
|
||
// sans toucher au panneau de droite, pour ne jamais faire perdre le
|
||
// focus/curseur pendant qu'on modifie un réglage.
|
||
var newList = doc.querySelector('.builderPanel .list');
|
||
var curList = document.querySelector('.builderPanel .list');
|
||
if (newList && curList) curList.innerHTML = newList.innerHTML;
|
||
});
|
||
}
|
||
|
||
function goToSelected(id) {
|
||
return refreshBuilderPanel(id);
|
||
}
|
||
|
||
// ---------- Glisser-déposer / redimensionner sur le canevas ----------
|
||
//
|
||
// Un mousedown qui bouge de plus de quelques pixels déplace l'élément
|
||
// (position sauvegardée en arrière-plan à la fin, sans recharger la page —
|
||
// comme l'élément déplacé reste celui déjà sélectionné, on se contente de
|
||
// mettre à jour les champs X/Y/Largeur/Hauteur en local, rien d'autre à
|
||
// rafraîchir) ; un mousedown qui ne bouge pas est traité comme un simple
|
||
// clic de sélection (qui, lui, doit rafraîchir le panneau de droite pour
|
||
// afficher les réglages du nouvel élément choisi).
|
||
function onElementMouseDown(e, id) {
|
||
if (e.target.classList.contains('resizeHandle')) return;
|
||
e.preventDefault();
|
||
var el = document.getElementById('el-' + id);
|
||
var canvas = document.getElementById('canvas');
|
||
var rect = canvas.getBoundingClientRect();
|
||
var startX = e.clientX, startY = e.clientY;
|
||
var startLeft = parseFloat(el.style.left), startTop = parseFloat(el.style.top);
|
||
var width = parseFloat(el.style.width), height = parseFloat(el.style.height);
|
||
var moved = false;
|
||
// Un clic (sans glisser) sélectionne l'élément précis qu'on a touché —
|
||
// même s'il est physiquement imbriqué dans ce conteneur — pour éviter de
|
||
// devoir repasser par l'arborescence à chaque fois. Le glisser-déposer,
|
||
// lui, reste toujours celui de la boîte de plus haut niveau (id).
|
||
var clickedTarget = e.target.closest('[data-element-id]');
|
||
var clickedId = clickedTarget ? clickedTarget.dataset.elementId : id;
|
||
|
||
function onMove(ev) {
|
||
var dx = ev.clientX - startX, dy = ev.clientY - startY;
|
||
if (Math.abs(dx) > 3 || Math.abs(dy) > 3) moved = true;
|
||
var newLeft = Math.max(0, Math.min(100 - width, startLeft + (dx / rect.width) * 100));
|
||
var newTop = Math.max(0, Math.min(100 - height, startTop + (dy / rect.height) * 100));
|
||
el.style.left = newLeft + '%';
|
||
el.style.top = newTop + '%';
|
||
}
|
||
function onUp() {
|
||
document.removeEventListener('mousemove', onMove);
|
||
document.removeEventListener('mouseup', onUp);
|
||
if (moved) {
|
||
var newLeft = parseFloat(el.style.left), newTop = parseFloat(el.style.top);
|
||
updatePosFields(newLeft, newTop, width, height);
|
||
saveGeometry(id, newLeft, newTop, width, height);
|
||
} else if (String(clickedId) !== String(CURRENT_SELECTED_ID)) {
|
||
goToSelected(clickedId);
|
||
}
|
||
}
|
||
document.addEventListener('mousemove', onMove);
|
||
document.addEventListener('mouseup', onUp);
|
||
}
|
||
|
||
function onResizeMouseDown(e, id) {
|
||
e.stopPropagation();
|
||
e.preventDefault();
|
||
var el = document.getElementById('el-' + id);
|
||
var canvas = document.getElementById('canvas');
|
||
var rect = canvas.getBoundingClientRect();
|
||
var startX = e.clientX, startY = e.clientY;
|
||
var startWidth = parseFloat(el.style.width), startHeight = parseFloat(el.style.height);
|
||
var left = parseFloat(el.style.left), top = parseFloat(el.style.top);
|
||
|
||
function onMove(ev) {
|
||
var dx = ev.clientX - startX, dy = ev.clientY - startY;
|
||
var newWidth = Math.max(5, Math.min(100 - left, startWidth + (dx / rect.width) * 100));
|
||
var newHeight = Math.max(5, Math.min(100 - top, startHeight + (dy / rect.height) * 100));
|
||
el.style.width = newWidth + '%';
|
||
el.style.height = newHeight + '%';
|
||
}
|
||
function onUp() {
|
||
document.removeEventListener('mousemove', onMove);
|
||
document.removeEventListener('mouseup', onUp);
|
||
var newWidth = parseFloat(el.style.width), newHeight = parseFloat(el.style.height);
|
||
updatePosFields(left, top, newWidth, newHeight);
|
||
saveGeometry(id, left, top, newWidth, newHeight);
|
||
}
|
||
document.addEventListener('mousemove', onMove);
|
||
document.addEventListener('mouseup', onUp);
|
||
}
|
||
|
||
function updatePosFields(x, y, w, h) {
|
||
var xEl = document.getElementById('pos-x'), yEl = document.getElementById('pos-y');
|
||
var wEl = document.getElementById('pos-w'), hEl = document.getElementById('pos-h');
|
||
if (xEl) xEl.value = Math.round(x * 10) / 10;
|
||
if (yEl) yEl.value = Math.round(y * 10) / 10;
|
||
if (wEl) wEl.value = Math.round(w * 10) / 10;
|
||
if (hEl) hEl.value = Math.round(h * 10) / 10;
|
||
}
|
||
|
||
// ---------- Panneau de propriétés : tout est enregistré automatiquement ----------
|
||
//
|
||
// Il n'y a plus de bouton "Enregistrer" : chaque réglage se sauvegarde tout
|
||
// seul dès qu'on le change (immédiatement pour une case à cocher/couleur/
|
||
// liste déroulante, après une courte pause pour un champ texte ou un
|
||
// slider qu'on est en train de glisser, pour éviter d'envoyer une requête à
|
||
// chaque caractère tapé ou à chaque pixel de glissement).
|
||
var autosaveTimer = null;
|
||
var autosaveIndicator = null;
|
||
|
||
function flashAutosave(state) {
|
||
if (!autosaveIndicator) return;
|
||
autosaveIndicator.classList.remove('saving', 'saved');
|
||
if (state === 'saving') {
|
||
autosaveIndicator.textContent = 'Enregistrement...';
|
||
autosaveIndicator.classList.add('saving');
|
||
} else {
|
||
autosaveIndicator.textContent = 'Enregistré automatiquement ✓';
|
||
autosaveIndicator.classList.add('saved');
|
||
}
|
||
}
|
||
|
||
function submitPropsForm() {
|
||
var form = document.getElementById('propsForm');
|
||
if (!form) return Promise.resolve();
|
||
flashAutosave('saving');
|
||
var data = new FormData(form);
|
||
return fetch(form.getAttribute('action'), {
|
||
method: 'POST', body: data, headers: { "X-Requested-With": "pjax" }
|
||
}).then(function () {
|
||
flashAutosave('saved');
|
||
return refreshCanvasOnly();
|
||
});
|
||
}
|
||
|
||
function debouncedSubmitPropsForm() {
|
||
clearTimeout(autosaveTimer);
|
||
autosaveTimer = setTimeout(submitPropsForm, 500);
|
||
}
|
||
|
||
function bindPropsAutosave() {
|
||
var form = document.getElementById('propsForm');
|
||
autosaveIndicator = document.getElementById('autosaveStatus');
|
||
if (!form) return;
|
||
form.querySelectorAll('input, textarea, select').forEach(function (field) {
|
||
if (field.classList.contains('uploadInput')) return; // géré séparément (upload de fichier)
|
||
var debounced = field.type === 'text' || field.type === 'range' || field.tagName === 'TEXTAREA';
|
||
field.addEventListener(debounced ? 'input' : 'change', function () {
|
||
// Le bascule visuelle de l'alignement (case active) est déjà gérée
|
||
// par l'attribut onchange="syncAlign(this)" posé sur chaque radio.
|
||
if (debounced) debouncedSubmitPropsForm(); else submitPropsForm();
|
||
});
|
||
});
|
||
}
|
||
|
||
// ---------- (Ré)initialisation du panneau central après chaque changement
|
||
// de sélection — nécessaire car #builder3 est entièrement remplacé, donc
|
||
// tout ce qui y était lié par addEventListener doit être rebranché (les
|
||
// attributs onclick/onmousedown inline, eux, fonctionnent automatiquement
|
||
// dès que le HTML est injecté, sans rien à refaire). ----------
|
||
function initBuilderPanel() {
|
||
(function () {
|
||
var xEl = document.getElementById('pos-x'), yEl = document.getElementById('pos-y');
|
||
var wEl = document.getElementById('pos-w'), hEl = document.getElementById('pos-h');
|
||
if (!xEl) return;
|
||
function apply() {
|
||
var canvasEl = document.getElementById('el-' + CURRENT_SELECTED_ID);
|
||
var x = parseFloat(xEl.value) || 0, y = parseFloat(yEl.value) || 0;
|
||
var w = parseFloat(wEl.value) || 1, h = parseFloat(hEl.value) || 1;
|
||
if (canvasEl) {
|
||
canvasEl.style.left = x + '%'; canvasEl.style.top = y + '%';
|
||
canvasEl.style.width = w + '%'; canvasEl.style.height = h + '%';
|
||
}
|
||
saveGeometry(CURRENT_SELECTED_ID, x, y, w, h);
|
||
}
|
||
[xEl, yEl, wEl, hEl].forEach(function (el) { el.addEventListener('change', apply); });
|
||
})();
|
||
|
||
bindAspectButtons();
|
||
|
||
document.querySelectorAll('.uploadInput').forEach(function (input) {
|
||
input.addEventListener('change', function () {
|
||
if (!input.files.length) return;
|
||
var status = input.parentElement.querySelector('.uploadStatus');
|
||
var targetField = document.getElementById(input.dataset.target);
|
||
status.textContent = 'Envoi en cours...';
|
||
var data = new FormData();
|
||
data.append('file', input.files[0]);
|
||
fetch("/game/{{ game.slug }}/upload", { method: 'POST', body: data })
|
||
.then(function (r) { return r.json(); })
|
||
.then(function (json) {
|
||
if (json.url) {
|
||
targetField.value = json.url;
|
||
status.textContent = 'Fichier envoyé ✓ Enregistrement...';
|
||
// Enregistre tout de suite, comme n'importe quel autre réglage —
|
||
// un fichier envoyé ne doit jamais rester "en attente".
|
||
submitPropsForm().then(function () { status.textContent = 'Fichier envoyé ✓ Enregistré.'; });
|
||
}
|
||
else { status.textContent = "Échec de l'envoi."; }
|
||
})
|
||
.catch(function () { status.textContent = "Échec de l'envoi."; });
|
||
});
|
||
});
|
||
|
||
bindPropsAutosave();
|
||
}
|
||
|
||
// Le retour/avance du navigateur (bouton "précédent") est déjà pris en
|
||
// charge par pjax.js (navigation globale sans rechargement, voir
|
||
// static/pjax.js) : il refait un rendu complet de <main> et rejoue ce
|
||
// script (voir runScriptsIn dans pjax.js), ce qui rappelle initBuilderPanel()
|
||
// ci-dessous — donc le canevas et le panneau de propriétés se retrouvent à
|
||
// jour automatiquement, sans qu'on ait besoin d'un second écouteur ici.
|
||
initBuilderPanel();
|
||
|
||
var DEFINITIONS_DATA = {{ definitions_json|safe }};
|
||
var CONDITION_OPERATORS_HTML = document.getElementById('nf-cond-operator') ? document.getElementById('nf-cond-operator').innerHTML : '';
|
||
|
||
function toggleActionFields(sel) {
|
||
var form = sel.closest('.actionAddForm');
|
||
var screenSel = form.querySelector('.targetScreenSelect');
|
||
var elementFields = form.querySelector('.targetElementFields');
|
||
var dataFields = form.querySelector('.targetDataFields');
|
||
screenSel.style.display = sel.value === 'aller_a' ? 'block' : 'none';
|
||
elementFields.style.display = sel.value === 'modifier_element' ? 'block' : 'none';
|
||
dataFields.style.display = sel.value === 'modifier_donnee' ? 'block' : 'none';
|
||
if (sel.value === 'modifier_element') {
|
||
var propSel = elementFields.querySelector('.elementPropertySelect');
|
||
toggleElementPropertyValue(propSel);
|
||
}
|
||
if (sel.value === 'modifier_donnee') {
|
||
var defSel = dataFields.querySelector('.targetDefinitionSelect');
|
||
if (defSel) onDataDefinitionChange(defSel);
|
||
}
|
||
}
|
||
document.querySelectorAll('.actionTypeSelect').forEach(toggleActionFields);
|
||
|
||
function toggleElementPropertyValue(sel) {
|
||
var wrap = sel.closest('.targetElementFields');
|
||
var visSel = wrap.querySelector('.elementValueVisibility');
|
||
var colorInp = wrap.querySelector('.elementValueColor');
|
||
var numInp = wrap.querySelector('.elementValueNumber');
|
||
[visSel, colorInp, numInp].forEach(function(el){ el.style.display = 'none'; el.removeAttribute('name'); });
|
||
if (sel.value === 'visibilite') {
|
||
visSel.style.display = 'block'; visSel.setAttribute('name', 'element_value');
|
||
} else if (sel.value === 'bg_color' || sel.value === 'border_color') {
|
||
colorInp.style.display = 'block'; colorInp.setAttribute('name', 'element_value');
|
||
} else {
|
||
numInp.style.display = 'block'; numInp.setAttribute('name', 'element_value');
|
||
}
|
||
}
|
||
document.querySelectorAll('.elementPropertySelect').forEach(toggleElementPropertyValue);
|
||
|
||
function onDataDefinitionChange(sel) {
|
||
var wrap = sel.closest('.targetDataFields');
|
||
var info = DEFINITIONS_DATA[sel.value] || {rows: [], fields: []};
|
||
var rowSel = wrap.querySelector('.targetRowSelect');
|
||
var fieldSel = wrap.querySelector('.targetFieldSelect');
|
||
if (!rowSel || !fieldSel) return;
|
||
rowSel.innerHTML = info.rows.length
|
||
? info.rows.map(function(r){ return '<option value="' + r.id + '">' + r.label + '</option>'; }).join('')
|
||
: '<option value="">Aucune donnée dans cet objet</option>';
|
||
fieldSel.innerHTML = info.fields.length
|
||
? info.fields.map(function(f){ return '<option value="' + f.name + '">' + f.name + ' (' + f.type + ')</option>'; }).join('')
|
||
: '<option value="">Aucun champ simple</option>';
|
||
}
|
||
document.querySelectorAll('.targetDefinitionSelect').forEach(onDataDefinitionChange);
|
||
|
||
function syncAlign(radio) {
|
||
radio.closest('.alignGroup').querySelectorAll('.alignOption').forEach(function(o){ o.classList.remove('active'); });
|
||
radio.closest('.alignOption').classList.add('active');
|
||
}
|
||
|
||
// Choix du format d'aperçu (Portrait/Paysage/Carré) : mémorisé côté serveur
|
||
// (voir screen_set_aspect.py) pour ne pas repartir sur Portrait à chaque
|
||
// ajout/suppression d'élément ou changement de sélection — ces actions
|
||
// redemandent ce panneau au serveur, qui doit donc connaître le choix déjà
|
||
// fait. Appelé depuis initBuilderPanel() (voir plus haut), une seule fois
|
||
// par (re)rendu de ce panneau.
|
||
function bindAspectButtons() {
|
||
document.querySelectorAll('.aspectBtn').forEach(function (btn) {
|
||
btn.addEventListener('click', function () {
|
||
document.querySelectorAll('.aspectBtn').forEach(function (b) { b.classList.remove('active'); });
|
||
btn.classList.add('active');
|
||
document.getElementById('canvas').style.aspectRatio = btn.dataset.aspect;
|
||
fetch("/game/{{ game.slug }}/screens/{{ screen.id }}/aspect", {
|
||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ aspect: btn.dataset.aspect }),
|
||
});
|
||
});
|
||
});
|
||
}
|
||
|
||
document.querySelectorAll('.uploadInput').forEach(function(input){
|
||
input.addEventListener('change', function(){
|
||
if (!input.files.length) return;
|
||
var status = input.parentElement.querySelector('.uploadStatus');
|
||
var targetField = document.getElementById(input.dataset.target);
|
||
status.textContent = 'Envoi en cours...';
|
||
var data = new FormData();
|
||
data.append('file', input.files[0]);
|
||
fetch("/game/{{ game.slug }}/upload", {method: 'POST', body: data})
|
||
.then(function(r){ return r.json(); })
|
||
.then(function(json){
|
||
if (json.url) {
|
||
targetField.value = json.url;
|
||
status.textContent = 'Fichier envoyé ✓ Enregistrement...';
|
||
// Enregistre tout de suite (comme un clic sur "Enregistrer") pour
|
||
// qu'un fichier envoyé ne reste jamais "en attente" si on oublie
|
||
// de cliquer sur le bouton ensuite — l'image doit apparaître dès
|
||
// l'envoi, sans étape supplémentaire à retenir.
|
||
var form = document.getElementById('propsForm');
|
||
if (form) { form.requestSubmit ? form.requestSubmit() : form.submit(); }
|
||
}
|
||
else { status.textContent = "Échec de l'envoi."; }
|
||
})
|
||
.catch(function(){ status.textContent = "Échec de l'envoi."; });
|
||
});
|
||
});
|
||
|
||
// ---------- Logique de la scène : éditeur de flow à nœuds ----------
|
||
|
||
var FLOW_SCREEN_ID = {{ screen.id }};
|
||
var FLOW = {
|
||
nodes: {{ flow_nodes_json|safe }},
|
||
edges: {{ flow_edges_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 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 flowPendingPort = null; // {nodeId, port} en attente de connexion
|
||
|
||
function flowUrl(path) { return "/game/{{ game.slug }}" + path; }
|
||
|
||
function nodeLabel(node) {
|
||
if (node.node_type === 'trigger') {
|
||
if (node.trigger_event === 'affichage') return '🖥️ ' + TRIGGER_LABELS_MAP['affichage'];
|
||
return '🖱️ ' + (TRIGGER_LABELS_MAP[node.trigger_event] || 'Au clic') + ' sur ' +
|
||
(ELEMENTS_LABELS[node.trigger_element_id] || '?');
|
||
}
|
||
if (node.node_type === 'condition') {
|
||
var mainClause = '❓ SI ' + (node.cond_field || '?') + ' ' + (CONDITION_OPERATOR_LABELS_MAP[node.cond_operator] || node.cond_operator) + ' ' + (node.cond_value || '');
|
||
if (node.cond_clauses) {
|
||
// 2.4 — plusieurs clauses combinées : on affiche chaque champ testé,
|
||
// relié par le connecteur ET/OU choisi, plutôt qu'une seule clause.
|
||
try {
|
||
var clauses = JSON.parse(node.cond_clauses);
|
||
var combLabel = node.cond_combinator === 'ou' ? ' OU ' : ' ET ';
|
||
return '❓ SI ' + clauses.map(function(c) {
|
||
return (c.field || '?') + ' ' + (CONDITION_OPERATOR_LABELS_MAP[c.operator] || c.operator) + ' ' + (c.value || '');
|
||
}).join(combLabel);
|
||
} catch (e) { /* JSON invalide : on retombe sur la clause historique */ }
|
||
}
|
||
return mainClause;
|
||
}
|
||
// action
|
||
var base = '⚡ ' + (ACTION_TYPE_LABELS_MAP[node.action_type] || node.action_type);
|
||
if (node.action_type === 'aller_a') {
|
||
base += ' → ' + (SCREENS_MAP[node.target_screen_id] || '?');
|
||
} else if (node.action_type === 'ouvrir_ligne') {
|
||
base += ' → ' + (SCREENS_MAP[node.target_screen_id] || '?') + ' (ligne cliquée)';
|
||
} else if (node.action_type === 'activer_onglet') {
|
||
base += ' → ' + (ELEMENTS_LABELS[node.target_element_id] || '?') + ' (masque ses frères)';
|
||
} else if (node.action_type === 'modifier_element') {
|
||
base += ' → ' + (ELEMENTS_LABELS[node.target_element_id] || '?') + ' : ' + (ELEMENT_PROPERTY_LABELS_MAP[node.element_property] || node.element_property);
|
||
if (node.element_property === 'visibilite') {
|
||
base += ' = ' + (ELEMENT_VISIBILITY_LABELS_MAP[node.element_value] || node.element_value);
|
||
} else if (node.element_property === 'surbrillance') {
|
||
base += ' = ' + (ELEMENT_SURBRILLANCE_LABELS_MAP[node.element_value] || node.element_value);
|
||
} else if (node.element_property === 'desactive') {
|
||
base += ' = ' + (ELEMENT_DESACTIVE_LABELS_MAP[node.element_value] || node.element_value);
|
||
} else if (node.element_value && node.element_value.indexOf('||') !== -1) {
|
||
var vparts = node.element_value.split('||');
|
||
base += ' = ' + vparts[0] + ' ↔ ' + vparts[1] + ' (va-et-vient)';
|
||
} else {
|
||
base += ' = ' + node.element_value;
|
||
}
|
||
} else if (node.action_type === 'modifier_donnee') {
|
||
base += ' → ' + (node.target_field || '?') + ' : ' + (DATA_OPERATION_LABELS_MAP[node.data_operation] || node.data_operation);
|
||
if (node.data_value) base += ' ' + node.data_value;
|
||
} else if (node.action_type === 'attendre') {
|
||
base += ' (' + (node.data_value || '0') + ' s)';
|
||
}
|
||
return base;
|
||
}
|
||
|
||
function renderFlow() {
|
||
var canvas = document.getElementById('flowCanvas');
|
||
var svg = document.getElementById('flowEdges');
|
||
canvas.innerHTML = '';
|
||
svg.innerHTML = '';
|
||
|
||
FLOW.nodes.forEach(function(node) {
|
||
var div = document.createElement('div');
|
||
div.className = 'flowNode flowNode-' + node.node_type;
|
||
div.style.left = node.x + 'px';
|
||
div.style.top = node.y + 'px';
|
||
div.dataset.nodeId = node.id;
|
||
|
||
var del = document.createElement('button');
|
||
del.className = 'flowNodeDelete';
|
||
del.type = 'button';
|
||
del.textContent = '✕';
|
||
del.onclick = function(ev) { ev.stopPropagation(); deleteFlowNode(node.id); };
|
||
div.appendChild(del);
|
||
|
||
var body = document.createElement('div');
|
||
body.className = 'flowNodeBody';
|
||
body.textContent = nodeLabel(node);
|
||
div.appendChild(body);
|
||
|
||
if (node.node_type !== 'trigger') {
|
||
var inPort = document.createElement('div');
|
||
inPort.className = 'flowPort flowPortIn';
|
||
inPort.onclick = function(ev) { ev.stopPropagation(); onPortClick(node.id, 'in', null); };
|
||
div.appendChild(inPort);
|
||
}
|
||
if (node.node_type === 'condition') {
|
||
var outTrue = document.createElement('div');
|
||
outTrue.className = 'flowPort flowPortOut flowPortTrue';
|
||
outTrue.title = 'Vrai';
|
||
outTrue.onclick = function(ev) { ev.stopPropagation(); onPortClick(node.id, 'out', 'vrai'); };
|
||
div.appendChild(outTrue);
|
||
var outFalse = document.createElement('div');
|
||
outFalse.className = 'flowPort flowPortOut flowPortFalse';
|
||
outFalse.title = 'Faux';
|
||
outFalse.onclick = function(ev) { ev.stopPropagation(); onPortClick(node.id, 'out', 'faux'); };
|
||
div.appendChild(outFalse);
|
||
} else {
|
||
var outPort = document.createElement('div');
|
||
outPort.className = 'flowPort flowPortOut';
|
||
outPort.onclick = function(ev) { ev.stopPropagation(); onPortClick(node.id, 'out', 'out'); };
|
||
div.appendChild(outPort);
|
||
}
|
||
|
||
div.addEventListener('mousedown', function(e) { onFlowNodeMouseDown(e, node.id); });
|
||
canvas.appendChild(div);
|
||
});
|
||
|
||
// Redessine les fils après que les nœuds soient dans le DOM (pour lire leur position réelle).
|
||
requestAnimationFrame(drawFlowEdges);
|
||
}
|
||
|
||
function portPosition(nodeId, side, sub) {
|
||
var div = document.querySelector('.flowNode[data-node-id="' + nodeId + '"]');
|
||
if (!div) return null;
|
||
var wrap = document.querySelector('.flowCanvasWrap');
|
||
var wrapRect = wrap.getBoundingClientRect();
|
||
var selector = side === 'in' ? '.flowPortIn' : (sub === 'vrai' ? '.flowPortTrue' : (sub === 'faux' ? '.flowPortFalse' : '.flowPortOut'));
|
||
var port = div.querySelector(selector);
|
||
if (!port) return null;
|
||
var r = port.getBoundingClientRect();
|
||
return { x: r.left + r.width / 2 - wrapRect.left + wrap.scrollLeft, y: r.top + r.height / 2 - wrapRect.top + wrap.scrollTop };
|
||
}
|
||
|
||
function drawFlowEdges() {
|
||
var svg = document.getElementById('flowEdges');
|
||
svg.innerHTML = '';
|
||
FLOW.edges.forEach(function(edge) {
|
||
var from = portPosition(edge.from_node_id, 'out', edge.from_port);
|
||
var to = portPosition(edge.to_node_id, 'in', null);
|
||
if (!from || !to) return;
|
||
var line = document.createElementNS('http://www.w3.org/2000/svg', 'line');
|
||
line.setAttribute('x1', from.x); line.setAttribute('y1', from.y);
|
||
line.setAttribute('x2', to.x); line.setAttribute('y2', to.y);
|
||
line.setAttribute('class', 'flowEdgeLine' + (edge.from_port === 'faux' ? ' flowEdgeFalse' : (edge.from_port === 'vrai' ? ' flowEdgeTrue' : '')));
|
||
line.style.pointerEvents = 'stroke';
|
||
line.addEventListener('click', function() { deleteFlowEdge(edge.id); });
|
||
svg.appendChild(line);
|
||
});
|
||
}
|
||
|
||
function onPortClick(nodeId, side, port) {
|
||
if (side === 'out') {
|
||
flowPendingPort = { nodeId: nodeId, port: port };
|
||
return;
|
||
}
|
||
// side === 'in'
|
||
if (!flowPendingPort) return;
|
||
if (flowPendingPort.nodeId === nodeId) { flowPendingPort = null; return; }
|
||
fetch(flowUrl('/screens/' + FLOW_SCREEN_ID + '/flow/edges/add'), {
|
||
method: 'POST', headers: {'Content-Type': 'application/json'},
|
||
body: JSON.stringify({ from_node_id: flowPendingPort.nodeId, from_port: flowPendingPort.port, to_node_id: nodeId })
|
||
}).then(function(r){ return r.json(); }).then(function(edge){
|
||
FLOW.edges.push({ id: edge.id, screen_id: FLOW_SCREEN_ID, from_node_id: flowPendingPort.nodeId, from_port: flowPendingPort.port, to_node_id: nodeId });
|
||
flowPendingPort = null;
|
||
drawFlowEdges();
|
||
});
|
||
}
|
||
|
||
function deleteFlowEdge(edgeId) {
|
||
fetch(flowUrl('/flow/edges/' + edgeId + '/delete'), { method: 'POST' }).then(function(){
|
||
FLOW.edges = FLOW.edges.filter(function(e){ return e.id !== edgeId; });
|
||
drawFlowEdges();
|
||
});
|
||
}
|
||
|
||
function deleteFlowNode(nodeId) {
|
||
fetch(flowUrl('/flow/nodes/' + nodeId + '/delete'), { method: 'POST' }).then(function(){
|
||
FLOW.nodes = FLOW.nodes.filter(function(n){ return n.id !== nodeId; });
|
||
FLOW.edges = FLOW.edges.filter(function(e){ return e.from_node_id !== nodeId && e.to_node_id !== nodeId; });
|
||
renderFlow();
|
||
});
|
||
}
|
||
|
||
function onFlowNodeMouseDown(e, nodeId) {
|
||
if (e.target.classList.contains('flowPort') || e.target.classList.contains('flowNodeDelete')) return;
|
||
e.preventDefault();
|
||
var node = FLOW.nodes.find(function(n){ return n.id === nodeId; });
|
||
var div = document.querySelector('.flowNode[data-node-id="' + nodeId + '"]');
|
||
var startX = e.clientX, startY = e.clientY;
|
||
var startLeft = node.x, startTop = node.y;
|
||
function onMove(ev) {
|
||
var dx = ev.clientX - startX, dy = ev.clientY - startY;
|
||
node.x = Math.max(0, startLeft + dx);
|
||
node.y = Math.max(0, startTop + dy);
|
||
div.style.left = node.x + 'px';
|
||
div.style.top = node.y + 'px';
|
||
drawFlowEdges();
|
||
}
|
||
function onUp() {
|
||
document.removeEventListener('mousemove', onMove);
|
||
document.removeEventListener('mouseup', onUp);
|
||
fetch(flowUrl('/flow/nodes/' + nodeId + '/move'), {
|
||
method: 'POST', headers: {'Content-Type': 'application/json'},
|
||
body: JSON.stringify({ x: node.x, y: node.y })
|
||
});
|
||
}
|
||
document.addEventListener('mousemove', onMove);
|
||
document.addEventListener('mouseup', onUp);
|
||
}
|
||
|
||
// ---------- Formulaire d'ajout de nœud ----------
|
||
|
||
var currentNodeFormType = null;
|
||
|
||
function openNodeForm(type) {
|
||
currentNodeFormType = type;
|
||
document.getElementById('nodeForm').style.display = 'block';
|
||
document.getElementById('nodeFormTitle').textContent =
|
||
type === 'trigger' ? 'Nouveau déclencheur' : (type === 'condition' ? 'Nouvelle condition' : 'Nouvelle action');
|
||
document.getElementById('nodeFormTrigger').style.display = type === 'trigger' ? 'block' : 'none';
|
||
document.getElementById('nodeFormCondition').style.display = type === 'condition' ? 'block' : 'none';
|
||
document.getElementById('nodeFormAction').style.display = type === 'action' ? 'block' : 'none';
|
||
if (type === 'trigger') toggleFlowTriggerFields();
|
||
if (type === 'condition') {
|
||
onFlowDefinitionChange('cond');
|
||
flowConditionExtraClauses = [];
|
||
renderFlowConditionClauses();
|
||
}
|
||
if (type === 'action') {
|
||
document.getElementById('nf-action-element-toggle').checked = false;
|
||
toggleFlowActionFields();
|
||
}
|
||
}
|
||
|
||
function toggleFlowTriggerFields() {
|
||
var isAffichage = document.getElementById('nf-trigger-event').value === 'affichage';
|
||
document.getElementById('nf-trigger-element-row').style.display = isAffichage ? 'none' : 'block';
|
||
document.getElementById('nf-trigger-repeater-hint').style.display = isAffichage ? 'none' : 'block';
|
||
document.getElementById('nf-trigger-affichage-hint').style.display = isAffichage ? 'block' : 'none';
|
||
}
|
||
|
||
function closeNodeForm() {
|
||
document.getElementById('nodeForm').style.display = 'none';
|
||
currentNodeFormType = null;
|
||
}
|
||
|
||
function onFlowDefinitionChange(prefix) {
|
||
var defSel = document.getElementById(prefix === 'cond' ? 'nf-cond-definition' : 'nf-action-data-definition');
|
||
if (!defSel) return;
|
||
var info = DEFINITIONS_DATA[defSel.value] || { rows: [], fields: [] };
|
||
var rowSel = document.getElementById(prefix === 'cond' ? 'nf-cond-row' : 'nf-action-data-row');
|
||
var fieldSel = document.getElementById(prefix === 'cond' ? 'nf-cond-field' : 'nf-action-data-field');
|
||
rowSel.innerHTML = info.rows.length
|
||
? info.rows.map(function(r){ return '<option value="' + r.id + '">' + r.label + '</option>'; }).join('')
|
||
: '<option value="">Aucune donnée dans cet objet</option>';
|
||
fieldSel.innerHTML = info.fields.length
|
||
? info.fields.map(function(f){ return '<option value="' + f.name + '" data-type="' + f.type + '">' + f.name + ' (' + f.type + ')</option>'; }).join('')
|
||
: '<option value="">Aucun champ simple</option>';
|
||
}
|
||
|
||
function onFlowFieldChange() {
|
||
// Pas d'action nécessaire ici pour l'instant : le type du champ est relu
|
||
// directement sur l'option sélectionnée au moment de la création du nœud.
|
||
}
|
||
|
||
// ---------- 2.4 — clauses supplémentaires (ET/OU) d'un nœud Condition ----------
|
||
// Chaque clause supplémentaire réutilise EXACTEMENT le même modèle que la
|
||
// clause principale (nf-cond-definition/row/field/operator/value) mais dans
|
||
// une ligne indépendante, numérotée par index dans flowConditionExtraClauses.
|
||
var flowConditionExtraClauses = [];
|
||
|
||
function addFlowConditionClause() {
|
||
var firstDefSel = document.getElementById('nf-cond-definition');
|
||
var defaultDefId = firstDefSel && firstDefSel.value ? firstDefSel.value : '';
|
||
flowConditionExtraClauses.push({ definition_id: defaultDefId, row_id: '', field: '', field_type: '', operator: 'egal', value: '' });
|
||
renderFlowConditionClauses();
|
||
}
|
||
|
||
function removeFlowConditionClause(idx) {
|
||
flowConditionExtraClauses.splice(idx, 1);
|
||
renderFlowConditionClauses();
|
||
}
|
||
|
||
function renderFlowConditionClauses() {
|
||
var wrap = document.getElementById('nf-cond-extra-clauses');
|
||
var combinatorRow = document.getElementById('nf-cond-combinator-row');
|
||
if (!wrap || !combinatorRow) return;
|
||
combinatorRow.style.display = flowConditionExtraClauses.length ? 'block' : 'none';
|
||
var defOptionsHtml = document.getElementById('nf-cond-definition') ? document.getElementById('nf-cond-definition').innerHTML : '';
|
||
wrap.innerHTML = flowConditionExtraClauses.map(function(clause, idx) {
|
||
return '' +
|
||
'<div class="flowConditionClauseRow" style="border-top:1px dashed #ccc; margin-top:8px; padding-top:8px;">' +
|
||
'<label>Objet<select id="nf-cond-extra-definition-' + idx + '" onchange="onFlowClauseDefinitionChange(' + idx + ')">' + defOptionsHtml + '</select></label>' +
|
||
'<label>Ligne concernée<select id="nf-cond-extra-row-' + idx + '" onchange="flowConditionExtraClauses[' + idx + '].row_id = this.value"></select></label>' +
|
||
'<label>Champ<select id="nf-cond-extra-field-' + idx + '" onchange="onFlowClauseFieldChange(' + idx + ')"></select></label>' +
|
||
'<label>Condition<select id="nf-cond-extra-operator-' + idx + '" onchange="flowConditionExtraClauses[' + idx + '].operator = this.value">' +
|
||
CONDITION_OPERATORS_HTML +
|
||
'</select></label>' +
|
||
'<label>Valeur de comparaison<input type="text" id="nf-cond-extra-value-' + idx + '" placeholder="ex. 1, vrai, 10..." value="' + (clause.value || '').replace(/"/g, '"') + '" onchange="flowConditionExtraClauses[' + idx + '].value = this.value"></label>' +
|
||
'<button type="button" class="smallBtn" onclick="removeFlowConditionClause(' + idx + ')">Retirer cette condition</button>' +
|
||
'</div>';
|
||
}).join('');
|
||
flowConditionExtraClauses.forEach(function(clause, idx) {
|
||
var defSel = document.getElementById('nf-cond-extra-definition-' + idx);
|
||
if (defSel && clause.definition_id) defSel.value = clause.definition_id;
|
||
onFlowClauseDefinitionChange(idx);
|
||
var opSel = document.getElementById('nf-cond-extra-operator-' + idx);
|
||
if (opSel) opSel.value = clause.operator || 'egal';
|
||
});
|
||
}
|
||
|
||
function onFlowClauseDefinitionChange(idx) {
|
||
var defSel = document.getElementById('nf-cond-extra-definition-' + idx);
|
||
var rowSel = document.getElementById('nf-cond-extra-row-' + idx);
|
||
var fieldSel = document.getElementById('nf-cond-extra-field-' + idx);
|
||
if (!defSel || !rowSel || !fieldSel) return;
|
||
flowConditionExtraClauses[idx].definition_id = defSel.value;
|
||
var info = DEFINITIONS_DATA[defSel.value] || { rows: [], fields: [] };
|
||
rowSel.innerHTML = info.rows.length
|
||
? info.rows.map(function(r){ return '<option value="' + r.id + '">' + r.label + '</option>'; }).join('')
|
||
: '<option value="">Aucune donnée dans cet objet</option>';
|
||
fieldSel.innerHTML = info.fields.length
|
||
? info.fields.map(function(f){ return '<option value="' + f.name + '" data-type="' + f.type + '">' + f.name + ' (' + f.type + ')</option>'; }).join('')
|
||
: '<option value="">Aucun champ simple</option>';
|
||
flowConditionExtraClauses[idx].row_id = rowSel.value;
|
||
onFlowClauseFieldChange(idx);
|
||
}
|
||
|
||
function onFlowClauseFieldChange(idx) {
|
||
var fieldSel = document.getElementById('nf-cond-extra-field-' + idx);
|
||
if (!fieldSel) return;
|
||
var opt = fieldSel.options[fieldSel.selectedIndex];
|
||
flowConditionExtraClauses[idx].field = fieldSel.value;
|
||
flowConditionExtraClauses[idx].field_type = opt ? opt.dataset.type : '';
|
||
}
|
||
|
||
function toggleFlowActionFields() {
|
||
var typeSel = document.getElementById('nf-action-type');
|
||
var screenSel = document.getElementById('nf-action-target-screen');
|
||
var elementFields = document.getElementById('nf-action-element-fields');
|
||
var dataFields = document.getElementById('nf-action-data-fields');
|
||
var waitFields = document.getElementById('nf-action-wait-fields');
|
||
var isOnglet = typeSel.value === 'activer_onglet';
|
||
screenSel.style.display = (typeSel.value === 'aller_a' || typeSel.value === 'ouvrir_ligne') ? 'block' : 'none';
|
||
elementFields.style.display = (typeSel.value === 'modifier_element' || isOnglet) ? 'block' : 'none';
|
||
dataFields.style.display = typeSel.value === 'modifier_donnee' ? 'block' : 'none';
|
||
waitFields.style.display = typeSel.value === 'attendre' ? 'block' : 'none';
|
||
document.getElementById('nf-action-onglet-hint').style.display = isOnglet ? 'block' : 'none';
|
||
document.getElementById('nf-action-element-property-wrap').style.display = isOnglet ? 'none' : 'block';
|
||
if (typeSel.value === 'modifier_element') toggleFlowElementPropertyValue();
|
||
if (typeSel.value === 'modifier_donnee') onFlowDefinitionChange('action');
|
||
}
|
||
|
||
function toggleFlowElementPropertyValue() {
|
||
var propSel = document.getElementById('nf-action-element-property');
|
||
var visSel = document.getElementById('nf-action-element-value-visibility');
|
||
var surbrillanceSel = document.getElementById('nf-action-element-value-surbrillance');
|
||
var desactiveSel = document.getElementById('nf-action-element-value-desactive');
|
||
var colorInp = document.getElementById('nf-action-element-value-color');
|
||
var numInp = document.getElementById('nf-action-element-value-number');
|
||
var toggleRow = document.getElementById('nf-action-element-toggle-row');
|
||
[visSel, surbrillanceSel, desactiveSel, colorInp, numInp].forEach(function(el){ el.style.display = 'none'; });
|
||
if (propSel.value === 'visibilite') {
|
||
visSel.style.display = 'block';
|
||
toggleRow.style.display = 'none';
|
||
document.getElementById('nf-action-element-toggle').checked = false;
|
||
} else if (propSel.value === 'surbrillance') {
|
||
surbrillanceSel.style.display = 'block';
|
||
toggleRow.style.display = 'none';
|
||
document.getElementById('nf-action-element-toggle').checked = false;
|
||
} else if (propSel.value === 'desactive') {
|
||
desactiveSel.style.display = 'block';
|
||
toggleRow.style.display = 'none';
|
||
document.getElementById('nf-action-element-toggle').checked = false;
|
||
} else if (propSel.value === 'bg_color' || propSel.value === 'border_color') {
|
||
colorInp.style.display = 'block';
|
||
toggleRow.style.display = 'flex';
|
||
} else {
|
||
numInp.style.display = 'block';
|
||
toggleRow.style.display = 'flex';
|
||
}
|
||
toggleFlowElementToggleMode();
|
||
}
|
||
|
||
function toggleFlowElementToggleMode() {
|
||
var propSel = document.getElementById('nf-action-element-property');
|
||
var checked = document.getElementById('nf-action-element-toggle').checked;
|
||
var wrap = document.getElementById('nf-action-element-value2-wrap');
|
||
var colorInp2 = document.getElementById('nf-action-element-value2-color');
|
||
var numInp2 = document.getElementById('nf-action-element-value2-number');
|
||
var isColor = propSel.value === 'bg_color' || propSel.value === 'border_color';
|
||
wrap.style.display = checked ? 'block' : 'none';
|
||
colorInp2.style.display = (checked && isColor) ? 'block' : 'none';
|
||
numInp2.style.display = (checked && !isColor) ? 'block' : 'none';
|
||
}
|
||
|
||
function submitNodeForm() {
|
||
var payload = { node_type: currentNodeFormType, x: 20, y: 20 + FLOW.nodes.length * 10 };
|
||
|
||
if (currentNodeFormType === 'trigger') {
|
||
payload.trigger_event = document.getElementById('nf-trigger-event').value;
|
||
if (payload.trigger_event !== 'affichage') {
|
||
payload.trigger_element_id = parseInt(document.getElementById('nf-trigger-element').value, 10);
|
||
}
|
||
} else if (currentNodeFormType === 'condition') {
|
||
var fieldSel = document.getElementById('nf-cond-field');
|
||
var fieldOpt = fieldSel.options[fieldSel.selectedIndex];
|
||
payload.cond_definition_id = parseInt(document.getElementById('nf-cond-definition').value, 10);
|
||
payload.cond_row_id = parseInt(document.getElementById('nf-cond-row').value, 10) || null;
|
||
payload.cond_field = fieldSel.value;
|
||
payload.cond_field_type = fieldOpt ? fieldOpt.dataset.type : '';
|
||
payload.cond_operator = document.getElementById('nf-cond-operator').value;
|
||
payload.cond_value = document.getElementById('nf-cond-value').value;
|
||
if (flowConditionExtraClauses.length) {
|
||
// 2.4 — clauses combinées : la clause "historique" ci-dessus devient
|
||
// la première clause de la liste, suivie des clauses supplémentaires.
|
||
var allClauses = [{
|
||
definition_id: payload.cond_definition_id, row_id: payload.cond_row_id,
|
||
field: payload.cond_field, field_type: payload.cond_field_type,
|
||
operator: payload.cond_operator, value: payload.cond_value
|
||
}].concat(flowConditionExtraClauses.map(function(c) {
|
||
return {
|
||
definition_id: parseInt(c.definition_id, 10), row_id: parseInt(c.row_id, 10) || null,
|
||
field: c.field, field_type: c.field_type, operator: c.operator, value: c.value
|
||
};
|
||
}));
|
||
payload.cond_clauses = JSON.stringify(allClauses);
|
||
payload.cond_combinator = document.getElementById('nf-cond-combinator').value;
|
||
}
|
||
} else if (currentNodeFormType === 'action') {
|
||
var actionType = document.getElementById('nf-action-type').value;
|
||
payload.action_type = actionType;
|
||
if (actionType === 'aller_a' || actionType === 'ouvrir_ligne') {
|
||
payload.target_screen_id = parseInt(document.getElementById('nf-action-target-screen').value, 10);
|
||
} else if (actionType === 'activer_onglet') {
|
||
payload.target_element_id = parseInt(document.getElementById('nf-action-target-element').value, 10);
|
||
} else if (actionType === 'modifier_element') {
|
||
payload.target_element_id = parseInt(document.getElementById('nf-action-target-element').value, 10);
|
||
var prop = document.getElementById('nf-action-element-property').value;
|
||
payload.element_property = prop;
|
||
var isColorProp = prop === 'bg_color' || prop === 'border_color';
|
||
var toggleOn = prop !== 'visibilite' && prop !== 'surbrillance' && prop !== 'desactive' && document.getElementById('nf-action-element-toggle').checked;
|
||
if (prop === 'visibilite') {
|
||
payload.element_value = document.getElementById('nf-action-element-value-visibility').value;
|
||
} else if (prop === 'surbrillance') {
|
||
payload.element_value = document.getElementById('nf-action-element-value-surbrillance').value;
|
||
} else if (prop === 'desactive') {
|
||
payload.element_value = document.getElementById('nf-action-element-value-desactive').value;
|
||
} else {
|
||
var v1 = isColorProp
|
||
? document.getElementById('nf-action-element-value-color').value
|
||
: document.getElementById('nf-action-element-value-number').value;
|
||
if (toggleOn) {
|
||
var v2 = isColorProp
|
||
? document.getElementById('nf-action-element-value2-color').value
|
||
: document.getElementById('nf-action-element-value2-number').value;
|
||
payload.element_value = v1 + '||' + v2;
|
||
} else {
|
||
payload.element_value = v1;
|
||
}
|
||
}
|
||
} else if (actionType === 'modifier_donnee') {
|
||
payload.target_definition_id = parseInt(document.getElementById('nf-action-data-definition').value, 10);
|
||
payload.target_row_id = parseInt(document.getElementById('nf-action-data-row').value, 10) || null;
|
||
payload.target_field = document.getElementById('nf-action-data-field').value;
|
||
payload.data_operation = document.getElementById('nf-action-data-operation').value;
|
||
payload.data_value = document.getElementById('nf-action-data-value').value;
|
||
} else if (actionType === 'attendre') {
|
||
payload.data_value = document.getElementById('nf-action-wait-seconds').value;
|
||
}
|
||
}
|
||
|
||
fetch(flowUrl('/screens/' + FLOW_SCREEN_ID + '/flow/nodes/add'), {
|
||
method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(payload)
|
||
}).then(function(r){ return r.json(); }).then(function(node){
|
||
FLOW.nodes.push(node);
|
||
closeNodeForm();
|
||
renderFlow();
|
||
});
|
||
}
|
||
|
||
// Panneau de logique de scène absent sur un écran-modèle (élément de jeu) —
|
||
// voir le commentaire Jinja au-dessus de la div flowPanel.
|
||
var HAS_FLOW_PANEL = !!document.getElementById('flowPanel');
|
||
if (HAS_FLOW_PANEL) { renderFlow(); }
|
||
|
||
// ---------- Ouverture / fermeture et redimensionnement du panneau du bas ----------
|
||
|
||
var FLOW_PANEL_DEFAULT_HEIGHT = 340;
|
||
var flowPanelLastHeight = FLOW_PANEL_DEFAULT_HEIGHT;
|
||
|
||
function toggleFlowPanel() {
|
||
var panel = document.getElementById('flowPanel');
|
||
var handle = document.getElementById('flowResizeHandle');
|
||
if (!panel || !handle) return;
|
||
var isOpen = panel.classList.contains('open');
|
||
if (isOpen) {
|
||
flowPanelLastHeight = panel.getBoundingClientRect().height;
|
||
panel.classList.remove('open');
|
||
panel.style.height = '';
|
||
handle.classList.remove('visible');
|
||
} else {
|
||
panel.classList.add('open');
|
||
panel.style.height = flowPanelLastHeight + 'px';
|
||
handle.classList.add('visible');
|
||
requestAnimationFrame(drawFlowEdges);
|
||
}
|
||
}
|
||
|
||
if (HAS_FLOW_PANEL) (function(){
|
||
var handle = document.getElementById('flowResizeHandle');
|
||
var panel = document.getElementById('flowPanel');
|
||
handle.addEventListener('mousedown', function(e){
|
||
e.preventDefault();
|
||
if (!panel.classList.contains('open')) return;
|
||
var startY = e.clientY;
|
||
var startHeight = panel.getBoundingClientRect().height;
|
||
var wrap = document.querySelector('.content-builder');
|
||
var maxHeight = Math.max(160, wrap.getBoundingClientRect().height - 160);
|
||
function onMove(ev) {
|
||
var dy = ev.clientY - startY;
|
||
var newHeight = Math.min(maxHeight, Math.max(140, startHeight - dy));
|
||
panel.style.height = newHeight + 'px';
|
||
flowPanelLastHeight = newHeight;
|
||
drawFlowEdges();
|
||
}
|
||
function onUp() {
|
||
document.removeEventListener('mousemove', onMove);
|
||
document.removeEventListener('mouseup', onUp);
|
||
}
|
||
document.addEventListener('mousemove', onMove);
|
||
document.addEventListener('mouseup', onUp);
|
||
});
|
||
})();
|
||
|
||
// ---------- Timeline d'animation : piste par élément, clips Animate.css ou
|
||
// personnalisés (images-clés), glissés/redimensionnés à la souris sur un
|
||
// axe du temps en secondes depuis l'affichage de l'écran. Même principe de
|
||
// panneau rétractable/redimensionnable que "Logique de la scène"
|
||
// ci-dessus, positionné juste en dessous. ----------
|
||
|
||
var ANIM_SCREEN_ID = {{ screen.id }};
|
||
var ANIM_CLIPS = {{ animation_clips_json|safe }};
|
||
var ANIMATE_CSS_LABELS = {{ animate_css_labels_json|safe }};
|
||
var ANIM_PX_PER_SEC = 60;
|
||
var animCurrentClipId = null;
|
||
|
||
function animUrl(path) { return "/game/{{ game.slug }}" + path; }
|
||
|
||
function animClipLabel(clip) {
|
||
if (clip.kind === 'custom') return '🎨 Personnalisée';
|
||
return '🎞️ ' + (ANIMATE_CSS_LABELS[clip.animate_name] || clip.animate_name);
|
||
}
|
||
|
||
function renderAnimTimeline() {
|
||
var lanesWrap = document.getElementById('animTracks');
|
||
var ticksWrap = document.getElementById('animRuler');
|
||
if (!lanesWrap || !ticksWrap) return;
|
||
lanesWrap.innerHTML = '';
|
||
ticksWrap.innerHTML = '';
|
||
|
||
var maxTime = ANIM_CLIPS.reduce(function(m, c) { return Math.max(m, (c.start_time || 0) + (c.duration || 1)); }, 5);
|
||
var totalSeconds = Math.ceil(maxTime) + 2;
|
||
var totalWidth = totalSeconds * ANIM_PX_PER_SEC;
|
||
|
||
var spacer = document.createElement('div');
|
||
spacer.className = 'animRulerSpacer';
|
||
ticksWrap.appendChild(spacer);
|
||
var ticksLane = document.createElement('div');
|
||
ticksLane.className = 'animRulerTicksWrap';
|
||
ticksLane.style.width = totalWidth + 'px';
|
||
for (var s = 0; s <= totalSeconds; s++) {
|
||
var tick = document.createElement('div');
|
||
tick.className = 'animRulerTick';
|
||
tick.style.left = (s * ANIM_PX_PER_SEC) + 'px';
|
||
tick.textContent = s + 's';
|
||
ticksLane.appendChild(tick);
|
||
}
|
||
ticksWrap.appendChild(ticksLane);
|
||
|
||
var elementIds = [];
|
||
ANIM_CLIPS.forEach(function(c) { if (elementIds.indexOf(c.element_id) === -1) elementIds.push(c.element_id); });
|
||
|
||
if (!elementIds.length) {
|
||
var empty = document.createElement('p');
|
||
empty.className = 'hint';
|
||
empty.style.padding = '10px 0';
|
||
empty.textContent = "Aucun clip pour l'instant — choisis un élément ci-dessus et clique « Ajouter un clip ».";
|
||
lanesWrap.appendChild(empty);
|
||
return;
|
||
}
|
||
|
||
elementIds.forEach(function(elId) {
|
||
var track = document.createElement('div');
|
||
track.className = 'animTrack';
|
||
|
||
var label = document.createElement('div');
|
||
label.className = 'animTrackLabel';
|
||
label.textContent = ELEMENTS_LABELS[elId] || ('elt-' + elId);
|
||
track.appendChild(label);
|
||
|
||
var lane = document.createElement('div');
|
||
lane.className = 'animTrackLane';
|
||
lane.style.width = totalWidth + 'px';
|
||
|
||
ANIM_CLIPS.filter(function(c) { return c.element_id === elId; }).forEach(function(clip) {
|
||
var block = document.createElement('div');
|
||
block.className = 'animClip';
|
||
block.style.left = ((clip.start_time || 0) * ANIM_PX_PER_SEC) + 'px';
|
||
block.style.width = Math.max(24, (clip.duration || 1) * ANIM_PX_PER_SEC) + 'px';
|
||
block.textContent = animClipLabel(clip);
|
||
block.dataset.clipId = clip.id;
|
||
block.addEventListener('mousedown', function(e) { onAnimClipMouseDown(e, clip.id); });
|
||
var handle = document.createElement('div');
|
||
handle.className = 'animClipResizeHandle';
|
||
handle.addEventListener('mousedown', function(e) { onAnimClipResizeMouseDown(e, clip.id); });
|
||
block.appendChild(handle);
|
||
lane.appendChild(block);
|
||
});
|
||
|
||
track.appendChild(lane);
|
||
lanesWrap.appendChild(track);
|
||
});
|
||
}
|
||
|
||
function onAnimClipMouseDown(e, clipId) {
|
||
if (e.target.classList.contains('animClipResizeHandle')) return;
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
var clip = ANIM_CLIPS.find(function(c) { return c.id === clipId; });
|
||
var block = document.querySelector('.animClip[data-clip-id="' + clipId + '"]');
|
||
var startX = e.clientX;
|
||
var startLeft = parseFloat(block.style.left);
|
||
var moved = false;
|
||
function onMove(ev) {
|
||
var dx = ev.clientX - startX;
|
||
if (Math.abs(dx) > 3) moved = true;
|
||
block.style.left = Math.max(0, startLeft + dx) + 'px';
|
||
}
|
||
function onUp() {
|
||
document.removeEventListener('mousemove', onMove);
|
||
document.removeEventListener('mouseup', onUp);
|
||
if (moved) {
|
||
var newStart = Math.round((parseFloat(block.style.left) / ANIM_PX_PER_SEC) * 10) / 10;
|
||
clip.start_time = newStart;
|
||
updateAnimClip(clipId, { start_time: newStart });
|
||
} else {
|
||
openAnimClipForm(clip);
|
||
}
|
||
}
|
||
document.addEventListener('mousemove', onMove);
|
||
document.addEventListener('mouseup', onUp);
|
||
}
|
||
|
||
function onAnimClipResizeMouseDown(e, clipId) {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
var clip = ANIM_CLIPS.find(function(c) { return c.id === clipId; });
|
||
var block = document.querySelector('.animClip[data-clip-id="' + clipId + '"]');
|
||
var startX = e.clientX;
|
||
var startWidth = parseFloat(block.style.width);
|
||
function onMove(ev) {
|
||
var dx = ev.clientX - startX;
|
||
block.style.width = Math.max(10, startWidth + dx) + 'px';
|
||
}
|
||
function onUp() {
|
||
document.removeEventListener('mousemove', onMove);
|
||
document.removeEventListener('mouseup', onUp);
|
||
var newDuration = Math.round((parseFloat(block.style.width) / ANIM_PX_PER_SEC) * 10) / 10;
|
||
clip.duration = newDuration;
|
||
updateAnimClip(clipId, { duration: newDuration });
|
||
}
|
||
document.addEventListener('mousemove', onMove);
|
||
document.addEventListener('mouseup', onUp);
|
||
}
|
||
|
||
function updateAnimClip(clipId, fields) {
|
||
return fetch(animUrl('/animations/clips/' + clipId + '/update'), {
|
||
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(fields)
|
||
}).then(function(r) { return r.json(); }).then(function(updated) {
|
||
var idx = ANIM_CLIPS.findIndex(function(c) { return c.id === updated.id; });
|
||
if (idx > -1) ANIM_CLIPS[idx] = updated;
|
||
return updated;
|
||
});
|
||
}
|
||
|
||
function addAnimTrack() {
|
||
var elId = parseInt(document.getElementById('anim-new-track-element').value, 10);
|
||
fetch(animUrl('/screens/' + ANIM_SCREEN_ID + '/animations/clips/add'), {
|
||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ element_id: elId, kind: 'animate_css', animate_name: 'fadeIn', start_time: 0, duration: 1 }),
|
||
}).then(function(r) { return r.json(); }).then(function(clip) {
|
||
ANIM_CLIPS.push(clip);
|
||
renderAnimTimeline();
|
||
openAnimClipForm(clip);
|
||
});
|
||
}
|
||
|
||
function toggleAnimClipKind() {
|
||
var kind = document.getElementById('anim-clip-kind').value;
|
||
document.getElementById('anim-clip-animate-css-fields').style.display = kind === 'animate_css' ? 'block' : 'none';
|
||
document.getElementById('anim-clip-custom-fields').style.display = kind === 'custom' ? 'block' : 'none';
|
||
}
|
||
|
||
function addAnimKeyframeRow(percent, cssText) {
|
||
var wrap = document.getElementById('anim-clip-keyframes');
|
||
var row = document.createElement('div');
|
||
row.className = 'fieldRow animKeyframeRow';
|
||
var percentInput = document.createElement('input');
|
||
percentInput.type = 'number'; percentInput.min = '0'; percentInput.max = '100'; percentInput.step = '1';
|
||
percentInput.className = 'anim-kf-percent'; percentInput.style.flex = '0 0 70px';
|
||
percentInput.value = percent != null ? percent : 0;
|
||
var cssInput = document.createElement('textarea');
|
||
cssInput.className = 'anim-kf-css'; cssInput.style.flex = '1'; cssInput.rows = 2;
|
||
cssInput.placeholder = 'ex. opacity: 0; transform: scale(0.5);';
|
||
cssInput.value = cssText || '';
|
||
var removeBtn = document.createElement('button');
|
||
removeBtn.type = 'button'; removeBtn.className = 'smallBtn danger';
|
||
removeBtn.textContent = '✕';
|
||
removeBtn.onclick = function() { row.remove(); };
|
||
row.appendChild(percentInput);
|
||
row.appendChild(cssInput);
|
||
row.appendChild(removeBtn);
|
||
wrap.appendChild(row);
|
||
}
|
||
|
||
function collectAnimKeyframes() {
|
||
var rows = document.querySelectorAll('#anim-clip-keyframes .animKeyframeRow');
|
||
var keyframes = [];
|
||
rows.forEach(function(row) {
|
||
var percent = parseFloat(row.querySelector('.anim-kf-percent').value) || 0;
|
||
var cssText = row.querySelector('.anim-kf-css').value;
|
||
var props = {};
|
||
cssText.split(';').forEach(function(decl) {
|
||
var parts = decl.split(':');
|
||
if (parts.length === 2) {
|
||
var prop = parts[0].trim(), val = parts[1].trim();
|
||
if (prop && val) props[prop] = val;
|
||
}
|
||
});
|
||
keyframes.push({ percent: percent, props: props });
|
||
});
|
||
return keyframes;
|
||
}
|
||
|
||
function openAnimClipForm(clip) {
|
||
animCurrentClipId = clip.id;
|
||
document.getElementById('animClipForm').style.display = 'block';
|
||
document.getElementById('animClipFormTitle').textContent = 'Clip — ' + (ELEMENTS_LABELS[clip.element_id] || ('elt-' + clip.element_id));
|
||
document.getElementById('anim-clip-kind').value = clip.kind || 'animate_css';
|
||
document.getElementById('anim-clip-animate-name').value = clip.animate_name || 'fadeIn';
|
||
document.getElementById('anim-clip-start').value = clip.start_time || 0;
|
||
document.getElementById('anim-clip-duration').value = clip.duration || 1;
|
||
document.getElementById('anim-clip-delay').value = clip.delay || 0;
|
||
document.getElementById('anim-clip-easing').value = clip.easing || 'ease';
|
||
document.getElementById('anim-clip-iteration').value = clip.iteration_count || '1';
|
||
document.getElementById('anim-clip-keyframes').innerHTML = '';
|
||
var keyframes = [];
|
||
if (clip.custom_keyframes) {
|
||
try { keyframes = JSON.parse(clip.custom_keyframes); } catch (e) { /* ignore */ }
|
||
}
|
||
if (!keyframes.length) keyframes = [{ percent: 0, props: { opacity: '0' } }, { percent: 100, props: { opacity: '1' } }];
|
||
keyframes.forEach(function(k) {
|
||
var cssText = Object.keys(k.props || {}).map(function(p) { return p + ': ' + k.props[p] + ';'; }).join(' ');
|
||
addAnimKeyframeRow(k.percent, cssText);
|
||
});
|
||
toggleAnimClipKind();
|
||
document.querySelectorAll('.animClip').forEach(function(b) {
|
||
b.classList.toggle('selected', parseInt(b.dataset.clipId, 10) === clip.id);
|
||
});
|
||
}
|
||
|
||
function closeAnimClipForm() {
|
||
animCurrentClipId = null;
|
||
document.getElementById('animClipForm').style.display = 'none';
|
||
document.querySelectorAll('.animClip').forEach(function(b) { b.classList.remove('selected'); });
|
||
}
|
||
|
||
function saveAnimClipForm() {
|
||
if (!animCurrentClipId) return Promise.resolve();
|
||
var kind = document.getElementById('anim-clip-kind').value;
|
||
var fields = {
|
||
kind: kind,
|
||
animate_name: document.getElementById('anim-clip-animate-name').value,
|
||
start_time: parseFloat(document.getElementById('anim-clip-start').value) || 0,
|
||
duration: parseFloat(document.getElementById('anim-clip-duration').value) || 1,
|
||
delay: parseFloat(document.getElementById('anim-clip-delay').value) || 0,
|
||
easing: document.getElementById('anim-clip-easing').value,
|
||
iteration_count: document.getElementById('anim-clip-iteration').value,
|
||
};
|
||
if (kind === 'custom') fields.custom_keyframes = collectAnimKeyframes();
|
||
return updateAnimClip(animCurrentClipId, fields).then(function() { renderAnimTimeline(); });
|
||
}
|
||
|
||
function deleteAnimClipForm() {
|
||
if (!animCurrentClipId) return;
|
||
if (!confirm('Supprimer ce clip ?')) return;
|
||
var id = animCurrentClipId;
|
||
fetch(animUrl('/animations/clips/' + id + '/delete'), { method: 'POST' }).then(function() {
|
||
ANIM_CLIPS = ANIM_CLIPS.filter(function(c) { return c.id !== id; });
|
||
closeAnimClipForm();
|
||
renderAnimTimeline();
|
||
});
|
||
}
|
||
|
||
function previewAnimClip() {
|
||
if (!animCurrentClipId) return;
|
||
saveAnimClipForm().then(function() {
|
||
var clip = ANIM_CLIPS.find(function(c) { return c.id === animCurrentClipId; });
|
||
if (!clip) return;
|
||
var target = document.querySelector('#canvas [data-anim-target="' + clip.element_id + '"]');
|
||
if (!target) return;
|
||
var duration = clip.duration || 1, delay = clip.delay || 0;
|
||
var iterationCount = clip.iteration_count || '1';
|
||
if (clip.kind === 'custom') {
|
||
var keyframes = [];
|
||
try { keyframes = JSON.parse(clip.custom_keyframes || '[]'); } catch (e) { /* ignore */ }
|
||
var steps = keyframes.map(function(k) {
|
||
var props = Object.keys(k.props || {}).map(function(p) { return p + ':' + k.props[p] + ';'; }).join(' ');
|
||
return k.percent + '% { ' + props + ' }';
|
||
}).join(' ');
|
||
var styleTag = document.getElementById('animEditorPreviewStyle');
|
||
if (!styleTag) {
|
||
styleTag = document.createElement('style');
|
||
styleTag.id = 'animEditorPreviewStyle';
|
||
document.head.appendChild(styleTag);
|
||
}
|
||
styleTag.textContent = '@keyframes animEditorPreview { ' + steps + ' }';
|
||
target.style.animation = 'animEditorPreview ' + duration + 's ' + (clip.easing || 'ease') + ' ' + delay + 's ' + iterationCount;
|
||
target.addEventListener('animationend', function handler() {
|
||
target.style.animation = '';
|
||
target.removeEventListener('animationend', handler);
|
||
});
|
||
} else {
|
||
var animClass = 'animate__' + clip.animate_name;
|
||
target.style.setProperty('--animate-duration', duration + 's');
|
||
target.style.setProperty('--animate-delay', delay + 's');
|
||
target.style.animationIterationCount = iterationCount;
|
||
target.classList.add('animate__animated', animClass);
|
||
target.addEventListener('animationend', function handler() {
|
||
target.classList.remove('animate__animated', animClass);
|
||
target.removeEventListener('animationend', handler);
|
||
});
|
||
}
|
||
});
|
||
}
|
||
|
||
// Panneau timeline absent sur un écran-modèle (élément de jeu), comme le
|
||
// panneau de logique — voir le commentaire Jinja au-dessus de la div animPanel.
|
||
var HAS_ANIM_PANEL = !!document.getElementById('animPanel');
|
||
if (HAS_ANIM_PANEL) { renderAnimTimeline(); }
|
||
|
||
var ANIM_PANEL_DEFAULT_HEIGHT = 260;
|
||
var animPanelLastHeight = ANIM_PANEL_DEFAULT_HEIGHT;
|
||
|
||
function toggleAnimPanel() {
|
||
var panel = document.getElementById('animPanel');
|
||
var handle = document.getElementById('animResizeHandle');
|
||
if (!panel || !handle) return;
|
||
var isOpen = panel.classList.contains('open');
|
||
if (isOpen) {
|
||
animPanelLastHeight = panel.getBoundingClientRect().height;
|
||
panel.classList.remove('open');
|
||
panel.style.height = '';
|
||
handle.classList.remove('visible');
|
||
} else {
|
||
panel.classList.add('open');
|
||
panel.style.height = animPanelLastHeight + 'px';
|
||
handle.classList.add('visible');
|
||
}
|
||
}
|
||
|
||
if (HAS_ANIM_PANEL) (function() {
|
||
var handle = document.getElementById('animResizeHandle');
|
||
var panel = document.getElementById('animPanel');
|
||
handle.addEventListener('mousedown', function(e) {
|
||
e.preventDefault();
|
||
if (!panel.classList.contains('open')) return;
|
||
var startY = e.clientY;
|
||
var startHeight = panel.getBoundingClientRect().height;
|
||
var wrap = document.querySelector('.content-builder');
|
||
var maxHeight = Math.max(160, wrap.getBoundingClientRect().height - 160);
|
||
function onMove(ev) {
|
||
var dy = ev.clientY - startY;
|
||
var newHeight = Math.min(maxHeight, Math.max(140, startHeight - dy));
|
||
panel.style.height = newHeight + 'px';
|
||
animPanelLastHeight = newHeight;
|
||
}
|
||
function onUp() {
|
||
document.removeEventListener('mousemove', onMove);
|
||
document.removeEventListener('mouseup', onUp);
|
||
}
|
||
document.addEventListener('mousemove', onMove);
|
||
document.addEventListener('mouseup', onUp);
|
||
});
|
||
})();
|
||
</script>
|
||
{% endblock %}
|