Phase 6 : son (musique de fond par écran + action "Jouer un son")
Build and deploy / test-python (push) Successful in 1m27s
Build and deploy / test-js (push) Successful in 6s
Build and deploy / build-and-push (push) Skipped
Build and deploy / deploy (push) Skipped

Ajoute deux mécanismes distincts, à la demande de l'utilisateur qui a
précisé qu'un son doit pouvoir être attaché à une SCÈNE (pas seulement
joué ponctuellement par une action de flow, comme prévu initialement) :

- Musique de fond par écran (nouveau champ background_music_url sur
  _screens, ALTER TABLE nullable) : réglée dans le panneau gauche de
  l'éditeur (URL ou envoi de fichier, réutilise la route d'upload
  générique existante), enregistrée en AJAX au même patron que le format
  d'aperçu (screen_set_aspect.py). Démarrée en boucle à l'affichage de
  l'écran et arrêtée au changement d'écran (runScreenBackgroundMusic(),
  appelée depuis showScreen() dans static/js/play/screens.js) — un seul
  Audio actif à la fois, jamais cumulé avec une musique restée d'un écran
  précédent.

- Action de flow "jouer_son" (aux côtés des actions existantes) : effet
  sonore ponctuel, non bouclé, déclenchable sur n'importe quel nœud
  Déclencheur. Réutilise data_value (déjà un champ texte générique sur
  le nœud action, comme pour "attendre") plutôt qu'une nouvelle colonne
  dédiée — fire-and-forget côté client (runActionNode), ne bloque jamais
  la suite du graphe.

Les deux réutilisent le même mécanisme d'upload de fichier déjà en place
ailleurs dans l'éditeur (ex. source d'une vidéo), sans nouvelle route.

Ceci complète les 6 phases du plan d'extension du moteur (état par
joueur, hasard/maths, clavier/minuteur, ajout de ligne, position/
collision, son) : Forge Engine peut désormais couvrir des jeux bien
au-delà du narratif/puzzle/quiz (action, arcade, jeux à contrainte de
temps).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
william
2026-08-30 17:56:49 +02:00
co-authored by Claude Sonnet 5
parent 9e263435e6
commit 76331f9285
12 changed files with 269 additions and 1 deletions
+1 -1
View File
@@ -1 +1 @@
from . import screens_new, screen_rename, screen_move, screen_delete, screen_edit, screen_set_aspect from . import screens_new, screen_rename, screen_move, screen_delete, screen_edit, screen_set_aspect, screen_set_background_music
@@ -0,0 +1,15 @@
from flask import request, jsonify
import screens
from core.flask_app import app
@app.route("/game/<slug>/screens/<int:screen_id>/background-music", methods=["POST"])
def screen_set_background_music(slug, screen_id):
"""Mémorise l'URL de musique de fond de cet écran, en AJAX — même patron
que screen_set_aspect."""
body = request.get_json(silent=True) or {}
url = body.get("url", "")
screens.set_screen_background_music(slug, screen_id, url)
return jsonify({"ok": True})
+1
View File
@@ -71,6 +71,7 @@ from .screens_repo.delete_screen import delete_screen
from .screens_repo.move_screen import move_screen from .screens_repo.move_screen import move_screen
from .screens_repo.screens_referencing import screens_referencing from .screens_repo.screens_referencing import screens_referencing
from .screens_repo.set_screen_aspect import set_screen_aspect from .screens_repo.set_screen_aspect import set_screen_aspect
from .screens_repo.set_screen_background_music import set_screen_background_music
from .elements.list_elements import list_elements from .elements.list_elements import list_elements
from .elements.get_element import get_element from .elements.get_element import get_element
+1
View File
@@ -10,5 +10,6 @@ ACTION_TYPE_LABELS = {
"attendre": "Attendre quelques secondes avant de continuer", "attendre": "Attendre quelques secondes avant de continuer",
"declencher_evenement": "Déclencher un événement", "declencher_evenement": "Déclencher un événement",
"ajouter_ligne": "Ajouter une ligne à un objet", "ajouter_ligne": "Ajouter une ligne à un objet",
"jouer_son": "Jouer un son",
"rien": "Ne rien faire", "rien": "Ne rien faire",
} }
+7
View File
@@ -107,6 +107,13 @@ def ensure_schema(slug):
# que la fenêtre du joueur n'a pas la même proportion que celle # que la fenêtre du joueur n'a pas la même proportion que celle
# utilisée à la conception. # utilisée à la conception.
conn.execute("ALTER TABLE _screens ADD COLUMN preview_aspect TEXT NOT NULL DEFAULT '9/16'") conn.execute("ALTER TABLE _screens ADD COLUMN preview_aspect TEXT NOT NULL DEFAULT '9/16'")
if "background_music_url" not in screen_cols:
# Phase 6 — musique de fond de cet écran (URL) : démarrée en boucle
# à l'affichage de l'écran, arrêtée au changement d'écran (voir
# runScreenBackgroundMusic() dans static/js/play/screens.js).
# Indépendante de l'action "Jouer un son" (effet sonore ponctuel,
# non bouclé, sur n'importe quel déclencheur).
conn.execute("ALTER TABLE _screens ADD COLUMN background_music_url TEXT")
action_cols = {r["name"] for r in conn.execute("PRAGMA table_info(_actions)").fetchall()} action_cols = {r["name"] for r in conn.execute("PRAGMA table_info(_actions)").fetchall()}
for col_name, col_def in [ for col_name, col_def in [
("target_element_id", "INTEGER REFERENCES _screen_elements(id)"), ("target_element_id", "INTEGER REFERENCES _screen_elements(id)"),
@@ -0,0 +1,17 @@
import db
from .ensure_schema import ensure_schema
def set_screen_background_music(slug, screen_id, url):
"""Mémorise l'URL de musique de fond de cet écran (voir le commentaire
sur la colonne background_music_url dans ensure_schema.py). Chaîne vide
-> NULL (pas de musique), comme un simple champ texte qu'on vide."""
ensure_schema(slug)
conn = db.connect(slug)
conn.execute(
"UPDATE _screens SET background_music_url = ? WHERE id = ?",
(url or None, screen_id),
)
conn.commit()
conn.close()
+7
View File
@@ -220,6 +220,13 @@ function runActionNode(node) {
window.lastInsertedRowId = json.row_id; window.lastInsertedRowId = json.row_id;
return refreshRuntimeData(); return refreshRuntimeData();
}); });
} else if (node.action_type === 'jouer_son') {
// Phase 6 — effet sonore ponctuel : non bouclé, indépendant de la
// musique de fond de l'écran (runScreenBackgroundMusic() dans
// screens.js, qui elle boucle et s'arrête au changement d'écran).
// Fire-and-forget : ne bloque jamais la suite du graphe.
if (node.data_value) new Audio(node.data_value).play().catch(function() {});
return Promise.resolve();
} }
// 'rien' : aucune action volontairement. // 'rien' : aucune action volontairement.
return Promise.resolve(); return Promise.resolve();
+21
View File
@@ -41,6 +41,27 @@ function showScreen(id) {
runScreenShowTriggers(id); runScreenShowTriggers(id);
runScreenTimerTriggers(id); runScreenTimerTriggers(id);
runAnimationTimeline(id); runAnimationTimeline(id);
runScreenBackgroundMusic(id);
}
// Phase 6 — musique de fond par écran (voir screen-bg-music-url dans
// screen_edit.html) : démarrée en boucle à l'affichage de l'écran,
// arrêtée au changement d'écran — un seul Audio actif à la fois (pas un
// tableau comme activeTimerTriggerIntervals dans triggers.js, une
// musique de fond ne se cumule jamais avec une autre).
var currentBackgroundAudio = null;
function runScreenBackgroundMusic(screenId) {
if (currentBackgroundAudio) {
currentBackgroundAudio.pause();
currentBackgroundAudio = null;
}
var screenData = screensData.find(function(s) { return String(s.id) === String(screenId); });
var url = screenData && screenData.background_music_url;
if (!url) return;
currentBackgroundAudio = new Audio(url);
currentBackgroundAudio.loop = true;
currentBackgroundAudio.play().catch(function() {});
} }
// Voir le commentaire sur #playFrame dans le <style> de play.html : la // Voir le commentaire sur #playFrame dans le <style> de play.html : la
+9
View File
@@ -113,6 +113,8 @@ function nodeLabel(node) {
base += ' « ' + (dispatchedEvent ? dispatchedEvent.name : '?') + ' »'; base += ' « ' + (dispatchedEvent ? dispatchedEvent.name : '?') + ' »';
} else if (node.action_type === 'ajouter_ligne') { } else if (node.action_type === 'ajouter_ligne') {
base += ' → ' + (DEFINITIONS_NAMES[node.target_definition_id] || '?'); base += ' → ' + (DEFINITIONS_NAMES[node.target_definition_id] || '?');
} else if (node.action_type === 'jouer_son') {
base += ' « ' + (node.data_value || '?') + ' »';
} }
return base; return base;
} }
@@ -497,7 +499,9 @@ function toggleFlowActionFields() {
var waitFields = document.getElementById('nf-action-wait-fields'); var waitFields = document.getElementById('nf-action-wait-fields');
var eventFields = document.getElementById('nf-action-event-fields'); var eventFields = document.getElementById('nf-action-event-fields');
var addRowFields = document.getElementById('nf-action-add-row-fields'); var addRowFields = document.getElementById('nf-action-add-row-fields');
var soundFields = document.getElementById('nf-action-sound-fields');
var isOnglet = typeSel.value === 'activer_onglet'; var isOnglet = typeSel.value === 'activer_onglet';
soundFields.style.display = typeSel.value === 'jouer_son' ? 'block' : 'none';
screenSel.style.display = (typeSel.value === 'aller_a' || typeSel.value === 'ouvrir_ligne') ? 'block' : 'none'; screenSel.style.display = (typeSel.value === 'aller_a' || typeSel.value === 'ouvrir_ligne') ? 'block' : 'none';
elementFields.style.display = (typeSel.value === 'modifier_element' || isOnglet) ? 'block' : 'none'; elementFields.style.display = (typeSel.value === 'modifier_element' || isOnglet) ? 'block' : 'none';
dataFields.style.display = typeSel.value === 'modifier_donnee' ? 'block' : 'none'; dataFields.style.display = typeSel.value === 'modifier_donnee' ? 'block' : 'none';
@@ -686,6 +690,11 @@ function submitNodeForm() {
// plusieurs nœuds "Modifier une donnée" enchaînés, ciblant // plusieurs nœuds "Modifier une donnée" enchaînés, ciblant
// FLOW_LAST_INSERTED_ROW_ID ("➕ Dernière ligne ajoutée"). // FLOW_LAST_INSERTED_ROW_ID ("➕ Dernière ligne ajoutée").
payload.target_definition_id = parseInt(document.getElementById('nf-action-add-row-definition').value, 10); payload.target_definition_id = parseInt(document.getElementById('nf-action-add-row-definition').value, 10);
} else if (actionType === 'jouer_son') {
// Phase 6 — réutilise data_value (déjà un simple champ texte générique
// sur le nœud action, comme pour "attendre") plutôt qu'une nouvelle
// colonne dédiée.
payload.data_value = document.getElementById('nf-action-sound-url').value;
} }
} }
+63
View File
@@ -21,6 +21,7 @@ function initBuilderPanel() {
})(); })();
bindAspectButtons(); bindAspectButtons();
bindScreenBackgroundMusicField();
document.querySelectorAll('.uploadInput').forEach(function (input) { document.querySelectorAll('.uploadInput').forEach(function (input) {
input.addEventListener('change', function () { input.addEventListener('change', function () {
@@ -46,6 +47,29 @@ function initBuilderPanel() {
}); });
}); });
// Phase 6 — champ d'upload DANS le formulaire de nœud (action "Jouer un
// son") : contrairement à .uploadInput (panneau de propriétés d'un
// élément), il n'y a pas de formulaire à autosauvegarder ici — le champ
// texte est simplement rempli, la valeur part avec le reste du nœud au
// clic sur "Créer" (voir submitNodeForm()).
document.querySelectorAll('.nodeFormUploadInput').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é ✓'; }
else { status.textContent = "Échec de l'envoi."; }
})
.catch(function () { status.textContent = "Échec de l'envoi."; });
});
});
bindPropsAutosave(); bindPropsAutosave();
bindJaugeDefinitionSelect(); bindJaugeDefinitionSelect();
bindDefinitionFieldSelects('field-definition_id', ['field-filtre_champ', 'field-filtre2_champ']); bindDefinitionFieldSelects('field-definition_id', ['field-filtre_champ', 'field-filtre2_champ']);
@@ -389,6 +413,45 @@ function syncAlign(radio) {
// redemandent ce panneau au serveur, qui doit donc connaître le choix déjà // redemandent ce panneau au serveur, qui doit donc connaître le choix déjà
// fait. Appelé depuis initBuilderPanel() (voir plus haut), une seule fois // fait. Appelé depuis initBuilderPanel() (voir plus haut), une seule fois
// par (re)rendu de ce panneau. // par (re)rendu de ce panneau.
// Phase 6 — musique de fond de cet écran : enregistrement en AJAX au
// blur/changement (pas d'autosave "au caractère près", même choix que le
// format d'aperçu ci-dessous), plus un bouton d'envoi de fichier qui
// remplit le champ URL puis déclenche le même enregistrement.
function saveScreenBackgroundMusic(url) {
return fetch("/game/" + GAME_SLUG + "/screens/" + SCREEN_ID + "/background-music", {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url: url }),
});
}
function bindScreenBackgroundMusicField() {
var urlInput = document.getElementById('screen-bg-music-url');
if (urlInput) {
urlInput.addEventListener('change', function () { saveScreenBackgroundMusic(urlInput.value); });
}
document.querySelectorAll('.screenUploadInput').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...';
saveScreenBackgroundMusic(json.url).then(function () { status.textContent = 'Fichier envoyé ✓ Enregistré.'; });
}
else { status.textContent = "Échec de l'envoi."; }
})
.catch(function () { status.textContent = "Échec de l'envoi."; });
});
});
}
function bindAspectButtons() { function bindAspectButtons() {
document.querySelectorAll('.aspectBtn').forEach(function (btn) { document.querySelectorAll('.aspectBtn').forEach(function (btn) {
btn.addEventListener('click', function () { btn.addEventListener('click', function () {
+31
View File
@@ -70,6 +70,22 @@
<button type="button" class="button smallBtn aspectBtn {{ 'active' if current_aspect == '16/9' else '' }}" data-aspect="16/9">🖥️ Paysage</button> <button type="button" class="button smallBtn aspectBtn {{ 'active' if current_aspect == '16/9' else '' }}" data-aspect="16/9">🖥️ Paysage</button>
<button type="button" class="button smallBtn aspectBtn {{ 'active' if current_aspect == '1/1' else '' }}" data-aspect="1/1">◻️ Carré</button> <button type="button" class="button smallBtn aspectBtn {{ 'active' if current_aspect == '1/1' else '' }}" data-aspect="1/1">◻️ Carré</button>
</div> </div>
<!-- Phase 6 — musique de fond de cet écran : démarrée en boucle à
l'affichage, arrêtée au changement d'écran (voir
runScreenBackgroundMusic() dans static/js/play/screens.js).
Enregistrement en AJAX au blur/changement, même patron que le
format d'aperçu ci-dessus. -->
<div class="field" style="margin-bottom:8px;">
<label class="label is-small" for="screen-bg-music-url">🎵 Musique de fond</label>
<div class="control">
<input type="text" class="input is-small" id="screen-bg-music-url" placeholder="URL du fichier audio" value="{{ screen.background_music_url or '' }}">
</div>
<div class="control" style="margin-top:4px;">
<label for="screen-bg-music-upload" style="font-size:12px;">Envoyer un fichier :</label>
<input type="file" class="screenUploadInput" id="screen-bg-music-upload" data-target="screen-bg-music-url" accept="audio/*" style="font-size:12px;">
<span class="uploadStatus" style="font-size:12px; color:var(--muted);"></span>
</div>
</div>
<hr style="border-color:var(--border); margin:10px 0;"> <hr style="border-color:var(--border); margin:10px 0;">
<h2 class="subtitle is-6">Éléments de cet écran</h2> <h2 class="subtitle is-6">Éléments de cet écran</h2>
{% if top_level_elements %} {% if top_level_elements %}
@@ -469,6 +485,21 @@
{% endif %} {% endif %}
</div> </div>
<!-- Phase 6 — effet sonore ponctuel (non bouclé, joué une seule
fois), sur n'importe quel déclencheur. Indépendant de la
musique de fond par écran (voir screen_edit.html, panneau
gauche) qui, elle, boucle et suit le cycle de vie de l'écran. -->
<div id="nf-action-sound-fields" style="display:none;">
<label>URL du son
<input type="text" id="nf-action-sound-url" placeholder="URL du fichier audio">
</label>
<div style="margin-top:6px;">
<label for="nf-action-sound-upload" style="font-size:12px;">Envoyer un fichier :</label>
<input type="file" class="nodeFormUploadInput" id="nf-action-sound-upload" data-target="nf-action-sound-url" accept="audio/*" style="font-size:12px;">
<span class="uploadStatus" style="font-size:12px; color:var(--text-dim);"></span>
</div>
</div>
<div id="nf-action-variable-fields" style="display:none;"> <div id="nf-action-variable-fields" style="display:none;">
{% if not global_variables %} {% if not global_variables %}
<p class="hint">Aucune variable globale pour l'instant — crées-en une depuis "Condition de visibilité" dans les propriétés d'un élément.</p> <p class="hint">Aucune variable globale pour l'instant — crées-en une depuis "Condition de visibilité" dans les propriétés d'un élément.</p>
+96
View File
@@ -0,0 +1,96 @@
"""Son — Phase 6 du plan d'extension du moteur : musique de fond par écran
(en boucle, suit le cycle de vie de l'écran) et action ponctuelle "Jouer un
son" (effet sonore, non bouclé). Comme le reste du graphe de logique côté
client, la lecture audio RÉELLE n'est pas testable sans navigateur (voir
play_js_bundle() — on vérifie que la logique existe dans le JS livré et que
les données persistent, pas qu'elles jouent un son)."""
import json
import re
from conftest import play_js_bundle, screen_edit_js_bundle
import screens
def _create_screen(client, slug, name="Accueil"):
resp = client.post(f"/game/{slug}/screens/new", data={"name": name}, follow_redirects=False)
return int(re.search(r"/screens/(\d+)/edit", resp.headers["Location"]).group(1))
def _add_flow_node(client, slug, screen_id, **fields):
resp = client.post(
f"/game/{slug}/screens/{screen_id}/flow/nodes/add",
data=json.dumps(fields),
content_type="application/json",
)
assert resp.status_code == 200
return resp.get_json()["id"]
def test_screen_background_music_persists(client, game):
screen_id = _create_screen(client, game)
resp = client.post(
f"/game/{game}/screens/{screen_id}/background-music",
data=json.dumps({"url": "https://example.com/song.mp3"}),
content_type="application/json",
)
assert resp.status_code == 200
screen = screens.get_screen(game, screen_id)
assert screen["background_music_url"] == "https://example.com/song.mp3"
def test_screen_background_music_empty_string_clears_it(client, game):
screen_id = _create_screen(client, game)
client.post(
f"/game/{game}/screens/{screen_id}/background-music",
data=json.dumps({"url": "https://example.com/song.mp3"}),
content_type="application/json",
)
client.post(
f"/game/{game}/screens/{screen_id}/background-music",
data=json.dumps({"url": ""}),
content_type="application/json",
)
screen = screens.get_screen(game, screen_id)
assert screen["background_music_url"] is None
def test_play_payload_exposes_background_music_per_screen(client, game):
screen_id = _create_screen(client, game)
client.post(
f"/game/{game}/screens/{screen_id}/background-music",
data=json.dumps({"url": "https://example.com/song.mp3"}),
content_type="application/json",
)
html = client.get(f"/game/{game}/runtime-payload").get_json()
screen_payload = next(s for s in html["screens"] if s["id"] == screen_id)
assert screen_payload["background_music_url"] == "https://example.com/song.mp3"
def test_sound_action_node_persists_url(client, game):
screen_id = _create_screen(client, game)
node_id = _add_flow_node(
client, game, screen_id, node_type="action", action_type="jouer_son",
data_value="https://example.com/effect.wav",
)
node = screens.get_flow_node(game, node_id)
assert node["action_type"] == "jouer_son"
assert node["data_value"] == "https://example.com/effect.wav"
def test_play_page_exposes_sound_runtime(client, game):
js = play_js_bundle()
assert "runScreenBackgroundMusic" in js
assert "'jouer_son'" in js
assert "new Audio" in js
def test_screen_edit_exposes_sound_editors(client, game):
screen_id = _create_screen(client, game)
html = client.get(f"/game/{game}/screens/{screen_id}/edit").get_data(as_text=True)
assert "screen-bg-music-url" in html
assert "nf-action-sound-fields" in html
assert "nf-action-sound-url" in html
js = screen_edit_js_bundle()
assert "jouer_son" in js
assert "bindScreenBackgroundMusicField" in js