first commit
Build and deploy / build-and-push (push) Successful in 17s
Build and deploy / deploy (push) Successful in 10s

This commit is contained in:
william
2026-08-21 16:23:49 +02:00
committed by william
commit 3f4ebc4527
248 changed files with 10180 additions and 0 deletions
+20
View File
@@ -0,0 +1,20 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}Forge Engine{% endblock %}</title>
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
{% block extra_head %}{% endblock %}
</head>
<body class="{% block body_class %}{% endblock %}">
<header class="topbar">
<a href="{{ url_for('index') }}" class="brand">🛠️ Forge Engine</a>
{% block breadcrumb %}{% endblock %}
</header>
<main class="content {% block content_class %}{% endblock %}">
{% block content %}{% endblock %}
</main>
<script src="{{ url_for('static', filename='pjax.js') }}"></script>
</body>
</html>
+57
View File
@@ -0,0 +1,57 @@
{% extends "base.html" %}
{% block title %}{{ 'Modifier' if row else 'Ajouter' }} — {{ definition.name }}{% 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('object_view', slug=game.slug, definition_id=definition.id) }}">{{ definition.name }}</a>
<span class="crumb-sep">/</span><span class="crumb">{{ 'Modifier #' ~ row.id if row else 'Nouvelle entrée' }}</span>
{% endblock %}
{% block content %}
<h1>{{ 'Modifier l\'entrée #' ~ row.id if row else 'Ajouter une entrée' }} — {{ definition.name }}</h1>
<p class="hint">Formulaire généré automatiquement à partir de la définition de cet objet — aucun champ n'est codé en dur ici.</p>
<form method="post" class="card">
{% for f in definition.fields %}
{% set col = f.name|colname %}
{% set current = row[col] if row else none %}
<div class="formField">
<label for="field_{{ f.id }}">{{ f.name }}{% if f.required %} *{% endif %}</label>
{% if f.type == 'texte' %}
<input type="text" id="field_{{ f.id }}" name="{{ col }}" value="{{ current or '' }}" {{ 'required' if f.required else '' }}>
{% elif f.type == 'texte_long' %}
<textarea id="field_{{ f.id }}" name="{{ col }}" rows="4" {{ 'required' if f.required else '' }}>{{ current or '' }}</textarea>
{% elif f.type == 'nombre_entier' %}
<input type="number" step="1" id="field_{{ f.id }}" name="{{ col }}" value="{{ current if current is not none else '' }}" {{ 'required' if f.required else '' }}>
{% elif f.type == 'nombre_decimal' %}
<input type="number" step="any" id="field_{{ f.id }}" name="{{ col }}" value="{{ current if current is not none else '' }}" {{ 'required' if f.required else '' }}>
{% elif f.type == 'booleen' %}
<input type="checkbox" id="field_{{ f.id }}" name="{{ col }}" {{ 'checked' if current else '' }}>
{% elif f.type == 'date' %}
<input type="date" id="field_{{ f.id }}" name="{{ col }}" value="{{ current or '' }}" {{ 'required' if f.required else '' }}>
{% elif f.type == 'relation' %}
{% set current_rel = row[col ~ '_id'] if row else none %}
<select id="field_{{ f.id }}" name="{{ col }}_id" {{ 'required' if f.required else '' }}>
<option value="">—</option>
{% for opt in relation_options.get(f.id, []) %}
<option value="{{ opt.id }}" {{ 'selected' if current_rel == opt.id else '' }}>{{ opt.label }}</option>
{% endfor %}
</select>
{% if not relation_options.get(f.id) %}
<p class="hint">Aucune entrée n'existe encore pour l'objet lié — crée-en une d'abord si tu veux pouvoir la sélectionner ici.</p>
{% endif %}
{% endif %}
</div>
{% endfor %}
<div class="actionsRow">
<button type="submit" class="primary">{{ 'Enregistrer les modifications' if row else 'Enregistrer en base' }}</button>
</div>
</form>
{% endblock %}
+71
View File
@@ -0,0 +1,71 @@
{% extends "base.html" %}
{% block title %}{{ definition.name }} — {{ game.name }}{% endblock %}
{% block breadcrumb %}
<a class="crumb" href="{{ url_for('game_dashboard', slug=game.slug) }}">{{ game.name }}</a>
<span class="crumb-sep">/</span><span class="crumb">{{ definition.name }}</span>
{% endblock %}
{% block content %}
<h1>{{ definition.name }}
<a class="smallBtn" href="{{ url_for('object_edit', slug=game.slug, definition_id=definition.id) }}" title="Modifier cet objet" style="text-decoration:none; margin-left:6px;">✏️</a>
</h1>
<p class="hint">Table SQL : {{ definition.table_name }} — {{ definition.fields|length }} champ(s)</p>
{% if referenced_by %}
<div class="warningBanner">
⚠️ D'autres objets ont une relation vers « {{ definition.name }} » :
{{ referenced_by|map(attribute='name')|join(', ') }}.
La suppression de cet objet est bloquée tant que ces relations existent.
</div>
{% endif %}
{% if blocked_row_id %}
<div class="warningBanner">
⚠️ Impossible de supprimer l'entrée #{{ blocked_row_id }} : elle est référencée par
{% for ref in blocked_row_refs %}{{ ref.count }} entrée(s) de « {{ ref.definition_name }} » (champ « {{ ref.field_name }} »){% if not loop.last %}, {% endif %}{% endfor %}.
Modifie ou supprime d'abord ces entrées.
</div>
{% endif %}
<a class="addBtn" href="{{ url_for('data_new', slug=game.slug, definition_id=definition.id) }}">+ Ajouter une entrée</a>
<h2>Données enregistrées</h2>
{% if rows %}
<table class="dataTable">
<thead>
<tr>
<th>#</th>
{% for f in definition.fields %}<th>{{ f.name }}</th>{% endfor %}
<th></th>
</tr>
</thead>
<tbody>
{% for r in rows %}
<tr>
<td>{{ r.id }}</td>
{% for f in definition.fields %}
{% if f.type == 'relation' %}
{% set rel_id = r[(f.name|colname) ~ '_id'] %}
<td>{{ relation_labels.get(f.id, {}).get(rel_id, '—') }}</td>
{% elif f.type == 'booleen' %}
<td>{{ 'Oui' if r[f.name|colname] else 'Non' }}</td>
{% else %}
<td>{{ r[f.name|colname] }}</td>
{% endif %}
{% endfor %}
<td>
<div class="rowActions">
<a class="smallBtn" href="{{ url_for('data_edit', slug=game.slug, definition_id=definition.id, row_id=r.id) }}">✏️</a>
<form method="post" action="{{ url_for('data_delete', slug=game.slug, definition_id=definition.id, row_id=r.id) }}"
onsubmit="return confirm('Supprimer définitivement l\'entrée #{{ r.id }} ?');">
<button type="submit" class="smallBtn danger">🗑️</button>
</form>
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p class="hint">Aucune donnée enregistrée pour l'instant.</p>
{% endif %}
{% endblock %}
+63
View File
@@ -0,0 +1,63 @@
{% extends "base.html" %}
{% block title %}Éléments de jeu — {{ game.name }}{% endblock %}
{% block breadcrumb %}
<a class="crumb" href="{{ url_for('game_dashboard', slug=game.slug) }}">{{ game.name }}</a>
<span class="crumb-sep">/</span><span class="crumb">Éléments de jeu</span>
{% endblock %}
{% block content %}
<h1>Éléments de jeu</h1>
<p class="hint">Un élément de jeu est un conteneur réutilisable.</p>
<form method="post" class="card">
<div class="fieldRow">
<div style="flex:0 0 80px;">
<label for="icon">Icône</label>
<input type="text" id="icon" name="icon" value="🧩" maxlength="4">
</div>
<div style="flex:1;">
<label for="name">Nom</label>
<input type="text" id="name" name="name" placeholder="ex. Personnage, Outil, Carte mail..." required>
</div>
</div>
<div class="fieldRow">
<div>
<label for="bound_definition_id">Objet lié (optionnel)</label>
<select id="bound_definition_id" name="bound_definition_id">
<option value="">(aucun — élément décoratif, sans donnée)</option>
{% for d in definitions %}
<option value="{{ d.id }}">{{ d.name }}</option>
{% endfor %}
</select>
</div>
</div>
<div class="actionsRow"><button type="submit" class="primary">+ Créer</button></div>
</form>
<h2>Catalogue</h2>
{% if types %}
<div class="list">
{% for t in types %}
<div class="listRow listRowFlex">
<div class="rowLink">
<div class="rowTitle">{{ t.icon }} {{ t.name }}</div>
<div class="rowSub">
{{ usage.get(t.id, 0) }} instance(s) posée(s) sur des écrans
{% if t.bound_definition_id %}
{% for d in definitions %}{% if d.id == t.bound_definition_id %} · lié à l'objet « {{ d.name }} »{% endif %}{% endfor %}
{% endif %}
</div>
</div>
<div class="rowActions">
<a class="smallBtn" href="{{ url_for('screen_edit', slug=game.slug, screen_id=t.template_screen_id) }}" title="Modifier le contenu de cet élément de jeu">✏️ Modifier le contenu</a>
<form method="post" action="{{ url_for('element_type_delete', slug=game.slug, element_type_id=t.id) }}"
onsubmit="return confirm('Supprimer l\'élément de jeu « {{ t.name }} » du catalogue (et tout son contenu) ?');">
<button type="submit" class="smallBtn danger" {{ 'disabled title="Utilisé sur un écran — retire-le des écrans d\'abord"' if usage.get(t.id, 0) > 0 else '' }}>🗑️</button>
</form>
</div>
</div>
{% endfor %}
</div>
{% else %}
<p class="hint">Aucun élément de jeu défini pour l'instant.</p>
{% endif %}
{% endblock %}
+61
View File
@@ -0,0 +1,61 @@
{% extends "base.html" %}
{% block title %}{{ game.name }} — Forge Engine{% endblock %}
{% block breadcrumb %}<span class="crumb">{{ game.name }}</span>{% endblock %}
{% block content %}
<h1>{{ game.name }}</h1>
<p class="hint">Base de données : projects/{{ game.slug }}/game.db</p>
<div class="fieldRow" style="margin:14px 0;">
<a class="listRow rowLink" style="flex:1;" href="{{ url_for('screens_list', slug=game.slug) }}">
<div class="rowTitle">🖥️ Écrans du jeu</div>
<div class="rowSub">Créer et enchaîner les écrans jouables (accueil, formulaires, scènes...)</div>
</a>
<a class="listRow rowLink" style="flex:1;" href="{{ url_for('element_types', slug=game.slug) }}">
<div class="rowTitle">🧩 Éléments de jeu</div>
<div class="rowSub">Catalogue réutilisable (Personnage, Outil, IA...)</div>
</a>
<a class="listRow rowLink" style="flex:1;" href="{{ url_for('game_play', slug=game.slug) }}" target="_blank">
<div class="rowTitle">▶️ Jouer</div>
<div class="rowSub">Aperçu jouable du jeu</div>
</a>
</div>
<a class="listRow rowLink" href="{{ url_for('object_new', slug=game.slug) }}">
<div class="rowTitle">➕ Définir un nouvel objet</div>
<div class="rowSub">Comme une table de base de données : un nom, et des champs typés</div>
</a>
<h2>Objets définis</h2>
{% if definitions %}
<div class="list">
{% for d in definitions %}
<div class="listRow listRowFlex">
<a class="rowLink" href="{{ url_for('object_view', slug=game.slug, definition_id=d.id) }}">
<div class="rowTitle">{{ d.name }}</div>
<div class="rowSub">table SQL : {{ d.table_name }}</div>
</a>
<div class="rowActions">
<a class="smallBtn" href="{{ url_for('object_edit', slug=game.slug, definition_id=d.id) }}" title="Modifier cet objet">✏️</a>
</div>
</div>
{% endfor %}
</div>
{% else %}
<p class="hint">Aucun objet défini pour l'instant — un objet, c'est comme une table de base de données : un nom, et des champs typés.</p>
{% endif %}
<div class="dangerZone">
<h3>Paramètres du jeu</h3>
<form method="post" action="{{ url_for('game_rename', slug=game.slug) }}" class="fieldRow" style="align-items:flex-end;">
<div>
<label for="rename">Renommer le jeu</label>
<input type="text" id="rename" name="name" value="{{ game.name }}" required>
</div>
<button type="submit">Renommer</button>
</form>
<form method="post" action="{{ url_for('game_delete', slug=game.slug) }}" style="margin-top:12px;"
onsubmit="return confirm('Supprimer définitivement le jeu « {{ game.name }} », son dossier et sa base de données ? Cette action est irréversible.');">
<button type="submit" class="danger">🗑️ Supprimer ce jeu</button>
</form>
</div>
{% endblock %}
+34
View File
@@ -0,0 +1,34 @@
{% extends "base.html" %}
{% block title %}Forge Engine — Mes jeux{% endblock %}
{% block content %}
<h1>Mes jeux</h1>
<form method="post" action="{{ url_for('games_new') }}" class="card inline-form">
<label for="name">Nom du nouveau jeu</label>
<div class="fieldRow">
<input type="text" id="name" name="name" placeholder="ex. CyberQuest RGPD" required>
<button type="submit" class="primary">+ Créer le jeu</button>
</div>
<p class="hint">Crée un dossier avec index.html / index.css / index.js reliés entre eux, et une base de données dédiée à ce jeu.</p>
</form>
<h2>Jeux existants</h2>
{% if games %}
<div class="list">
{% for g in games %}
<div class="listRow listRowFlex">
<a class="rowLink" href="{{ url_for('game_dashboard', slug=g.slug) }}">
<div class="rowTitle">{{ g.name }}</div>
<div class="rowSub">projects/{{ g.slug }}/</div>
</a>
<form method="post" action="{{ url_for('game_delete', slug=g.slug) }}"
onsubmit="return confirm('Supprimer définitivement le jeu « {{ g.name }} » et tout son contenu (dossier + base de données) ?');">
<button type="submit" class="smallBtn danger" title="Supprimer ce jeu">🗑️</button>
</form>
</div>
{% endfor %}
</div>
{% else %}
<p class="hint">Aucun jeu pour l'instant — crée le premier ci-dessus.</p>
{% endif %}
{% endblock %}
+153
View File
@@ -0,0 +1,153 @@
{% extends "base.html" %}
{% block title %}Modifier {{ definition.name }} — {{ game.name }}{% endblock %}
{% block content_class %}content-wide{% 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('object_view', slug=game.slug, definition_id=definition.id) }}">{{ definition.name }}</a>
<span class="crumb-sep">/</span><span class="crumb">Modifier</span>
{% endblock %}
{% block content %}
<h1>Modifier l'objet « {{ definition.name }} »</h1>
<p class="hint">Table SQL : {{ definition.table_name }}</p>
<form method="post" class="card fieldRow" style="align-items:flex-end;">
<div>
<label for="object_name">Nom de l'objet</label>
<input type="text" id="object_name" name="object_name" value="{{ definition.name }}" required>
</div>
<button type="submit">Renommer</button>
</form>
<div class="twoCol">
<!-- Colonne de gauche : ajouter un champ + supprimer l'objet -->
<div class="colLeft">
<h2>Ajouter un champ</h2>
<form method="post" action="{{ url_for('object_field_add', slug=game.slug, definition_id=definition.id) }}" class="card" id="addFieldForm">
<label>Nom du champ</label>
<input type="text" name="field_name" placeholder="ex. nom, description, pv..." required>
<label>Type</label>
<select name="field_type" id="newFieldType">
{% for key, val in field_types.items() %}
<option value="{{ key }}">{{ val.label }}</option>
{% endfor %}
</select>
<div id="newFieldRelationWrap" style="display:none; margin-top:8px;">
<label>Objet lié</label>
<select name="field_relation">
{% for d in existing_definitions %}
<option value="{{ d.id }}">{{ d.name }}</option>
{% endfor %}
</select>
{% if not existing_definitions %}
<p class="hint">Aucun autre objet défini dans ce jeu pour l'instant.</p>
{% endif %}
</div>
<div id="newFieldBoundsWrap" class="fieldRow" style="display:none; margin-top:8px;">
<div>
<label>Valeur mini (optionnel)</label>
<input type="number" step="any" name="field_min" placeholder="ex. 0">
</div>
<div>
<label>Valeur maxi (optionnel)</label>
<input type="number" step="any" name="field_max" placeholder="ex. 100">
</div>
</div>
<label>Obligatoire ?</label>
<select name="field_required">
<option value="0">Non</option>
<option value="1">Oui</option>
</select>
<div class="actionsRow">
<button type="submit" class="primary">+ Ajouter ce champ</button>
</div>
</form>
<div class="dangerZone">
<h3>Supprimer cet objet</h3>
<form method="post" action="{{ url_for('object_delete', slug=game.slug, definition_id=definition.id) }}"
onsubmit="return confirm('Supprimer définitivement l\'objet « {{ definition.name }} », sa table et toutes ses données ? Cette action est irréversible.');">
<button type="submit" class="danger">🗑️ Supprimer l'objet « {{ definition.name }} »</button>
</form>
</div>
</div>
<!-- Colonne de droite : champs existants, chacun modifiable -->
<div class="colRight">
<h2>Champs existants</h2>
{% if definition.fields %}
{% for f in definition.fields %}
<div class="card fieldEditCard">
<form method="post" action="{{ url_for('object_field_edit', slug=game.slug, definition_id=definition.id, field_id=f.id) }}">
<div class="fieldRow">
<div>
<label>Nom du champ</label>
<input type="text" name="field_name" value="{{ f.name }}" required>
</div>
<div>
<label>Type</label>
<input type="text" value="{{ field_types.get(f.type, {}).get('label', f.type) }}" disabled>
</div>
</div>
{% if f.type == 'relation' %}
<label>Objet lié</label>
<select name="field_relation">
{% for d in existing_definitions %}
<option value="{{ d.id }}" {{ 'selected' if d.id == f.relation_definition_id else '' }}>{{ d.name }}</option>
{% endfor %}
</select>
{% endif %}
{% if f.type in ('nombre_entier', 'nombre_decimal') %}
<div class="fieldRow">
<div>
<label>Valeur mini (bornage automatique)</label>
<input type="number" step="any" name="field_min" value="{{ f.min_value if f.min_value is not none else '' }}" placeholder="ex. 0">
</div>
<div>
<label>Valeur maxi (bornage automatique)</label>
<input type="number" step="any" name="field_max" value="{{ f.max_value if f.max_value is not none else '' }}" placeholder="ex. 100">
</div>
</div>
<p class="hint">Laisser vide = pas de limite. S'applique à "Donner cette valeur", "Augmenter de..." et "Diminuer de..." dans le graphe de logique.</p>
{% endif %}
<label>Obligatoire ?</label>
<select name="field_required">
<option value="0" {{ 'selected' if not f.required else '' }}>Non</option>
<option value="1" {{ 'selected' if f.required else '' }}>Oui</option>
</select>
<div class="actionsRow fieldEditActions">
<button type="submit">Enregistrer</button>
</div>
</form>
<form method="post" action="{{ url_for('object_field_delete', slug=game.slug, definition_id=definition.id, field_id=f.id) }}"
onsubmit="return confirm('Retirer le champ « {{ f.name }} » ? Les données déjà enregistrées dans ce champ seront perdues.');">
<button type="submit" class="smallBtn danger">🗑️ Retirer ce champ</button>
</form>
</div>
{% endfor %}
{% else %}
<p class="hint">Aucun champ pour l'instant.</p>
{% endif %}
</div>
</div>
<script>
(function(){
var sel = document.getElementById('newFieldType');
var relWrap = document.getElementById('newFieldRelationWrap');
var boundsWrap = document.getElementById('newFieldBoundsWrap');
var NUMERIC_TYPES = ['nombre_entier', 'nombre_decimal'];
function sync(){
relWrap.style.display = sel.value === 'relation' ? 'block' : 'none';
boundsWrap.style.display = NUMERIC_TYPES.indexOf(sel.value) !== -1 ? 'flex' : 'none';
}
sel.addEventListener('change', sync);
sync();
})();
</script>
{% endblock %}
+72
View File
@@ -0,0 +1,72 @@
{% extends "base.html" %}
{% block title %}Nouvel objet — {{ game.name }}{% endblock %}
{% block breadcrumb %}
<a class="crumb" href="{{ url_for('game_dashboard', slug=game.slug) }}">{{ game.name }}</a>
<span class="crumb-sep">/</span><span class="crumb">Nouvel objet</span>
{% endblock %}
{% block content %}
<h1>Définir un nouvel objet</h1>
<form method="post" class="card" id="objectForm">
<label for="object_name">Nom de l'objet (ex. Personnage, Niveau, Question...)</label>
<input type="text" id="object_name" name="object_name" required>
<h3>Champs</h3>
<div id="fieldsList"></div>
<button type="button" class="addBtn" id="btnAddField">+ Ajouter un champ</button>
<div class="actionsRow">
<button type="submit" class="primary">Créer l'objet</button>
</div>
</form>
<template id="fieldRowTemplate">
<div class="fieldRowCard card">
<div class="fieldRow">
<div>
<label>Nom du champ</label>
<input type="text" name="field_name[]" placeholder="ex. nom, description, pv..." required>
</div>
<div>
<label>Type</label>
<select name="field_type[]" class="fieldTypeSelect">
{% for key, val in field_types.items() %}
<option value="{{ key }}">{{ val.label }}</option>
{% endfor %}
</select>
</div>
</div>
<div class="relationTarget" style="display:none;">
<label>Objet lié</label>
<select name="field_relation[]">
{% for d in existing_definitions %}
<option value="{{ d.id }}">{{ d.name }}</option>
{% endfor %}
</select>
{% if not existing_definitions %}
<p class="hint">Aucun autre objet défini.</p>
{% endif %}
</div>
<div class="boundsTarget fieldRow" style="display:none;">
<div>
<label>Valeur mini</label>
<input type="number" step="any" name="field_min[]" placeholder="ex. 0">
</div>
<div>
<label>Valeur maxi</label>
<input type="number" step="any" name="field_max[]" placeholder="ex. 100">
</div>
</div>
<div>
<label>Obligatoire ?</label>
<select name="field_required[]">
<option value="0">Non</option>
<option value="1">Oui</option>
</select>
</div>
<button type="button" class="smallBtn danger btnRemoveField">🗑️ Retirer ce champ</button>
</div>
</template>
<script src="{{ url_for('static', filename='object_form.js') }}"></script>
{% endblock %}
+608
View File
@@ -0,0 +1,608 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ game.name }} — Jouer</title>
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
<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">
<style>
body{ margin:0; background:#000; }
/* ---------- Zone de jeu à proportion fixe (voir #playFrame) ----------
Chaque écran est conçu avec un format précis (Portrait/Paysage/Carré,
choisi dans l'éditeur — voir preview_aspect). Positionner les éléments
en % de la fenêtre du NAVIGATEUR (dont la forme varie selon l'appareil
du joueur) les déformait : un carré dessiné en Portrait pouvait
s'afficher étiré/écrasé sur un écran de proportion différente. #playFrame
garde donc toujours la même proportion que l'écran en cours (mise à
jour dans showScreen ci-dessous), quelle que soit la fenêtre réelle —
avec des bandes noires (letterboxing) si besoin, comme un lecteur
vidéo — pour que les % restent fidèles à ce qui a été conçu. */
#playRoot{
position:relative; width:100vw; height:100vh; overflow:hidden; background:#000;
display:flex; align-items:center; justify-content:center;
}
#playFrame{
position:relative; overflow:hidden; background:#0b0d12;
width: min(100vw, calc(100vh * var(--ratio-w, 9) / var(--ratio-h, 16)));
height: min(100vh, calc(100vw * var(--ratio-h, 16) / var(--ratio-w, 9)));
}
.playScreen{ position:absolute; inset:0; display:none; }
.playElement{
position:absolute; box-sizing:border-box; font-family:system-ui,sans-serif;
}
.playElement.clickable{ cursor:pointer; }
.playElementInner{
width:100%; height:100%;
display:flex; align-items:center; justify-content:center;
}
.playElement input{ width:90%; height:70%; box-sizing:border-box; border:1px solid #2b3140; border-radius:4px; padding:0 8px; }
#emptyState{ color:#8b93a7; font-family:system-ui,sans-serif; text-align:center; padding-top:40vh; }
/* 3.3 (Confort) — surbrillance générique dynamique : liseré clignotant,
posé/enlevé par une action "Modifier un élément → Surbrillance",
applicable à n'importe quel élément (pas seulement les boutons). */
@keyframes forgeHighlightPulse {
0%, 100% { box-shadow: 0 0 0 3px rgba(255, 196, 0, 0.9); }
50% { box-shadow: 0 0 0 3px rgba(255, 196, 0, 0.15); }
}
.forgeHighlight{ animation: forgeHighlightPulse 1.1s ease-in-out infinite; }
/* 3.5 (Confort) — verrouillage d'un élément après décision : grisé +
inerte, posé/enlevé par une action "Modifier un élément → Désactivé". */
.forgeDisabled{ opacity:0.45; filter:grayscale(60%); pointer-events:none; }
</style>
</head>
<body>
<div id="playRoot">
<div id="playFrame">
{% for s in screens_data %}
<div class="playScreen" data-screen-id="{{ s.id }}" data-aspect="{{ s.preview_aspect or '9/16' }}">
{% for el in s.elements if not el.parent_id %}
<div class="playElement" data-el-id="{{ el.id }}" data-anim-target="{{ el.id }}" style="{{ el|elstyle }}">
<div class="playElementInner" style="{{ el|eltransform }}">{{ el.rendered_html|safe }}</div>
</div>
{% endfor %}
</div>
{% endfor %}
{% if not screens_data %}
<div id="emptyState">Aucun écran défini pour l'instant — retourne dans l'éditeur pour en créer un.</div>
{% endif %}
</div>
</div>
<script>
let gameData = {{ payload_json|safe }};
let screensData = gameData.screens;
function showScreen(id) {
document.querySelectorAll('.playScreen').forEach(s => s.style.display = 'none');
const el = document.querySelector('.playScreen[data-screen-id="' + id + '"]');
if (el) {
el.style.display = 'block';
window.currentScreenId = id;
applyScreenAspect(el.dataset.aspect || '9/16');
}
runScreenShowTriggers(id);
runAnimationTimeline(id);
}
// Voir le commentaire sur #playFrame dans le <style> ci-dessus : la zone
// de jeu garde toujours la proportion de l'écran affiché, quelle que soit
// la forme réelle de la fenêtre du joueur.
function applyScreenAspect(aspect) {
var parts = String(aspect).split('/');
var w = parseFloat(parts[0]) || 9, h = parseFloat(parts[1]) || 16;
var frame = document.getElementById('playFrame');
if (frame) {
frame.style.setProperty('--ratio-w', w);
frame.style.setProperty('--ratio-h', h);
}
}
// ---------- Timeline d'animation (menu "Ajouter un élément" → panneau
// rétractable sous la Logique de la scène) : chaque clip démarre à un
// instant précis (start_time, en secondes) après l'affichage de l'écran —
// exactement comme une timeline Unity jouée au chargement d'une scène.
// Deux types de clip : "animate_css" pose une classe de la bibliothèque
// Animate.css (voir animate_css_catalog.py), "custom" joue des
// images-clés propres au jeu (voir injectCustomKeyframes ci-dessous). ----------
var pendingAnimationTimers = [];
function injectCustomKeyframes() {
// Construit UNE FOIS toutes les @keyframes "personnalisées" de tous les
// écrans (les données sont déjà toutes chargées dans gameData) et les
// pose dans une unique balise <style>, plutôt que d'en injecter une par
// clip à chaque lecture — un nom de règle unique par clip (customAnim_id)
// évite toute collision entre deux clips personnalisés différents.
var rules = [];
Object.keys(gameData.animations || {}).forEach(function(screenId) {
(gameData.animations[screenId] || []).forEach(function(clip) {
if (clip.kind !== 'custom' || !clip.custom_keyframes) return;
var keyframes;
try { keyframes = JSON.parse(clip.custom_keyframes); } catch (e) { return; }
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(' ');
rules.push('@keyframes customAnim_' + clip.id + ' { ' + steps + ' }');
});
});
var styleTag = document.getElementById('customAnimStyles');
if (!styleTag) {
styleTag = document.createElement('style');
styleTag.id = 'customAnimStyles';
document.head.appendChild(styleTag);
}
styleTag.textContent = rules.join('\n');
}
function applyAnimationClip(clip) {
var target = document.querySelector('[data-anim-target="' + clip.element_id + '"]');
if (!target) return;
var duration = clip.duration || 1, delay = clip.delay || 0;
var iterationCount = clip.iteration_count || '1';
var infinite = iterationCount === 'infinite';
if (clip.kind === 'custom') {
target.style.animation = 'customAnim_' + clip.id + ' ' + duration + 's ' + (clip.easing || 'ease') +
' ' + delay + 's ' + iterationCount;
if (!infinite) {
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);
if (!infinite) {
target.addEventListener('animationend', function handler() {
target.classList.remove('animate__animated', animClass);
target.removeEventListener('animationend', handler);
});
}
}
}
function runAnimationTimeline(screenId) {
// Repart de zéro à chaque affichage de cet écran (comme
// runScreenShowTriggers) : on annule d'abord tout clip encore en
// attente d'un affichage précédent, pour ne jamais empiler des
// déclenchements en double si le joueur revient plusieurs fois de
// suite sur le même écran avant la fin de sa timeline.
pendingAnimationTimers.forEach(function(t) { clearTimeout(t); });
pendingAnimationTimers = [];
var clips = (gameData.animations || {})[String(screenId)] || [];
clips.forEach(function(clip) {
var t = setTimeout(function() { applyAnimationClip(clip); }, (clip.start_time || 0) * 1000);
pendingAnimationTimers.push(t);
});
}
// Déclenche tous les nœuds "À l'affichage de l'écran" de CET écran — pas
// besoin d'avoir cliqué sur quoi que ce soit : ça couvre le premier
// affichage, un retour en arrière, un changement d'écran (ecran_suivant/
// ecran_precedent/aller_a/ouvrir_ligne appellent tous showScreen). C'est
// ce qui permet à une condition ("SI outil débloqué = faux") + une action
// "Modifier un élément → Visibilité" de refléter l'état de la partie sans
// attendre un clic (voir 1.2 dans la doc de cadrage Forge Engine).
function runScreenShowTriggers(screenId) {
const flow = gameData.flows[String(screenId)];
if (!flow) return;
flow.nodes
.filter(function(n){ return n.node_type === 'trigger' && n.trigger_event === 'affichage'; })
.forEach(function(n){ runFlowFrom(n.id); });
}
function screenIndexById(id) {
return screensData.findIndex(s => s.id === id);
}
// Trouve, pour un élément et un type de déclencheur ("clic"/"soumission"),
// le nœud Déclencheur qui lui est rattaché — c'est le point d'entrée du
// graphe à exécuter. On cherche dans TOUS les écrans (pas seulement
// l'écran courant) : bindClicks() attache les gestionnaires une seule
// fois, sur tous les éléments de tous les écrans (déjà tous présents dans
// le DOM, seuls les écrans non actifs sont masqués) — chaque identifiant
// d'élément est unique dans tout le jeu, donc pas d'ambiguïté possible.
function findTriggerNode(elementId, event) {
for (const screenIdKey in gameData.flows) {
const flow = gameData.flows[screenIdKey];
const found = flow.nodes.find(n => n.node_type === 'trigger' && n.trigger_element_id === elementId && (n.trigger_event || 'clic') === event);
if (found) return found;
}
return null;
}
// Ré-attache les gestionnaires de clic sur chaque élément — appelé au
// chargement, puis à nouveau après un rafraîchissement des données (le
// DOM d'un écran est reconstruit après une action "Modifier une donnée",
// les nœuds précédents et leurs écouteurs disparaissent avec).
// 3.1 (Confort) — interactions au survol : échange le texte affiché
// contre data-hover-text pendant que la souris survole l'élément, puis le
// restaure au départ de la souris. On ignore volontairement les éléments
// qui ont des enfants (ex. un conteneur) pour ne jamais écraser une mise
// en page imbriquée avec du texte brut — cette fonctionnalité vise les
// éléments de texte simples (nom, lien...), pas les conteneurs.
function bindHoverTexts() {
document.querySelectorAll('[data-hover-text]').forEach(function(el) {
if (el.dataset.hoverBound) return;
if (el.children.length > 0) return;
el.dataset.hoverBound = '1';
var original = el.textContent;
el.addEventListener('mouseenter', function(){ el.textContent = el.dataset.hoverText; });
el.addEventListener('mouseleave', function(){ el.textContent = original; });
});
}
function bindClicks() {
document.querySelectorAll('[data-element-id]').forEach(elDiv => {
const elementId = parseInt(elDiv.dataset.elementId, 10);
const trigger = findTriggerNode(elementId, 'clic');
if (trigger) {
// Garde-fou anti-doublon : refreshRuntimeData() rappelle bindClicks()
// après chaque changement de donnée, mais un élément qui n'est ni un
// Répéteur ni une Jauge garde le MÊME nœud DOM d'un rafraîchissement
// à l'autre (seul son innerHTML change pour ceux-là, voir
// refreshRuntimeData()) — sans ce garde-fou, addEventListener('click')
// s'empilerait à chaque rafraîchissement et un seul clic sur un
// bouton "+10 points" finirait par déclencher l'action 2, 3, 10 fois
// d'affilée (d'où les sauts de +100/-20/0 constatés au lieu de ±10).
if (elDiv.dataset.clickBound) return;
elDiv.dataset.clickBound = '1';
elDiv.style.cursor = 'pointer';
elDiv.addEventListener('click', (ev) => {
ev.stopPropagation();
// Si l'élément cliqué (ou un de ses parents, jusqu'au Répéteur
// qui reçoit le gestionnaire) est en fait une LIGNE de Répéteur,
// on retrouve son vrai id de donnée (data-row-id, posé par
// render_repeater.py) — c'est ce qui permet à l'action "Ouvrir la
// ligne cliquée" de savoir sur quel mail précis on a cliqué,
// alors même que le déclencheur, lui, vise le Répéteur entier.
const rowEl = ev.target.closest('.repeaterItem');
window.lastClickedRowId = rowEl ? parseInt(rowEl.dataset.rowId, 10) : null;
window.lastClickedDefinitionId = elDiv.dataset.definitionId ? parseInt(elDiv.dataset.definitionId, 10) : null;
runFlowFrom(trigger.id);
});
}
});
}
// Résout, sur l'écran couramment affiché, les placeholders "champ entre
// doubles accolades" restés tels quels dans le HTML statique (hors
// Répéteur, donc jamais résolus au moment du rendu serveur) avec les
// valeurs de la DERNIÈRE ligne ouverte via l'action "Ouvrir la ligne
// cliquée" (window.currentOpenRow). On part à chaque fois du HTML
// D'ORIGINE (el.rendered_html, jamais modifié) et pas du DOM déjà
// affiché, sinon rouvrir un mail différent laisserait les valeurs du mail
// précédent là où le nouveau mail n'a pas ce champ.
//
// Note : on construit les accolades doubles caractère par caractère
// (PLACEHOLDER_OPEN/CLOSE) plutôt que de les écrire en dur dans ce
// fichier — {{ et }} adjacents seraient interprétés par Jinja (le moteur
// de templates serveur) comme DU JINJA, pas comme du JavaScript littéral.
var PLACEHOLDER_OPEN = '{' + '{';
var PLACEHOLDER_CLOSE = '}' + '}';
function applyOpenRowBindings() {
if (!window.currentOpenRow) return;
const rows = gameData.data[String(window.currentOpenRow.definitionId)] || [];
const row = rows.find(r => r.id === window.currentOpenRow.rowId);
const screenData = screensData.find(s => s.id === window.currentScreenId);
const screenDiv = document.querySelector('.playScreen[data-screen-id="' + window.currentScreenId + '"]');
if (!row || !screenData || !screenDiv) return;
screenData.elements.filter(function(e){ return !e.parent_id; }).forEach(function(el) {
const wrapper = screenDiv.querySelector('.playElement[data-el-id="' + el.id + '"] .playElementInner');
if (!wrapper || !el.rendered_html || el.rendered_html.indexOf(PLACEHOLDER_OPEN) === -1) return;
let html = el.rendered_html;
Object.keys(row).forEach(function(fieldName) {
if (fieldName === 'id') return;
const value = row[fieldName] == null ? '' : String(row[fieldName]);
const tmp = document.createElement('div');
tmp.textContent = value;
html = html.split(PLACEHOLDER_OPEN + fieldName + PLACEHOLDER_CLOSE).join(tmp.innerHTML);
});
wrapper.innerHTML = html;
});
}
// Recharge l'état du jeu depuis le serveur (après une action qui a
// modifié une donnée d'objet) pour refléter la nouvelle valeur — utile
// pour un Répéteur de données, qui doit réafficher ses lignes à jour.
// Important : on ne touche QU'AUX éléments qui contiennent réellement un
// répéteur (repérable par la classe "repeaterItem" dans leur HTML rendu).
// Reconstruire TOUT l'écran à chaque rafraîchissement effacerait les
// changements purement visuels faits en même temps par une action
// "Modifier un élément" (ex: masquer une boîte) déclenchée par le même
// clic, puisque ces changements ne sont jamais enregistrés côté serveur.
function refreshRuntimeData() {
return fetch("{{ url_for('runtime_payload', slug=game.slug) }}")
.then(r => r.json())
.then(newData => {
gameData = newData;
screensData = gameData.screens;
const screenData = screensData.find(s => s.id === window.currentScreenId);
const screenDiv = document.querySelector('.playScreen[data-screen-id="' + window.currentScreenId + '"]');
if (!screenData || !screenDiv) return;
screenData.elements.filter(function(e){ return !e.parent_id; }).forEach(function(el) {
if (!el.rendered_html) return;
// "repeaterItem" (liste de données) et "jaugeBar" (2.1 — jauge liée
// à une donnée) sont les deux seuls types d'éléments dont le
// contenu affiché dépend de la donnée elle-même plutôt que d'une
// action "Modifier un élément" posée à la main dans le graphe —
// eux seuls doivent être régénérés après un changement de donnée.
if (el.rendered_html.indexOf('repeaterItem') === -1 && el.rendered_html.indexOf('jaugeBar') === -1) return;
const wrapper = screenDiv.querySelector('.playElement[data-el-id="' + el.id + '"] .playElementInner');
if (wrapper) wrapper.innerHTML = el.rendered_html;
});
applyOpenRowBindings();
bindClicks();
bindHoverTexts();
// Réévalue aussi les nœuds "À l'affichage de l'écran" de l'écran où
// l'on se trouve déjà : une donnée vient de changer (ex: un outil
// qu'on vient de débloquer), donc l'affichage doit se mettre à jour
// tout de suite, sans attendre un aller-retour d'écran.
runScreenShowTriggers(window.currentScreenId);
});
}
// Propriété CSS réelle + éventuel suffixe (unité) pour chaque propriété
// pilotable par une action "Modifier un élément".
var ELEMENT_PROPERTY_CSS = {
bg_color: { css: 'backgroundColor', suffix: '' },
border_color: { css: 'borderColor', suffix: '' },
width: { css: 'width', suffix: '%' },
height: { css: 'height', suffix: '%' },
};
function applyElementProperty(targetEl, property, value) {
if (property === 'surbrillance') {
// 3.3 (Confort) — surbrillance générique dynamique : un liseré qui
// clignote (voir la classe .forgeHighlight et son animation dans le
// <style> ci-dessus), applicable à N'IMPORTE QUEL élément sans poser
// de bordure colorée togglée à la main — utile pour un mentor qui
// attire l'attention du joueur vers l'élément à toucher ensuite.
if (value === 'toggle') {
targetEl.classList.toggle('forgeHighlight');
} else if (value === 'off') {
targetEl.classList.remove('forgeHighlight');
} else {
targetEl.classList.add('forgeHighlight');
}
return;
}
if (property === 'desactive') {
// 3.5 (Confort) — verrouillage d'un élément après décision : DISTINCT
// de "invisible" (l'élément reste visible, mais grisé et inerte —
// pointer-events:none bloque aussi le clic, donc le déclencheur "Au
// clic" éventuellement posé sur cet élément ne se redéclenche plus).
if (value === 'toggle') {
targetEl.classList.toggle('forgeDisabled');
} else if (value === 'off') {
targetEl.classList.remove('forgeDisabled');
} else {
targetEl.classList.add('forgeDisabled');
}
return;
}
if (property === 'visibilite') {
if (value === 'toggle') {
const hidden = targetEl.style.display === 'none';
targetEl.style.display = hidden ? '' : 'none';
targetEl.style.visibility = '';
} else if (value === 'masque') {
targetEl.style.display = 'none';
} else if (value === 'invisible') {
targetEl.style.display = '';
targetEl.style.visibility = 'hidden';
} else {
targetEl.style.display = '';
targetEl.style.visibility = '';
}
return;
}
const spec = ELEMENT_PROPERTY_CSS[property];
if (!spec || value === undefined || value === null) return;
// "valeur1||valeur2" : effet va-et-vient — un état par élément et par
// propriété, mémorisé directement sur le nœud DOM (data-toggle-<prop>) :
// un clic passe à la 2e valeur, le clic suivant revient à la 1re, et
// ainsi de suite. Ce n'est PAS enregistré en base : l'état repart de la
// 1re valeur si l'écran est rechargé.
if (typeof value === 'string' && value.indexOf('||') !== -1) {
const parts = value.split('||');
const key = 'toggle' + property;
const atSecond = targetEl.dataset[key] === '1';
const next = atSecond ? parts[0] : parts[1];
targetEl.style[spec.css] = next + spec.suffix;
targetEl.dataset[key] = atSecond ? '0' : '1';
return;
}
targetEl.style[spec.css] = value + spec.suffix;
}
function runActionNode(node) {
if (node.action_type === 'ecran_suivant') {
const idx = screenIndexById(window.currentScreenId);
if (idx > -1 && idx < screensData.length - 1) showScreen(screensData[idx + 1].id);
return Promise.resolve();
} else if (node.action_type === 'ecran_precedent') {
const idx = screenIndexById(window.currentScreenId);
if (idx > 0) showScreen(screensData[idx - 1].id);
return Promise.resolve();
} else if (node.action_type === 'aller_a' && node.target_screen_id) {
showScreen(node.target_screen_id);
return Promise.resolve();
} else if (node.action_type === 'ouvrir_ligne' && node.target_screen_id) {
// Mémorise QUELLE ligne a été cliquée juste avant d'exécuter cette
// action (voir bindClicks) puis affiche l'écran de détail avec ses
// placeholders "champ" résolus contre cette ligne précise.
if (window.lastClickedRowId != null && window.lastClickedDefinitionId != null) {
window.currentOpenRow = { definitionId: window.lastClickedDefinitionId, rowId: window.lastClickedRowId };
}
showScreen(node.target_screen_id);
applyOpenRowBindings();
return Promise.resolve();
} else if (node.action_type === 'modifier_element' && node.target_element_id) {
const targetEl = document.querySelector('[data-element-id="' + node.target_element_id + '"]');
if (targetEl) applyElementProperty(targetEl, node.element_property, node.element_value);
return Promise.resolve();
} else if (node.action_type === 'activer_onglet' && node.target_element_id) {
// 2.3 — panneau à onglets / visibilité mutuellement exclusive : montre
// l'élément visé et masque tous ses FRÈRES DIRECTS (même parent_id,
// y compris "aucun parent" = les autres éléments de premier niveau de
// l'écran) en une seule action, plutôt qu'une action "Masquer" par
// frère. screenData.elements contient TOUS les éléments de l'écran
// (imbriqués compris, voir list_elements côté serveur), donc ça
// fonctionne aussi bien pour des onglets imbriqués dans un même
// conteneur que pour des "vues" posées directement sur l'écran.
const screenData = screensData.find(function(s){ return s.id === window.currentScreenId; });
const allElements = screenData ? screenData.elements : [];
const target = allElements.find(function(e){ return e.id === node.target_element_id; });
if (target) {
const parentKey = target.parent_id || null;
allElements
.filter(function(e){ return (e.parent_id || null) === parentKey; })
.forEach(function(e) {
const el = document.querySelector('[data-element-id="' + e.id + '"]');
if (!el) return;
if (e.id === target.id) { el.style.display = ''; el.style.visibility = ''; }
else { el.style.display = 'none'; }
});
}
return Promise.resolve();
} else if (node.action_type === 'attendre') {
// 3.2 (Confort) — séquences temporisées : suspend la suite du graphe
// pendant N secondes (node.data_value) avant de continuer sur le(s)
// fil(s) de sortie — permet par ex. un mail qui "arrive" tout seul
// quelques secondes après l'affichage de l'écran, sans action du
// joueur (à combiner avec le déclencheur "À l'affichage de l'écran").
const seconds = parseFloat(node.data_value);
const ms = isNaN(seconds) ? 0 : Math.max(0, seconds) * 1000;
return new Promise(function(resolve) { setTimeout(resolve, ms); });
} else if (node.action_type === 'modifier_donnee') {
const runDataUrl = "{{ url_for('flow_node_run_data', slug=game.slug, node_id=999999) }}".replace('999999', node.id);
return fetch(runDataUrl, {method: 'POST'})
.then(r => r.json())
.then(() => refreshRuntimeData());
}
// 'rien' : aucune action volontairement.
return Promise.resolve();
}
// Lit la valeur actuelle d'un champ d'objet dans l'instantané de données
// du jeu (gameData.data), pour l'évaluation d'une condition.
function readFieldValue(definitionId, rowId, fieldName) {
const rows = gameData.data[String(definitionId)] || [];
const row = rows.find(r => r.id === rowId);
return row ? row[fieldName] : undefined;
}
function compareValues(actual, operator, expected, fieldType) {
if (fieldType === 'booleen') {
const a = actual ? 1 : 0;
const e = (expected === '1' || expected === 'true' || expected === true) ? 1 : 0;
return operator === 'different' ? a !== e : a === e;
}
const an = parseFloat(actual), en = parseFloat(expected);
const numeric = !isNaN(an) && !isNaN(en);
if (numeric) {
switch (operator) {
case 'egal': return an === en;
case 'different': return an !== en;
case 'superieur': return an > en;
case 'inferieur': return an < en;
case 'superieur_egal': return an >= en;
case 'inferieur_egal': return an <= en;
}
}
const as = (actual === undefined || actual === null) ? '' : String(actual);
const es = (expected === undefined || expected === null) ? '' : String(expected);
switch (operator) {
case 'egal': return as === es;
case 'different': return as !== es;
case 'superieur': return as > es;
case 'inferieur': return as < es;
case 'superieur_egal': return as >= es;
case 'inferieur_egal': return as <= es;
default: return false;
}
}
// 2.4 — conditions combinées (ET/OU) : un nœud Condition peut porter une
// liste cond_clauses (JSON) en plus de sa clause historique. Un nœud sans
// cond_clauses (tous les nœuds créés avant 2.4, ou un nœud à une seule
// clause) garde EXACTEMENT son ancien comportement — une seule comparaison.
function evaluateConditionClause(clause) {
const actual = readFieldValue(clause.definition_id ?? clause.cond_definition_id, clause.row_id ?? clause.cond_row_id, clause.field ?? clause.cond_field);
return compareValues(actual, clause.operator ?? clause.cond_operator, clause.value ?? clause.cond_value, clause.field_type ?? clause.cond_field_type);
}
function evaluateConditionNode(node) {
if (node.cond_clauses) {
let clauses;
try { clauses = JSON.parse(node.cond_clauses); } catch (e) { clauses = null; }
if (Array.isArray(clauses) && clauses.length) {
const results = clauses.map(evaluateConditionClause);
return node.cond_combinator === 'ou' ? results.some(Boolean) : results.every(Boolean);
}
}
return evaluateConditionClause(node);
}
// Exécute le graphe de logique de l'écran courant à partir d'un nœud
// (typiquement un Déclencheur touché par le joueur). Un même point de
// sortie peut être relié à PLUSIEURS nœuds suivants (plusieurs actions
// pour un même déclencheur, ou plusieurs suites pour une même branche
// Vrai/Faux) — tous les fils sortants d'un port sont donc suivis, pas
// seulement le premier. Chaque nœud Action produit son effet puis
// continue sur son (ou ses) fil de sortie ; chaque nœud Condition évalue
// une donnée du jeu et suit le(s) fil(s) "Vrai" ou "Faux" en conséquence.
function runFlowFrom(nodeId) {
const flow = gameData.flows[String(window.currentScreenId)];
if (!flow) return;
const nodesById = {};
flow.nodes.forEach(n => { nodesById[n.id] = n; });
function nextEdges(fromId, port) {
return flow.edges.filter(e => e.from_node_id === fromId && e.from_port === port);
}
function step(currentId) {
const node = nodesById[currentId];
if (!node) return;
if (node.node_type === 'trigger') {
nextEdges(node.id, 'out').forEach(edge => step(edge.to_node_id));
} else if (node.node_type === 'condition') {
const port = evaluateConditionNode(node) ? 'vrai' : 'faux';
nextEdges(node.id, port).forEach(edge => step(edge.to_node_id));
} else if (node.node_type === 'action') {
runActionNode(node).then(() => {
nextEdges(node.id, 'out').forEach(edge => step(edge.to_node_id));
});
}
}
step(nodeId);
}
// L'écran affiché au démarrage doit être choisi AVANT le premier
// bindClicks() : la recherche du nœud Déclencheur associé à un élément se
// fait sur l'écran courant (window.currentScreenId), qui doit donc déjà
// être connu au moment où on attache les gestionnaires de clic.
injectCustomKeyframes();
if (screensData.length) showScreen(screensData[0].id);
// [data-element-id] est posé par le moteur de rendu sur CHAQUE élément,
// qu'il soit directement sur l'écran ou imbriqué à l'intérieur d'un
// conteneur/répéteur — donc un bouton posé à l'intérieur d'un conteneur
// déclenche bien son action au clic, exactement comme un bouton posé
// directement sur l'écran.
bindClicks();
bindHoverTexts();
</script>
</body>
</html>
File diff suppressed because it is too large Load Diff
+47
View File
@@ -0,0 +1,47 @@
{% extends "base.html" %}
{% block title %}Écrans — {{ game.name }}{% endblock %}
{% block breadcrumb %}
<a class="crumb" href="{{ url_for('game_dashboard', slug=game.slug) }}">{{ game.name }}</a>
<span class="crumb-sep">/</span><span class="crumb">Écrans</span>
{% endblock %}
{% block content %}
<h1>Écrans du jeu</h1>
<form method="post" action="{{ url_for('screens_new', slug=game.slug) }}" class="card inline-form">
<label for="name">Nom du nouvel écran</label>
<div class="fieldRow">
<input type="text" id="name" name="name" placeholder="ex. Accueil, Formulaire d'inscription, Scène 1..." required>
<button type="submit" class="primary">+ Créer l'écran</button>
</div>
</form>
{% if request.args.get('blocked_screen') %}
<div class="warningBanner">
⚠️ Suppression impossible : au moins un autre écran a un bouton qui cible précisément celui-ci. Retire d'abord cette action.
</div>
{% endif %}
<h2>Enchaînement des écrans</h2>
{% if screen_list %}
<div class="list">
{% for s in screen_list %}
<div class="listRow listRowFlex">
<a class="rowLink" href="{{ url_for('screen_edit', slug=game.slug, screen_id=s.id) }}">
<div class="rowTitle">{{ loop.index }}. {{ s.name }}{% if loop.first %} <span class="hint">★ Écran de départ</span>{% endif %}</div>
</a>
<div class="rowActions">
<form method="post" action="{{ url_for('screen_move', slug=game.slug, screen_id=s.id) }}"><input type="hidden" name="direction" value="up"><button type="submit" class="smallBtn" {{ 'disabled' if loop.first else '' }}>↑</button></form>
<form method="post" action="{{ url_for('screen_move', slug=game.slug, screen_id=s.id) }}"><input type="hidden" name="direction" value="down"><button type="submit" class="smallBtn" {{ 'disabled' if loop.last else '' }}>↓</button></form>
<a class="smallBtn" href="{{ url_for('screen_edit', slug=game.slug, screen_id=s.id) }}">✏️</a>
<form method="post" action="{{ url_for('screen_delete', slug=game.slug, screen_id=s.id) }}"
onsubmit="return confirm('Supprimer l\'écran « {{ s.name }} » et tous ses éléments ?');">
<button type="submit" class="smallBtn danger">🗑️</button>
</form>
</div>
</div>
{% endfor %}
</div>
{% else %}
<p class="hint">Aucun écran pour l'instant — crée le premier ci-dessus.</p>
{% endif %}
{% endblock %}