Quêtes/collision : personnages nommés, dialogues qui parlent, boîte de dialogue en jeu
Build and deploy / test-python (push) Successful in 11m32s
Build and deploy / test-js (push) Successful in 1m12s
Build and deploy / build-and-push (push) Skipped
Build and deploy / deploy (push) Skipped

- "ℹ️ Informations" (propriétés d'un personnage) : nom éditable, réutilisé
  comme "qui parle" dans l'éditeur de dialogue de quête (menu déroulant,
  plus "Joueur" toujours disponible) — remplace l'ancien choix binaire
  joueur/pnj. db.sanitize_quest_dialogues accepte maintenant un nom libre.

- Nouveau menu "🖥️ Interface" (palette d'objets) avec le premier widget :
  "💬 Boîte de dialogue" (kind="dialogue_box", screens/rendering/
  dialogue_box_style.py) — position/taille comme tout objet de scène,
  panneau "🎨 Style" dédié (police, taille, épaisseur, couleur du texte,
  couleur header/body/footer). Rendu en <div> à 3 zones, jamais soumis à
  la collision ni à l'éditeur de collision (exclu partout : obstacles,
  liste des règles, payload). Rendu côté jeu dans .sceneUI, une couche
  FIXE au viewport (jamais .sceneWorld, qui défile avec la caméra).

- static/js/play/dialogue-box-controller.js : fait le lien moteur entre
  l'action "quete" de l'éditeur de collision et ce widget — affiche la
  réplique en cours du dialogue correspondant au STATUT ACTUEL de la
  quête (gameData.quests, désormais exposé game-wide par
  full_game_payload.py), avance au clic sur "Suivant" (header = qui
  parle, body = texte, footer = bouton), se masque à la dernière réplique.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
william
2026-09-03 11:20:58 +02:00
co-authored by Claude Sonnet 5
parent 390ee831ca
commit 2347a70c45
27 changed files with 774 additions and 41 deletions
@@ -71,10 +71,13 @@ test('forgeRunCollisionRuleAction — "evenement" appelle window.dispatchGameEve
assert.deepEqual(calls, [42]);
});
test('forgeRunCollisionRuleAction — "quete" mémorise l\'id dans window.forgeQuestLog (pas d\'éditeur de quête pour le moment)', () => {
window.forgeQuestLog = [];
forgeRunCollisionRuleAction({ type: 'quete', quete_id: 'q-1' }, fakeEl(), { id: 1 });
assert.deepEqual(window.forgeQuestLog, ['q-1']);
test('forgeRunCollisionRuleAction — "quete" délègue à forgeShowDialogueBox (voir dialogue-box-controller.js)', () => {
const calls = [];
const previousShow = global.forgeShowDialogueBox;
global.forgeShowDialogueBox = id => calls.push(id);
forgeRunCollisionRuleAction({ type: 'quete', quete_id: 42 }, fakeEl(), { id: 1 });
global.forgeShowDialogueBox = previousShow;
assert.deepEqual(calls, [42]);
});
test('tick — la collision entre le joueur et un ennemi déclenche "attaque" une seule fois tant que le contact persiste', () => {
@@ -0,0 +1,98 @@
// Widget "💬 Boîte de dialogue" — moteur d'exécution (voir
// static/js/play/dialogue-box-controller.js) : affiche la réplique en
// cours du dialogue d'une quête (gameData.quests) correspondant à son
// STATUT ACTUEL, avance au clic sur "Suivant", se masque à la fin.
const test = require('node:test');
const assert = require('node:assert/strict');
global.window = global.window || {};
global.gameData = {};
const {
forgeQuestById, forgeShowDialogueBox, forgeDialogueBoxAdvance,
} = require('../dialogue-box-controller.js');
function fakeWidget(objectId) {
const roles = {
header: { textContent: '' },
body: { textContent: '' },
};
const classes = new Set();
return {
getAttribute: attr => (attr === 'data-object-id' ? String(objectId) : null),
classList: { add: c => classes.add(c), remove: c => classes.delete(c), contains: c => classes.has(c) },
querySelector: sel => {
if (sel === '[data-dialogue-role="header"]') return roles.header;
if (sel === '[data-dialogue-role="body"]') return roles.body;
return null;
},
_roles: roles, _classes: classes,
};
}
test.beforeEach(() => {
global.window.currentScreenId = 1;
global.gameData = {
quests: [
{ id: 5, status: 'nouvelle', dialogues: { nouvelle: [{ speaker: 'Le Sorcier', text: 'Bonjour !' }, { speaker: 'Joueur', text: 'Salut.' }], en_cours: [], terminee: [] } },
{ id: 6, status: 'en_cours', dialogues: { nouvelle: [{ speaker: 'PNJ', text: 'x' }], en_cours: [], terminee: [] } },
],
};
});
test('forgeQuestById retrouve une quête par id', () => {
assert.equal(forgeQuestById(5).id, 5);
assert.equal(forgeQuestById(999), undefined);
});
test('forgeShowDialogueBox — quête introuvable : aucun effet (pas de plantage)', () => {
const previousDoc = global.document;
global.document = { querySelector: () => { throw new Error('ne devrait jamais chercher de widget'); } };
try {
assert.doesNotThrow(() => forgeShowDialogueBox(999));
} finally { global.document = previousDoc; }
});
test('forgeShowDialogueBox — quête sans réplique pour son statut actuel : aucun effet', () => {
const previousDoc = global.document;
global.document = { querySelector: () => { throw new Error('ne devrait jamais chercher de widget'); } };
try {
assert.doesNotThrow(() => forgeShowDialogueBox(6)); // "en_cours" -> dialogues.en_cours est vide
} finally { global.document = previousDoc; }
});
test('forgeShowDialogueBox — aucun widget posé sur l\'écran : aucun effet', () => {
const previousDoc = global.document;
global.document = { querySelector: () => null };
try {
assert.doesNotThrow(() => forgeShowDialogueBox(5));
} finally { global.document = previousDoc; }
});
test('forgeShowDialogueBox puis forgeDialogueBoxAdvance — cycle toutes les répliques puis se masque', () => {
const widget = fakeWidget(42);
const previousDoc = global.document;
global.document = { querySelector: sel => (sel.indexOf('data-object-id="42"') !== -1 ? widget : (sel.indexOf('dialogueBoxWidget') !== -1 ? widget : null)) };
try {
forgeShowDialogueBox(5);
assert.equal(widget._roles.header.textContent, 'Le Sorcier');
assert.equal(widget._roles.body.textContent, 'Bonjour !');
assert.equal(widget._classes.has('is-active'), true);
forgeDialogueBoxAdvance(42);
assert.equal(widget._roles.header.textContent, 'Joueur');
assert.equal(widget._roles.body.textContent, 'Salut.');
assert.equal(widget._classes.has('is-active'), true); // encore une réplique visible
forgeDialogueBoxAdvance(42); // dernière réplique consommée -> se masque
assert.equal(widget._classes.has('is-active'), false);
} finally { global.document = previousDoc; }
});
test('forgeDialogueBoxAdvance — objet inconnu (jamais affiché) : aucun effet', () => {
const previousDoc = global.document;
global.document = { querySelector: () => null };
try {
assert.doesNotThrow(() => forgeDialogueBoxAdvance(999));
} finally { global.document = previousDoc; }
});
+8 -4
View File
@@ -80,10 +80,12 @@ function forgeRunCollisionRuleAction(action, targetEl, obj) {
return;
}
if (action.type === 'quete') {
// Éditeur de quête pas encore construit (voir plan) — mémorise
// l'intention pour un futur système de quêtes plutôt que de la perdre.
window.forgeQuestLog = window.forgeQuestLog || [];
window.forgeQuestLog.push(action.quete_id);
// Affiche la réplique en cours du dialogue de cette quête, selon son
// statut actuel, dans le widget "💬 Boîte de dialogue" posé sur
// l'écran (voir static/js/play/dialogue-box-controller.js) — sans
// effet si aucun widget n'est posé ou si la quête n'a aucune
// réplique pour son statut, comme le reste de ce moteur.
forgeShowDialogueBox(action.quete_id);
return;
}
}
@@ -185,4 +187,6 @@ if (typeof module !== 'undefined' && module.exports) {
const personnageMod = require('./personnage-controller.js');
global.forgePersonnageAnimExists = global.forgePersonnageAnimExists || personnageMod.forgePersonnageAnimExists;
global.FORGE_PERSONNAGE_FPS = global.FORGE_PERSONNAGE_FPS || 8;
const dialogueBoxMod = require('./dialogue-box-controller.js');
global.forgeShowDialogueBox = global.forgeShowDialogueBox || dialogueBoxMod.forgeShowDialogueBox;
}
+72
View File
@@ -0,0 +1,72 @@
// ---------- Widget "💬 Boîte de dialogue" — moteur d'exécution ----------
// Fait le lien entre l'éditeur de collision (action "quete", voir
// static/js/play/collision-rules-controller.js) et le widget d'interface
// posé par l'auteur (voir "🖥️ Interface", templates/scene_edit.html,
// screens/rendering/dialogue_box_style.py) : à l'exécution d'une action
// "quete", affiche la réplique en cours du dialogue correspondant au
// STATUT ACTUEL de la quête (gameData.quests, voir full_game_payload.py),
// avance d'une réplique à chaque clic sur "Suivant", se masque à la fin.
// Silencieux (aucun effet) si la quête est introuvable, n'a aucune
// réplique pour son statut, ou si l'auteur n'a posé aucun widget sur
// l'écran — même esprit que le reste du moteur (jamais planter).
var forgeDialogueBoxState = {};
function forgeQuestById(questId) {
return (gameData.quests || []).find(function (q) { return q.id === questId; });
}
function forgeDialogueBoxWidgetOnCurrentScreen() {
return document.querySelector('.playScreen[data-screen-id="' + window.currentScreenId + '"] .dialogueBoxWidget');
}
function forgeDialogueBoxWidgetById(objectId) {
return document.querySelector('.dialogueBoxWidget[data-object-id="' + objectId + '"]');
}
function forgeRenderDialogueBoxLine(widget, line) {
var header = widget.querySelector('[data-dialogue-role="header"]');
var body = widget.querySelector('[data-dialogue-role="body"]');
if (header) header.textContent = line.speaker;
if (body) body.textContent = line.text;
}
// Appelée par l'action "quete" (collision-rules-controller.js) — trouve
// le widget posé sur l'écran ACTUELLEMENT affiché (un seul à la fois,
// comme le reste du moteur 2D).
function forgeShowDialogueBox(questId) {
var quest = forgeQuestById(questId);
if (!quest) return;
var lines = (quest.dialogues && quest.dialogues[quest.status]) || [];
if (!lines.length) return;
var widget = forgeDialogueBoxWidgetOnCurrentScreen();
if (!widget) return;
var objectId = widget.getAttribute('data-object-id');
forgeDialogueBoxState[objectId] = { lines: lines, index: 0 };
forgeRenderDialogueBoxLine(widget, lines[0]);
widget.classList.add('is-active');
}
// Appelée par le bouton "Suivant →" rendu dans le widget lui-même (voir
// render_scene_object.py, onclick="forgeDialogueBoxAdvance(<id>)").
function forgeDialogueBoxAdvance(objectId) {
var state = forgeDialogueBoxState[objectId];
var widget = forgeDialogueBoxWidgetById(objectId);
if (!state || !widget) return;
state.index += 1;
if (state.index >= state.lines.length) {
widget.classList.remove('is-active');
delete forgeDialogueBoxState[objectId];
return;
}
forgeRenderDialogueBoxLine(widget, state.lines[state.index]);
}
// static/js/play/__tests__/ (node:test) — même garde que les autres
// contrôleurs de ce dossier.
if (typeof module !== 'undefined' && module.exports) {
module.exports = {
forgeQuestById, forgeShowDialogueBox, forgeDialogueBoxAdvance,
forgeDialogueBoxWidgetOnCurrentScreen, forgeDialogueBoxWidgetById, forgeRenderDialogueBoxLine,
};
}
+1 -1
View File
@@ -113,7 +113,7 @@ function forgeObjectCollisionRectAt(left, top, width, height, collision) {
// se déplace lui-même.
function forgeSolidObstacles(screenData, movingId) {
return (screenData.elements || []).filter(function (o) {
return o.id !== movingId && o.kind !== 'fond' && !(o.collision && o.collision.enabled === false);
return o.id !== movingId && o.kind !== 'fond' && o.kind !== 'dialogue_box' && !(o.collision && o.collision.enabled === false);
});
}
+45 -10
View File
@@ -27,6 +27,20 @@ var QUEST_STATUS_ORDER = ['nouvelle', 'en_cours', 'terminee'];
// besoin (collisionRuleActionSummary).
var QUESTS = (typeof QUESTS_JSON !== 'undefined') ? QUESTS_JSON : [];
// Noms de personnages nommés (voir "ℹ️ Informations", templates/
// scene_edit.html) — proposés comme "qui parle" pour une réplique, en
// plus de "Joueur" (toujours disponible, jamais un objet de scène).
var PERSONNAGE_NAMES = (typeof PERSONNAGE_NAMES_JSON !== 'undefined') ? PERSONNAGE_NAMES_JSON : [];
// Une réplique n'a que deux COULEURS possibles (voir .questBubble-joueur/
// .questBubble-pnj, static/style.css) même si le nom affiché est
// n'importe quel personnage nommé — "Joueur" à droite en vert, n'importe
// quel autre nom à gauche en bleu (voir demande utilisateur : "les bulles
// du pnj et du joueur sont d'une couleur différente").
function questBubbleKind(speaker) {
return speaker === 'Joueur' ? 'joueur' : 'pnj';
}
function questById(questId) {
return QUESTS.find(function (q) { return q.id === questId; });
}
@@ -178,13 +192,26 @@ function renderQuestFieldsModalBody(questId) {
'</div>';
}
// Options du <select> "qui parle" — "Joueur" toujours en premier, puis
// chaque personnage nommé (voir "ℹ️ Informations"), plus le speaker
// ACTUEL de la réplique s'il n'est plus dans cette liste (personnage
// renommé/supprimé depuis) pour ne jamais perdre silencieusement la
// valeur déjà enregistrée.
function questSpeakerOptions(currentSpeaker) {
var options = ['Joueur'].concat(PERSONNAGE_NAMES);
if (currentSpeaker && options.indexOf(currentSpeaker) === -1) options.push(currentSpeaker);
return options;
}
function questDialogueBubbleHtml(line, status, index) {
var kind = questBubbleKind(line.speaker);
var speakerSelect = questSpeakerOptions(line.speaker).map(function (name) {
return '<option value="' + name.replace(/"/g, '&quot;') + '"' + (name === line.speaker ? ' selected' : '') + '>' + (name === 'Joueur' ? '🧑 Joueur' : '🗨️ ' + name) + '</option>';
}).join('');
return (
'<div class="questBubbleRow questBubbleRow-' + line.speaker + '">' +
'<div class="questBubble questBubble-' + line.speaker + '">' +
'<div class="questBubbleSpeaker" onclick="questToggleSpeaker(\'' + status + '\', ' + index + ')" title="Changer qui parle">' +
(line.speaker === 'joueur' ? '🧑 Joueur' : '🗨️ PNJ') +
'</div>' +
'<div class="questBubbleRow questBubbleRow-' + kind + '">' +
'<div class="questBubble questBubble-' + kind + '">' +
'<select class="questBubbleSpeaker" title="Qui parle" onchange="questSetBubbleSpeaker(\'' + status + '\', ' + index + ', this.value)">' + speakerSelect + '</select>' +
'<textarea class="questBubbleText" rows="1" placeholder="Réplique..." ' +
'oninput="this.style.height=\'auto\'; this.style.height=this.scrollHeight+\'px\';" ' +
'onchange="questUpdateBubbleText(\'' + status + '\', ' + index + ', this.value)">' + line.text.replace(/</g, '&lt;') + '</textarea>' +
@@ -292,22 +319,30 @@ function questSaveDialogues(quest) {
.then(function (json) { if (json.dialogues) quest.dialogues = json.dialogues; });
}
// Alterne "Joueur" <-> le premier personnage nommé (ou "PNJ" faute de
// mieux, si aucun personnage n'a encore de nom — voir "ℹ️ Informations")
// — juste un défaut de départ, l'auteur choisit ensuite librement dans
// le menu déroulant de la réplique.
function questNextDefaultSpeaker(lastSpeaker) {
if (lastSpeaker === 'Joueur') return PERSONNAGE_NAMES[0] || 'PNJ';
return 'Joueur';
}
function questAddBubble(status) {
var quest = questById(_currentQuestModalId());
if (!quest) return;
quest.dialogues = quest.dialogues || {};
var lines = quest.dialogues[status] || (quest.dialogues[status] = []);
var lastSpeaker = lines.length ? lines[lines.length - 1].speaker : 'pnj';
lines.push({ speaker: lastSpeaker === 'pnj' ? 'joueur' : 'pnj', text: '' });
var lastSpeaker = lines.length ? lines[lines.length - 1].speaker : 'PNJ';
lines.push({ speaker: questNextDefaultSpeaker(lastSpeaker), text: '' });
renderQuestDialogueModalBody(quest.id);
questSaveDialogues(quest);
}
function questToggleSpeaker(status, index) {
function questSetBubbleSpeaker(status, index, speaker) {
var quest = questById(_currentQuestModalId());
if (!quest) return;
var line = quest.dialogues[status][index];
line.speaker = line.speaker === 'joueur' ? 'pnj' : 'joueur';
quest.dialogues[status][index].speaker = speaker;
renderQuestDialogueModalBody(quest.id);
questSaveDialogues(quest);
}
+41
View File
@@ -361,6 +361,47 @@ function addPersonnageCommandExtraRow() {
container.appendChild(personnageCommandExtraRowEl(objectId, '', ''));
}
// "ℹ️ Informations" (voir templates/scene_edit.html) : le nom d'un
// personnage, réutilisé comme "qui parle" dans l'éditeur de dialogue de
// quête (voir static/js/quests/quest-editor.js).
function saveSceneObjectName(objectId, name) {
fetch('/game/' + GAME_SLUG + '/scene-objects/' + objectId + '/name', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: name }),
});
}
// "🎨 Style" du widget "💬 Boîte de dialogue" (voir screens/rendering/
// dialogue_box_style.py) — envoie TOUJOURS les 7 champs ensemble (même
// convention "replace-all" que saveSceneObjectCollision), jamais un seul
// champ isolé.
function saveDialogueBoxStyle(objectId) {
var style = {
font_family: document.getElementById('dbox-font-family').value,
font_size: Number(document.getElementById('dbox-font-size').value),
font_weight: document.getElementById('dbox-font-weight').value,
text_color: document.getElementById('dbox-text-color').value,
header_bg: document.getElementById('dbox-header-bg').value,
body_bg: document.getElementById('dbox-body-bg').value,
footer_bg: document.getElementById('dbox-footer-bg').value,
};
var el = document.getElementById('el-' + objectId);
var widget = el && el.querySelector('.dialogueBoxWidget');
if (widget) {
widget.style.fontFamily = style.font_family;
widget.style.fontSize = style.font_size + 'px';
widget.style.fontWeight = style.font_weight;
widget.style.color = style.text_color;
var header = widget.querySelector('.dialogueBoxHeader'); if (header) header.style.background = style.header_bg;
var b = widget.querySelector('.dialogueBoxBody'); if (b) b.style.background = style.body_bg;
var footer = widget.querySelector('.dialogueBoxFooter'); if (footer) footer.style.background = style.footer_bg;
}
fetch('/game/' + GAME_SLUG + '/scene-objects/' + objectId + '/dialogue-box-style', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ style: style }),
});
}
(function () {
var body = document.getElementById('personnageCommandsBody');
if (!body) return;