Remplace le système de quêtes par des déclencheurs, ajoute l'action variable et le chaînage
Supprime le concept de "quête" au profit d'un onglet unique "Déclencheurs"
portant toute la logique (dialogue, condition, marquage terminé) directement
sur l'objet de scène. Ajoute une nouvelle action "Modifier une variable"
(réutilisant le vocabulaire du graphe de flow) utilisable après une
collision, une interaction ou une branche de condition, ainsi qu'un
chaînage d'actions ("then") permettant d'enchaîner plusieurs actions à la
suite et d'étendre un déclencheur déjà posé sans le recréer.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
c504ace167
commit
d664ed5637
+21
-29
@@ -1,11 +1,12 @@
|
||||
// Bout en bout : une règle de collision "quete" doit réellement faire
|
||||
// Bout en bout : une règle de collision "dialogue" doit réellement faire
|
||||
// apparaître/avancer/disparaître la boîte de dialogue posée sur la scène
|
||||
// — pas seulement chaque brique testée isolément (voir
|
||||
// collision-rules-controller.test.js et dialogue-box-controller.test.js).
|
||||
// Reproduit le scénario signalé : un objet "Mr. Pinguin" avec une règle
|
||||
// "à la collision -> déclencher la quête « test »", une quête au statut
|
||||
// "nouvelle" avec 2 répliques, un widget "💬 Boîte de dialogue" posé sur
|
||||
// la même scène.
|
||||
// "à la collision -> déclencher ce dialogue (avec mark_completed)", un
|
||||
// widget "💬 Boîte de dialogue" posé sur la même scène — plus aucun écran
|
||||
// d'offre automatique, le dialogue s'affiche directement et se marque
|
||||
// terminé à la fin.
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
@@ -22,7 +23,6 @@ const { forgeDialogueBoxAdvance } = require('../dialogue-box-controller.js'); //
|
||||
const {
|
||||
forgeCollisionRuleTick,
|
||||
} = require('../collision-rules-controller.js');
|
||||
const { forgeDialogueBoxAcceptQuest, forgeQuestById } = require('../dialogue-box-controller.js');
|
||||
|
||||
function fakeEl(style) {
|
||||
const classes = new Set();
|
||||
@@ -43,11 +43,17 @@ function fakeEl(style) {
|
||||
};
|
||||
}
|
||||
|
||||
test('collision "quete" -> dialogue puis offre de quête -> Accepter fait passer la quête en_cours', () => {
|
||||
test('collision "dialogue" -> boîte de dialogue -> fin de dialogue (mark_completed) marque le dialogue terminé', () => {
|
||||
const player = { id: 1, kind: 'personnage', personnage_role: 'joueur' };
|
||||
const mrPinguin = {
|
||||
id: 2, kind: 'personnage',
|
||||
collision_rules: [{ trigger: 'collision', action: { type: 'quete', quete_id: 99 } }],
|
||||
id: 2, kind: 'personnage', name: 'Mr. Pinguin',
|
||||
collision_rules: [{
|
||||
trigger: 'collision',
|
||||
action: {
|
||||
type: 'dialogue', mark_completed: true,
|
||||
dialogue: { id: 'd_intro', lines: [{ speaker: 'Mr. Pinguin', text: 'Bonjour !' }, { speaker: 'Charlie', text: 'Salut !' }] },
|
||||
},
|
||||
}],
|
||||
};
|
||||
const box = { id: 3, kind: 'dialogue_box' };
|
||||
|
||||
@@ -61,18 +67,10 @@ test('collision "quete" -> dialogue puis offre de quête -> Accepter fait passer
|
||||
boxWidget._footer = { innerHTML: '' };
|
||||
|
||||
global.window.currentScreenId = 1;
|
||||
global.window.forgeCompletedDialogueIds = {};
|
||||
global.gameData = {
|
||||
personnage_animations: {},
|
||||
quests: [{
|
||||
id: 99, title: 'Retrouver Charlie', status: 'nouvelle', objectif: 'Parler à Mr. Pinguin',
|
||||
dialogues: {
|
||||
nouvelle: [
|
||||
{ speaker: 'Mr. Pinguin', text: 'Bonjour !' },
|
||||
{ speaker: 'Charlie', text: 'Salut !' },
|
||||
],
|
||||
en_cours: [], terminee: [],
|
||||
},
|
||||
}],
|
||||
completable_dialogue_ids: ['d_intro'],
|
||||
};
|
||||
|
||||
const previousDoc = global.document;
|
||||
@@ -94,12 +92,12 @@ test('collision "quete" -> dialogue puis offre de quête -> Accepter fait passer
|
||||
try {
|
||||
const screenData = { elements: [player, mrPinguin, box] };
|
||||
|
||||
forgeCollisionRuleTick(screenData); // entrée en collision -> déclenche la quête
|
||||
forgeCollisionRuleTick(screenData); // entrée en collision -> déclenche le dialogue
|
||||
assert.equal(boxWidget._header.textContent, 'Mr. Pinguin');
|
||||
assert.equal(boxWidget._body.textContent, 'Bonjour !');
|
||||
assert.equal(boxWidget.classList.contains('is-active'), true);
|
||||
|
||||
forgeCollisionRuleTick(screenData); // toujours en collision -> ne redéclenche pas la quête
|
||||
forgeCollisionRuleTick(screenData); // toujours en collision -> ne redéclenche pas
|
||||
assert.equal(boxWidget._header.textContent, 'Mr. Pinguin'); // pas repartie de la 1ère réplique
|
||||
|
||||
forgeDialogueBoxAdvance('3'); // clic "Suivant" (bouton du widget)
|
||||
@@ -107,14 +105,8 @@ test('collision "quete" -> dialogue puis offre de quête -> Accepter fait passer
|
||||
assert.equal(boxWidget._body.textContent, 'Salut !');
|
||||
assert.equal(boxWidget.classList.contains('is-active'), true);
|
||||
|
||||
forgeDialogueBoxAdvance('3'); // dernière réplique consommée -> quête "nouvelle" -> écran d'offre, PAS masqué
|
||||
assert.equal(boxWidget.classList.contains('is-active'), true);
|
||||
assert.equal(boxWidget._header.textContent, 'Quête : Retrouver Charlie');
|
||||
assert.equal(boxWidget._body.textContent, 'Parler à Mr. Pinguin');
|
||||
assert.match(boxWidget._footer.innerHTML, /Accepter/);
|
||||
|
||||
forgeDialogueBoxAcceptQuest(99, '3'); // clic "Accepter"
|
||||
assert.equal(forgeQuestById(99).status, 'en_cours');
|
||||
assert.equal(boxWidget.classList.contains('is-active'), false); // la modale disparaît
|
||||
forgeDialogueBoxAdvance('3'); // dernière réplique consommée -> mark_completed -> dialogue terminé, boîte masquée
|
||||
assert.equal(boxWidget.classList.contains('is-active'), false);
|
||||
assert.equal(window.forgeCompletedDialogueIds['d_intro'], true);
|
||||
} finally { global.document = previousDoc; }
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
// Moteur d'exécution des règles "🧩 Collision" (voir
|
||||
// Moteur d'exécution des déclencheurs (voir
|
||||
// static/js/play/collision-rules-controller.js) : déclenche une action une
|
||||
// seule fois par ENTRÉE en collision/périmètre, jamais en continu tant que
|
||||
// la condition reste vraie — et gère "interagir" à part (bulle + attente
|
||||
@@ -14,6 +14,7 @@ global.heldKeys = new Set();
|
||||
const actionsMod = require('../actions.js');
|
||||
Object.assign(global, actionsMod);
|
||||
require('../personnage-controller.js');
|
||||
global.forgeApplyVariableActionOffline = global.forgeApplyVariableActionOffline || require('../offline/apply-actions.js').forgeApplyVariableActionOffline;
|
||||
|
||||
const {
|
||||
forgeCollisionRuleTriggerActive, forgeRunCollisionRuleAction, forgeCollisionRuleTick,
|
||||
@@ -86,54 +87,117 @@ test('forgeCollisionRuleTriggerActive — trigger "collision" : vrai aussi pour
|
||||
assert.equal(forgeCollisionRuleTriggerActive({ trigger: 'collision' }, objRect, touchingPlayer), true);
|
||||
});
|
||||
|
||||
test('forgeCollisionRuleTriggerActive — trigger "perimetre" : distance entre centres <= perimetre_px', () => {
|
||||
const objRect = rect(0, 0, 10, 10); // centre (5,5)
|
||||
const near = rect(10, 0, 10, 10); // centre (15,5) -> distance 10
|
||||
assert.equal(forgeCollisionRuleTriggerActive({ trigger: 'perimetre', perimetre_px: 12 }, objRect, near), true);
|
||||
assert.equal(forgeCollisionRuleTriggerActive({ trigger: 'perimetre', perimetre_px: 5 }, objRect, near), false);
|
||||
});
|
||||
|
||||
test('forgeRunCollisionRuleAction — "evenement" appelle window.dispatchGameEvent avec evenement_id', () => {
|
||||
const calls = [];
|
||||
global.window.dispatchGameEvent = id => calls.push(id);
|
||||
forgeRunCollisionRuleAction({ type: 'evenement', evenement_id: 42 }, fakeEl(), { id: 1 });
|
||||
assert.deepEqual(calls, [42]);
|
||||
});
|
||||
|
||||
test('forgeRunCollisionRuleAction — "quete" délègue à forgeShowDialogueBox (voir dialogue-box-controller.js)', () => {
|
||||
test('forgeRunCollisionRuleAction — "dialogue" délègue à forgeShowDialogueBox (voir dialogue-box-controller.js) avec le nom de l\'objet, les répliques DE CETTE ACTION et mark_completed', () => {
|
||||
const calls = [];
|
||||
const previousShow = global.forgeShowDialogueBox;
|
||||
global.forgeShowDialogueBox = id => calls.push(id);
|
||||
forgeRunCollisionRuleAction({ type: 'quete', quete_id: 42 }, fakeEl(), { id: 1 });
|
||||
global.forgeShowDialogueBox = (...args) => calls.push(args);
|
||||
const lines = [{ speaker: 'PNJ', text: 'Bonjour !' }];
|
||||
forgeRunCollisionRuleAction({ type: 'dialogue', dialogue: { id: 'd_1', lines: lines }, mark_completed: true }, fakeEl(), { id: 1, name: 'Crokine' });
|
||||
global.forgeShowDialogueBox = previousShow;
|
||||
assert.deepEqual(calls, [42]);
|
||||
assert.deepEqual(calls, [['d_1', 'Crokine', lines, true, undefined]]);
|
||||
});
|
||||
|
||||
test('tick — la collision entre le joueur et un ennemi déclenche "attaque" une seule fois tant que le contact persiste', () => {
|
||||
global.window.dispatchGameEvent = () => {};
|
||||
test('forgeRunCollisionRuleAction — "dialogue" sans dialogue (référence orpheline) : transmet une liste vide plutôt que de planter', () => {
|
||||
const calls = [];
|
||||
const previousShow = global.forgeShowDialogueBox;
|
||||
global.forgeShowDialogueBox = (...args) => calls.push(args);
|
||||
forgeRunCollisionRuleAction({ type: 'dialogue' }, fakeEl(), { id: 1, name: 'Crokine' });
|
||||
global.forgeShowDialogueBox = previousShow;
|
||||
assert.deepEqual(calls, [[undefined, 'Crokine', [], undefined, undefined]]);
|
||||
});
|
||||
|
||||
test('forgeRunCollisionRuleAction — "condition" vraie exécute la branche si_vrai', () => {
|
||||
global.gameData = { variables: { inventaire_cle: { value: '1' } } };
|
||||
const calls = [];
|
||||
const previousShow = global.forgeShowDialogueBox;
|
||||
global.forgeShowDialogueBox = (...args) => calls.push(args);
|
||||
const vraiLines = [{ speaker: 'PNJ', text: 'Vrai !' }];
|
||||
const action = {
|
||||
type: 'condition', variable: 'inventaire_cle', operateur: 'superieur', valeur: '0',
|
||||
si_vrai: { type: 'dialogue', dialogue: { id: 'd_vrai', lines: vraiLines } }, si_faux: null,
|
||||
};
|
||||
forgeRunCollisionRuleAction(action, fakeEl(), { id: 1, name: 'Crokine' });
|
||||
global.forgeShowDialogueBox = previousShow;
|
||||
assert.deepEqual(calls, [['d_vrai', 'Crokine', vraiLines, undefined, undefined]]);
|
||||
});
|
||||
|
||||
test('forgeRunCollisionRuleAction — "condition" fausse exécute la branche si_faux, jamais si_vrai', () => {
|
||||
global.gameData = { variables: { inventaire_cle: { value: '0' } } };
|
||||
const calls = [];
|
||||
const previousShow = global.forgeShowDialogueBox;
|
||||
global.forgeShowDialogueBox = (...args) => calls.push(args);
|
||||
const fauxLines = [{ speaker: 'PNJ', text: 'Faux !' }];
|
||||
const action = {
|
||||
type: 'condition', variable: 'inventaire_cle', operateur: 'superieur', valeur: '0',
|
||||
si_vrai: { type: 'dialogue', dialogue: { id: 'd_vrai', lines: [] } },
|
||||
si_faux: { type: 'dialogue', dialogue: { id: 'd_faux', lines: fauxLines } },
|
||||
};
|
||||
forgeRunCollisionRuleAction(action, fakeEl(), { id: 1, name: 'Crokine' });
|
||||
global.forgeShowDialogueBox = previousShow;
|
||||
assert.deepEqual(calls, [['d_faux', 'Crokine', fauxLines, undefined, undefined]]);
|
||||
});
|
||||
|
||||
test('forgeRunCollisionRuleAction — "condition" avec une branche "Ne rien faire" (null) ne fait rien', () => {
|
||||
global.gameData = { variables: { inventaire_cle: { value: '0' } } };
|
||||
const calls = [];
|
||||
const previousShow = global.forgeShowDialogueBox;
|
||||
global.forgeShowDialogueBox = (...args) => calls.push(args);
|
||||
const action = {
|
||||
type: 'condition', variable: 'inventaire_cle', operateur: 'superieur', valeur: '0',
|
||||
si_vrai: { type: 'dialogue', dialogue: { id: 'd_vrai', lines: [] } }, si_faux: null,
|
||||
};
|
||||
forgeRunCollisionRuleAction(action, fakeEl(), { id: 1, name: 'Crokine' });
|
||||
global.forgeShowDialogueBox = previousShow;
|
||||
assert.deepEqual(calls, []);
|
||||
});
|
||||
|
||||
test('forgeRunCollisionRuleAction — "variable" modifie gameData.variables via forgeApplyVariableActionOffline', () => {
|
||||
global.gameData = { variables: { score: { type: 'nombre_entier', value: '2' } } };
|
||||
forgeRunCollisionRuleAction({ type: 'variable', target_variable: 'score', data_operation: 'incrementer', data_value: '3' }, fakeEl(), { id: 1, name: 'Crokine' });
|
||||
assert.equal(gameData.variables.score.value, '5');
|
||||
});
|
||||
|
||||
test('forgeRunCollisionRuleAction — "variable" chaînée (then) exécute IMMÉDIATEMENT la suivante (instantané, pas besoin d\'attendre)', () => {
|
||||
global.gameData = { variables: { score: { type: 'nombre_entier', value: '0' }, vies: { type: 'nombre_entier', value: '3' } } };
|
||||
const action = {
|
||||
type: 'variable', target_variable: 'score', data_operation: 'incrementer', data_value: '1',
|
||||
then: { type: 'variable', target_variable: 'vies', data_operation: 'decrementer', data_value: '1' },
|
||||
};
|
||||
forgeRunCollisionRuleAction(action, fakeEl(), { id: 1, name: 'Crokine' });
|
||||
assert.equal(gameData.variables.score.value, '1');
|
||||
assert.equal(gameData.variables.vies.value, '2');
|
||||
});
|
||||
|
||||
test('forgeRunCollisionRuleAction — "dialogue" transmet son "then" à forgeShowDialogueBox (exécuté APRÈS le dialogue, pas ici)', () => {
|
||||
global.gameData = { variables: {} };
|
||||
const calls = [];
|
||||
const previousShow = global.forgeShowDialogueBox;
|
||||
global.forgeShowDialogueBox = (...args) => calls.push(args);
|
||||
const thenAction = { type: 'variable', target_variable: 'score', data_operation: 'incrementer', data_value: '1' };
|
||||
forgeRunCollisionRuleAction({ type: 'dialogue', dialogue: { id: 'd_1', lines: [] }, then: thenAction }, fakeEl(), { id: 1, name: 'Crokine' });
|
||||
global.forgeShowDialogueBox = previousShow;
|
||||
assert.deepEqual(calls, [['d_1', 'Crokine', [], undefined, thenAction]]);
|
||||
});
|
||||
|
||||
test('tick — la collision entre le joueur et un objet ne redéclenche pas "dialogue" tant que le contact persiste', () => {
|
||||
const player = { id: 1, kind: 'personnage', personnage_role: 'joueur', personnage_commandes: { interagir: 'e' } };
|
||||
const enemy = { id: 2, kind: 'personnage', collision_rules: [{ trigger: 'collision', action: { type: 'attaque' } }] };
|
||||
const npc = { id: 2, kind: 'personnage', name: 'Crokine', collision_rules: [{ trigger: 'collision', action: { type: 'dialogue', dialogue: { id: 'd_7', lines: [] } } }] };
|
||||
const playerEl = fakeEl();
|
||||
const enemyEl = fakeEl();
|
||||
enemyEl.style = { left: '5px', top: '5px', width: '20px', height: '20px' }; // chevauche playerEl (0,0,20,20)
|
||||
const npcEl = fakeEl();
|
||||
npcEl.style = { left: '5px', top: '5px', width: '20px', height: '20px' }; // chevauche playerEl (0,0,20,20)
|
||||
|
||||
const previousDoc = global.document;
|
||||
global.document = fakeDocument({ 1: playerEl, 2: enemyEl });
|
||||
global.document = fakeDocument({ 1: playerEl, 2: npcEl });
|
||||
global.gameData = { personnage_animations: {} };
|
||||
const previousShow = global.forgeShowDialogueBox;
|
||||
const calls = [];
|
||||
global.forgeShowDialogueBox = id => calls.push(id);
|
||||
try {
|
||||
const screenData = { elements: [player, enemy] };
|
||||
let attackCalls = 0;
|
||||
const originalRun = global.runSpriteAnimation;
|
||||
global.runSpriteAnimation = () => { attackCalls++; };
|
||||
const screenData = { elements: [player, npc] };
|
||||
forgeCollisionRuleTick(screenData); // entrée en collision -> déclenche
|
||||
forgeCollisionRuleTick(screenData); // toujours en collision -> ne redéclenche pas
|
||||
global.runSpriteAnimation = originalRun;
|
||||
// "attaque" ne joue une animation que si le personnage a une pose
|
||||
// "attaque" (voir forgePersonnageAnimExists) — absente ici, donc 0
|
||||
// appel réel à runSpriteAnimation, mais le test vérifie surtout
|
||||
// l'absence de double déclenchement plutôt que l'appel lui-même.
|
||||
assert.equal(attackCalls, 0);
|
||||
} finally { global.document = previousDoc; }
|
||||
assert.deepEqual(calls, ['d_7']);
|
||||
} finally { global.document = previousDoc; global.forgeShowDialogueBox = previousShow; }
|
||||
});
|
||||
|
||||
test('tick — la bulle "Appuie sur X" s\'ancre sur la BOÎTE DE COLLISION, pas le cadre brut du sprite', () => {
|
||||
@@ -148,7 +212,7 @@ test('tick — la bulle "Appuie sur X" s\'ancre sur la BOÎTE DE COLLISION, pas
|
||||
// Sprite de 100×200 px, mais silhouette réelle 20×20 centrée en bas
|
||||
// (offset_y positif = décalée vers le bas, voir collision_settings.py).
|
||||
collision: { enabled: true, shape: 'rectangle', width: 20, height: 20, offset_x: 0, offset_y: 80 },
|
||||
collision_rules: [{ trigger: 'collision', action: { type: 'interagir', sub_action: { type: 'quete', quete_id: 1 } } }],
|
||||
collision_rules: [{ trigger: 'collision', action: { type: 'interagir', sub_action: { type: 'dialogue' } } }],
|
||||
};
|
||||
const parent = fakeParentEl();
|
||||
const playerEl = fakeEl(parent);
|
||||
@@ -170,7 +234,7 @@ test('tick — la bulle "Appuie sur X" s\'ancre sur la BOÎTE DE COLLISION, pas
|
||||
|
||||
test('tick — "interagir" affiche la bulle "Appuie sur [touche]" pendant la collision, puis la cache en sortant', () => {
|
||||
const player = { id: 1, kind: 'personnage', personnage_role: 'joueur', personnage_commandes: { interagir: 'e' } };
|
||||
const pnj = { id: 3, kind: 'personnage', collision_rules: [{ trigger: 'collision', action: { type: 'interagir', sub_action: { type: 'quete', quete_id: 'q-2' } } }] };
|
||||
const pnj = { id: 3, kind: 'personnage', collision_rules: [{ trigger: 'collision', action: { type: 'interagir', sub_action: { type: 'dialogue' } } }] };
|
||||
const parentNear = fakeParentEl();
|
||||
const parentFar = fakeParentEl();
|
||||
const playerElNear = fakeEl(parentNear);
|
||||
@@ -198,5 +262,5 @@ test('tick — "interagir" affiche la bulle "Appuie sur [touche]" pendant la col
|
||||
|
||||
test('forgeStartCollisionRuleControllers — inactif sans joueur ou sans règle (aucun intervalle démarré)', () => {
|
||||
assert.doesNotThrow(() => forgeStartCollisionRuleControllers({ kind: 'jeu_2d', elements: [{ kind: 'personnage', personnage_role: 'joueur' }] }));
|
||||
assert.doesNotThrow(() => forgeStartCollisionRuleControllers({ kind: 'jeu_2d', elements: [{ kind: 'personnage', collision_rules: [{ trigger: 'collision', action: { type: 'attaque' } }] }] }));
|
||||
assert.doesNotThrow(() => forgeStartCollisionRuleControllers({ kind: 'jeu_2d', elements: [{ kind: 'personnage', collision_rules: [{ trigger: 'collision', action: { type: 'dialogue' } }] }] }));
|
||||
});
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
// 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.
|
||||
// static/js/play/dialogue-box-controller.js) : affiche les répliques
|
||||
// D'UNE ACTION PRÉCISE (transmises en paramètre par collision-rules-
|
||||
// controller.js, chaque occurrence de "Déclencher ce dialogue" porte les
|
||||
// siennes, voir screens/rendering/collision_rules.py), avance au clic sur
|
||||
// "Suivant", se masque à la fin — sans écran d'offre automatique ni
|
||||
// notion de quête : le nom affiché (en-tête du quiz, xAPI) est celui de
|
||||
// l'objet déclencheur.
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
@@ -9,10 +13,9 @@ global.window = global.window || {};
|
||||
global.gameData = {};
|
||||
|
||||
const {
|
||||
forgeQuestById, forgeShowDialogueBox, forgeDialogueBoxAdvance,
|
||||
forgeDialogueBoxAcceptQuest, forgeDialogueBoxDeclineQuest,
|
||||
forgeShowDialogueBox, forgeDialogueBoxAdvance, forgeCompleteDialogue,
|
||||
forgeQuizBoxAnswer, forgeQuizBoxContinue, forgeUpdateAllScoreWidgets,
|
||||
forgeAddQuizScore, forgeSyncAllQuestsCompletionToScorm,
|
||||
forgeAddQuizScore, forgeSyncAllDialoguesCompletionToScorm,
|
||||
forgeComputeFinalScormStatus,
|
||||
} = require('../dialogue-box-controller.js');
|
||||
|
||||
@@ -43,50 +46,37 @@ test.beforeEach(() => {
|
||||
// fuiraient sur le calcul de statut final du test suivant.
|
||||
global.window.forgeQuizTotalAnswered = 0;
|
||||
global.window.forgeQuizCorrectAnswered = 0;
|
||||
global.gameData = {
|
||||
quests: [
|
||||
{ id: 5, title: 'La quête du sorcier', status: 'nouvelle', objectif: 'Parler au sorcier', 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: [] } },
|
||||
{ id: 8, status: 'terminee', dialogues: { nouvelle: [], en_cours: [], terminee: [{ speaker: 'PNJ', text: 'Merci !' }] } },
|
||||
],
|
||||
};
|
||||
global.window.forgeCompletedDialogueIds = {};
|
||||
global.gameData = { completable_dialogue_ids: [] };
|
||||
});
|
||||
|
||||
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)', () => {
|
||||
test('forgeShowDialogueBox — sans dialogueId : 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));
|
||||
assert.doesNotThrow(() => forgeShowDialogueBox(null, 'PNJ', []));
|
||||
} finally { global.document = previousDoc; }
|
||||
});
|
||||
|
||||
test('forgeShowDialogueBox — quête sans réplique pour son statut "en_cours" (pas "nouvelle") : aucun effet', () => {
|
||||
test('forgeShowDialogueBox — dialogue vide, sans mark_completed : aucun effet', () => {
|
||||
const widget = fakeWidget(42);
|
||||
const previousDoc = global.document;
|
||||
global.document = { querySelector: () => widget };
|
||||
try {
|
||||
forgeShowDialogueBox(6); // "en_cours" -> dialogues.en_cours est vide, et statut != "nouvelle"
|
||||
forgeShowDialogueBox('d_6', 'PNJ', []);
|
||||
assert.equal(widget._classes.has('is-active'), false);
|
||||
assert.equal(window.forgeCompletedDialogueIds['d_6'], undefined);
|
||||
} finally { global.document = previousDoc; }
|
||||
});
|
||||
|
||||
test('forgeShowDialogueBox — quête "nouvelle" SANS réplique : propose directement l\'offre (accepter/refuser)', () => {
|
||||
global.gameData.quests.push({ id: 7, title: 'Le trésor perdu', status: 'nouvelle', objectif: 'Trouver le trésor', dialogues: {} });
|
||||
test('forgeShowDialogueBox — dialogue vide AVEC mark_completed : marque le dialogue terminé immédiatement, sans rien afficher', () => {
|
||||
const widget = fakeWidget(42);
|
||||
const previousDoc = global.document;
|
||||
global.document = { querySelector: () => widget };
|
||||
try {
|
||||
forgeShowDialogueBox(7);
|
||||
assert.equal(widget._roles.header.textContent, 'Quête : Le trésor perdu');
|
||||
assert.equal(widget._roles.body.textContent, 'Trouver le trésor');
|
||||
assert.match(widget._roles.footer.innerHTML, /Accepter/);
|
||||
assert.match(widget._roles.footer.innerHTML, /Refuser/);
|
||||
assert.equal(widget._classes.has('is-active'), true);
|
||||
forgeShowDialogueBox('d_6', 'PNJ', [], true);
|
||||
assert.equal(widget._classes.has('is-active'), false);
|
||||
assert.equal(window.forgeCompletedDialogueIds['d_6'], true);
|
||||
} finally { global.document = previousDoc; }
|
||||
});
|
||||
|
||||
@@ -94,16 +84,16 @@ test('forgeShowDialogueBox — aucun widget posé sur l\'écran : aucun effet',
|
||||
const previousDoc = global.document;
|
||||
global.document = { querySelector: () => null };
|
||||
try {
|
||||
assert.doesNotThrow(() => forgeShowDialogueBox(5));
|
||||
assert.doesNotThrow(() => forgeShowDialogueBox('d_5', 'Le Sorcier', [{ speaker: 'Le Sorcier', text: 'Bonjour !' }]));
|
||||
} finally { global.document = previousDoc; }
|
||||
});
|
||||
|
||||
test('forgeShowDialogueBox puis forgeDialogueBoxAdvance — cycle toutes les répliques puis se masque (quête pas "nouvelle")', () => {
|
||||
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(8); // statut "terminee" -> pas d'offre à la fin, se masque normalement
|
||||
forgeShowDialogueBox('d_8', 'PNJ', [{ speaker: 'PNJ', text: 'Merci !' }]);
|
||||
assert.equal(widget._roles.header.textContent, 'PNJ');
|
||||
assert.equal(widget._roles.body.textContent, 'Merci !');
|
||||
assert.equal(widget._classes.has('is-active'), true);
|
||||
@@ -113,48 +103,68 @@ test('forgeShowDialogueBox puis forgeDialogueBoxAdvance — cycle toutes les ré
|
||||
} finally { global.document = previousDoc; }
|
||||
});
|
||||
|
||||
test('quête "nouvelle" : le dialogue épuisé bascule sur l\'offre (Accepter/Refuser) au lieu de se masquer', () => {
|
||||
test('fin de dialogue SANS mark_completed : le dialogue n\'est jamais marqué terminé', () => {
|
||||
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)) };
|
||||
global.document = { querySelector: () => widget };
|
||||
try {
|
||||
forgeShowDialogueBox(5);
|
||||
forgeShowDialogueBox('d_5', 'Le Sorcier', [{ speaker: 'Le Sorcier', text: 'Bonjour !' }, { speaker: 'Joueur', text: 'Salut.' }]);
|
||||
forgeDialogueBoxAdvance(42);
|
||||
forgeDialogueBoxAdvance(42); // dernière réplique consommée -> écran d'offre, PAS masqué
|
||||
assert.equal(widget._classes.has('is-active'), true);
|
||||
assert.equal(widget._roles.header.textContent, 'Quête : La quête du sorcier');
|
||||
assert.equal(widget._roles.body.textContent, 'Parler au sorcier');
|
||||
assert.match(widget._roles.footer.innerHTML, /Accepter/);
|
||||
assert.match(widget._roles.footer.innerHTML, /Refuser/);
|
||||
} finally { global.document = previousDoc; }
|
||||
});
|
||||
|
||||
test('forgeDialogueBoxAcceptQuest — fait passer la quête "en_cours" (en mémoire seulement) et masque la boîte', () => {
|
||||
const widget = fakeWidget(42);
|
||||
const previousDoc = global.document;
|
||||
global.document = { querySelector: () => widget };
|
||||
try {
|
||||
forgeShowDialogueBox(5);
|
||||
widget._classes.add('is-active');
|
||||
forgeDialogueBoxAcceptQuest(5, 42);
|
||||
assert.equal(forgeQuestById(5).status, 'en_cours');
|
||||
forgeDialogueBoxAdvance(42); // dernière réplique consommée
|
||||
assert.equal(window.forgeCompletedDialogueIds['d_5'], undefined);
|
||||
assert.equal(widget._classes.has('is-active'), false);
|
||||
} finally { global.document = previousDoc; }
|
||||
});
|
||||
|
||||
test('forgeDialogueBoxDeclineQuest — masque la boîte SANS changer le statut (reste "nouvelle")', () => {
|
||||
test('fin de dialogue AVEC mark_completed : marque le dialogue terminé et propage vers le SCORM', () => {
|
||||
const widget = fakeWidget(42);
|
||||
global.gameData.completable_dialogue_ids = ['d_5']; // seul dialogue connu
|
||||
global.gameData.scoring = { score: 0, status: 'en_cours' };
|
||||
const previousDoc = global.document;
|
||||
global.document = { querySelector: () => widget };
|
||||
try {
|
||||
forgeShowDialogueBox(5);
|
||||
widget._classes.add('is-active');
|
||||
forgeDialogueBoxDeclineQuest(42);
|
||||
assert.equal(forgeQuestById(5).status, 'nouvelle'); // inchangé -> le dialogue rejouera depuis le début
|
||||
forgeShowDialogueBox('d_5', 'Le Sorcier', [{ speaker: 'Le Sorcier', text: 'Bonjour !' }, { speaker: 'Joueur', text: 'Salut.' }], true);
|
||||
forgeDialogueBoxAdvance(42);
|
||||
forgeDialogueBoxAdvance(42); // dernière réplique consommée -> dialogue terminé
|
||||
assert.equal(window.forgeCompletedDialogueIds['d_5'], true);
|
||||
assert.equal(widget._classes.has('is-active'), false);
|
||||
assert.equal(gameData.scoring.status, 'termine'); // seul dialogue connu, désormais complété
|
||||
} finally { global.document = previousDoc; }
|
||||
});
|
||||
|
||||
test('forgeShowDialogueBox — dialogue vide AVEC "then" : exécute immédiatement l\'action chaînée (rien à exhiber avant)', () => {
|
||||
const widget = fakeWidget(42);
|
||||
const previousDoc = global.document;
|
||||
const previousRun = global.forgeRunCollisionRuleAction;
|
||||
const calls = [];
|
||||
global.forgeRunCollisionRuleAction = (...args) => calls.push(args);
|
||||
global.document = { querySelector: () => widget };
|
||||
try {
|
||||
const thenAction = { type: 'variable', target_variable: 'score', data_operation: 'incrementer', data_value: '1' };
|
||||
forgeShowDialogueBox('d_6', 'Crokine', [], false, thenAction);
|
||||
assert.equal(calls.length, 1);
|
||||
assert.equal(calls[0][0], thenAction);
|
||||
assert.equal(calls[0][2].name, 'Crokine');
|
||||
} finally { global.document = previousDoc; global.forgeRunCollisionRuleAction = previousRun; }
|
||||
});
|
||||
|
||||
test('fin de dialogue AVEC "then" : exécute l\'action chaînée APRÈS la dernière réplique, jamais avant', () => {
|
||||
const widget = fakeWidget(42);
|
||||
const previousDoc = global.document;
|
||||
const previousRun = global.forgeRunCollisionRuleAction;
|
||||
const calls = [];
|
||||
global.forgeRunCollisionRuleAction = (...args) => calls.push(args);
|
||||
global.document = { querySelector: () => widget };
|
||||
try {
|
||||
const thenAction = { type: 'variable', target_variable: 'score', data_operation: 'incrementer', data_value: '1' };
|
||||
forgeShowDialogueBox('d_9', 'Crokine', [{ speaker: 'PNJ', text: 'Salut !' }], false, thenAction);
|
||||
assert.equal(calls.length, 0); // pas encore, le dialogue n'est pas terminé
|
||||
forgeDialogueBoxAdvance(42); // dernière réplique consommée -> "then" exécuté maintenant
|
||||
assert.equal(calls.length, 1);
|
||||
assert.equal(calls[0][0], thenAction);
|
||||
} finally { global.document = previousDoc; global.forgeRunCollisionRuleAction = previousRun; }
|
||||
});
|
||||
|
||||
test('forgeDialogueBoxAdvance — objet inconnu (jamais affiché) : aucun effet', () => {
|
||||
const previousDoc = global.document;
|
||||
global.document = { querySelector: () => null };
|
||||
@@ -206,36 +216,30 @@ function docWithDialogueAndQuiz(dialogueWidget, quizWidget) {
|
||||
};
|
||||
}
|
||||
|
||||
const QUIZ_LINES = [
|
||||
{ type: 'dialogue', speaker: 'PNJ', text: 'Une question pour toi.' },
|
||||
{ type: 'question', text: '2+2 ?', choices: ['3', '4'], correct_index: 1, reward_type: 'score', reward_amount: 5 },
|
||||
{ type: 'dialogue', speaker: 'PNJ', text: 'Bien joué !' },
|
||||
];
|
||||
|
||||
test.beforeEach(() => {
|
||||
global.gameData.quests.push({
|
||||
id: 10, title: 'Le quiz du sorcier', status: 'en_cours',
|
||||
dialogues: {
|
||||
nouvelle: [],
|
||||
en_cours: [
|
||||
{ type: 'dialogue', speaker: 'PNJ', text: 'Une question pour toi.' },
|
||||
{ type: 'question', text: '2+2 ?', choices: ['3', '4'], correct_index: 1, reward_type: 'score', reward_amount: 5 },
|
||||
{ type: 'dialogue', speaker: 'PNJ', text: 'Bien joué !' },
|
||||
],
|
||||
terminee: [],
|
||||
},
|
||||
});
|
||||
window.forgeQuizScore = 0;
|
||||
});
|
||||
|
||||
test('une ligne "question" est affichée dans la boîte à QUIZ (header = quête, corps = question+choix), pas la boîte de dialogue', () => {
|
||||
test('une ligne "question" est affichée dans la boîte à QUIZ (header = nom de l\'objet déclencheur, corps = question+choix), pas la boîte de dialogue', () => {
|
||||
const dialogueWidget = fakeWidget(42);
|
||||
dialogueWidget.getAttribute = attr => (attr === 'data-object-id' ? '42' : null);
|
||||
const quizWidget = fakeQuizWidget(99, 2);
|
||||
const previousDoc = global.document;
|
||||
global.document = docWithDialogueAndQuiz(dialogueWidget, quizWidget);
|
||||
try {
|
||||
forgeShowDialogueBox(10);
|
||||
forgeShowDialogueBox('d_10', 'Le sorcier', QUIZ_LINES);
|
||||
assert.equal(dialogueWidget._classes.has('is-active'), true); // 1ère ligne : dialogue normal
|
||||
|
||||
forgeDialogueBoxAdvance(42); // -> ligne "question"
|
||||
assert.equal(dialogueWidget._classes.has('is-active'), false); // masquée pendant la question
|
||||
assert.equal(quizWidget._classes.has('is-active'), true);
|
||||
assert.equal(quizWidget._roles.header.textContent, 'Quête : Le quiz du sorcier');
|
||||
assert.equal(quizWidget._roles.header.textContent, 'Le sorcier');
|
||||
assert.equal(quizWidget._roles.question.textContent, '2+2 ?');
|
||||
assert.match(quizWidget._roles.choices.innerHTML, /forgeQuizBoxAnswer\(99, 0\)/);
|
||||
assert.match(quizWidget._roles.choices.innerHTML, /forgeQuizBoxAnswer\(99, 1\)/);
|
||||
@@ -248,7 +252,7 @@ test('mauvaise réponse au quiz : aucun point, MAIS avance quand même sur "Cont
|
||||
const previousDoc = global.document;
|
||||
global.document = docWithDialogueAndQuiz(dialogueWidget, quizWidget);
|
||||
try {
|
||||
forgeShowDialogueBox(10);
|
||||
forgeShowDialogueBox('d_10', 'Le sorcier', QUIZ_LINES);
|
||||
forgeDialogueBoxAdvance(42); // -> question
|
||||
forgeQuizBoxAnswer(99, 0); // mauvaise réponse (correct_index=1)
|
||||
assert.equal(window.forgeQuizScore, 0); // aucun point
|
||||
@@ -271,7 +275,7 @@ test('un second clic pendant la révélation (avant "Continuer →") est ignoré
|
||||
const previousDoc = global.document;
|
||||
global.document = docWithDialogueAndQuiz(dialogueWidget, quizWidget);
|
||||
try {
|
||||
forgeShowDialogueBox(10);
|
||||
forgeShowDialogueBox('d_10', 'Le sorcier', QUIZ_LINES);
|
||||
forgeDialogueBoxAdvance(42);
|
||||
forgeQuizBoxAnswer(99, 0); // 1er clic (faux)
|
||||
forgeQuizBoxAnswer(99, 1); // 2nd clic pendant la révélation -> ignoré
|
||||
@@ -287,7 +291,7 @@ test('bonne réponse au quiz : crédite le score, la surligne en vert, "Continue
|
||||
const previousDoc = global.document;
|
||||
global.document = docWithDialogueAndQuiz(dialogueWidget, quizWidget);
|
||||
try {
|
||||
forgeShowDialogueBox(10);
|
||||
forgeShowDialogueBox('d_10', 'Le sorcier', QUIZ_LINES, true);
|
||||
forgeDialogueBoxAdvance(42); // -> question
|
||||
forgeQuizBoxAnswer(99, 1); // bonne réponse
|
||||
assert.equal(window.forgeQuizScore, 5);
|
||||
@@ -298,8 +302,8 @@ test('bonne réponse au quiz : crédite le score, la surligne en vert, "Continue
|
||||
assert.equal(dialogueWidget._classes.has('is-active'), true);
|
||||
assert.equal(dialogueWidget._roles.body.textContent, 'Bien joué !');
|
||||
|
||||
forgeDialogueBoxAdvance(42); // dernière ligne -> dialogue "en_cours" épuisé -> quête "terminee"
|
||||
assert.equal(forgeQuestById(10).status, 'terminee');
|
||||
forgeDialogueBoxAdvance(42); // dernière ligne -> dialogue épuisé, mark_completed -> terminé
|
||||
assert.equal(window.forgeCompletedDialogueIds['d_10'], true);
|
||||
assert.equal(dialogueWidget._classes.has('is-active'), false);
|
||||
} finally { global.document = previousDoc; }
|
||||
});
|
||||
@@ -313,7 +317,7 @@ test('aucune boîte à quiz posée sur la scène : la question est ignorée, la
|
||||
const previousDoc = global.document;
|
||||
global.document = docWithDialogueAndQuiz(dialogueWidget, null); // pas de widget à quiz
|
||||
try {
|
||||
forgeShowDialogueBox(10);
|
||||
forgeShowDialogueBox('d_10', 'Le sorcier', QUIZ_LINES);
|
||||
forgeDialogueBoxAdvance(42); // -> question, mais pas de boîte à quiz -> avance directement
|
||||
assert.equal(dialogueWidget._roles.body.textContent, 'Bien joué !'); // déjà à la ligne suivante
|
||||
assert.equal(window.forgeQuizScore, 0); // pas de récompense sans réponse
|
||||
@@ -333,9 +337,9 @@ test('forgeUpdateAllScoreWidgets — met à jour tous les widgets "🏆 Score" a
|
||||
});
|
||||
|
||||
// ---------- Pont vers SCORM (gameData.scoring, voir static/js/play/offline/scorm-api.js) ----------
|
||||
// Bug signalé : "les indicateurs qu'on utilise pour les quêtes devraient
|
||||
// aussi servir aux indicateurs SCORM, or ce n'est pas le cas" — un jeu
|
||||
// construit uniquement avec des quêtes/quiz ne touchait jamais
|
||||
// Bug signalé : "les indicateurs qu'on utilise pour les dialogues
|
||||
// devraient aussi servir aux indicateurs SCORM, or ce n'est pas le cas" —
|
||||
// un jeu construit uniquement avec des dialogues/quiz ne touchait jamais
|
||||
// gameData.scoring, laissant Score/Completion perpétuellement "unknown"
|
||||
// dans le LMS même après avoir fini le jeu.
|
||||
|
||||
@@ -366,42 +370,40 @@ test('forgeAddQuizScore ne rétrograde jamais un statut déjà plus avancé que
|
||||
} finally { global.document = previousDoc; }
|
||||
});
|
||||
|
||||
test('accepter une quête fait passer gameData.scoring.status à "en_cours" (la partie a commencé)', () => {
|
||||
test('afficher un dialogue fait passer gameData.scoring.status à "en_cours" (la partie a commencé)', () => {
|
||||
const widget = fakeWidget(42);
|
||||
global.gameData.scoring = undefined;
|
||||
const previousDoc = global.document;
|
||||
global.document = { querySelector: () => widget };
|
||||
try {
|
||||
forgeDialogueBoxAcceptQuest(5, 42);
|
||||
forgeShowDialogueBox('d_5', 'Le Sorcier', [{ speaker: 'Le Sorcier', text: 'Bonjour !' }]);
|
||||
assert.equal(gameData.scoring.status, 'en_cours');
|
||||
} finally { global.document = previousDoc; }
|
||||
});
|
||||
|
||||
test('forgeSyncAllQuestsCompletionToScorm — passe "termine" seulement quand TOUTES les quêtes sont "terminee"', () => {
|
||||
global.gameData.quests = [{ id: 1, status: 'terminee' }, { id: 2, status: 'en_cours' }];
|
||||
test('forgeSyncAllDialoguesCompletionToScorm — passe "termine" seulement quand TOUS les dialogues completable sont completed', () => {
|
||||
global.gameData.completable_dialogue_ids = ['d_1', 'd_2'];
|
||||
window.forgeCompletedDialogueIds = { d_1: true };
|
||||
global.gameData.scoring = { score: 0, status: 'en_cours' };
|
||||
forgeSyncAllQuestsCompletionToScorm();
|
||||
forgeSyncAllDialoguesCompletionToScorm();
|
||||
assert.equal(gameData.scoring.status, 'en_cours'); // pas encore toutes finies
|
||||
|
||||
gameData.quests[1].status = 'terminee';
|
||||
forgeSyncAllQuestsCompletionToScorm();
|
||||
window.forgeCompletedDialogueIds.d_2 = true;
|
||||
forgeSyncAllDialoguesCompletionToScorm();
|
||||
assert.equal(gameData.scoring.status, 'termine');
|
||||
});
|
||||
|
||||
test('la dernière ligne d\'un dialogue "en_cours" propage la complétion à gameData.scoring si c\'est la seule quête', () => {
|
||||
const widget = fakeWidget(42);
|
||||
global.gameData.quests = [
|
||||
{ id: 20, status: 'en_cours', dialogues: { nouvelle: [], en_cours: [{ speaker: 'PNJ', text: 'Bravo !' }], terminee: [] } },
|
||||
];
|
||||
test('forgeCompleteDialogue marque le dialogue completed et notifie xAPI', () => {
|
||||
const calls = [];
|
||||
global.forgeXapiNotifyDialogueCompleted = (id, name) => calls.push({ id, name });
|
||||
global.gameData.completable_dialogue_ids = ['d_20'];
|
||||
global.gameData.scoring = { score: 0, status: 'en_cours' };
|
||||
const previousDoc = global.document;
|
||||
global.document = { querySelector: sel => (sel.indexOf('data-object-id="42"') !== -1 ? widget : (sel.indexOf('dialogueBoxWidget') !== -1 ? widget : null)) };
|
||||
try {
|
||||
forgeShowDialogueBox(20);
|
||||
forgeDialogueBoxAdvance(42); // dernière (et unique) ligne consommée -> quête "terminee"
|
||||
assert.equal(forgeQuestById(20).status, 'terminee');
|
||||
forgeCompleteDialogue('d_20', 'Crokine');
|
||||
assert.equal(window.forgeCompletedDialogueIds['d_20'], true);
|
||||
assert.deepEqual(calls, [{ id: 'd_20', name: 'Crokine' }]);
|
||||
assert.equal(gameData.scoring.status, 'termine');
|
||||
} finally { global.document = previousDoc; }
|
||||
} finally { delete global.forgeXapiNotifyDialogueCompleted; }
|
||||
});
|
||||
|
||||
// ---------- Seuil de réussite (voir db/games/get_success_threshold.py) ----------
|
||||
@@ -433,31 +435,18 @@ test('forgeComputeFinalScormStatus — seuil par défaut (70%) si FORGE_SUCCESS_
|
||||
assert.equal(forgeComputeFinalScormStatus(), 'reussi');
|
||||
});
|
||||
|
||||
test('forgeSyncAllQuestsCompletionToScorm propage reussi/echoue (pas juste "termine") une fois toutes les quêtes finies', () => {
|
||||
global.gameData.quests = [{ id: 1, status: 'terminee' }];
|
||||
test('forgeSyncAllDialoguesCompletionToScorm propage reussi/echoue (pas juste "termine") une fois tous les dialogues finis', () => {
|
||||
global.gameData.completable_dialogue_ids = ['d_1'];
|
||||
window.forgeCompletedDialogueIds = { d_1: true };
|
||||
global.gameData.scoring = { score: 0, status: 'en_cours' };
|
||||
global.window.forgeQuizTotalAnswered = 4;
|
||||
global.window.forgeQuizCorrectAnswered = 1; // 25% < seuil par défaut (70%)
|
||||
delete global.window.FORGE_SUCCESS_THRESHOLD_PERCENT;
|
||||
forgeSyncAllQuestsCompletionToScorm();
|
||||
forgeSyncAllDialoguesCompletionToScorm();
|
||||
assert.equal(gameData.scoring.status, 'echoue');
|
||||
});
|
||||
|
||||
// ---------- Granularité xAPI (quête acceptée/terminée, question répondue) ----------
|
||||
|
||||
test('accepter une quête notifie xAPI (verbe "attempted" ciblant CETTE quête)', () => {
|
||||
const widget = fakeWidget(42);
|
||||
global.gameData.scoring = undefined;
|
||||
const previousDoc = global.document;
|
||||
global.document = { querySelector: () => widget };
|
||||
const calls = [];
|
||||
global.forgeXapiNotifyQuestAccepted = quest => calls.push(quest);
|
||||
try {
|
||||
forgeDialogueBoxAcceptQuest(5, 42);
|
||||
assert.equal(calls.length, 1);
|
||||
assert.equal(calls[0].id, 5);
|
||||
} finally { global.document = previousDoc; delete global.forgeXapiNotifyQuestAccepted; }
|
||||
});
|
||||
// ---------- Granularité xAPI (dialogue terminé, question répondue) ----------
|
||||
|
||||
test('répondre à une question notifie xAPI avec la bonne/mauvaise réponse et le score gagné', () => {
|
||||
const dialogueWidget = fakeWidget(42);
|
||||
@@ -467,21 +456,19 @@ test('répondre à une question notifie xAPI avec la bonne/mauvaise réponse et
|
||||
querySelector: sel => (sel.indexOf('choices') !== -1 ? { innerHTML: '' } : { textContent: '' }),
|
||||
querySelectorAll: () => [],
|
||||
};
|
||||
global.gameData.quests = [
|
||||
{ id: 5, title: 'La quête du sorcier', status: 'en_cours', dialogues: { nouvelle: [], en_cours: [{ type: 'question', text: 'Qui es-tu ?', choices: ['A', 'B'], correct_index: 0, reward_amount: 5 }], terminee: [] } },
|
||||
];
|
||||
const previousDoc = global.document;
|
||||
global.document = {
|
||||
querySelector: sel => (sel.indexOf('quizBoxWidget') !== -1 ? quizWidget : dialogueWidget),
|
||||
querySelectorAll: () => [],
|
||||
};
|
||||
const calls = [];
|
||||
global.forgeXapiNotifyQuestionAnswered = (quest, index, text, correct, score) => calls.push({ quest, index, text, correct, score });
|
||||
global.forgeXapiNotifyQuestionAnswered = (dialogueId, objectName, index, text, correct, score) => calls.push({ dialogueId, objectName, index, text, correct, score });
|
||||
try {
|
||||
forgeShowDialogueBox(5);
|
||||
forgeShowDialogueBox('d_5', 'Le Sorcier', [{ type: 'question', text: 'Qui es-tu ?', choices: ['A', 'B'], correct_index: 0, reward_amount: 5 }]);
|
||||
forgeQuizBoxAnswer(99, 0); // bonne réponse (correct_index=0)
|
||||
assert.equal(calls.length, 1);
|
||||
assert.equal(calls[0].quest.id, 5);
|
||||
assert.equal(calls[0].dialogueId, 'd_5');
|
||||
assert.equal(calls[0].objectName, 'Le Sorcier');
|
||||
assert.equal(calls[0].correct, true);
|
||||
assert.equal(calls[0].score, 5);
|
||||
forgeQuizBoxContinue(99); // clic explicite (RGAA 2.2.1, plus d'avance automatique)
|
||||
@@ -496,22 +483,19 @@ test('répondre à une question notifie AUSSI le SCORM 2004 (cmi.interactions.n.
|
||||
querySelector: sel => (sel.indexOf('choices') !== -1 ? { innerHTML: '' } : { textContent: '' }),
|
||||
querySelectorAll: () => [],
|
||||
};
|
||||
global.gameData.quests = [
|
||||
{ id: 5, title: 'La quête du sorcier', status: 'en_cours', dialogues: { nouvelle: [], en_cours: [{ type: 'question', text: 'Qui es-tu ?', choices: ['A', 'B'], correct_index: 0, reward_amount: 5 }], terminee: [] } },
|
||||
];
|
||||
const previousDoc = global.document;
|
||||
global.document = {
|
||||
querySelector: sel => (sel.indexOf('quizBoxWidget') !== -1 ? quizWidget : dialogueWidget),
|
||||
querySelectorAll: () => [],
|
||||
};
|
||||
const calls = [];
|
||||
global.forgeScorm2004NotifyQuestionAnswered = (quest, index, text, correct, choiceIndex, correctIndex) =>
|
||||
calls.push({ quest, index, text, correct, choiceIndex, correctIndex });
|
||||
global.forgeScorm2004NotifyQuestionAnswered = (dialogueId, objectName, index, text, correct, choiceIndex, correctIndex) =>
|
||||
calls.push({ dialogueId, objectName, index, text, correct, choiceIndex, correctIndex });
|
||||
try {
|
||||
forgeShowDialogueBox(5);
|
||||
forgeShowDialogueBox('d_5', 'Le Sorcier', [{ type: 'question', text: 'Qui es-tu ?', choices: ['A', 'B'], correct_index: 0, reward_amount: 5 }]);
|
||||
forgeQuizBoxAnswer(99, 1); // mauvaise réponse (correct_index=0, répond 1)
|
||||
assert.equal(calls.length, 1);
|
||||
assert.equal(calls[0].quest.id, 5);
|
||||
assert.equal(calls[0].dialogueId, 'd_5');
|
||||
assert.equal(calls[0].correct, false);
|
||||
assert.equal(calls[0].choiceIndex, 1);
|
||||
assert.equal(calls[0].correctIndex, 0);
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
// ---------- Éditeur de collision — moteur d'exécution en jeu ----------
|
||||
// Chaque objet de scène AUTRE que le joueur (jamais un "fond", jamais le
|
||||
// personnage "joueur" — voir screens/rendering/collision_rules.py) peut
|
||||
// porter des règles "déclencheur -> action" posées dans l'onglet
|
||||
// "🧩 Collision" (static/js/scenes/collision-rules-editor.js). Ce fichier
|
||||
// les FAIT VIVRE pendant le jeu : à chaque tick, teste chaque règle contre
|
||||
// la position du joueur, déclenche l'action une seule fois par entrée
|
||||
// (pas en continu tant que la condition reste vraie), même cadence que
|
||||
// personnage-controller.js (50ms) et réutilise ses mêmes briques
|
||||
// (forgeCollisionRectFromBox/forgeShapesOverlap de conditions.js).
|
||||
// porter des règles "déclencheur -> action", posées depuis l'onglet
|
||||
// "Déclencheurs" (static/js/triggers/trigger-editor.js::openTriggerWizard).
|
||||
// Ce fichier les FAIT VIVRE pendant le jeu : à chaque tick, teste chaque
|
||||
// règle contre la position du joueur, déclenche l'action une seule fois
|
||||
// par entrée (pas en continu tant que la condition reste vraie), même
|
||||
// cadence que personnage-controller.js (50ms) et réutilise ses mêmes
|
||||
// briques (forgeCollisionRectFromBox/forgeShapesOverlap de conditions.js).
|
||||
// Pur JS client, sans le moindre appel serveur — fonctionne à l'identique
|
||||
// en ligne et dans un export SCORM (voir static/js/play/offline/), aucun
|
||||
// branchement FORGE_OFFLINE nécessaire ici.
|
||||
@@ -62,16 +62,10 @@ function forgeExpandRect(rect, margin) {
|
||||
|
||||
// "collision" : chevauchement des boîtes, ÉLARGI d'une petite marge pour
|
||||
// détecter un simple contact (voir FORGE_COLLISION_RULE_TOUCH_MARGIN
|
||||
// ci-dessus). "perimetre" : distance entre les CENTRES des deux boîtes
|
||||
// <= rule.perimetre_px (pas entre les bords — plus simple et suffisant
|
||||
// pour une portée approximative, cohérent avec le reste du moteur qui ne
|
||||
// vise pas une physique précise).
|
||||
// ci-dessus) — seul déclencheur encore proposé ("perimetre" retiré, ne
|
||||
// servait à rien pour l'instant, demande explicite).
|
||||
function forgeCollisionRuleTriggerActive(rule, objRect, playerRect) {
|
||||
if (rule.trigger === 'collision') return forgeShapesOverlap(forgeExpandRect(objRect, FORGE_COLLISION_RULE_TOUCH_MARGIN), playerRect);
|
||||
var ocx = objRect.left + objRect.width / 2, ocy = objRect.top + objRect.height / 2;
|
||||
var pcx = playerRect.left + playerRect.width / 2, pcy = playerRect.top + playerRect.height / 2;
|
||||
var dist = Math.hypot(ocx - pcx, ocy - pcy);
|
||||
return dist <= (rule.perimetre_px || 0);
|
||||
return forgeShapesOverlap(forgeExpandRect(objRect, FORGE_COLLISION_RULE_TOUCH_MARGIN), playerRect);
|
||||
}
|
||||
|
||||
// La bulle "Appuie sur X" ne peut PAS être un enfant de objectEl : un
|
||||
@@ -118,27 +112,50 @@ function forgeHideCollisionInteractBubble(objectId, objectEl) {
|
||||
if (bubble) bubble.style.display = 'none';
|
||||
}
|
||||
|
||||
// Exécute une action (top-level ou sub_action d'un "interagir") — jamais
|
||||
// appelée pour un "interagir" lui-même à ce niveau, voir forgeCollisionRuleTick
|
||||
// qui gère "interagir" à part (affichage de bulle + attente de touche).
|
||||
// Exécute une action (top-level, sub_action d'un "interagir", ou branche
|
||||
// si_vrai/si_faux d'une "condition") — jamais appelée pour un "interagir"
|
||||
// lui-même à ce niveau, voir forgeCollisionRuleTick qui le gère à part
|
||||
// (affichage de bulle + attente de touche).
|
||||
function forgeRunCollisionRuleAction(action, targetEl, obj) {
|
||||
if (action.type === 'evenement') {
|
||||
window.dispatchGameEvent(action.evenement_id);
|
||||
return;
|
||||
}
|
||||
if (action.type === 'attaque') {
|
||||
var attackAnim = forgePersonnageAnimExists(obj.id, 'attaque') ? 'attaque' : null;
|
||||
if (attackAnim) runSpriteAnimation(targetEl, resolveSpriteFrames(obj.id, { animation: attackAnim, fps: FORGE_PERSONNAGE_FPS, loop: false }));
|
||||
return;
|
||||
}
|
||||
if (action.type === 'quete') {
|
||||
// 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;
|
||||
if (action.type === 'dialogue') {
|
||||
// Affiche LE DIALOGUE DE CETTE ACTION PRÉCISE (action.dialogue.lines
|
||||
// — chaque occurrence de "Déclencher ce dialogue" porte le sien,
|
||||
// voir screens/rendering/collision_rules.py) 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é, comme le reste de ce moteur. Le nom affiché (en-tête du
|
||||
// quiz, xAPI) est celui de l'objet déclencheur (voir "ℹ️
|
||||
// Informations") — plus de "quête" pour le porter. mark_completed
|
||||
// (optionnel) : marque ce dialogue terminé une fois épuisé.
|
||||
// `action.then` (chaînage, voir maquette validée : "pouvoir modifier
|
||||
// un déclencheur pour lui ajouter des actions") NE PEUT PAS
|
||||
// s'exécuter immédiatement — un dialogue attend des clics du joueur
|
||||
// — transmis à forgeShowDialogueBox, exécuté par dialogue-box-
|
||||
// controller.js une fois le dialogue épuisé.
|
||||
var lines = (action.dialogue && action.dialogue.lines) || [];
|
||||
var dialogueId = action.dialogue && action.dialogue.id;
|
||||
forgeShowDialogueBox(dialogueId, obj.name || 'PNJ', lines, action.mark_completed, action.then);
|
||||
} else if (action.type === 'variable') {
|
||||
// Modifie une variable globale — même vocabulaire/calcul que l'action
|
||||
// "Modifier une variable" du graphe de logique document (voir
|
||||
// screens/data_actions/apply_variable_action.py), portée ici par
|
||||
// forgeApplyVariableActionOffline (static/js/play/offline/apply-actions.js,
|
||||
// TOUJOURS chargée — voir templates/play.html — pas seulement en
|
||||
// export, donc aucun branchement FORGE_OFFLINE nécessaire, même
|
||||
// philosophie que le reste de ce fichier). Instantané : `then`
|
||||
// s'exécute tout de suite, contrairement à un dialogue.
|
||||
forgeApplyVariableActionOffline(gameData, action);
|
||||
if (action.then) forgeRunCollisionRuleAction(action.then, targetEl, obj);
|
||||
} else if (action.type === 'condition') {
|
||||
// Réutilise evaluateConditionClause (static/js/play/conditions.js),
|
||||
// même comparaison qu'un nœud Condition de l'éditeur de logique, sur
|
||||
// une VARIABLE GLOBALE (gameData.variables) — jamais un champ d'objet
|
||||
// ici (voir maquette validée : le losange ne teste qu'une variable).
|
||||
var isTrue = evaluateConditionClause({
|
||||
source: 'variable', variable: action.variable, operator: action.operateur, value: action.valeur,
|
||||
});
|
||||
var branch = isTrue ? action.si_vrai : action.si_faux;
|
||||
if (branch) forgeRunCollisionRuleAction(branch, targetEl, obj);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -236,6 +253,7 @@ if (typeof module !== 'undefined' && module.exports) {
|
||||
const conditionsMod = require('./conditions.js');
|
||||
global.forgeCollisionRectFromBox = global.forgeCollisionRectFromBox || conditionsMod.forgeCollisionRectFromBox;
|
||||
global.forgeShapesOverlap = global.forgeShapesOverlap || conditionsMod.forgeShapesOverlap;
|
||||
global.evaluateConditionClause = global.evaluateConditionClause || conditionsMod.evaluateConditionClause;
|
||||
const personnageMod = require('./personnage-controller.js');
|
||||
global.forgePersonnageAnimExists = global.forgePersonnageAnimExists || personnageMod.forgePersonnageAnimExists;
|
||||
global.FORGE_PERSONNAGE_FPS = global.FORGE_PERSONNAGE_FPS || 8;
|
||||
|
||||
@@ -1,55 +1,58 @@
|
||||
// ---------- Widgets "💬 Boîte de dialogue" / "❓ Boîte à quiz" / "🏆 Score" ----------
|
||||
// Fait le lien entre l'éditeur de collision (action "quete", voir
|
||||
// Fait le lien entre l'éditeur de déclencheurs (action "dialogue", voir
|
||||
// static/js/play/collision-rules-controller.js) et les widgets d'interface
|
||||
// posés par l'auteur (voir "🖥️ Interface", templates/scene_edit.html,
|
||||
// screens/rendering/dialogue_box_style.py) : à l'exécution d'une action
|
||||
// "quete", parcourt le dialogue correspondant au STATUT ACTUEL de la
|
||||
// quête (gameData.quests, voir full_game_payload.py) ligne par ligne —
|
||||
// une ligne "dialogue" s'affiche dans la boîte de dialogue (bouton
|
||||
// "Suivant" pour avancer) ; une ligne "question" ("❓ Question", voir
|
||||
// static/js/quests/quest-editor.js) s'affiche dans la boîte à QUIZ (si
|
||||
// posée sur la scène — sinon ignorée silencieusement, la conversation
|
||||
// avance sans elle) : cliquer la BONNE réponse crédite le score du
|
||||
// montant prévu et avance ; une mauvaise réponse ne fait RIEN, la
|
||||
// question reste affichée (le joueur peut réessayer).
|
||||
// "dialogue", parcourt les répliques DE CETTE ACTION PRÉCISE
|
||||
// (action.dialogue.lines, transmises telles quelles par
|
||||
// collision-rules-controller.js — chaque occurrence de "Déclencher ce
|
||||
// dialogue" porte son propre dialogue, voir screens/rendering/
|
||||
// collision_rules.py) ligne par ligne — une ligne "dialogue" s'affiche
|
||||
// dans la boîte de dialogue (bouton "Suivant" pour avancer) ; une ligne
|
||||
// "question" ("❓ Question", voir static/js/triggers/trigger-editor.js)
|
||||
// s'affiche dans la boîte à QUIZ (si posée sur la scène — sinon ignorée
|
||||
// silencieusement, la conversation avance sans elle) : cliquer la BONNE
|
||||
// réponse crédite le score du montant prévu et avance ; une mauvaise
|
||||
// réponse ne fait RIEN, la question reste affichée (le joueur peut
|
||||
// réessayer).
|
||||
//
|
||||
// Une fois le dialogue "nouvelle" épuisé : la boîte bascule en écran
|
||||
// d'OFFRE ("Quête : <titre>" / objectif / boutons Accepter-Refuser) au
|
||||
// lieu de se masquer — accepter fait passer la quête "en_cours" ; refuser
|
||||
// ferme simplement la boîte SANS rien changer (la quête reste "nouvelle",
|
||||
// la prochaine interaction rejoue exactement le même dialogue). Une fois
|
||||
// le dialogue "en_cours" épuisé (donc, s'il contenait des questions,
|
||||
// toutes répondues correctement), la quête passe "terminee" — le joueur
|
||||
// peut alors quitter la scène normalement (aucun blocage, ces widgets ne
|
||||
// sont jamais des obstacles). Tout ceci reste LOCAL à la partie en cours
|
||||
// (gameData.quests n'est qu'un instantané par joueur, voir
|
||||
// full_game_payload.py) : jamais persisté en base, qui garde le statut
|
||||
// de DÉPART pour toute nouvelle partie.
|
||||
// Plus aucune "quête" ni écran d'offre automatique (Accepter/Refuser) :
|
||||
// c'est le CRÉATEUR qui pilote l'avancement, via ses propres variables
|
||||
// globales et conditions (voir screens/rendering/collision_rules.py::
|
||||
// "condition") — le dialogue choisi s'affiche directement. Une action
|
||||
// "dialogue" peut cocher `mark_completed` : une fois ce dialogue épuisé
|
||||
// (ou immédiatement s'il est vide), son id est ajouté à
|
||||
// forgeCompletedDialogueIds — RUNTIME uniquement (jamais persisté en
|
||||
// base), qui sert à savoir quand calculer la synthèse SCORM finale (voir
|
||||
// forgeSyncAllDialoguesCompletionToScorm : "terminé" une fois TOUS les
|
||||
// dialogues `mark_completed` du jeu joués, voir
|
||||
// gameData.completable_dialogue_ids). Le seuil de réussite/échec SCORM
|
||||
// lui-même reste un réglage du créateur totalement indépendant
|
||||
// (db/games/get_success_threshold.py), inchangé ici.
|
||||
//
|
||||
// Le score du quiz (window.forgeQuizScore) est un compteur RUNTIME dédié
|
||||
// à ce système de quête/quiz — distinct du score "classique" du moteur
|
||||
// à ce système de dialogue/quiz — distinct du score "classique" du moteur
|
||||
// document (db/scoring/, action de flow "Modifier un score"), qui
|
||||
// suppose un nœud de flow préconfiguré et un aller-retour serveur, hors
|
||||
// de propos pour ce sous-système entièrement client (voir
|
||||
// collision-rules-controller.js : "Pur JS client, sans le moindre appel
|
||||
// serveur").
|
||||
//
|
||||
// Silencieux (aucun effet) si la quête est introuvable ou si l'auteur n'a
|
||||
// posé aucun widget requis sur l'écran — même esprit que le reste du moteur.
|
||||
// Silencieux (aucun effet) si le widget requis n'est pas posé sur
|
||||
// l'écran — même esprit que le reste du moteur.
|
||||
|
||||
var forgeDialogueBoxState = {};
|
||||
var forgeQuizBoxState = {};
|
||||
window.forgeQuizScore = window.forgeQuizScore || 0;
|
||||
// Compte TOUTE réponse (bonne ou mauvaise) au fil de la partie — sert
|
||||
// uniquement à calculer un taux de réussite global (voir
|
||||
// forgeSyncAllQuestsCompletionToScorm), distinct de window.forgeQuizScore
|
||||
// forgeComputeFinalScormStatus), distinct de window.forgeQuizScore
|
||||
// qui ne compte que les points gagnés.
|
||||
window.forgeQuizTotalAnswered = window.forgeQuizTotalAnswered || 0;
|
||||
window.forgeQuizCorrectAnswered = window.forgeQuizCorrectAnswered || 0;
|
||||
|
||||
function forgeQuestById(questId) {
|
||||
return (gameData.quests || []).find(function (q) { return q.id === questId; });
|
||||
}
|
||||
// Id de dialogue -> true une fois joué ET marqué `mark_completed` — voir
|
||||
// forgeSyncAllDialoguesCompletionToScorm.
|
||||
window.forgeCompletedDialogueIds = window.forgeCompletedDialogueIds || {};
|
||||
|
||||
function forgeEscapeHtml(str) {
|
||||
return String(str == null ? '' : str)
|
||||
@@ -78,9 +81,8 @@ function forgeRenderDialogueBoxLine(widget, line) {
|
||||
}
|
||||
|
||||
// Remet le pied de la boîte dans son état "dialogue" (un seul bouton
|
||||
// "Suivant →", voir render_scene_object.py) — nécessaire pour effacer
|
||||
// les boutons Accepter/Refuser d'une offre PRÉCÉDENTE avant de rejouer
|
||||
// un nouveau dialogue dans la même boîte.
|
||||
// "Suivant →", voir render_scene_object.py) — appelé avant CHAQUE ligne
|
||||
// affichée (voir forgeShowCurrentConversationLine).
|
||||
function forgeResetDialogueBoxFooter(widget, objectId) {
|
||||
var footer = forgeDialogueBoxFooterEl(widget);
|
||||
if (footer) footer.innerHTML = '<button type="button" class="dialogueBoxNextBtn" data-dialogue-role="next-btn" onclick="forgeDialogueBoxAdvance(' + objectId + ')">Suivant →</button>';
|
||||
@@ -110,11 +112,11 @@ function forgeUpdateAllScoreWidgets() {
|
||||
|
||||
// Alimente aussi gameData.scoring (voir static/js/play/offline/
|
||||
// apply-actions.js, même forme {score, status}) — bug signalé : "les
|
||||
// indicateurs qu'on utilise pour les quêtes devraient aussi servir aux
|
||||
// indicateurs qu'on utilise pour les dialogues devraient aussi servir aux
|
||||
// indicateurs SCORM, or ce n'est pas le cas". C'est CE champ que
|
||||
// static/js/play/offline/scorm-api.js lit toutes les 2s pour pousser
|
||||
// cmi.core.score.raw/cmi.core.lesson_status au LMS — sans jamais y
|
||||
// toucher, un jeu construit uniquement avec des quêtes/quiz (jamais
|
||||
// toucher, un jeu construit uniquement avec des dialogues/quiz (jamais
|
||||
// l'action de flow "Modifier un score") laissait Score/Completion
|
||||
// perpétuellement "unknown" dans SCORM Cloud, même après y avoir joué
|
||||
// jusqu'au bout. Vocabulaire de statut identique à db/scoring/
|
||||
@@ -124,32 +126,36 @@ function forgeSyncQuizScoreToScorm() {
|
||||
gameData.scoring.score = window.forgeQuizScore;
|
||||
if (gameData.scoring.status === 'non_commence') gameData.scoring.status = 'en_cours';
|
||||
// xAPI bolt-on (voir static/js/play/offline/xapi-client.js) : ce chemin
|
||||
// quête/quiz contourne static/js/play/offline/apply-actions.js (aucune
|
||||
// action de flow "Modifier un score" ici), donc jamais notifié sans cet
|
||||
// appel direct — même bug que le commentaire ci-dessus pour SCORM.
|
||||
// dialogue/quiz contourne static/js/play/offline/apply-actions.js
|
||||
// (aucune action de flow "Modifier un score" ici), donc jamais notifié
|
||||
// sans cet appel direct — même bug que le commentaire ci-dessus pour
|
||||
// SCORM.
|
||||
if (typeof forgeXapiNotifyScoreChanged === "function") forgeXapiNotifyScoreChanged(gameData.scoring.score);
|
||||
}
|
||||
|
||||
// Une quête acceptée = la partie a commencé, même sans le moindre point
|
||||
// Un dialogue affiché = la partie a commencé, même sans le moindre point
|
||||
// encore gagné — sans cet appel, Score restait "0" mais Completion
|
||||
// restait "unknown" tant qu'aucune question n'avait encore été résolue.
|
||||
function forgeSyncQuestStartedToScorm() {
|
||||
// Appelé depuis forgeShowDialogueBox.
|
||||
function forgeSyncDialogueStartedToScorm() {
|
||||
gameData.scoring = gameData.scoring || { score: 0, status: 'non_commence' };
|
||||
if (gameData.scoring.status === 'non_commence') gameData.scoring.status = 'en_cours';
|
||||
}
|
||||
|
||||
// Le jeu entier est considéré terminé quand TOUTES les quêtes connues
|
||||
// sont "terminee" — règle simple et prévisible plutôt que de deviner
|
||||
// laquelle des quêtes créées "compte" pour la fin du jeu.
|
||||
// Réussi/Échoué (voir db/games/get_success_threshold.py,
|
||||
// Le jeu entier est considéré terminé quand TOUS les dialogues du jeu
|
||||
// marqués `mark_completed` (voir gameData.completable_dialogue_ids,
|
||||
// screens/rendering/trigger_graph.py::list_completable_dialogue_ids) ont
|
||||
// été joués (voir forgeAdvanceConversation) — règle simple et
|
||||
// prévisible plutôt que de deviner lequel des dialogues créés "compte"
|
||||
// pour la fin du jeu. Réussi/Échoué (voir db/games/get_success_threshold.py,
|
||||
// window.FORGE_SUCCESS_THRESHOLD_PERCENT injecté par publish/
|
||||
// build_scorm_package.py) : demande utilisateur — "il manque
|
||||
// l'indicateur succès/échec sur le SCORM". Calculé sur le taux de
|
||||
// bonnes réponses au quiz (forgeQuizCorrectAnswered/forgeQuizTotalAnswered)
|
||||
// SUR TOUTE LA PARTIE, pas quête par quête — cohérent avec "toutes les
|
||||
// quêtes terminées" qui est déjà la seule condition de fin de partie.
|
||||
// Aucune question posée du tout (jeu fait uniquement de dialogues) :
|
||||
// reste "termine" (ni réussi ni échoué, rien à évaluer).
|
||||
// SUR TOUTE LA PARTIE, pas dialogue par dialogue — cohérent avec "tous
|
||||
// les dialogues terminés" qui est déjà la seule condition de fin de
|
||||
// partie. Aucune question posée du tout (jeu fait uniquement de
|
||||
// dialogues) : reste "termine" (ni réussi ni échoué, rien à évaluer).
|
||||
function forgeComputeFinalScormStatus() {
|
||||
var total = window.forgeQuizTotalAnswered || 0;
|
||||
if (total === 0) return 'termine';
|
||||
@@ -158,9 +164,10 @@ function forgeComputeFinalScormStatus() {
|
||||
return percent >= threshold ? 'reussi' : 'echoue';
|
||||
}
|
||||
|
||||
function forgeSyncAllQuestsCompletionToScorm() {
|
||||
var quests = gameData.quests || [];
|
||||
if (!quests.length || !quests.every(function (q) { return q.status === 'terminee'; })) return;
|
||||
function forgeSyncAllDialoguesCompletionToScorm() {
|
||||
var completableIds = (gameData.completable_dialogue_ids || []);
|
||||
var allDone = completableIds.length > 0 && completableIds.every(function (id) { return !!window.forgeCompletedDialogueIds[id]; });
|
||||
if (!allDone) return;
|
||||
gameData.scoring = gameData.scoring || { score: 0, status: 'non_commence' };
|
||||
var finalStatus = forgeComputeFinalScormStatus();
|
||||
gameData.scoring.status = finalStatus;
|
||||
@@ -187,7 +194,7 @@ function forgeShowCurrentConversationLine(dialogueObjectId) {
|
||||
var widget = forgeDialogueBoxWidgetById(dialogueObjectId);
|
||||
if (line.type === 'question') {
|
||||
if (widget) widget.classList.remove('is-active');
|
||||
forgeShowQuizBox(dialogueObjectId, forgeQuestById(state.questId), line, state.index);
|
||||
forgeShowQuizBox(dialogueObjectId, state.dialogueId, state.objectName, line, state.index);
|
||||
return;
|
||||
}
|
||||
forgeHideQuizBoxOnCurrentScreen();
|
||||
@@ -197,30 +204,54 @@ function forgeShowCurrentConversationLine(dialogueObjectId) {
|
||||
widget.classList.add('is-active');
|
||||
}
|
||||
|
||||
// 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]) || [];
|
||||
// Appelée par l'action "dialogue" (collision-rules-controller.js) —
|
||||
// trouve le widget posé sur l'écran ACTUELLEMENT affiché (un seul à la
|
||||
// fois, comme le reste du moteur 2D). `lines` : les répliques DE CETTE
|
||||
// ACTION PRÉCISE (action.dialogue.lines, voir le commentaire d'en-tête).
|
||||
// `objectName` : le nom de l'objet déclencheur (voir "ℹ️ Informations"),
|
||||
// affiché en en-tête du quiz et transmis à xAPI — plus de "quête" pour
|
||||
// le porter. `markCompleted` : voir forgeAdvanceConversation ci-dessous.
|
||||
// `thenAction` (chaînage, voir screens/rendering/collision_rules.py::
|
||||
// "then") : l'action suivante, exécutée seulement une fois ce dialogue
|
||||
// épuisé — jamais avant, contrairement à une action "variable" qui,
|
||||
// elle, chaîne immédiatement (voir collision-rules-controller.js).
|
||||
function forgeShowDialogueBox(dialogueId, objectName, lines, markCompleted, thenAction) {
|
||||
if (!dialogueId) return;
|
||||
lines = lines || [];
|
||||
var widget = forgeDialogueBoxWidgetOnCurrentScreen();
|
||||
if (!widget) return;
|
||||
var objectId = widget.getAttribute('data-object-id');
|
||||
// Aucune ligne posée pour ce statut : une quête "nouvelle" propose
|
||||
// quand même directement l'offre (accepter/refuser), les autres
|
||||
// statuts restent silencieux (rien à montrer).
|
||||
forgeSyncDialogueStartedToScorm();
|
||||
if (!lines.length) {
|
||||
if (quest.status === 'nouvelle') forgeShowQuestOffer(widget, objectId, quest);
|
||||
// Rien à jouer : applique quand même markCompleted PUIS thenAction
|
||||
// immédiatement (rien à exhiber avant), sinon silencieux.
|
||||
if (markCompleted) forgeCompleteDialogue(dialogueId, objectName);
|
||||
if (thenAction) forgeRunCollisionRuleAction(thenAction, null, { name: objectName });
|
||||
return;
|
||||
}
|
||||
forgeDialogueBoxState[objectId] = { questId: questId, lines: lines, index: 0 };
|
||||
forgeDialogueBoxState[objectId] = {
|
||||
dialogueId: dialogueId, objectName: objectName, lines: lines, index: 0,
|
||||
markCompleted: !!markCompleted, thenAction: thenAction,
|
||||
};
|
||||
forgeShowCurrentConversationLine(objectId);
|
||||
}
|
||||
|
||||
// Marque un dialogue `completed` (RUNTIME uniquement, jamais persisté —
|
||||
// le créateur pilote lui-même quand, via la case "Marquer ce dialogue
|
||||
// comme terminé" de l'assistant) et propage vers le SCORM/xAPI.
|
||||
function forgeCompleteDialogue(dialogueId, objectName) {
|
||||
window.forgeCompletedDialogueIds[dialogueId] = true;
|
||||
if (typeof forgeXapiNotifyDialogueCompleted === "function") forgeXapiNotifyDialogueCompleted(dialogueId, objectName);
|
||||
forgeSyncAllDialoguesCompletionToScorm();
|
||||
}
|
||||
|
||||
// Avance d'une ligne (fin de dialogue -> question, fin de question
|
||||
// correcte -> ligne suivante) — appelée par le bouton "Suivant →" ET par
|
||||
// une bonne réponse au quiz (voir forgeQuizBoxAnswer).
|
||||
// une bonne réponse au quiz (voir forgeQuizBoxAnswer). À l'épuisement des
|
||||
// lignes : markCompleted PUIS thenAction (voir screens/rendering/
|
||||
// collision_rules.py::"then", demande explicite : "pouvoir modifier un
|
||||
// déclencheur pour lui ajouter des actions") — MÊME point de sortie "ce
|
||||
// dialogue est fini", jamais avant.
|
||||
function forgeAdvanceConversation(dialogueObjectId) {
|
||||
var state = forgeDialogueBoxState[dialogueObjectId];
|
||||
if (!state) return;
|
||||
@@ -228,20 +259,8 @@ function forgeAdvanceConversation(dialogueObjectId) {
|
||||
if (state.index >= state.lines.length) {
|
||||
forgeHideQuizBoxOnCurrentScreen();
|
||||
var widget = forgeDialogueBoxWidgetById(dialogueObjectId);
|
||||
var quest = forgeQuestById(state.questId);
|
||||
if (quest && quest.status === 'nouvelle') {
|
||||
if (widget) forgeShowQuestOffer(widget, dialogueObjectId, quest);
|
||||
return;
|
||||
}
|
||||
// Tout le dialogue "en_cours" épuisé (donc, s'il contenait des
|
||||
// questions, toutes répondues correctement) -> quête terminée. Voir
|
||||
// demande utilisateur : "quand le joueur a répondu à toutes les
|
||||
// questions la quête passe au statut terminé".
|
||||
if (quest && quest.status === 'en_cours') {
|
||||
quest.status = 'terminee';
|
||||
if (typeof forgeXapiNotifyQuestCompleted === "function") forgeXapiNotifyQuestCompleted(quest);
|
||||
forgeSyncAllQuestsCompletionToScorm();
|
||||
}
|
||||
if (state.markCompleted) forgeCompleteDialogue(state.dialogueId, state.objectName);
|
||||
if (state.thenAction) forgeRunCollisionRuleAction(state.thenAction, null, { name: state.objectName });
|
||||
if (widget) widget.classList.remove('is-active');
|
||||
delete forgeDialogueBoxState[dialogueObjectId];
|
||||
return;
|
||||
@@ -260,23 +279,23 @@ function forgeDialogueBoxAdvance(objectId) {
|
||||
}
|
||||
|
||||
// Affiche une question ("❓ Question") dans la boîte à quiz posée sur
|
||||
// l'écran — header = "Quête : <titre>", corps = la question + ses choix
|
||||
// (voir demande utilisateur) — si l'auteur n'a posé aucune boîte à quiz,
|
||||
// la question est ignorée (avance directement) plutôt que de bloquer la
|
||||
// conversation pour toujours en attendant une réponse qu'aucun widget ne
|
||||
// peut recueillir.
|
||||
function forgeShowQuizBox(dialogueObjectId, quest, line, questionIndex) {
|
||||
// l'écran — header = le nom de l'objet déclencheur (voir "ℹ️
|
||||
// Informations"), corps = la question + ses choix — si l'auteur n'a posé
|
||||
// aucune boîte à quiz, la question est ignorée (avance directement)
|
||||
// plutôt que de bloquer la conversation pour toujours en attendant une
|
||||
// réponse qu'aucun widget ne peut recueillir.
|
||||
function forgeShowQuizBox(dialogueObjectId, dialogueId, objectName, line, questionIndex) {
|
||||
var widget = forgeQuizBoxWidgetOnCurrentScreen();
|
||||
if (!widget) { forgeAdvanceConversation(dialogueObjectId); return; }
|
||||
var quizObjectId = widget.getAttribute('data-object-id');
|
||||
forgeQuizBoxState[quizObjectId] = {
|
||||
dialogueObjectId: dialogueObjectId, correctIndex: line.correct_index, rewardAmount: line.reward_amount, resolved: false,
|
||||
quest: quest, questionIndex: questionIndex, questionText: line.text,
|
||||
dialogueId: dialogueId, objectName: objectName, questionIndex: questionIndex, questionText: line.text,
|
||||
};
|
||||
var header = widget.querySelector('[data-quiz-role="header"]');
|
||||
var questionEl = widget.querySelector('[data-quiz-role="question"]');
|
||||
var choicesEl = widget.querySelector('[data-quiz-role="choices"]');
|
||||
if (header) header.textContent = 'Quête : ' + (quest ? quest.title : '');
|
||||
if (header) header.textContent = objectName || '';
|
||||
if (questionEl) questionEl.textContent = line.text;
|
||||
if (choicesEl) {
|
||||
choicesEl.innerHTML = line.choices.map(function (choice, i) {
|
||||
@@ -320,13 +339,13 @@ function forgeQuizBoxAnswer(quizObjectId, choiceIndex) {
|
||||
window.forgeQuizCorrectAnswered += 1;
|
||||
forgeAddQuizScore(state.rewardAmount);
|
||||
}
|
||||
if (typeof forgeXapiNotifyQuestionAnswered === "function" && state.quest) {
|
||||
forgeXapiNotifyQuestionAnswered(state.quest, state.questionIndex, state.questionText, correct, correct ? state.rewardAmount : 0);
|
||||
if (typeof forgeXapiNotifyQuestionAnswered === "function") {
|
||||
forgeXapiNotifyQuestionAnswered(state.dialogueId, state.objectName, state.questionIndex, state.questionText, correct, correct ? state.rewardAmount : 0);
|
||||
}
|
||||
// cmi.interactions.n.* (SCORM 2004 uniquement, voir scorm2004-api.js) :
|
||||
// no-op en SCORM 1.2/hors LMS (forgeScorm2004Api y reste null).
|
||||
if (typeof forgeScorm2004NotifyQuestionAnswered === "function" && state.quest) {
|
||||
forgeScorm2004NotifyQuestionAnswered(state.quest, state.questionIndex, state.questionText, correct, choiceIndex, state.correctIndex);
|
||||
if (typeof forgeScorm2004NotifyQuestionAnswered === "function") {
|
||||
forgeScorm2004NotifyQuestionAnswered(state.dialogueId, state.objectName, state.questionIndex, state.questionText, correct, choiceIndex, state.correctIndex);
|
||||
}
|
||||
// RGAA/WCAG 2.2.1 (délai réglable) : avancait auparavant tout seul
|
||||
// après un délai fixe (700ms/1400ms), jamais assez pour un lecteur
|
||||
@@ -354,58 +373,16 @@ function forgeQuizBoxContinue(quizObjectId) {
|
||||
forgeAdvanceConversation(state.dialogueObjectId);
|
||||
}
|
||||
|
||||
// ---------- Offre de quête (Accepter/Refuser) ----------
|
||||
|
||||
// Écran d'offre : header = "Quête : <titre>", body = l'objectif, pied =
|
||||
// Accepter/Refuser au lieu du "Suivant" habituel.
|
||||
function forgeShowQuestOffer(widget, objectId, quest) {
|
||||
var header = widget.querySelector('[data-dialogue-role="header"]');
|
||||
var body = widget.querySelector('[data-dialogue-role="body"]');
|
||||
var footer = forgeDialogueBoxFooterEl(widget);
|
||||
if (header) header.textContent = 'Quête : ' + quest.title;
|
||||
if (body) body.textContent = quest.objectif || '';
|
||||
if (footer) {
|
||||
footer.innerHTML =
|
||||
'<button type="button" class="dialogueBoxNextBtn dialogueBoxAcceptBtn" onclick="forgeDialogueBoxAcceptQuest(' + quest.id + ', ' + objectId + ')">Accepter</button>' +
|
||||
'<button type="button" class="dialogueBoxNextBtn dialogueBoxDeclineBtn" onclick="forgeDialogueBoxDeclineQuest(' + objectId + ')">Refuser</button>';
|
||||
}
|
||||
forgeDialogueBoxState[objectId] = { questId: quest.id, lines: [], index: 0 };
|
||||
widget.classList.add('is-active');
|
||||
}
|
||||
|
||||
// Fait passer la quête "en_cours" — UNIQUEMENT dans l'instantané en
|
||||
// mémoire (gameData.quests), jamais persisté en base : voir la note en
|
||||
// tête de fichier (le statut en base est celui de DÉPART pour toute
|
||||
// nouvelle partie, pas un état de partie en cours).
|
||||
function forgeDialogueBoxAcceptQuest(questId, objectId) {
|
||||
var quest = forgeQuestById(questId);
|
||||
if (quest) quest.status = 'en_cours';
|
||||
forgeSyncQuestStartedToScorm();
|
||||
if (quest && typeof forgeXapiNotifyQuestAccepted === "function") forgeXapiNotifyQuestAccepted(quest);
|
||||
var widget = forgeDialogueBoxWidgetById(objectId);
|
||||
if (widget) widget.classList.remove('is-active');
|
||||
delete forgeDialogueBoxState[objectId];
|
||||
}
|
||||
|
||||
// Referme la boîte SANS toucher au statut de la quête (reste "nouvelle")
|
||||
// — la prochaine interaction rejoue donc le même dialogue depuis le
|
||||
// début : impossible d'avancer la quête sans un jour l'accepter.
|
||||
function forgeDialogueBoxDeclineQuest(objectId) {
|
||||
var widget = forgeDialogueBoxWidgetById(objectId);
|
||||
if (widget) widget.classList.remove('is-active');
|
||||
delete forgeDialogueBoxState[objectId];
|
||||
}
|
||||
|
||||
// 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,
|
||||
forgeShowDialogueBox, forgeDialogueBoxAdvance, forgeCompleteDialogue,
|
||||
forgeDialogueBoxWidgetOnCurrentScreen, forgeDialogueBoxWidgetById, forgeRenderDialogueBoxLine,
|
||||
forgeShowQuestOffer, forgeDialogueBoxAcceptQuest, forgeDialogueBoxDeclineQuest, forgeResetDialogueBoxFooter,
|
||||
forgeResetDialogueBoxFooter,
|
||||
forgeShowQuizBox, forgeQuizBoxAnswer, forgeQuizBoxContinue, forgeQuizBoxWidgetOnCurrentScreen, forgeQuizBoxWidgetById,
|
||||
forgeUpdateAllScoreWidgets, forgeAddQuizScore, forgeAdvanceConversation, forgeShowCurrentConversationLine,
|
||||
forgeSyncQuizScoreToScorm, forgeSyncQuestStartedToScorm, forgeSyncAllQuestsCompletionToScorm,
|
||||
forgeSyncQuizScoreToScorm, forgeSyncDialogueStartedToScorm, forgeSyncAllDialoguesCompletionToScorm,
|
||||
forgeComputeFinalScormStatus,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -116,9 +116,9 @@ test('forgeScorm2004NotifyQuestionAnswered pose une entrée cmi.interactions.n.*
|
||||
global.window.API_1484_11 = api;
|
||||
try {
|
||||
forgeScorm2004Init();
|
||||
forgeScorm2004NotifyQuestionAnswered({ id: 5, title: 'La quête' }, 1, 'Qui es-tu ?', true, 0, 0);
|
||||
forgeScorm2004NotifyQuestionAnswered('d_5', 'Le sorcier', 1, 'Qui es-tu ?', true, 0, 0);
|
||||
const sets = Object.fromEntries(api.calls.filter(c => c[0] === 'SetValue').map(c => [c[1], c[2]]));
|
||||
assert.equal(sets['cmi.interactions.2.id'], 'quest:5:question:1');
|
||||
assert.equal(sets['cmi.interactions.2.id'], 'dialogue:d_5:question:1');
|
||||
assert.equal(sets['cmi.interactions.2.type'], 'choice');
|
||||
assert.match(sets['cmi.interactions.2.timestamp'], /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}$/);
|
||||
assert.equal(sets['cmi.interactions.2.description'], '{fr-FR}Qui es-tu ?');
|
||||
@@ -134,7 +134,7 @@ test('forgeScorm2004NotifyQuestionAnswered — mauvaise réponse : result="incor
|
||||
global.window.API_1484_11 = api;
|
||||
try {
|
||||
forgeScorm2004Init();
|
||||
forgeScorm2004NotifyQuestionAnswered({ id: 5, title: 'La quête' }, 0, 'Qui es-tu ?', false, 1, 0);
|
||||
forgeScorm2004NotifyQuestionAnswered('d_5', 'Le sorcier', 0, 'Qui es-tu ?', false, 1, 0);
|
||||
const sets = Object.fromEntries(api.calls.filter(c => c[0] === 'SetValue').map(c => [c[1], c[2]]));
|
||||
assert.equal(sets['cmi.interactions.0.result'], 'incorrect');
|
||||
assert.equal(sets['cmi.interactions.0.learner_response'], '1');
|
||||
@@ -147,5 +147,5 @@ test('forgeScorm2004NotifyQuestionAnswered ne fait rien hors LMS (forgeScorm2004
|
||||
forgeScorm2004Init(); // aucune API trouvée -> remet forgeScorm2004Api à null
|
||||
// Si le repli silencieux (if (!forgeScorm2004Api) return;) était absent,
|
||||
// ceci lèverait en tentant d'appeler .GetValue sur null.
|
||||
assert.doesNotThrow(() => forgeScorm2004NotifyQuestionAnswered({ id: 1 }, 0, 'x', true, 0, 0));
|
||||
assert.doesNotThrow(() => forgeScorm2004NotifyQuestionAnswered('d_1', 'x', 0, 'x', true, 0, 0));
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Export Web/SCORM — bolt-on xAPI (voir ../xapi-client.js) : vérifie la
|
||||
// granularité par quête/question demandée par l'utilisateur (quête
|
||||
// acceptée/terminée, réponse à une question), en plus du score/statut
|
||||
// global déjà couvert par dialogue-box-controller.test.js.
|
||||
// granularité par dialogue/question demandée par l'utilisateur (dialogue
|
||||
// terminé, réponse à une question), en plus du score/statut global déjà
|
||||
// couvert par dialogue-box-controller.test.js.
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
@@ -10,8 +10,8 @@ global.window.addEventListener = global.window.addEventListener || function () {
|
||||
global.btoa = global.btoa || (str => Buffer.from(str).toString('base64'));
|
||||
|
||||
const {
|
||||
forgeXapiNotifyQuestAccepted, forgeXapiNotifyQuestCompleted, forgeXapiNotifyQuestionAnswered,
|
||||
forgeXapiQuestObject, forgeXapiQuestionObject,
|
||||
forgeXapiNotifyDialogueCompleted, forgeXapiNotifyQuestionAnswered,
|
||||
forgeXapiDialogueObject, forgeXapiQuestionObject,
|
||||
} = require('../xapi-client.js');
|
||||
|
||||
test.beforeEach(() => {
|
||||
@@ -21,45 +21,35 @@ test.beforeEach(() => {
|
||||
};
|
||||
});
|
||||
|
||||
test('forgeXapiQuestObject construit un id de sous-activité dérivé de l\'activité du jeu', () => {
|
||||
const obj = forgeXapiQuestObject({ id: 5, title: 'La quête du sorcier' });
|
||||
assert.equal(obj.id, 'urn:forge-engine:game:test:quest:5');
|
||||
assert.equal(obj.definition.name['fr-FR'], 'La quête du sorcier');
|
||||
test('forgeXapiDialogueObject construit un id de sous-activité dérivé de l\'activité du jeu, nommé d\'après l\'objet déclencheur', () => {
|
||||
const obj = forgeXapiDialogueObject('d_5', 'Le sorcier');
|
||||
assert.equal(obj.id, 'urn:forge-engine:game:test:dialogue:d_5');
|
||||
assert.equal(obj.definition.name['fr-FR'], 'Le sorcier');
|
||||
});
|
||||
|
||||
test('forgeXapiQuestionObject construit un id dérivé incluant la quête ET l\'index de question', () => {
|
||||
const obj = forgeXapiQuestionObject({ id: 5, title: 'La quête du sorcier' }, 2, 'Qui es-tu ?');
|
||||
assert.equal(obj.id, 'urn:forge-engine:game:test:quest:5:question:2');
|
||||
test('forgeXapiQuestionObject construit un id dérivé incluant le dialogue ET l\'index de question', () => {
|
||||
const obj = forgeXapiQuestionObject('d_5', 'Le sorcier', 2, 'Qui es-tu ?');
|
||||
assert.equal(obj.id, 'urn:forge-engine:game:test:dialogue:d_5:question:2');
|
||||
assert.equal(obj.definition.description['fr-FR'], 'Qui es-tu ?');
|
||||
});
|
||||
|
||||
test('forgeXapiNotifyQuestAccepted envoie un statement "attempted" ciblant la quête', () => {
|
||||
test('forgeXapiNotifyDialogueCompleted envoie un statement "completed" ciblant le dialogue (result.completion=true)', () => {
|
||||
const calls = [];
|
||||
global.fetch = (url, opts) => { calls.push({ url, opts }); return Promise.resolve(); };
|
||||
forgeXapiNotifyQuestAccepted({ id: 5, title: 'La quête du sorcier' });
|
||||
assert.equal(calls.length, 1);
|
||||
const body = JSON.parse(calls[0].opts.body);
|
||||
assert.equal(body.verb.id, 'http://adlnet.gov/expapi/verbs/attempted');
|
||||
assert.equal(body.object.id, 'urn:forge-engine:game:test:quest:5');
|
||||
});
|
||||
|
||||
test('forgeXapiNotifyQuestCompleted envoie un statement "completed" ciblant la quête (result.completion=true)', () => {
|
||||
const calls = [];
|
||||
global.fetch = (url, opts) => { calls.push({ url, opts }); return Promise.resolve(); };
|
||||
forgeXapiNotifyQuestCompleted({ id: 5, title: 'La quête du sorcier' });
|
||||
forgeXapiNotifyDialogueCompleted('d_5', 'Le sorcier');
|
||||
const body = JSON.parse(calls[0].opts.body);
|
||||
assert.equal(body.verb.id, 'http://adlnet.gov/expapi/verbs/completed');
|
||||
assert.equal(body.object.id, 'urn:forge-engine:game:test:quest:5');
|
||||
assert.equal(body.object.id, 'urn:forge-engine:game:test:dialogue:d_5');
|
||||
assert.equal(body.result.completion, true);
|
||||
});
|
||||
|
||||
test('forgeXapiNotifyQuestionAnswered envoie un statement "answered" avec success/score, ciblant la question', () => {
|
||||
const calls = [];
|
||||
global.fetch = (url, opts) => { calls.push({ url, opts }); return Promise.resolve(); };
|
||||
forgeXapiNotifyQuestionAnswered({ id: 5, title: 'La quête du sorcier' }, 1, 'Qui es-tu ?', true, 5);
|
||||
forgeXapiNotifyQuestionAnswered('d_5', 'Le sorcier', 1, 'Qui es-tu ?', true, 5);
|
||||
const body = JSON.parse(calls[0].opts.body);
|
||||
assert.equal(body.verb.id, 'http://adlnet.gov/expapi/verbs/answered');
|
||||
assert.equal(body.object.id, 'urn:forge-engine:game:test:quest:5:question:1');
|
||||
assert.equal(body.object.id, 'urn:forge-engine:game:test:dialogue:d_5:question:1');
|
||||
assert.equal(body.result.success, true);
|
||||
assert.equal(body.result.score.raw, 5);
|
||||
});
|
||||
@@ -67,7 +57,7 @@ test('forgeXapiNotifyQuestionAnswered envoie un statement "answered" avec succes
|
||||
test('une mauvaise réponse envoie quand même un statement, avec success=false et score=0', () => {
|
||||
const calls = [];
|
||||
global.fetch = (url, opts) => { calls.push({ url, opts }); return Promise.resolve(); };
|
||||
forgeXapiNotifyQuestionAnswered({ id: 5, title: 'La quête du sorcier' }, 0, 'Qui es-tu ?', false, 0);
|
||||
forgeXapiNotifyQuestionAnswered('d_5', 'Le sorcier', 0, 'Qui es-tu ?', false, 0);
|
||||
const body = JSON.parse(calls[0].opts.body);
|
||||
assert.equal(body.result.success, false);
|
||||
assert.equal(body.result.score.raw, 0);
|
||||
@@ -76,18 +66,17 @@ test('une mauvaise réponse envoie quand même un statement, avec success=false
|
||||
test('le libellé du verbe est fourni en anglais ET en français (LRS libre d\'afficher l\'un ou l\'autre)', () => {
|
||||
const calls = [];
|
||||
global.fetch = (url, opts) => { calls.push({ url, opts }); return Promise.resolve(); };
|
||||
forgeXapiNotifyQuestAccepted({ id: 5, title: 'La quête du sorcier' });
|
||||
forgeXapiNotifyDialogueCompleted('d_5', 'Le sorcier');
|
||||
const body = JSON.parse(calls[0].opts.body);
|
||||
assert.equal(body.verb.display['en-US'], 'attempted');
|
||||
assert.equal(body.verb.display['fr-FR'], 'commencé');
|
||||
assert.equal(body.verb.display['en-US'], 'completed');
|
||||
assert.equal(body.verb.display['fr-FR'], 'terminé');
|
||||
});
|
||||
|
||||
test('aucun envoi si FORGE_XAPI_CONFIG est null (xAPI non configuré pour ce jeu)', () => {
|
||||
global.window.FORGE_XAPI_CONFIG = null;
|
||||
let called = false;
|
||||
global.fetch = () => { called = true; return Promise.resolve(); };
|
||||
forgeXapiNotifyQuestAccepted({ id: 5, title: 'x' });
|
||||
forgeXapiNotifyQuestCompleted({ id: 5, title: 'x' });
|
||||
forgeXapiNotifyQuestionAnswered({ id: 5, title: 'x' }, 0, 'x', false, 0);
|
||||
forgeXapiNotifyDialogueCompleted('d_5', 'x');
|
||||
forgeXapiNotifyQuestionAnswered('d_5', 'x', 0, 'x', false, 0);
|
||||
assert.equal(called, false);
|
||||
});
|
||||
|
||||
@@ -24,7 +24,7 @@ function forgeScormInit() {
|
||||
if (!forgeScormApi) return; // ouvert hors LMS (test local) : dégrade en silence
|
||||
forgeScormApi.LMSInitialize("");
|
||||
// cmi.core.score.min/max (voir publish/build_scorm_package.py,
|
||||
// db/quests/reward_budget.py) : sans eux, le LMS n'a que le score BRUT
|
||||
// db/dialogue_lines.py::sum_question_rewards) : sans eux, le LMS n'a que le score BRUT
|
||||
// (cmi.core.score.raw, un total de points sans barème) et l'affiche
|
||||
// parfois à tort avec un "%" — en fournissant aussi min/max une seule
|
||||
// fois ici, le LMS peut calculer raw/max lui-même, un vrai pourcentage.
|
||||
|
||||
@@ -93,12 +93,12 @@ function forgeScorm2004Timestamp() {
|
||||
return new Date().toISOString().replace(/\.\d+Z$/, "");
|
||||
}
|
||||
|
||||
function forgeScorm2004NotifyQuestionAnswered(quest, questionIndex, questionText, correct, choiceIndex, correctIndex) {
|
||||
function forgeScorm2004NotifyQuestionAnswered(dialogueId, objectName, questionIndex, questionText, correct, choiceIndex, correctIndex) {
|
||||
if (!forgeScorm2004Api) return;
|
||||
var n = parseInt(forgeScorm2004Api.GetValue("cmi.interactions._count"), 10);
|
||||
if (isNaN(n)) n = 0;
|
||||
var prefix = "cmi.interactions." + n + ".";
|
||||
forgeScorm2004Api.SetValue(prefix + "id", "quest:" + quest.id + ":question:" + questionIndex);
|
||||
forgeScorm2004Api.SetValue(prefix + "id", "dialogue:" + dialogueId + ":question:" + questionIndex);
|
||||
forgeScorm2004Api.SetValue(prefix + "type", "choice");
|
||||
forgeScorm2004Api.SetValue(prefix + "timestamp", forgeScorm2004Timestamp());
|
||||
forgeScorm2004Api.SetValue(prefix + "description", "{fr-FR}" + (questionText || ""));
|
||||
|
||||
@@ -73,26 +73,28 @@ function forgeXapiObject() {
|
||||
}
|
||||
|
||||
// Sous-activités xAPI (voir static/js/play/dialogue-box-controller.js) :
|
||||
// une quête/question n'est PAS l'activité de premier niveau du jeu
|
||||
// un dialogue/question n'est PAS l'activité de premier niveau du jeu
|
||||
// (forgeXapiObject ci-dessus) mais une activité fille, identifiée par un
|
||||
// id dérivé — assez pour que le LRS les distingue sans configuration
|
||||
// supplémentaire côté créateur du jeu.
|
||||
function forgeXapiQuestObject(quest) {
|
||||
// supplémentaire côté créateur du jeu. `objectName` : le nom de l'objet
|
||||
// de scène qui a déclenché ce dialogue (voir "ℹ️ Informations") — plus de
|
||||
// "quête" pour le porter.
|
||||
function forgeXapiDialogueObject(dialogueId, objectName) {
|
||||
var config = window.FORGE_XAPI_CONFIG;
|
||||
return {
|
||||
objectType: "Activity",
|
||||
id: config.activity_id + ":quest:" + quest.id,
|
||||
definition: { name: { "fr-FR": quest.title || "Quête" }, type: "http://adlnet.gov/expapi/activities/simulation" },
|
||||
id: config.activity_id + ":dialogue:" + dialogueId,
|
||||
definition: { name: { "fr-FR": objectName || "Dialogue" }, type: "http://adlnet.gov/expapi/activities/simulation" },
|
||||
};
|
||||
}
|
||||
|
||||
function forgeXapiQuestionObject(quest, questionIndex, questionText) {
|
||||
function forgeXapiQuestionObject(dialogueId, objectName, questionIndex, questionText) {
|
||||
var config = window.FORGE_XAPI_CONFIG;
|
||||
return {
|
||||
objectType: "Activity",
|
||||
id: config.activity_id + ":quest:" + quest.id + ":question:" + questionIndex,
|
||||
id: config.activity_id + ":dialogue:" + dialogueId + ":question:" + questionIndex,
|
||||
definition: {
|
||||
name: { "fr-FR": "Question " + (questionIndex + 1) + " — " + (quest.title || "Quête") },
|
||||
name: { "fr-FR": "Question " + (questionIndex + 1) + " — " + (objectName || "Dialogue") },
|
||||
description: { "fr-FR": questionText || "" },
|
||||
type: "http://adlnet.gov/expapi/activities/cmi.interaction",
|
||||
interactionType: "choice",
|
||||
@@ -146,37 +148,25 @@ function forgeXapiNotifyScoreChanged(newScore) {
|
||||
);
|
||||
}
|
||||
|
||||
// Granularité quête/quiz (voir dialogue-box-controller.js) : demande
|
||||
// explicite "il faut savoir quand une quête a été acceptée, à telle
|
||||
// question quel point obtenu, bien répondu ou non" — un statement DÉDIÉ
|
||||
// par évènement, distinct du score/statut global (forgeXapiNotify
|
||||
// ScoreChanged/StatusChanged ci-dessus, qui restent l'activité de
|
||||
// premier niveau).
|
||||
function forgeXapiNotifyQuestAccepted(quest) {
|
||||
if (!window.FORGE_XAPI_CONFIG) return;
|
||||
// "attempted" (ADL standard) : pas de verbe dédié "accepted" dans le
|
||||
// vocabulaire ADL de base — attempted rend fidèlement "le joueur s'est
|
||||
// engagé sur cette quête".
|
||||
forgeXapiSendStatement(
|
||||
"http://adlnet.gov/expapi/verbs/attempted",
|
||||
null, forgeXapiQuestObject(quest)
|
||||
);
|
||||
}
|
||||
|
||||
function forgeXapiNotifyQuestCompleted(quest) {
|
||||
// Granularité dialogue/quiz (voir dialogue-box-controller.js) : demande
|
||||
// explicite "il faut savoir [...] à telle question quel point obtenu,
|
||||
// bien répondu ou non" — un statement DÉDIÉ par évènement, distinct du
|
||||
// score/statut global (forgeXapiNotifyScoreChanged/StatusChanged
|
||||
// ci-dessus, qui restent l'activité de premier niveau).
|
||||
function forgeXapiNotifyDialogueCompleted(dialogueId, objectName) {
|
||||
if (!window.FORGE_XAPI_CONFIG) return;
|
||||
forgeXapiSendStatement(
|
||||
"http://adlnet.gov/expapi/verbs/completed",
|
||||
{ completion: true }, forgeXapiQuestObject(quest)
|
||||
{ completion: true }, forgeXapiDialogueObject(dialogueId, objectName)
|
||||
);
|
||||
}
|
||||
|
||||
function forgeXapiNotifyQuestionAnswered(quest, questionIndex, questionText, correct, scoreAwarded) {
|
||||
function forgeXapiNotifyQuestionAnswered(dialogueId, objectName, questionIndex, questionText, correct, scoreAwarded) {
|
||||
if (!window.FORGE_XAPI_CONFIG) return;
|
||||
forgeXapiSendStatement(
|
||||
"http://adlnet.gov/expapi/verbs/answered",
|
||||
{ success: correct, score: { raw: scoreAwarded || 0 } },
|
||||
forgeXapiQuestionObject(quest, questionIndex, questionText)
|
||||
forgeXapiQuestionObject(dialogueId, objectName, questionIndex, questionText)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -203,7 +193,7 @@ if (typeof window !== "undefined") {
|
||||
if (typeof module !== "undefined" && module.exports) {
|
||||
module.exports = {
|
||||
forgeXapiSendStatement, forgeXapiNotifyScoreChanged, forgeXapiNotifyStatusChanged, FORGE_TO_XAPI_VERB,
|
||||
forgeXapiNotifyQuestAccepted, forgeXapiNotifyQuestCompleted, forgeXapiNotifyQuestionAnswered,
|
||||
forgeXapiQuestObject, forgeXapiQuestionObject, FORGE_XAPI_VERB_DISPLAY,
|
||||
forgeXapiNotifyDialogueCompleted, forgeXapiNotifyQuestionAnswered,
|
||||
forgeXapiDialogueObject, forgeXapiQuestionObject, FORGE_XAPI_VERB_DISPLAY,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,588 +0,0 @@
|
||||
// ---------- Éditeur de quêtes ----------
|
||||
// Onglet "🗺️ Quêtes" à côté de "🧩 Collision" (templates/scene_edit.html,
|
||||
// jamais une page à part) : une LISTE (mêmes tableau/lignes que l'onglet
|
||||
// "📣 Événements") plutôt qu'une grille de cartes. Un clic sur une ligne
|
||||
// ouvre la modale de FICHE (titre/objectif/récompense/statut/résultat —
|
||||
// "je crée"), qui ouvre à son tour, sur un bouton dédié, la modale
|
||||
// PLEIN ÉCRAN d'arbre de dialogue ("j'ajoute les dialogues") — deux
|
||||
// étapes séparées, jamais le même formulaire. Les deux modales sont
|
||||
// aussi ouvertes depuis l'assistant "+ Action" de l'éditeur de collision
|
||||
// (voir static/js/scenes/collision-rules-editor.js) quand l'auteur
|
||||
// choisit "Déclencher une quête" — créées dynamiquement (ensureQuest*Modal)
|
||||
// plutôt que dupliquées dans deux endroits.
|
||||
//
|
||||
// Une quête (voir db/quests/) : id, title, objectif, recompense_score,
|
||||
// status (nouvelle/en_cours/terminee — statut COURANT, informatif),
|
||||
// resultat (reussi/echoue), dialogues (une liste de répliques PAR
|
||||
// STATUT — 3 colonnes dans la modale : quel dialogue jouer selon l'état
|
||||
// de la quête au moment où le joueur parle au PNJ).
|
||||
|
||||
var QUEST_STATUS_LABELS = { nouvelle: 'Nouvelle', en_cours: 'En cours', terminee: 'Terminée' };
|
||||
var QUEST_RESULT_LABELS = { reussi: 'Réussi', echoue: 'Échoué' };
|
||||
var QUEST_STATUS_ORDER = ['nouvelle', 'en_cours', 'terminee'];
|
||||
|
||||
// Quêtes connues côté client — préremplies par QUESTS_JSON (voir
|
||||
// templates/scene_edit.html), toujours présentes puisque l'onglet
|
||||
// Quêtes vit dans le même éditeur que l'onglet Collision qui en a
|
||||
// besoin (collisionRuleActionSummary).
|
||||
var QUESTS = (typeof QUESTS_JSON !== 'undefined') ? QUESTS_JSON : [];
|
||||
|
||||
// Noms d'objets de scène nommés (voir "ℹ️ Informations", templates/
|
||||
// scene_edit.html) — N'IMPORTE QUEL objet (personnage, décor, fond), pas
|
||||
// seulement un personnage — proposés comme "qui parle" pour une réplique,
|
||||
// en plus de "Joueur" (toujours disponible, jamais un objet de scène).
|
||||
var SCENE_OBJECT_NAMES = (typeof SCENE_OBJECT_NAMES_JSON !== 'undefined') ? SCENE_OBJECT_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 objet nommé — "Joueur" 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; });
|
||||
}
|
||||
|
||||
function questListRowHtml(quest) {
|
||||
var resultatHtml = quest.resultat ? '<span class="questPill questPill-resultat">' + QUEST_RESULT_LABELS[quest.resultat] + '</span>' : '<span class="hint">—</span>';
|
||||
return (
|
||||
'<tr class="questListRow" onclick="openQuestFieldsModal(' + quest.id + ')">' +
|
||||
'<td><strong>' + (quest.title || '').replace(/</g, '<') + '</strong></td>' +
|
||||
'<td>' + (quest.objectif ? quest.objectif.replace(/</g, '<') : '<span class="hint">—</span>') + '</td>' +
|
||||
'<td><span class="questPill questPill-score">+' + (quest.recompense_score || 0) + '</span></td>' +
|
||||
'<td><span class="questPill questPill-status questPill-status-' + quest.status + '">' + QUEST_STATUS_LABELS[quest.status] + '</span></td>' +
|
||||
'<td>' + resultatHtml + '</td>' +
|
||||
'<td class="fieldsTableActions">' +
|
||||
'<button type="button" class="button is-small is-danger" title="Supprimer" onclick="event.stopPropagation(); questDeleteFromList(' + quest.id + ')">🗑️</button>' +
|
||||
'</td>' +
|
||||
'</tr>'
|
||||
);
|
||||
}
|
||||
|
||||
function renderQuestListPage() {
|
||||
var container = document.getElementById('questsTabPanel');
|
||||
if (!container) return;
|
||||
var wrap = document.getElementById('questsListWrap');
|
||||
if (!QUESTS.length) {
|
||||
wrap.innerHTML = '<p class="hint">Aucune quête pour l\'instant — crée-en une avec le bouton ci-dessus.</p>';
|
||||
return;
|
||||
}
|
||||
wrap.innerHTML =
|
||||
'<div class="fieldsTableWrap"><table class="table is-fullwidth is-hoverable fieldsTable">' +
|
||||
'<thead><tr><th>Titre</th><th>Objectif</th><th>Récompense</th><th>Statut</th><th>Résultat</th><th></th></tr></thead>' +
|
||||
'<tbody>' + QUESTS.map(questListRowHtml).join('') + '</tbody>' +
|
||||
'</table></div>';
|
||||
}
|
||||
|
||||
function createQuestFromListPage() {
|
||||
fetch('/game/' + GAME_SLUG + '/quests/new', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({}),
|
||||
})
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (json) {
|
||||
QUESTS.unshift(json.quest);
|
||||
renderQuestListPage();
|
||||
openQuestFieldsModal(json.quest.id);
|
||||
});
|
||||
}
|
||||
|
||||
function questDeleteFromList(questId) {
|
||||
var quest = questById(questId);
|
||||
if (!quest || !confirm('Supprimer la quête « ' + quest.title + ' » ? Toute règle de collision qui la référence perdra sa cible.')) return;
|
||||
fetch('/game/' + GAME_SLUG + '/quests/' + questId + '/delete', { method: 'POST' })
|
||||
.then(function () {
|
||||
QUESTS = QUESTS.filter(function (q) { return q.id !== questId; });
|
||||
renderQuestListPage();
|
||||
});
|
||||
}
|
||||
|
||||
// ---------- Deux modales distinctes ----------
|
||||
// 1. #questFieldsModal (taille normale) : "je crée" — titre/objectif/
|
||||
// récompense/statut/résultat, + un bouton qui ouvre...
|
||||
// 2. #questDialogueModal (PLEIN ÉCRAN) : "j'ajoute les dialogues" — les
|
||||
// 3 colonnes de répliques, rien d'autre. Étapes séparées demandées
|
||||
// explicitement par l'utilisateur (pas un seul gros formulaire).
|
||||
// Créées une seule fois chacune (au premier appel), réutilisées ensuite —
|
||||
// partagées avec l'assistant "+ Action" de l'éditeur de collision,
|
||||
// jamais dupliquées dans les templates.
|
||||
var _questModalOnUse = null;
|
||||
var _questModalCurrentId = null;
|
||||
|
||||
function ensureQuestFieldsModal() {
|
||||
var modal = document.getElementById('questFieldsModal');
|
||||
if (modal) return modal;
|
||||
modal = document.createElement('div');
|
||||
modal.className = 'modal';
|
||||
modal.id = 'questFieldsModal';
|
||||
modal.innerHTML =
|
||||
'<div class="modal-background" onclick="closeQuestFieldsModal()"></div>' +
|
||||
'<div class="modal-content questFieldsModalContent"><div class="box">' +
|
||||
'<div id="questFieldsModalBody"></div>' +
|
||||
'</div></div>';
|
||||
document.body.appendChild(modal);
|
||||
return modal;
|
||||
}
|
||||
|
||||
// `onUse(quest)` optionnel : posé par collisionWizardChooseAction('quete', ...)
|
||||
// pour finaliser la règle de collision avec cette quête, affiche alors un
|
||||
// bouton "✓ Utiliser cette quête" dans les deux modales.
|
||||
//
|
||||
// Masque #collisionRuleModal s'il est ouvert, SANS effacer _collisionWizard
|
||||
// (contrairement à closeCollisionRuleModal(), static/js/scenes/
|
||||
// collision-rules-editor.js) : appelée depuis questPickerChoose()/
|
||||
// questPickerCreate() (assistant "+ Action" de l'onglet Collision, action
|
||||
// "quete" — voir renderQuestPickerStep()), CETTE modale et
|
||||
// #collisionRuleModal partagent le même z-index (.modal de base) — sans la
|
||||
// masquer, les deux restaient actives en même temps, empilées dans l'ordre
|
||||
// du DOM plutôt que l'une à la place de l'autre : le bouton "+ Action"
|
||||
// semblait ne plus rien faire ensuite (il rouvrait bien #collisionRuleModal,
|
||||
// mais caché derrière celle-ci restée active). _collisionWizard doit
|
||||
// SURVIVRE à ce masquage : "✓ Utiliser cette quête" (questUseThisQuest)
|
||||
// finalise la règle en cours via collisionWizardFinalizeAction(), qui en a
|
||||
// encore besoin (objectId, trigger, pendingInteragir pour "interagir").
|
||||
function _hideCollisionRuleModalKeepingWizardState() {
|
||||
var modal = document.getElementById('collisionRuleModal');
|
||||
if (modal) modal.classList.remove('is-active');
|
||||
}
|
||||
|
||||
function openQuestFieldsModal(questId, onUse) {
|
||||
_hideCollisionRuleModalKeepingWizardState();
|
||||
_questModalOnUse = onUse || null;
|
||||
_questModalCurrentId = questId;
|
||||
var modal = ensureQuestFieldsModal();
|
||||
modal.classList.add('is-active');
|
||||
renderQuestFieldsModalBody(questId);
|
||||
}
|
||||
|
||||
// _questModalOnUse/_questModalCurrentId ne sont PAS remis à null ici :
|
||||
// openQuestDialogueModal() ferme cette modale en s'ouvrant (voir plus
|
||||
// bas) et a besoin de les garder pour son propre bouton "✓ Utiliser
|
||||
// cette quête" — chaque point d'entrée (openQuestFieldsModal) les fixe
|
||||
// de toute façon fraîchement à chaque ouverture.
|
||||
function closeQuestFieldsModal() {
|
||||
var modal = document.getElementById('questFieldsModal');
|
||||
if (modal) modal.classList.remove('is-active');
|
||||
}
|
||||
|
||||
function questUseThisQuest(questId) {
|
||||
var quest = questById(questId);
|
||||
if (_questModalOnUse && quest) _questModalOnUse(quest);
|
||||
closeQuestDialogueModal();
|
||||
closeQuestFieldsModal();
|
||||
_questModalOnUse = null;
|
||||
_questModalCurrentId = null;
|
||||
}
|
||||
|
||||
function questFieldRowHtml(quest) {
|
||||
return (
|
||||
'<div class="questFieldsGrid">' +
|
||||
'<label class="questField"><span>Titre</span><input type="text" class="input" value="' + (quest.title || '').replace(/"/g, '"') + '" onchange="questUpdateField(' + quest.id + ", 'title', this.value)\"></label>" +
|
||||
'<label class="questField questField-wide"><span>Objectif</span><input type="text" class="input" value="' + (quest.objectif || '').replace(/"/g, '"') + '" onchange="questUpdateField(' + quest.id + ", 'objectif', this.value)\"></label>" +
|
||||
'<label class="questField"><span>Récompense (score)</span><input type="number" class="input" value="' + (quest.recompense_score || 0) + '" onchange="questUpdateField(' + quest.id + ", 'recompense_score', this.value)\"></label>" +
|
||||
'<label class="questField"><span>Statut</span><select class="input" onchange="questUpdateField(' + quest.id + ", 'status', this.value)\">" +
|
||||
QUEST_STATUS_ORDER.map(function (s) { return '<option value="' + s + '"' + (quest.status === s ? ' selected' : '') + '>' + QUEST_STATUS_LABELS[s] + '</option>'; }).join('') +
|
||||
'</select></label>' +
|
||||
'<label class="questField"><span>Résultat</span><select class="input" onchange="questUpdateField(' + quest.id + ", 'resultat', this.value)\">" +
|
||||
'<option value=""' + (!quest.resultat ? ' selected' : '') + '>—</option>' +
|
||||
'<option value="reussi"' + (quest.resultat === 'reussi' ? ' selected' : '') + '>Réussi</option>' +
|
||||
'<option value="echoue"' + (quest.resultat === 'echoue' ? ' selected' : '') + '>Échoué</option>' +
|
||||
'</select></label>' +
|
||||
'</div>'
|
||||
);
|
||||
}
|
||||
|
||||
function renderQuestFieldsModalBody(questId) {
|
||||
var quest = questById(questId);
|
||||
var body = document.getElementById('questFieldsModalBody');
|
||||
if (!quest || !body) return;
|
||||
var useBtn = _questModalOnUse
|
||||
? '<button type="button" class="collisionRuleAddBtn" onclick="questUseThisQuest(' + quest.id + ')">Utiliser cette quête</button>'
|
||||
: '';
|
||||
body.innerHTML =
|
||||
'<div class="fieldRow questDialogueModalHeader" style="align-items:center; justify-content:space-between;">' +
|
||||
'<h2 class="title is-5" style="margin:0;">' + (quest.title || '').replace(/</g, '<') + '</h2>' +
|
||||
'<button type="button" class="button is-small" onclick="closeQuestFieldsModal()">✕</button>' +
|
||||
'</div>' +
|
||||
questFieldRowHtml(quest) +
|
||||
'<div class="fieldRow" style="justify-content:space-between; align-items:center; margin-top:6px;">' +
|
||||
'<button type="button" class="collisionRuleAddBtn" onclick="openQuestDialogueModal(' + quest.id + ')">Ajouter les dialogues</button>' +
|
||||
useBtn +
|
||||
'</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(SCENE_OBJECT_NAMES);
|
||||
if (currentSpeaker && options.indexOf(currentSpeaker) === -1) options.push(currentSpeaker);
|
||||
return options;
|
||||
}
|
||||
|
||||
// Une bulle = header (qui parle, menu déroulant des objets de scène
|
||||
// nommés — voir "ℹ️ Informations") + body (le texte) — deux zones
|
||||
// distinctes demandées explicitement, pas un seul bloc. Toujours centrée
|
||||
// dans sa colonne, 70% de la largeur (voir .questBubble, static/style.css) —
|
||||
// aucune limite au nombre de bulles par colonne (voir questAddBubble).
|
||||
function questDialogueBubbleHtml(line, status, index) {
|
||||
var kind = questBubbleKind(line.speaker);
|
||||
var speakerSelect = questSpeakerOptions(line.speaker).map(function (name) {
|
||||
return '<option value="' + name.replace(/"/g, '"') + '"' + (name === line.speaker ? ' selected' : '') + '>' + (name === 'Joueur' ? 'Joueur' : name) + '</option>';
|
||||
}).join('');
|
||||
return (
|
||||
'<div class="questBubbleRow">' +
|
||||
'<div class="questBubble questBubble-' + kind + '">' +
|
||||
'<div class="questBubbleHeader">' +
|
||||
'<select class="questBubbleSpeaker" title="Qui parle" onchange="questSetBubbleSpeaker(\'' + status + '\', ' + index + ', this.value)">' + speakerSelect + '</select>' +
|
||||
'<button type="button" class="questBubbleDelete" title="Supprimer" onclick="questDeleteBubble(\'' + status + '\', ' + index + ')">✕</button>' +
|
||||
'</div>' +
|
||||
'<div class="questBubbleBody">' +
|
||||
'<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, '<') + '</textarea>' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'</div>'
|
||||
);
|
||||
}
|
||||
|
||||
function questEscAttr(str) {
|
||||
return (str || '').replace(/"/g, '"');
|
||||
}
|
||||
|
||||
// "❓ Question" — une bulle JAUNE dans la même chaîne qu'une réplique de
|
||||
// dialogue (même colonne, même alignement vertical relié par un trait,
|
||||
// voir .questBubbleRow/.questBubble ci-dessus et static/style.css) mais
|
||||
// un type de ligne différent (voir db/quests/sanitize_quest_dialogues.py) :
|
||||
// header = nombre de choix + récompense (type + montant), body = la
|
||||
// question et ses choix, avec la bonne réponse cochée. Aucune limite au
|
||||
// nombre de questions par colonne (voir questAddQuestion), comme pour
|
||||
// une réplique.
|
||||
function questQuestionChoiceRowHtml(choice, choiceIndex, correctIndex, status, index) {
|
||||
var radioName = 'questCorrect-' + status + '-' + index;
|
||||
return (
|
||||
'<div class="questChoiceRow">' +
|
||||
'<input type="radio" name="' + radioName + '" class="questChoiceRadio" title="Bonne réponse" ' +
|
||||
(choiceIndex === correctIndex ? 'checked' : '') +
|
||||
' onchange="questSetQuestionCorrectIndex(\'' + status + '\', ' + index + ', ' + choiceIndex + ')">' +
|
||||
'<input type="text" class="questChoiceInput" placeholder="Choix ' + (choiceIndex + 1) + '" value="' + questEscAttr(choice) + '" ' +
|
||||
'onchange="questSetQuestionChoiceText(\'' + status + '\', ' + index + ', ' + choiceIndex + ', this.value)">' +
|
||||
'</div>'
|
||||
);
|
||||
}
|
||||
|
||||
function questQuestionBubbleHtml(line, status, index) {
|
||||
var choicesHtml = line.choices.map(function (c, i) { return questQuestionChoiceRowHtml(c, i, line.correct_index, status, index); }).join('');
|
||||
return (
|
||||
'<div class="questBubbleRow">' +
|
||||
'<div class="questBubble questBubble-question">' +
|
||||
'<div class="questBubbleHeader">' +
|
||||
'<label class="questQuestionHeaderField" title="Nombre de choix"><input type="number" min="2" max="4" value="' + line.choices.length + '" ' +
|
||||
'onchange="questSetQuestionChoiceCount(\'' + status + '\', ' + index + ', this.value)"></label>' +
|
||||
'<label class="questQuestionHeaderField" title="Récompense (points de score)"><input type="number" min="0" value="' + line.reward_amount + '" ' +
|
||||
'onchange="questSetQuestionRewardAmount(\'' + status + '\', ' + index + ', this.value)"> pts</label>' +
|
||||
'<button type="button" class="questBubbleDelete" title="Supprimer" onclick="questDeleteBubble(\'' + status + '\', ' + index + ')">✕</button>' +
|
||||
'</div>' +
|
||||
'<div class="questBubbleBody">' +
|
||||
'<input type="text" class="questQuestionText" placeholder="Question..." value="' + questEscAttr(line.text) + '" ' +
|
||||
'onchange="questUpdateQuestionText(\'' + status + '\', ' + index + ', this.value)">' +
|
||||
'<div class="questChoicesList">' + choicesHtml + '</div>' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'</div>'
|
||||
);
|
||||
}
|
||||
|
||||
function questDialogueColumnHtml(quest, status) {
|
||||
var lines = (quest.dialogues && quest.dialogues[status]) || [];
|
||||
var bubblesHtml = lines.map(function (line, i) {
|
||||
return line.type === 'question' ? questQuestionBubbleHtml(line, status, i) : questDialogueBubbleHtml(line, status, i);
|
||||
}).join('');
|
||||
return (
|
||||
'<div class="questDialogueColumn">' +
|
||||
'<h4 class="questDialogueColumnTitle">' + QUEST_STATUS_LABELS[status] + '</h4>' +
|
||||
'<div class="questDialogueColumnBubbles" id="questDialogueColumn-' + status + '">' + bubblesHtml + '</div>' +
|
||||
'<div class="questDialogueColumnButtons">' +
|
||||
'<button type="button" class="collisionRuleAddBtn questDialogueAddBtn" onclick="questAddBubble(\'' + status + '\')">Réplique</button>' +
|
||||
'<button type="button" class="collisionRuleAddBtn questDialogueAddBtn questQuestionAddBtn" onclick="questAddQuestion(\'' + status + '\')">Question</button>' +
|
||||
'</div>' +
|
||||
'</div>'
|
||||
);
|
||||
}
|
||||
|
||||
// Modale de dialogue — PLEIN ÉCRAN (voir .questDialogueModal en CSS),
|
||||
// ouverte DEPUIS la modale de fiche (bouton "💬 Ajouter les dialogues"),
|
||||
// jamais les deux à la fois : referme la fiche en s'ouvrant plutôt que de
|
||||
// les empiler, "← Fiche de la quête" y ramène.
|
||||
function ensureQuestDialogueModal() {
|
||||
var modal = document.getElementById('questDialogueModal');
|
||||
if (modal) return modal;
|
||||
modal = document.createElement('div');
|
||||
modal.className = 'modal questDialogueModal';
|
||||
modal.id = 'questDialogueModal';
|
||||
modal.innerHTML =
|
||||
'<div class="modal-background" onclick="closeQuestDialogueModal()"></div>' +
|
||||
'<div class="modal-content questDialogueModalContent"><div class="box">' +
|
||||
'<div id="questDialogueModalBody"></div>' +
|
||||
'</div></div>';
|
||||
document.body.appendChild(modal);
|
||||
return modal;
|
||||
}
|
||||
|
||||
function openQuestDialogueModal(questId) {
|
||||
_questModalCurrentId = questId;
|
||||
closeQuestFieldsModal();
|
||||
var modal = ensureQuestDialogueModal();
|
||||
modal.classList.add('is-active');
|
||||
renderQuestDialogueModalBody(questId);
|
||||
}
|
||||
|
||||
function closeQuestDialogueModal() {
|
||||
var modal = document.getElementById('questDialogueModal');
|
||||
if (modal) modal.classList.remove('is-active');
|
||||
}
|
||||
|
||||
function questBackToFieldsModal(questId) {
|
||||
closeQuestDialogueModal();
|
||||
openQuestFieldsModal(questId, _questModalOnUse);
|
||||
}
|
||||
|
||||
function renderQuestDialogueModalBody(questId) {
|
||||
var quest = questById(questId);
|
||||
var body = document.getElementById('questDialogueModalBody');
|
||||
if (!quest || !body) return;
|
||||
_questModalCurrentId = questId;
|
||||
var useBtn = _questModalOnUse
|
||||
? '<button type="button" class="collisionRuleAddBtn" onclick="questUseThisQuest(' + quest.id + ')">Utiliser cette quête</button>'
|
||||
: '';
|
||||
body.innerHTML =
|
||||
'<div class="fieldRow questDialogueModalHeader" style="align-items:center; justify-content:space-between;">' +
|
||||
'<div style="display:flex; align-items:center; gap:10px;">' +
|
||||
'<button type="button" class="button is-small" onclick="questBackToFieldsModal(' + quest.id + ')" title="Retour à la fiche">←</button>' +
|
||||
'<h2 class="title is-5" style="margin:0;">' + (quest.title || '').replace(/</g, '<') + ' — dialogues</h2>' +
|
||||
'</div>' +
|
||||
'<div style="display:flex; gap:8px; align-items:center;">' + useBtn +
|
||||
'<button type="button" class="button is-small" onclick="closeQuestDialogueModal()">✕</button>' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'<p class="hint mt-2 mb-2">Dialogue joué selon l\'état de la quête au moment où le joueur parle au PNJ :</p>' +
|
||||
'<div class="questDialogueColumns">' + QUEST_STATUS_ORDER.map(function (s) { return questDialogueColumnHtml(quest, s); }).join('') + '</div>';
|
||||
}
|
||||
|
||||
function questUpdateField(questId, field, value) {
|
||||
var quest = questById(questId);
|
||||
if (!quest) return;
|
||||
var body = {};
|
||||
body[field] = field === 'recompense_score' ? Number(value) : (value || null);
|
||||
fetch('/game/' + GAME_SLUG + '/quests/' + questId, {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body),
|
||||
})
|
||||
.then(function (r) { return r.json().then(function (json) { return { ok: r.ok, json: json }; }); })
|
||||
.then(function (res) {
|
||||
// Refusé (400) : recompense_score abaissé sous le total déjà
|
||||
// réparti entre les questions (voir routes/quests/quest_update.py)
|
||||
// — message d'erreur demandé explicitement, et le champ est
|
||||
// remis à sa valeur RÉELLE (inchangée côté serveur).
|
||||
if (!res.ok) { alert(res.json.error || "Impossible d'enregistrer."); renderQuestFieldsModalBody(questId); return; }
|
||||
if (!res.json.quest) return;
|
||||
Object.assign(quest, res.json.quest);
|
||||
renderQuestListPage();
|
||||
renderQuestFieldsModalBody(questId); // seule modale à porter des champs éditables
|
||||
});
|
||||
}
|
||||
|
||||
// Dialogues : toujours les 3 colonnes ENTIÈRES postées ensemble (voir
|
||||
// db/quests/set_quest_dialogues.py) — jamais une réplique isolée.
|
||||
// N'ADOPTE JAMAIS la réponse du serveur comme nouvel état local (bug
|
||||
// corrigé : sanitize_quest_dialogues.py rejette toute réplique au texte
|
||||
// encore vide — juste ajoutée, pas encore tapée — donc chaque
|
||||
// enregistrement renvoyait un tableau amputé de la bulle en cours
|
||||
// d'écriture ; l'adopter effaçait silencieusement la bulle que l'auteur
|
||||
// venait d'ajouter, et le clic suivant sur "+ Réplique" semblait la
|
||||
// "remplacer" au lieu d'en ajouter une seconde). L'état local, construit
|
||||
// par des mutations client explicites, est déjà la seule source de
|
||||
// vérité pendant l'édition.
|
||||
function questSaveDialogues(quest) {
|
||||
fetch('/game/' + GAME_SLUG + '/quests/' + quest.id + '/dialogues', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ dialogues: quest.dialogues }),
|
||||
})
|
||||
.then(function (r) {
|
||||
// Refusé (400) : la somme des récompenses des questions dépasse la
|
||||
// récompense totale de la quête (voir routes/quests/quest_dialogues.py)
|
||||
// — message d'erreur demandé explicitement. La bulle en trop reste
|
||||
// affichée localement (non enregistrée) : l'auteur baisse son
|
||||
// montant ou celui d'une autre question pour repasser sous le budget.
|
||||
if (!r.ok) return r.json().then(function (json) { alert(json.error || "Impossible d'enregistrer."); });
|
||||
});
|
||||
}
|
||||
|
||||
// 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 SCENE_OBJECT_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({ type: 'dialogue', speaker: questNextDefaultSpeaker(lastSpeaker), text: '' });
|
||||
renderQuestDialogueModalBody(quest.id);
|
||||
questSaveDialogues(quest);
|
||||
}
|
||||
|
||||
// "❓ Question" — voir questQuestionBubbleHtml ci-dessus. Défauts : 2
|
||||
// choix vides, la 1ère cochée bonne réponse, récompense "score" à 0 pt
|
||||
// (l'auteur règle le montant dans le header de la bulle).
|
||||
function questAddQuestion(status) {
|
||||
var quest = questById(_currentQuestModalId());
|
||||
if (!quest) return;
|
||||
quest.dialogues = quest.dialogues || {};
|
||||
var lines = quest.dialogues[status] || (quest.dialogues[status] = []);
|
||||
lines.push({ type: 'question', text: '', choices: ['', ''], correct_index: 0, reward_type: 'score', reward_amount: 0 });
|
||||
renderQuestDialogueModalBody(quest.id);
|
||||
questSaveDialogues(quest);
|
||||
}
|
||||
|
||||
function questUpdateQuestionText(status, index, text) {
|
||||
var quest = questById(_currentQuestModalId());
|
||||
if (!quest) return;
|
||||
quest.dialogues[status][index].text = text;
|
||||
questSaveDialogues(quest);
|
||||
}
|
||||
|
||||
// Ajuste le NOMBRE de choix (2 à 8, voir db/quests/sanitize_quest_dialogues.py
|
||||
// ::_MIN_CHOICES/_MAX_CHOICES) : ajoute des choix vides ou retire les
|
||||
// derniers, sans jamais toucher aux choix déjà remplis qui restent dans
|
||||
// la nouvelle taille. Recadre la bonne réponse sur le premier choix si
|
||||
// elle pointait vers un choix retiré.
|
||||
function questSetQuestionChoiceCount(status, index, value) {
|
||||
var quest = questById(_currentQuestModalId());
|
||||
if (!quest) return;
|
||||
var line = quest.dialogues[status][index];
|
||||
var count = Math.max(2, Math.min(4, parseInt(value, 10) || 2));
|
||||
while (line.choices.length < count) line.choices.push('');
|
||||
while (line.choices.length > count) line.choices.pop();
|
||||
if (line.correct_index >= line.choices.length) line.correct_index = 0;
|
||||
renderQuestDialogueModalBody(quest.id);
|
||||
questSaveDialogues(quest);
|
||||
}
|
||||
|
||||
function questSetQuestionChoiceText(status, index, choiceIndex, text) {
|
||||
var quest = questById(_currentQuestModalId());
|
||||
if (!quest) return;
|
||||
quest.dialogues[status][index].choices[choiceIndex] = text;
|
||||
questSaveDialogues(quest);
|
||||
}
|
||||
|
||||
function questSetQuestionCorrectIndex(status, index, choiceIndex) {
|
||||
var quest = questById(_currentQuestModalId());
|
||||
if (!quest) return;
|
||||
quest.dialogues[status][index].correct_index = choiceIndex;
|
||||
questSaveDialogues(quest);
|
||||
}
|
||||
|
||||
function questSetQuestionRewardAmount(status, index, value) {
|
||||
var quest = questById(_currentQuestModalId());
|
||||
if (!quest) return;
|
||||
quest.dialogues[status][index].reward_amount = Math.max(0, parseInt(value, 10) || 0);
|
||||
questSaveDialogues(quest);
|
||||
}
|
||||
|
||||
function questSetBubbleSpeaker(status, index, speaker) {
|
||||
var quest = questById(_currentQuestModalId());
|
||||
if (!quest) return;
|
||||
quest.dialogues[status][index].speaker = speaker;
|
||||
renderQuestDialogueModalBody(quest.id);
|
||||
questSaveDialogues(quest);
|
||||
}
|
||||
|
||||
function questUpdateBubbleText(status, index, text) {
|
||||
var quest = questById(_currentQuestModalId());
|
||||
if (!quest) return;
|
||||
quest.dialogues[status][index].text = text;
|
||||
questSaveDialogues(quest);
|
||||
}
|
||||
|
||||
function questDeleteBubble(status, index) {
|
||||
var quest = questById(_currentQuestModalId());
|
||||
if (!quest) return;
|
||||
quest.dialogues[status].splice(index, 1);
|
||||
renderQuestDialogueModalBody(quest.id);
|
||||
questSaveDialogues(quest);
|
||||
}
|
||||
|
||||
function _currentQuestModalId() {
|
||||
return _questModalCurrentId;
|
||||
}
|
||||
|
||||
// ---------- Sélecteur de quête (assistant "+ Action" de l'éditeur de collision) ----------
|
||||
// Affiche des cartes + "+ Nouvelle quête" DANS la modale de collision
|
||||
// déjà ouverte (voir renderCollisionWizardActionStep) ; choisir/créer une
|
||||
// quête ouvre ENSUITE la modale de FICHE (celle-ci par-dessus), avec un
|
||||
// bouton "✓ Utiliser cette quête" (disponible aussi depuis la modale de
|
||||
// dialogue) qui referme tout et finalise la règle — exactement le
|
||||
// comportement demandé : cliquer "Déclencher une quête" ouvre l'éditeur
|
||||
// de quête, pas un simple champ texte.
|
||||
function questPickerHtml(quests) {
|
||||
if (!quests.length) {
|
||||
return '<p class="hint mb-2">Aucune quête pour l\'instant.</p>';
|
||||
}
|
||||
return '<div class="collisionWizardChoices">' + quests.map(function (q, i) {
|
||||
return '<button type="button" class="collisionWizardChoice" style="animation-delay:' + (i * 40) + 'ms" onclick="questPickerChoose(' + q.id + ')">' +
|
||||
'<span>' + (q.title || '').replace(/</g, '<') + '</span>' +
|
||||
'</button>';
|
||||
}).join('') + '</div>';
|
||||
}
|
||||
|
||||
var _questPickerOnUse = null;
|
||||
|
||||
function renderQuestPickerStep(onUse) {
|
||||
_questPickerOnUse = onUse;
|
||||
var body = collisionWizardBodyEl();
|
||||
body.innerHTML = '<p class="hint mb-2">Quelle quête déclencher ?</p><div id="questPickerList">Chargement…</div>' +
|
||||
'<button type="button" class="collisionRuleAddBtn mt-2" onclick="questPickerCreate()">Nouvelle quête</button>';
|
||||
fetch('/game/' + GAME_SLUG + '/quests/list.json')
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (json) {
|
||||
QUESTS = json.quests;
|
||||
var list = document.getElementById('questPickerList');
|
||||
if (list) list.innerHTML = questPickerHtml(QUESTS);
|
||||
});
|
||||
}
|
||||
|
||||
function questPickerChoose(questId) {
|
||||
openQuestFieldsModal(questId, _questPickerOnUse);
|
||||
}
|
||||
|
||||
function questPickerCreate() {
|
||||
fetch('/game/' + GAME_SLUG + '/quests/new', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({}),
|
||||
})
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (json) {
|
||||
QUESTS.unshift(json.quest);
|
||||
openQuestFieldsModal(json.quest.id, _questPickerOnUse);
|
||||
});
|
||||
}
|
||||
|
||||
// Appelé directement (pas de DOMContentLoaded) : ce fichier est chargé par
|
||||
// un <script src> posé à la fin du contenu de la page — les éléments dont
|
||||
// on a besoin existent déjà à ce stade. Nécessaire aussi pour la
|
||||
// navigation pjax (voir static/pjax.js — runScriptsIn ré-exécute ce
|
||||
// script à chaque page swappée, mais DOMContentLoaded, lui, ne se
|
||||
// redéclenche jamais après le tout premier chargement réel) — sans ça,
|
||||
// l'onglet "🗺️ Quêtes" restait vide après un remplacement de <main> (ex.
|
||||
// suppression d'un objet de scène, voir scene_object_delete.py) qui ne
|
||||
// redéclenche jamais cet événement.
|
||||
if (typeof QUESTS_JSON !== 'undefined') renderQuestListPage();
|
||||
@@ -1,185 +1,77 @@
|
||||
// ---------- Éditeur de collision (nouvel onglet "🧩 Collision") ----------
|
||||
// Une carte par objet ÉLIGIBLE (jamais un "fond", jamais le personnage
|
||||
// "joueur" — voir COLLISION_RULE_OBJECTS, construit par routes/scenes/
|
||||
// scene_edit_view.py), listant ses règles "déclencheur -> action" déjà
|
||||
// posées + un bouton "+ Action" qui ouvre un assistant pas à pas (jamais
|
||||
// un vrai formulaire à plusieurs champs) : un clic choisit une option, la
|
||||
// carte suivante s'emboîte aussitôt. Voir screens/rendering/
|
||||
// collision_rules.py pour la forme exacte d'une règle (côté serveur, où
|
||||
// elle est validée une seconde fois avant d'être enregistrée).
|
||||
// ---------- Assistant "déclencheur -> action" (fusionné dans l'onglet "Déclencheurs") ----------
|
||||
// L'onglet "🧩 Collision" séparé a été retiré (redondant une fois la
|
||||
// vue/gestion des déclencheurs intégrée à l'onglet "Déclencheurs", voir
|
||||
// static/js/triggers/trigger-editor.js::openAddTriggerModal) — ce
|
||||
// fichier ne porte plus que le MOTEUR de l'assistant lui-même
|
||||
// (déclencheur -> action, un clic choisit une option, la carte suivante
|
||||
// s'emboîte aussitôt), invoqué exclusivement via openTriggerWizard()
|
||||
// ci-dessous. Voir screens/rendering/collision_rules.py pour la forme
|
||||
// exacte d'une règle (côté serveur, où elle est validée une seconde fois
|
||||
// avant d'être enregistrée).
|
||||
|
||||
var COLLISION_ACTION_LABELS = {
|
||||
quete: 'Déclencher une quête', attaque: 'Attaquer la cible',
|
||||
evenement: 'Déclencher un événement', interagir: 'Interagir avec une touche',
|
||||
dialogue: 'Déclencher ce dialogue', variable: 'Modifier une variable',
|
||||
interagir: 'Interagir avec une touche', condition: 'Si une condition est vraie',
|
||||
};
|
||||
// "attaque"/"evenement" retirés de l'assistant (pas besoin pour le
|
||||
// moment, demande explicite) — server-side (screens/rendering/
|
||||
// collision_rules.py::ACTION_TYPES) les accepte encore telles quelles,
|
||||
// pour ne jamais casser une règle déjà enregistrée avec l'un des deux.
|
||||
var COLLISION_WIZARD_ACTION_TYPES = ['quete', 'interagir'];
|
||||
var COLLISION_TRIGGER_LABELS = { collision: 'À la collision', perimetre: 'Dans un périmètre' };
|
||||
|
||||
function collisionRuleObjectLabel(obj) {
|
||||
var kindLabel = obj.kind === 'personnage' ? (obj.role === 'ennemie' ? 'Ennemi' : 'PNJ') : 'Décor';
|
||||
return (obj.name || kindLabel);
|
||||
}
|
||||
|
||||
// Un maillon = un segment de la "chaîne de pièces" affichée dans la carte
|
||||
// (déclencheur, puis action, puis éventuellement sous-action) — texte
|
||||
// seul, la logique de lecture vit dans collisionRuleActionSummary/
|
||||
// collisionRuleTriggerSummary ci-dessous.
|
||||
function collisionRuleTriggerSummary(rule) {
|
||||
if (rule.trigger === 'perimetre') return COLLISION_TRIGGER_LABELS.perimetre + ' (' + rule.perimetre_px + 'px)';
|
||||
return COLLISION_TRIGGER_LABELS.collision;
|
||||
}
|
||||
|
||||
function collisionRuleActionSummary(action) {
|
||||
if (action.type === 'quete') {
|
||||
var quest = (typeof questById === 'function') ? questById(action.quete_id) : null;
|
||||
return COLLISION_ACTION_LABELS.quete + ' « ' + (quest ? quest.title : '#' + action.quete_id) + ' »';
|
||||
}
|
||||
if (action.type === 'evenement') {
|
||||
var ev = CUSTOM_EVENTS_MAP[action.evenement_id];
|
||||
return COLLISION_ACTION_LABELS.evenement + ' « ' + (ev ? ev.name : '?') + ' »';
|
||||
}
|
||||
return COLLISION_ACTION_LABELS[action.type] || action.type;
|
||||
}
|
||||
|
||||
// Une règle = une chaîne de "maillons" en forme de pièces qui s'emboîtent
|
||||
// (déclencheur -> action -> sous-action éventuelle), pas une liste plate —
|
||||
// chaque maillon porte un data-kind (couleur en CSS) et un délai d'entrée
|
||||
// en cascade (voir @keyframes collisionLinkIn, static/style.css).
|
||||
function collisionRuleChainHtml(rule, ruleIndex, objectId) {
|
||||
var links = [
|
||||
{ kind: 'trigger', text: collisionRuleTriggerSummary(rule) },
|
||||
{ kind: rule.action.type, text: collisionRuleActionSummary(rule.action), questId: rule.action.quete_id },
|
||||
];
|
||||
var action = rule.action;
|
||||
while (action.type === 'interagir' && action.sub_action) {
|
||||
links.push({ kind: action.sub_action.type, text: collisionRuleActionSummary(action.sub_action), questId: action.sub_action.quete_id });
|
||||
action = action.sub_action;
|
||||
}
|
||||
// Un maillon "quete" (kind==='quete') ouvre directement sa modale de
|
||||
// dialogue (voir openQuestDialogueModal(), static/js/quests/
|
||||
// quest-editor.js) au clic — demande explicite, plutôt qu'un simple
|
||||
// texte informatif comme les autres maillons. Reste un <span> comme les
|
||||
// autres (jamais un <button>, dont le style natif — apparence système,
|
||||
// marges par défaut — grignotait la pointe en clip-path malgré les
|
||||
// resets CSS) : un onclick sur un <span> suffit à le rendre cliquable,
|
||||
// ".collisionRuleLink[data-kind='quete']" ajoute juste le curseur/hover
|
||||
// qui le distingue visuellement (voir static/style.css).
|
||||
var linksHtml = links.map(function (link, i) {
|
||||
var clickable = link.kind === 'quete' && link.questId != null;
|
||||
var onclick = clickable ? ' onclick="openQuestDialogueModal(' + link.questId + ')"' : '';
|
||||
return '<span class="collisionRuleLink" data-kind="' + link.kind + '" style="animation-delay:' + (i * 60) + 'ms"' + onclick + '>' +
|
||||
link.text.replace(/</g, '<') + '</span>';
|
||||
}).join('');
|
||||
return (
|
||||
'<div class="collisionRuleChain">' +
|
||||
'<div class="collisionRuleChainLinks">' + linksHtml + '</div>' +
|
||||
'<button type="button" class="collisionRuleChainDelete" title="Supprimer cette règle" ' +
|
||||
'onclick="deleteCollisionRule(' + objectId + ', ' + ruleIndex + ')">🗑️</button>' +
|
||||
'</div>'
|
||||
);
|
||||
}
|
||||
var COLLISION_WIZARD_ACTION_TYPES = ['dialogue', 'variable', 'interagir', 'condition'];
|
||||
var COLLISION_TRIGGER_LABELS = { collision: 'À la collision' };
|
||||
// Opérations qui n'ont besoin d'AUCUNE valeur (voir screens/labels/
|
||||
// data_operations.py::DATA_OPERATIONS) — le champ valeur de l'étape
|
||||
// "Modifier une variable" (renderCollisionWizardVariableStep) se masque
|
||||
// pour celles-ci.
|
||||
var COLLISION_NO_VALUE_OPERATIONS = ['definir_bool_vrai', 'definir_bool_faux', 'basculer_bool'];
|
||||
|
||||
// obj.thumbnail_html (screens.render_scene_object) porte un style inline
|
||||
// PENSÉ POUR LE CANEVAS (position:absolute + left/top en dur, voir
|
||||
// screens/scenes/render_scene_object.py) — un personnage déplacé loin
|
||||
// dans la scène (left/top de plusieurs centaines de px) ressortait donc
|
||||
// hors du petit cadre 120×120 de cette miniature : la règle CSS
|
||||
// `.collisionRuleCardThumb img` (voir static/style.css) est censée le
|
||||
// neutraliser (position:static !important etc.), mais s'appuyer sur un
|
||||
// forçage CSS pour annuler un style inline entier reste fragile. Plus
|
||||
// robuste : reconstruire ici une balise <img> "propre", sans AUCUN style
|
||||
// hérité du canevas — seul le src (l'image elle-même) est repris.
|
||||
// hors du petit cadre de la miniature : la règle CSS `.questTriggerObjectCard
|
||||
// img`/`.questTriggerObjectThumb img` (static/style.css) est censée le
|
||||
// neutraliser, mais s'appuyer sur un forçage CSS pour annuler un style
|
||||
// inline entier reste fragile. Plus robuste : reconstruire ici une balise
|
||||
// <img> "propre", sans AUCUN style hérité du canevas — seul le src
|
||||
// (l'image elle-même) est repris. Utilisée par static/js/triggers/
|
||||
// trigger-editor.js (chaîne de bulles + grille de choix d'objet).
|
||||
function collisionRuleThumbHtml(obj) {
|
||||
var match = /\ssrc="([^"]*)"/.exec(obj.thumbnail_html || '');
|
||||
return match ? '<img src="' + match[1] + '" alt="">' : '';
|
||||
}
|
||||
|
||||
function collisionRuleCardHtml(obj) {
|
||||
var rulesHtml = obj.rules.length
|
||||
? obj.rules.map(function (rule, i) { return collisionRuleChainHtml(rule, i, obj.id); }).join('')
|
||||
: '<p class="hint">Aucune règle pour l\'instant.</p>';
|
||||
return (
|
||||
'<div class="collisionRuleCard" id="collisionRuleCard-' + obj.id + '">' +
|
||||
'<div class="collisionRuleCardHeader">' +
|
||||
'<div class="collisionRuleCardThumb">' + collisionRuleThumbHtml(obj) + '</div>' +
|
||||
'<h3 class="collisionRuleCardTitle">' + collisionRuleObjectLabel(obj).replace(/</g, '<') + '</h3>' +
|
||||
'</div>' +
|
||||
'<div class="collisionRuleCardRules">' + rulesHtml + '</div>' +
|
||||
'<button type="button" class="collisionRuleAddBtn" onclick="openCollisionRuleModal(' + obj.id + ')">Action</button>' +
|
||||
'</div>'
|
||||
);
|
||||
// Id court généré CÔTÉ CLIENT pour une NOUVELLE feuille ("dialogue" ou
|
||||
// "variable", voir screens/rendering/collision_rules.py) — jamais
|
||||
// recalculé/réattribué côté serveur. Pour un dialogue, sert de
|
||||
// dialogue.id (édité en ligne, voir trigger-editor.js) ; pour une
|
||||
// variable, sert d'id à la feuille elle-même — dans les deux cas,
|
||||
// permet d'adresser CETTE feuille précise pour y attacher un "then"
|
||||
// plus tard (voir "+ Ajouter une action", trigger-editor.js).
|
||||
function collisionGenerateActionId() {
|
||||
return 'd_' + Date.now().toString(36) + Math.random().toString(36).slice(2, 6);
|
||||
}
|
||||
|
||||
function renderCollisionRuleCards() {
|
||||
var container = document.getElementById('collisionRulesCards');
|
||||
if (!container) return;
|
||||
if (!COLLISION_RULE_OBJECTS.length) {
|
||||
container.innerHTML = '<p class="hint">Aucun objet éligible pour l\'instant — un décor, un ennemi ou un PNJ (jamais le joueur lui-même).</p>';
|
||||
return;
|
||||
}
|
||||
// Chaque carte entre en cascade (voir @keyframes collisionCardIn,
|
||||
// static/style.css) plutôt que toutes en même temps.
|
||||
container.innerHTML = COLLISION_RULE_OBJECTS.map(function (obj, i) {
|
||||
return collisionRuleCardHtml(obj).replace('<div class="collisionRuleCard"', '<div style="animation-delay:' + (i * 50) + 'ms" class="collisionRuleCard"');
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function collisionRuleObjectById(objectId) {
|
||||
return COLLISION_RULE_OBJECTS.find(function (o) { return o.id === objectId; });
|
||||
}
|
||||
|
||||
// Enregistre la liste COMPLÈTE des règles de cet objet (voir
|
||||
// routes/scenes/scene_object_collision_rules.py — toujours la liste
|
||||
// entière, jamais une règle isolée) puis redessine SA carte.
|
||||
function saveCollisionRules(objectId) {
|
||||
var obj = collisionRuleObjectById(objectId);
|
||||
if (!obj) return;
|
||||
fetch('/game/' + GAME_SLUG + '/scene-objects/' + objectId + '/collision-rules', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ rules: obj.rules }),
|
||||
})
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (json) {
|
||||
if (json.rules) obj.rules = json.rules; // reflète la validation serveur (voir sanitize_collision_rules)
|
||||
renderCollisionRuleCards();
|
||||
});
|
||||
}
|
||||
|
||||
function deleteCollisionRule(objectId, ruleIndex) {
|
||||
var obj = collisionRuleObjectById(objectId);
|
||||
if (!obj) return;
|
||||
obj.rules.splice(ruleIndex, 1);
|
||||
saveCollisionRules(objectId);
|
||||
}
|
||||
|
||||
// ---------- Assistant "+ Action" ----------
|
||||
// ---------- Assistant "déclencheur -> action" ----------
|
||||
// État du parcours en cours — un seul à la fois (la modale n'autorise
|
||||
// qu'une règle en construction) : l'objet ciblé, le déclencheur choisi,
|
||||
// et l'action en cours de construction (posée directement dans
|
||||
// `action`, ou dans `action.sub_action` au second niveau pour "interagir").
|
||||
// Plus de "quête cible" à connaître d'avance — la règle est posée sur
|
||||
// l'objet, un déclencheur "dialogue" se suffit à lui-même.
|
||||
var _collisionWizard = null;
|
||||
|
||||
function openCollisionRuleModal(objectId) {
|
||||
_collisionWizard = { objectId: objectId, trigger: null, perimetrePx: COLLISION_DEFAULT_PERIMETRE_PX };
|
||||
document.getElementById('collisionRuleModal').classList.add('is-active');
|
||||
// Appelée après avoir choisi l'objet dans la modale d'ajout de déclencheur
|
||||
// (voir triggerSelectObject, trigger-editor.js).
|
||||
function openTriggerWizard(objectId, bodyEl) {
|
||||
_collisionWizard = { objectId: objectId, trigger: null, bodyEl: bodyEl };
|
||||
renderCollisionWizardTriggerStep();
|
||||
}
|
||||
|
||||
function closeCollisionRuleModal() {
|
||||
document.getElementById('collisionRuleModal').classList.remove('is-active');
|
||||
_collisionWizard = null;
|
||||
}
|
||||
|
||||
function collisionWizardBodyEl() {
|
||||
return document.getElementById('collisionRuleModalBody');
|
||||
return _collisionWizard.bodyEl;
|
||||
}
|
||||
|
||||
// Étape 1 — déclencheur : collision directe, ou périmètre (px réglable,
|
||||
// valeur par défaut COLLISION_DEFAULT_PERIMETRE_PX).
|
||||
// Étape 1 — déclencheur (un seul choix pour l'instant, "collision") : la
|
||||
// carte à choix reste affichée telle quelle (plutôt que sautée
|
||||
// automatiquement) pour rester extensible à de futurs types sans rien
|
||||
// changer à la structure de l'assistant.
|
||||
function renderCollisionWizardTriggerStep() {
|
||||
collisionWizardBodyEl().innerHTML =
|
||||
'<p class="hint mb-2">Cette règle se déclenche...</p>' +
|
||||
@@ -187,22 +79,11 @@ function renderCollisionWizardTriggerStep() {
|
||||
'<button type="button" class="collisionWizardChoice" style="animation-delay:0ms" onclick="collisionWizardChooseTrigger(\'collision\')">' +
|
||||
'<span>' + COLLISION_TRIGGER_LABELS.collision + '</span>' +
|
||||
'</button>' +
|
||||
'<button type="button" class="collisionWizardChoice" style="animation-delay:40ms" onclick="collisionWizardChooseTrigger(\'perimetre\')">' +
|
||||
'<span>' + COLLISION_TRIGGER_LABELS.perimetre + '</span>' +
|
||||
'<span class="fieldRow" style="align-items:center; gap:6px;" onclick="event.stopPropagation()">' +
|
||||
'<input type="number" min="1" id="collisionWizardPerimetrePx" value="' + COLLISION_DEFAULT_PERIMETRE_PX + '" style="width:64px;" class="input is-small">' +
|
||||
'<span class="hint">px</span>' +
|
||||
'</span>' +
|
||||
'</button>' +
|
||||
'</div>';
|
||||
}
|
||||
|
||||
function collisionWizardChooseTrigger(trigger) {
|
||||
_collisionWizard.trigger = trigger;
|
||||
if (trigger === 'perimetre') {
|
||||
var pxEl = document.getElementById('collisionWizardPerimetrePx');
|
||||
_collisionWizard.perimetrePx = (pxEl && parseFloat(pxEl.value)) || COLLISION_DEFAULT_PERIMETRE_PX;
|
||||
}
|
||||
renderCollisionWizardActionStep(false);
|
||||
}
|
||||
|
||||
@@ -240,54 +121,233 @@ function collisionWizardFinalizeAction(action, nested) {
|
||||
collisionWizardSaveRule(action);
|
||||
}
|
||||
|
||||
// Enregistre via la route /game/<slug>/triggers (routes/triggers/triggers.py)
|
||||
// — même contrat "liste ENTIÈRE" que l'ancien éditeur de collision
|
||||
// (recharge les règles existantes de l'objet, y ajoute la nouvelle,
|
||||
// sauvegarde tout), géré côté serveur : le client ne poste que la
|
||||
// nouvelle règle + l'id d'objet.
|
||||
function collisionWizardSaveRule(action) {
|
||||
var rule = { trigger: _collisionWizard.trigger, action: action };
|
||||
if (_collisionWizard.trigger === 'perimetre') rule.perimetre_px = _collisionWizard.perimetrePx;
|
||||
var obj = collisionRuleObjectById(_collisionWizard.objectId);
|
||||
obj.rules.push(rule);
|
||||
var objectId = _collisionWizard.objectId;
|
||||
closeCollisionRuleModal();
|
||||
saveCollisionRules(objectId);
|
||||
closeTriggerModal();
|
||||
fetch('/game/' + GAME_SLUG + '/triggers', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ object_id: _collisionWizard.objectId, rule: rule }),
|
||||
}).then(function () { triggerLoadAll(); });
|
||||
}
|
||||
|
||||
// Chaque type d'action demande éventuellement une précision avant de
|
||||
// pouvoir se finaliser ("quête" ouvre l'éditeur de quête lui-même — voir
|
||||
// static/js/quests/quest-editor.js::renderQuestPickerStep, pas un simple
|
||||
// champ texte ; événement -> lequel) — "attaque" n'a besoin de rien de
|
||||
// plus, se finalise directement.
|
||||
// "dialogue"/"variable" (des FEUILLES, voir screens/rendering/
|
||||
// collision_rules.py) enchaînent sur "Ensuite ?" (voir
|
||||
// renderCollisionWizardChainStep — Marquer terminé + chaîner une autre
|
||||
// action, demande explicite : "pouvoir modifier un déclencheur pour lui
|
||||
// ajouter des actions") ; "interagir" enchaîne sur une carte de plus
|
||||
// (voir collisionWizardFinalizeAction) ; "condition" enchaîne sur son
|
||||
// propre mini-formulaire (voir renderCollisionWizardConditionStep).
|
||||
function collisionWizardChooseAction(type, nested) {
|
||||
if (type === 'attaque' || type === 'interagir') {
|
||||
if (type === 'interagir') {
|
||||
collisionWizardFinalizeAction({ type: type }, nested);
|
||||
return;
|
||||
}
|
||||
if (type === 'quete') {
|
||||
renderQuestPickerStep(function (quest) {
|
||||
collisionWizardFinalizeAction({ type: 'quete', quete_id: quest.id }, nested);
|
||||
if (type === 'dialogue') {
|
||||
var leaf = { type: 'dialogue', dialogue: { id: collisionGenerateActionId(), lines: [] } };
|
||||
renderCollisionWizardChainStep(leaf, function (finalLeaf) { collisionWizardFinalizeAction(finalLeaf, nested); });
|
||||
return;
|
||||
}
|
||||
if (type === 'variable') {
|
||||
renderCollisionWizardVariableStep(function (action) {
|
||||
renderCollisionWizardChainStep(action, function (finalLeaf) { collisionWizardFinalizeAction(finalLeaf, nested); });
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (type === 'evenement') {
|
||||
var eventIds = Object.keys(CUSTOM_EVENTS_MAP);
|
||||
if (!eventIds.length) {
|
||||
collisionWizardBodyEl().innerHTML = '<p class="hint">Aucun événement personnalisé pour l\'instant — crée-en un dans l\'onglet "Événements", puis reviens ici.</p>';
|
||||
return;
|
||||
}
|
||||
var buttons = eventIds.map(function (id, i) {
|
||||
return '<button type="button" class="collisionWizardChoice" style="animation-delay:' + (i * 40) + 'ms" onclick="collisionWizardFinalizeAction({type:\'evenement\', evenement_id:' + id + '}, ' + (nested ? 'true' : 'false') + ')">' +
|
||||
'<span>' + CUSTOM_EVENTS_MAP[id].name.replace(/</g, '<') + '</span>' +
|
||||
'</button>';
|
||||
}).join('');
|
||||
collisionWizardBodyEl().innerHTML = '<p class="hint mb-2">Quel événement ?</p><div class="collisionWizardChoices">' + buttons + '</div>';
|
||||
if (type === 'condition') {
|
||||
renderCollisionWizardConditionStep(nested);
|
||||
}
|
||||
}
|
||||
|
||||
// Appelé directement (pas de DOMContentLoaded) : ce fichier est chargé par
|
||||
// un <script src> posé à la fin du contenu de la page — les éléments dont
|
||||
// on a besoin existent déjà à ce stade. Nécessaire aussi pour la
|
||||
// navigation pjax (voir static/pjax.js — runScriptsIn ré-exécute ce
|
||||
// script à chaque page swappée, mais DOMContentLoaded, lui, ne se
|
||||
// redéclenche jamais après le tout premier chargement réel) — sans ça,
|
||||
// l'onglet "🧩 Collision" restait vide après un remplacement de <main>
|
||||
// (ex. suppression d'un objet de scène, voir scene_object_delete.py) qui
|
||||
// ne redéclenche jamais cet événement.
|
||||
if (typeof COLLISION_RULE_OBJECTS !== 'undefined') renderCollisionRuleCards();
|
||||
// ---------- Étape "Modifier une variable" ----------
|
||||
// Même vocabulaire EXACT que l'action "Modifier une variable" du graphe
|
||||
// de logique document (GLOBAL_VARIABLE_NAMES et DATA_OPERATION_LABELS_MAP
|
||||
// déjà posés par templates/scene_edit.html — ce dernier n'était utilisé
|
||||
// nulle part avant cette fonctionnalité) : variable ciblée, opération,
|
||||
// valeur (masquée pour les opérations qui n'en ont pas besoin, voir
|
||||
// COLLISION_NO_VALUE_OPERATIONS). `onChoose(action)`.
|
||||
function renderCollisionWizardVariableStep(onChoose) {
|
||||
var variableOptions = (typeof GLOBAL_VARIABLE_NAMES !== 'undefined' ? GLOBAL_VARIABLE_NAMES : [])
|
||||
.map(function (name) { return '<option value="' + name + '">' + name + '</option>'; }).join('');
|
||||
var operationOptions = Object.keys(DATA_OPERATION_LABELS_MAP).map(function (key) {
|
||||
return '<option value="' + key + '">' + DATA_OPERATION_LABELS_MAP[key] + '</option>';
|
||||
}).join('');
|
||||
collisionWizardBodyEl().innerHTML =
|
||||
'<p class="hint mb-2">Modifier...</p>' +
|
||||
'<div class="collisionWizardConditionForm">' +
|
||||
'<select id="varWizVariable">' + variableOptions + '</select>' +
|
||||
'<select id="varWizOperation" onchange="collisionWizardVariableOperationChanged()">' + operationOptions + '</select>' +
|
||||
'<input type="text" id="varWizValue" placeholder="Valeur">' +
|
||||
'</div>' +
|
||||
'<button type="button" class="button primary mt-3" onclick="collisionWizardVariableNext()">Continuer</button>';
|
||||
_collisionWizard.onVariableChosen = onChoose;
|
||||
collisionWizardVariableOperationChanged();
|
||||
}
|
||||
|
||||
function collisionWizardVariableOperationChanged() {
|
||||
var operation = document.getElementById('varWizOperation').value;
|
||||
var valueInput = document.getElementById('varWizValue');
|
||||
valueInput.style.display = COLLISION_NO_VALUE_OPERATIONS.indexOf(operation) === -1 ? '' : 'none';
|
||||
}
|
||||
|
||||
function collisionWizardVariableNext() {
|
||||
var operation = document.getElementById('varWizOperation').value;
|
||||
var action = {
|
||||
type: 'variable', id: collisionGenerateActionId(),
|
||||
target_variable: document.getElementById('varWizVariable').value,
|
||||
data_operation: operation,
|
||||
};
|
||||
if (COLLISION_NO_VALUE_OPERATIONS.indexOf(operation) === -1) {
|
||||
action.data_value = document.getElementById('varWizValue').value;
|
||||
}
|
||||
var onChoose = _collisionWizard.onVariableChosen;
|
||||
_collisionWizard.onVariableChosen = null;
|
||||
onChoose(action);
|
||||
}
|
||||
|
||||
// ---------- Étape "Ensuite ?" (chaînage + Marquer terminé) ----------
|
||||
// Après CHAQUE feuille construite (dialogue ou variable, au premier
|
||||
// niveau, sous "interagir", ou dans une branche de condition) : la case
|
||||
// "Marquer terminé" (seulement pour un dialogue) PUIS le choix de
|
||||
// chaîner une autre action à la suite (voir screens/rendering/
|
||||
// collision_rules.py::"then", demande explicite "pouvoir modifier un
|
||||
// déclencheur pour lui ajouter des actions") — jamais bloqué à une seule
|
||||
// action par déclencheur. `onDone(leafAction)` reçoit la PREMIÈRE
|
||||
// feuille de la chaîne (les suivantes sont attachées via `.then`).
|
||||
function renderCollisionWizardChainStep(leafAction, onDone) {
|
||||
var completionHtml = leafAction.type === 'dialogue'
|
||||
? '<label style="display:flex; align-items:center; gap:6px; font-size:13px; margin-bottom:10px;">' +
|
||||
'<input type="checkbox" id="condWizMarkCompleted"' + (leafAction.mark_completed ? ' checked' : '') + '> Marquer ce dialogue comme terminé une fois joué' +
|
||||
'</label>'
|
||||
: '';
|
||||
collisionWizardBodyEl().innerHTML =
|
||||
completionHtml +
|
||||
'<p class="hint mb-2">Ensuite ?</p>' +
|
||||
'<div class="collisionWizardChoices">' +
|
||||
'<button type="button" class="collisionWizardChoice" style="animation-delay:0ms" onclick="collisionWizardChainDone(\'none\')"><span>Rien de plus</span></button>' +
|
||||
'<button type="button" class="collisionWizardChoice" style="animation-delay:40ms" onclick="collisionWizardChainDone(\'dialogue\')"><span>Déclencher un dialogue</span></button>' +
|
||||
'<button type="button" class="collisionWizardChoice" style="animation-delay:80ms" onclick="collisionWizardChainDone(\'variable\')"><span>Modifier une variable</span></button>' +
|
||||
'</div>';
|
||||
_collisionWizard.chainLeaf = leafAction;
|
||||
_collisionWizard.onChainDone = onDone;
|
||||
}
|
||||
|
||||
function collisionWizardChainDone(choice) {
|
||||
var leafAction = _collisionWizard.chainLeaf;
|
||||
var onDone = _collisionWizard.onChainDone;
|
||||
if (leafAction.type === 'dialogue') {
|
||||
if (document.getElementById('condWizMarkCompleted').checked) leafAction.mark_completed = true;
|
||||
else delete leafAction.mark_completed;
|
||||
}
|
||||
if (choice === 'none') {
|
||||
onDone(leafAction);
|
||||
return;
|
||||
}
|
||||
if (choice === 'dialogue') {
|
||||
var nextLeaf = { type: 'dialogue', dialogue: { id: collisionGenerateActionId(), lines: [] } };
|
||||
leafAction.then = nextLeaf;
|
||||
renderCollisionWizardChainStep(nextLeaf, onDone);
|
||||
return;
|
||||
}
|
||||
// 'variable'
|
||||
renderCollisionWizardVariableStep(function (action) {
|
||||
leafAction.then = action;
|
||||
renderCollisionWizardChainStep(action, onDone);
|
||||
});
|
||||
}
|
||||
|
||||
// Étape "condition" — variable/opérateur/valeur (même vocabulaire que le
|
||||
// nœud Condition de l'éditeur de logique, voir screens/flow/constants.py
|
||||
// ::CONDITION_OPERATORS ; GLOBAL_VARIABLE_NAMES et
|
||||
// CONDITION_OPERATOR_LABELS_MAP posés par templates/scene_edit.html).
|
||||
// `nested` (interagir -> condition) est reporté jusqu'à la finalisation,
|
||||
// une fois les deux branches Vrai/Faux choisies ci-dessous.
|
||||
function renderCollisionWizardConditionStep(nested) {
|
||||
var variableOptions = (typeof GLOBAL_VARIABLE_NAMES !== 'undefined' ? GLOBAL_VARIABLE_NAMES : [])
|
||||
.map(function (name) { return '<option value="' + name + '">' + name + '</option>'; }).join('');
|
||||
var operatorOptions = Object.keys(CONDITION_OPERATOR_LABELS_MAP).map(function (key) {
|
||||
return '<option value="' + key + '">' + CONDITION_OPERATOR_LABELS_MAP[key] + '</option>';
|
||||
}).join('');
|
||||
collisionWizardBodyEl().innerHTML =
|
||||
'<p class="hint mb-2">Si...</p>' +
|
||||
'<div class="collisionWizardConditionForm">' +
|
||||
'<select id="condWizVariable">' + variableOptions + '</select>' +
|
||||
'<select id="condWizOperator">' + operatorOptions + '</select>' +
|
||||
'<input type="text" id="condWizValue" placeholder="Valeur">' +
|
||||
'</div>' +
|
||||
'<button type="button" class="button primary mt-3" onclick="collisionWizardConditionNext(' + (nested ? 'true' : 'false') + ')">Continuer</button>';
|
||||
}
|
||||
|
||||
function collisionWizardConditionNext(nested) {
|
||||
_collisionWizard.pendingCondition = {
|
||||
type: 'condition',
|
||||
variable: document.getElementById('condWizVariable').value,
|
||||
operateur: document.getElementById('condWizOperator').value,
|
||||
valeur: document.getElementById('condWizValue').value,
|
||||
si_vrai: null, si_faux: null,
|
||||
};
|
||||
_collisionWizard.pendingConditionNested = nested;
|
||||
renderCollisionWizardBranchStep('si_vrai');
|
||||
}
|
||||
|
||||
// Étape branche (Vrai puis Faux) — chaque branche est soit "Ne rien
|
||||
// faire", soit une FEUILLE ("Déclencher un dialogue" ou "Modifier une
|
||||
// variable", voir screens/rendering/collision_rules.py::
|
||||
// _sanitize_condition_branch : une branche reste une feuille, jamais une
|
||||
// nouvelle "interagir"/"condition" imbriquée) — les DEUX branches
|
||||
// peuvent chacune déclencher LEUR PROPRE feuille (et sa propre chaîne),
|
||||
// indépendamment l'une de l'autre.
|
||||
function renderCollisionWizardBranchStep(branchKey) {
|
||||
var label = branchKey === 'si_vrai' ? '✓ Si VRAI...' : '✕ Si FAUX...';
|
||||
collisionWizardBodyEl().innerHTML =
|
||||
'<p class="hint mb-2">' + label + '</p>' +
|
||||
'<div class="collisionWizardChoices">' +
|
||||
'<button type="button" class="collisionWizardChoice" style="animation-delay:0ms" onclick="collisionWizardChooseBranch(\'' + branchKey + '\', \'dialogue\')">' +
|
||||
'<span>Déclencher un dialogue</span>' +
|
||||
'</button>' +
|
||||
'<button type="button" class="collisionWizardChoice" style="animation-delay:40ms" onclick="collisionWizardChooseBranch(\'' + branchKey + '\', \'variable\')">' +
|
||||
'<span>Modifier une variable</span>' +
|
||||
'</button>' +
|
||||
'<button type="button" class="collisionWizardChoice" style="animation-delay:80ms" onclick="collisionWizardChooseBranch(\'' + branchKey + '\', \'none\')">' +
|
||||
'<span>Ne rien faire</span>' +
|
||||
'</button>' +
|
||||
'</div>';
|
||||
}
|
||||
|
||||
function collisionWizardChooseBranch(branchKey, choice) {
|
||||
if (choice === 'none') {
|
||||
_collisionWizard.pendingCondition[branchKey] = null;
|
||||
collisionWizardBranchDone(branchKey);
|
||||
return;
|
||||
}
|
||||
if (choice === 'dialogue') {
|
||||
var leaf = { type: 'dialogue', dialogue: { id: collisionGenerateActionId(), lines: [] } };
|
||||
renderCollisionWizardChainStep(leaf, function (finalLeaf) {
|
||||
_collisionWizard.pendingCondition[branchKey] = finalLeaf;
|
||||
collisionWizardBranchDone(branchKey);
|
||||
});
|
||||
return;
|
||||
}
|
||||
// 'variable'
|
||||
renderCollisionWizardVariableStep(function (action) {
|
||||
renderCollisionWizardChainStep(action, function (finalLeaf) {
|
||||
_collisionWizard.pendingCondition[branchKey] = finalLeaf;
|
||||
collisionWizardBranchDone(branchKey);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function collisionWizardBranchDone(branchKey) {
|
||||
if (branchKey === 'si_vrai') {
|
||||
renderCollisionWizardBranchStep('si_faux');
|
||||
return;
|
||||
}
|
||||
var nested = _collisionWizard.pendingConditionNested;
|
||||
var condition = _collisionWizard.pendingCondition;
|
||||
_collisionWizard.pendingCondition = null;
|
||||
collisionWizardFinalizeAction(condition, nested);
|
||||
}
|
||||
|
||||
@@ -51,7 +51,6 @@ function addSceneObject(kind, forgeCharacter, backgroundSlug) {
|
||||
'<div class="canvasElementInner">' + json.rendered_html + '</div>' +
|
||||
'<div class="resizeHandle" onmousedown="onSceneObjectResizeMouseDown(event, ' + json.id + ')"></div>';
|
||||
canvas.appendChild(el);
|
||||
refreshCollisionRuleObjects();
|
||||
if (json.animations) {
|
||||
ELEMENT_ANIMATIONS_MAP[json.id] = json.animations;
|
||||
restartPersonnageIdlePreview(json.id, json.animations);
|
||||
@@ -87,7 +86,6 @@ function deleteSceneObject(id) {
|
||||
var el = document.getElementById('el-' + id);
|
||||
if (el) el.remove();
|
||||
if (typeof _stopPersonnageIdlePreview === 'function') _stopPersonnageIdlePreview(id);
|
||||
refreshCollisionRuleObjects();
|
||||
// L'objet supprimé était forcément le sélectionné (seul cas où 🗑️
|
||||
// est affiché) — referme le panneau de propriétés et l'URL, sans
|
||||
// requête supplémentaire au serveur.
|
||||
@@ -355,25 +353,7 @@ function saveSceneObjectRole(role) {
|
||||
fetch('/game/' + GAME_SLUG + '/scene-objects/' + objectId + '/role', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ role: role }),
|
||||
}).then(refreshCollisionRuleObjects);
|
||||
}
|
||||
|
||||
// Réutilisé après ajout/suppression/changement de rôle ou de nom (voir
|
||||
// routes/scenes/scene_collision_rule_objects.py) : un changement de rôle
|
||||
// peut faire apparaître/disparaître une carte (passage à/depuis "joueur",
|
||||
// jamais dans "🧩 Collision") et un changement de nom change son libellé
|
||||
// affiché — recalculée entièrement côté serveur plutôt que rejouée en JS
|
||||
// (même logique déjà nécessaire pour le rôle implicite d'une scène posée
|
||||
// avant ce réglage, voir build_collision_rule_objects) SANS recharger la
|
||||
// page.
|
||||
function refreshCollisionRuleObjects() {
|
||||
if (typeof COLLISION_RULE_OBJECTS === 'undefined') return;
|
||||
fetch('/game/' + GAME_SLUG + '/screens/' + SCREEN_ID + '/collision-rule-objects')
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (json) {
|
||||
COLLISION_RULE_OBJECTS = json;
|
||||
renderCollisionRuleCards();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Boîte de collision (voir screens/rendering/collision_settings.py,
|
||||
@@ -579,26 +559,24 @@ function addPersonnageCommandExtraRow() {
|
||||
}
|
||||
|
||||
// "ℹ️ 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).
|
||||
// personnage, réutilisé comme "qui parle" dans l'éditeur de dialogue d'un
|
||||
// déclencheur (voir static/js/triggers/trigger-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 }),
|
||||
}).then(function () {
|
||||
refreshCollisionRuleObjects();
|
||||
refreshSceneObjectNames();
|
||||
});
|
||||
}).then(refreshSceneObjectNames);
|
||||
}
|
||||
|
||||
// SCENE_OBJECT_NAMES (voir routes/scenes/scene_object_names_list.py) :
|
||||
// posée UNE SEULE FOIS au chargement complet de la page (<script> inline
|
||||
// en bas, HORS de #builder3, jamais rafraîchie par une simple sélection
|
||||
// sans rechargement, voir goToSelected()) — sans ce rafraîchissement,
|
||||
// renommer un personnage puis ouvrir la modale de dialogue dans la MÊME
|
||||
// session ne faisait jamais apparaître son nouveau nom dans "qui parle"
|
||||
// (voir questSpeakerOptions(), static/js/quests/quest-editor.js) tant
|
||||
// qu'aucun vrai rechargement de page n'avait eu lieu entre temps.
|
||||
// renommer un personnage puis ouvrir un dialogue de déclencheur dans la
|
||||
// MÊME session ne faisait jamais apparaître son nouveau nom dans "qui
|
||||
// parle" (voir triggerSpeakerOptions(), static/js/triggers/
|
||||
// trigger-editor.js) tant qu'aucun vrai rechargement de page n'avait eu
|
||||
// lieu entre temps.
|
||||
function refreshSceneObjectNames() {
|
||||
if (typeof SCENE_OBJECT_NAMES === 'undefined') return;
|
||||
fetch('/game/' + GAME_SLUG + '/scene-object-names')
|
||||
@@ -721,21 +699,16 @@ initPersonnageCommandsPanel();
|
||||
// viennent d'être recréés par le même remplacement, voir
|
||||
// startAllPersonnagePreviews()/personnage-preview.js).
|
||||
//
|
||||
// .builderCanvasArea (les onglets "🖥️ Scène"/"🧩 Collision"/"🗺️ Quêtes"/
|
||||
// "📣 Événements") est ENTIÈREMENT à l'intérieur de #builder3 (voir
|
||||
// templates/scene_edit.html) — le remplacer réinjecte donc aussi
|
||||
// #collisionRulesCards/le conteneur des quêtes tels que le SERVEUR les
|
||||
// rend, c'est-à-dire VIDES (ces listes sont rendues entièrement en JS,
|
||||
// jamais en Jinja, voir collision-rules-editor.js/quest-editor.js) : sans
|
||||
// les redessiner ici, les onglets "🧩 Collision"/"🗺️ Quêtes" semblaient se
|
||||
// "vider" à chaque sélection d'un objet de scène. COLLISION_RULE_OBJECTS/
|
||||
// QUESTS_JSON eux-mêmes ne sont PAS affectés par ce remplacement (déclarés
|
||||
// dans le <script> inline en bas de page, HORS de #builder3) — il suffit
|
||||
// donc de rejouer leur rendu, pas de les recharger.
|
||||
// .builderCanvasArea (les onglets "🖥️ Scène"/"Déclencheurs"/"📣 Événements")
|
||||
// est ENTIÈREMENT à l'intérieur de #builder3 (voir templates/scene_edit.html)
|
||||
// — le remplacer réinjecte donc aussi le conteneur des déclencheurs tel
|
||||
// que le SERVEUR le rend, c'est-à-dire VIDE (rendu entièrement en JS,
|
||||
// jamais en Jinja, voir trigger-editor.js) : sans le redessiner ici,
|
||||
// l'onglet "Déclencheurs" semblait se "vider" à chaque sélection d'un
|
||||
// objet de scène.
|
||||
function initSceneBuilderPanel() {
|
||||
initScenePosFields();
|
||||
initPersonnageCommandsPanel();
|
||||
if (typeof startAllPersonnagePreviews === 'function') startAllPersonnagePreviews();
|
||||
if (typeof renderCollisionRuleCards === 'function') renderCollisionRuleCards();
|
||||
if (typeof renderQuestListPage === 'function') renderQuestListPage();
|
||||
if (typeof initTriggersTab === 'function') initTriggersTab();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,648 @@
|
||||
// ---------- Onglet "Déclencheurs" ----------
|
||||
// templates/scene_edit.html : un GRAPHE DE LOGIQUE lu par déclencheur
|
||||
// (demande explicite : "un RH doit le comprendre, un visuel global") —
|
||||
// TOUS les déclencheurs du JEU ENTIER vivent côte à côte dans la même
|
||||
// colonne (plus de "quête" pour les regrouper, voir screens/rendering/
|
||||
// trigger_graph.py::list_all_triggers), chacun avec SON PROPRE dialogue
|
||||
// embarqué ({id, lines}, voir screens/rendering/collision_rules.py::
|
||||
// "dialogue".dialogue). Les boutons Réplique/Question sont posés
|
||||
// DIRECTEMENT sur le rectangle de dialogue de chaque déclencheur — le
|
||||
// seul bouton au bas de la colonne est "Ajouter un déclencheur". Le
|
||||
// créateur décide lui-même, via ses variables globales et conditions
|
||||
// (voir screens/rendering/collision_rules.py::"condition"), QUAND
|
||||
// déclencher quel dialogue et QUAND marquer un dialogue comme terminé
|
||||
// (action.mark_completed, voir static/js/play/dialogue-box-controller.js).
|
||||
|
||||
// Noms d'objets de scène nommés (voir "ℹ️ Informations", templates/
|
||||
// scene_edit.html) — N'IMPORTE QUEL objet (personnage, décor, fond), pas
|
||||
// seulement un personnage — proposés comme "qui parle" pour une réplique,
|
||||
// en plus de "Joueur" (toujours disponible, jamais un objet de scène).
|
||||
var SCENE_OBJECT_NAMES = (typeof SCENE_OBJECT_NAMES_JSON !== 'undefined') ? SCENE_OBJECT_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 objet nommé — "Joueur" en vert, n'importe quel autre nom
|
||||
// en bleu.
|
||||
function triggerBubbleKind(speaker) {
|
||||
return speaker === 'Joueur' ? 'joueur' : 'pnj';
|
||||
}
|
||||
|
||||
// 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 triggerSpeakerOptions(currentSpeaker) {
|
||||
var options = ['Joueur'].concat(SCENE_OBJECT_NAMES);
|
||||
if (currentSpeaker && options.indexOf(currentSpeaker) === -1) options.push(currentSpeaker);
|
||||
return options;
|
||||
}
|
||||
|
||||
function triggerEscAttr(str) {
|
||||
return (str || '').replace(/"/g, '"');
|
||||
}
|
||||
|
||||
// ---------- Colonne = un graphe de logique lu par déclencheur ----------
|
||||
function triggerColumnHtml() {
|
||||
return (
|
||||
'<div class="questDialogueColumn">' +
|
||||
'<div class="questDialogueColumnBody" id="triggerColumnBody">' +
|
||||
'<div class="questTriggersSlot" id="triggersSlot"></div>' +
|
||||
'</div>' +
|
||||
'<div class="questDialogueColumnButtons">' +
|
||||
'<button type="button" class="collisionRuleAddBtn questDialogueAddBtn" onclick="openAddTriggerModal()">Ajouter un déclencheur</button>' +
|
||||
'</div>' +
|
||||
'</div>'
|
||||
);
|
||||
}
|
||||
|
||||
// ---------- Dialogue INLINE (voir screens/rendering/collision_rules.py
|
||||
// ::"dialogue".dialogue) ----------
|
||||
// Un dialogue = {id, lines} porté PAR L'ACTION elle-même — rendu comme un
|
||||
// rectangle à bords arrondis, MÊME largeur que le reste de la chaîne
|
||||
// (pastilles/carte objet), avec un bouton "Réplique"/"Question" qui
|
||||
// ajoute une bulle directement dedans.
|
||||
|
||||
// Une bulle = header (qui parle, menu déroulant des objets de scène
|
||||
// nommés — voir "ℹ️ Informations") + body (le texte) — deux zones
|
||||
// distinctes, pas un seul bloc.
|
||||
function triggerDialogueBubbleHtml(dialogueId, line, index) {
|
||||
var kind = triggerBubbleKind(line.speaker);
|
||||
var speakerSelect = triggerSpeakerOptions(line.speaker).map(function (name) {
|
||||
return '<option value="' + name.replace(/"/g, '"') + '"' + (name === line.speaker ? ' selected' : '') + '>' + (name === 'Joueur' ? 'Joueur' : name) + '</option>';
|
||||
}).join('');
|
||||
return (
|
||||
'<div class="questBubbleRow">' +
|
||||
'<div class="questBubble questBubble-' + kind + '">' +
|
||||
'<div class="questBubbleHeader">' +
|
||||
'<select class="questBubbleSpeaker" title="Qui parle" onchange="triggerInlineSetBubbleSpeaker(\'' + dialogueId + '\', ' + index + ', this.value)">' + speakerSelect + '</select>' +
|
||||
'<button type="button" class="questBubbleDelete" title="Supprimer" onclick="triggerInlineDeleteBubble(\'' + dialogueId + '\', ' + index + ')">✕</button>' +
|
||||
'</div>' +
|
||||
'<div class="questBubbleBody">' +
|
||||
'<textarea class="questBubbleText" rows="1" placeholder="Réplique..." ' +
|
||||
'oninput="this.style.height=\'auto\'; this.style.height=this.scrollHeight+\'px\';" ' +
|
||||
'onchange="triggerInlineUpdateBubbleText(\'' + dialogueId + '\', ' + index + ', this.value)">' + line.text.replace(/</g, '<') + '</textarea>' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'</div>'
|
||||
);
|
||||
}
|
||||
|
||||
// "❓ Question" — une bulle JAUNE dans la même chaîne qu'une réplique de
|
||||
// dialogue (voir .questBubbleRow/.questBubble et static/style.css) mais
|
||||
// un type de ligne différent (voir db/dialogue_lines.py) : header =
|
||||
// nombre de choix + récompense (type + montant), body = la question et
|
||||
// ses choix, avec la bonne réponse cochée.
|
||||
function triggerQuestionChoiceRowHtml(dialogueId, choice, choiceIndex, correctIndex, index) {
|
||||
var radioName = 'triggerCorrect-' + dialogueId + '-' + index;
|
||||
return (
|
||||
'<div class="questChoiceRow">' +
|
||||
'<input type="radio" name="' + radioName + '" class="questChoiceRadio" title="Bonne réponse" ' +
|
||||
(choiceIndex === correctIndex ? 'checked' : '') +
|
||||
' onchange="triggerInlineSetQuestionCorrectIndex(\'' + dialogueId + '\', ' + index + ', ' + choiceIndex + ')">' +
|
||||
'<input type="text" class="questChoiceInput" placeholder="Choix ' + (choiceIndex + 1) + '" value="' + triggerEscAttr(choice) + '" ' +
|
||||
'onchange="triggerInlineSetQuestionChoiceText(\'' + dialogueId + '\', ' + index + ', ' + choiceIndex + ', this.value)">' +
|
||||
'</div>'
|
||||
);
|
||||
}
|
||||
|
||||
function triggerQuestionBubbleHtml(dialogueId, line, index) {
|
||||
var choicesHtml = line.choices.map(function (c, i) { return triggerQuestionChoiceRowHtml(dialogueId, c, i, line.correct_index, index); }).join('');
|
||||
return (
|
||||
'<div class="questBubbleRow">' +
|
||||
'<div class="questBubble questBubble-question">' +
|
||||
'<div class="questBubbleHeader">' +
|
||||
'<label class="questQuestionHeaderField"><span class="questQuestionHeaderFieldLabel">Nombre de choix</span>' +
|
||||
'<input type="number" min="2" max="4" value="' + line.choices.length + '" ' +
|
||||
'onchange="triggerInlineSetQuestionChoiceCount(\'' + dialogueId + '\', ' + index + ', this.value)"></label>' +
|
||||
'<label class="questQuestionHeaderField"><span class="questQuestionHeaderFieldLabel">Points gagnés</span>' +
|
||||
'<input type="number" min="0" value="' + line.reward_amount + '" ' +
|
||||
'onchange="triggerInlineSetQuestionRewardAmount(\'' + dialogueId + '\', ' + index + ', this.value)"> pts</label>' +
|
||||
'<button type="button" class="questBubbleDelete" title="Supprimer" onclick="triggerInlineDeleteBubble(\'' + dialogueId + '\', ' + index + ')">✕</button>' +
|
||||
'</div>' +
|
||||
'<div class="questBubbleBody">' +
|
||||
'<input type="text" class="questQuestionText" placeholder="Question..." value="' + triggerEscAttr(line.text) + '" ' +
|
||||
'onchange="triggerInlineUpdateQuestionText(\'' + dialogueId + '\', ' + index + ', this.value)">' +
|
||||
'<div class="questChoicesList">' + choicesHtml + '</div>' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'</div>'
|
||||
);
|
||||
}
|
||||
|
||||
// Pastille "Déclencher ce dialogue" — visuelle uniquement, la même pour
|
||||
// TOUTES les occurrences : posée juste avant le rectangle de dialogue
|
||||
// lui-même (voir triggerInlineDialogueHtml).
|
||||
function triggerDeclencherDialoguePillHtml() {
|
||||
return '<div class="questTriggerPill quete">Déclencher ce dialogue</div>';
|
||||
}
|
||||
|
||||
// Libellé lisible d'une action "variable" (voir screens/rendering/
|
||||
// collision_rules.py::"variable" et screens/labels/data_operations.py) —
|
||||
// DATA_OPERATION_LABELS_MAP est déjà posé par templates/scene_edit.html.
|
||||
function triggerVariableActionLabel(item) {
|
||||
var opLabel = (typeof DATA_OPERATION_LABELS_MAP !== 'undefined' && DATA_OPERATION_LABELS_MAP[item.data_operation]) || item.data_operation;
|
||||
var valuePart = (item.data_value !== undefined && item.data_value !== null && item.data_value !== '') ? ' ' + item.data_value : '';
|
||||
return (item.target_variable || '').replace(/</g, '<') + ' — ' + opLabel.replace(/</g, '<') + valuePart;
|
||||
}
|
||||
|
||||
// Feuille "Modifier une variable" — un encart compact (même largeur que
|
||||
// le reste de la chaîne), pas de bulles dedans contrairement au
|
||||
// rectangle de dialogue.
|
||||
function triggerVariableActionHtml(item) {
|
||||
return (
|
||||
'<div class="dialogueBox questVariableAction">' +
|
||||
'<div class="dialogueBoxHeader">' +
|
||||
'<span class="dialogueBoxTitle">Variable</span>' +
|
||||
'</div>' +
|
||||
'<p class="questVariableActionLabel">' + triggerVariableActionLabel(item) + '</p>' +
|
||||
'</div>'
|
||||
);
|
||||
}
|
||||
|
||||
// Une chaîne d'actions (voir screens/rendering/trigger_graph.py::
|
||||
// _describe_chain) : "Déclencher ce dialogue" + son rectangle, ou une
|
||||
// feuille "variable", à la suite les unes des autres — PUIS, en bout de
|
||||
// chaîne, "+ Ajouter une action" (demande explicite : "pouvoir modifier
|
||||
// un déclencheur pour lui ajouter des actions", jamais bloqué à une
|
||||
// seule action). `objectId` : nécessaire pour poster l'ajout sur le bon
|
||||
// objet (voir triggerOpenAppendActionModal).
|
||||
function triggerActionChainHtml(chain, objectId) {
|
||||
if (!chain || !chain.length) return '';
|
||||
var itemsHtml = chain.map(function (item) {
|
||||
var pill = item.type === 'dialogue' ? triggerDeclencherDialoguePillHtml() : '<div class="questTriggerPill quete">Modifier une variable</div>';
|
||||
var box = item.type === 'dialogue' ? triggerInlineDialogueHtml(item.dialogue) : triggerVariableActionHtml(item);
|
||||
return '<div class="questTriggerConnector"></div>' + pill + '<div class="questTriggerConnector"></div>' + box;
|
||||
}).join('');
|
||||
var lastItem = chain[chain.length - 1];
|
||||
var lastLeafId = lastItem.type === 'dialogue' ? lastItem.dialogue && lastItem.dialogue.id : lastItem.id;
|
||||
var appendBtn = lastLeafId
|
||||
? '<div class="questTriggerConnector"></div>' +
|
||||
'<button type="button" class="collisionRuleAddBtn questTriggerAddChainedActionBtn" onclick="triggerOpenAppendActionModal(' + objectId + ', \'' + lastLeafId + '\')">+ Ajouter une action</button>'
|
||||
: '';
|
||||
return itemsHtml + appendBtn;
|
||||
}
|
||||
|
||||
// Le rectangle lui-même — voir screens/rendering/collision_rules.py::
|
||||
// "dialogue".dialogue pour la forme {id, lines}. `dialogue` peut être
|
||||
// absent (action posée sans passer par l'assistant) : rien n'est rendu
|
||||
// plutôt que de planter, même tolérance que le reste de ce moteur.
|
||||
function triggerInlineDialogueHtml(dialogue) {
|
||||
if (!dialogue) return '';
|
||||
var lines = dialogue.lines || [];
|
||||
var bubblesHtml = lines.map(function (line, i) {
|
||||
return line.type === 'question' ? triggerQuestionBubbleHtml(dialogue.id, line, i) : triggerDialogueBubbleHtml(dialogue.id, line, i);
|
||||
}).join('');
|
||||
return (
|
||||
'<div class="dialogueBox">' +
|
||||
'<div class="dialogueBoxHeader">' +
|
||||
'<span class="dialogueBoxTitle">Dialogue</span>' +
|
||||
'<div class="dialogueBoxActions">' +
|
||||
'<button type="button" class="addReplyBtn" onclick="triggerInlineAddBubble(\'' + dialogue.id + '\')">Réplique</button>' +
|
||||
'<button type="button" class="addReplyBtn addQuestionBtn" onclick="triggerInlineAddQuestion(\'' + dialogue.id + '\')">Question</button>' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
bubblesHtml +
|
||||
'</div>'
|
||||
);
|
||||
}
|
||||
|
||||
// ---------- Déclencheurs (voir screens/rendering/trigger_graph.py::
|
||||
// list_all_triggers) ----------
|
||||
// Maquette validée : carte objet + pastilles empilées VERTICALEMENT
|
||||
// reliées par des flèches. Le "Déclencher ce dialogue" se termine par le
|
||||
// rectangle de dialogue lui-même — la flèche pointe DIRECTEMENT dessus.
|
||||
// Quand l'action se termine par une "condition", la chaîne se sépare en
|
||||
// un losange à embranchement Vrai/Faux (voir triggerConditionColumnHtml) —
|
||||
// chaque branche affiche DIRECTEMENT son propre contenu (plus de "branche
|
||||
// qui mène à telle quête" à choisir, chaque branche est indépendante).
|
||||
// collisionRuleThumbHtml (static/js/scenes/collision-rules-editor.js,
|
||||
// chargé sur la même page) reste réutilisé pour la vignette elle-même.
|
||||
|
||||
// Accordéon : object_id -> true si REPLIÉ — par défaut tout est déplié,
|
||||
// absent de cette map = déplié.
|
||||
var _triggerCollapsed = {};
|
||||
// Le déclencheur actif est celui où Réplique/Question ajoutent leur bulle
|
||||
// (surligné, voir .questTriggerCol.active en CSS) — par défaut le
|
||||
// DERNIER dialogue trouvé, mais cliquer sur la carte d'un autre
|
||||
// déclencheur en fait le nouvel actif.
|
||||
var _triggerActiveDialogueId = null;
|
||||
|
||||
function _chainLeafIds(chain) {
|
||||
return (chain || []).map(function (item) { return item.type === 'dialogue' ? (item.dialogue && item.dialogue.id) : item.id; }).filter(Boolean);
|
||||
}
|
||||
|
||||
function _triggerLeafIds(trigger) {
|
||||
if (trigger.condition) {
|
||||
var vrai = trigger.condition.si_vrai, faux = trigger.condition.si_faux;
|
||||
return _chainLeafIds(vrai && vrai.chain).concat(_chainLeafIds(faux && faux.chain));
|
||||
}
|
||||
return _chainLeafIds(trigger.chain);
|
||||
}
|
||||
|
||||
function triggerChainHtml(trigger) {
|
||||
var isCollapsed = !!_triggerCollapsed[trigger.object_id];
|
||||
var leafIds = _triggerLeafIds(trigger);
|
||||
var isActive = _triggerActiveDialogueId && leafIds.indexOf(_triggerActiveDialogueId) !== -1;
|
||||
var cardOnclick = 'triggerClickObjectCard(' + trigger.object_id + ', ' + (leafIds[0] ? '\'' + leafIds[0] + '\'' : 'null') + ')';
|
||||
var card =
|
||||
'<div class="questTriggerObjectCard" title="Cliquer pour replier/déplier — replie aussi la logique associée" onclick="' + cardOnclick + '">' + collisionRuleThumbHtml(trigger) +
|
||||
'<span class="questTriggerObjectName">' + (trigger.object_name || '').replace(/</g, '<') + '</span>' +
|
||||
'<span class="questTriggerCollapseChevron">▾</span>' +
|
||||
'</div>';
|
||||
if (isCollapsed) {
|
||||
return (
|
||||
'<div class="questTriggerCol collapsed">' +
|
||||
'<button type="button" class="questTriggerDelete" title="Retirer ce déclencheur" ' +
|
||||
'onclick="event.stopPropagation(); triggerDeleteTrigger(' + trigger.object_id + ')">✕</button>' +
|
||||
card +
|
||||
'</div>'
|
||||
);
|
||||
}
|
||||
var pills = [{ kind: 'trigger', text: COLLISION_TRIGGER_LABELS.collision }];
|
||||
if (trigger.has_interagir) pills.push({ kind: 'interagir', text: 'Interagir avec une touche' });
|
||||
var pillsHtml = pills.map(function (pill, i) {
|
||||
return (i > 0 ? '<div class="questTriggerConnector"></div>' : '') +
|
||||
'<div class="questTriggerPill ' + pill.kind + '">' + pill.text + '</div>';
|
||||
}).join('');
|
||||
var tailHtml = trigger.condition
|
||||
? '<div class="questTriggerConnector tall"></div>' + triggerConditionColumnHtml(trigger.condition, trigger.object_id)
|
||||
: triggerActionChainHtml(trigger.chain, trigger.object_id);
|
||||
return (
|
||||
'<div class="questTriggerCol' + (isActive ? ' active' : '') + '">' +
|
||||
'<button type="button" class="questTriggerDelete" title="Retirer ce déclencheur" ' +
|
||||
'onclick="event.stopPropagation(); triggerDeleteTrigger(' + trigger.object_id + ')">✕</button>' +
|
||||
card +
|
||||
'<div class="questTriggerConnector"></div>' +
|
||||
pillsHtml +
|
||||
tailHtml +
|
||||
'</div>'
|
||||
);
|
||||
}
|
||||
|
||||
// Clic sur la carte objet : replie/déplie TOUTE la logique associée
|
||||
// (accordéon, demande explicite) — et rend ce déclencheur actif au
|
||||
// passage puisqu'on vient d'y porter son attention.
|
||||
function triggerClickObjectCard(objectId, leafId) {
|
||||
_triggerCollapsed[objectId] = !_triggerCollapsed[objectId];
|
||||
if (leafId) _triggerActiveDialogueId = leafId;
|
||||
triggerRenderSlot();
|
||||
}
|
||||
|
||||
// Losange "condition" — lecture ENTIÈREMENT VERTICALE (demande explicite :
|
||||
// "il faut tout mettre verticalement", sans toucher au losange lui-même) :
|
||||
// la branche Vrai descend tout droit vers son "Déclencher ce dialogue"
|
||||
// (ou "Ne rien faire"), PUIS la branche Faux descend à son tour — jamais
|
||||
// deux branches côte à côte. Chaque branche affiche SON PROPRE dialogue
|
||||
// (voir screens/rendering/trigger_graph.py::_resolve_action_trigger),
|
||||
// indépendamment de l'autre.
|
||||
function triggerCondArrowHtml(kind, label) {
|
||||
var color = kind === 'yes' ? '#2ecc71' : '#e0524d';
|
||||
var markerId = 'triggerCondArrow_' + kind + '_' + Math.random().toString(36).slice(2, 8);
|
||||
return (
|
||||
'<div class="questCondArrowWrap">' +
|
||||
'<div class="questCondArrowLabel ' + kind + '">' + label + '</div>' +
|
||||
'<svg viewBox="0 0 34 68" preserveAspectRatio="none" aria-hidden="true">' +
|
||||
'<defs><marker id="' + markerId + '" markerWidth="10" markerHeight="10" refX="7" refY="5" orient="auto">' +
|
||||
'<path d="M0,0 L10,5 L0,10 Z" fill="' + color + '"></path></marker></defs>' +
|
||||
'<line x1="17" y1="0" x2="17" y2="56" stroke="' + color + '" stroke-width="4" marker-end="url(#' + markerId + ')"></line>' +
|
||||
'</svg>' +
|
||||
'</div>'
|
||||
);
|
||||
}
|
||||
|
||||
function triggerConditionColumnHtml(condition, objectId) {
|
||||
var opLabel = CONDITION_OPERATOR_LABELS_MAP[condition.operateur] || condition.operateur;
|
||||
var label = (condition.variable || '').replace(/</g, '<') + ' ' + (opLabel || '').replace(/</g, '<') + ' ' + (condition.valeur || '').replace(/</g, '<');
|
||||
var branchesHtml = ['si_vrai', 'si_faux'].map(function (branchKey) {
|
||||
var kind = branchKey === 'si_vrai' ? 'yes' : 'no';
|
||||
var arrowLabel = branchKey === 'si_vrai' ? '✓ Vrai' : '✕ Faux';
|
||||
var branch = condition[branchKey];
|
||||
var content = branch
|
||||
? triggerActionChainHtml(branch.chain, objectId)
|
||||
: '<div class="questTriggerPill none">Ne rien faire</div>';
|
||||
return triggerCondArrowHtml(kind, arrowLabel) + content;
|
||||
}).join('');
|
||||
return (
|
||||
'<div class="questCondColumn">' +
|
||||
'<div class="questCondDiamond"><span>' + label + '</span></div>' +
|
||||
branchesHtml +
|
||||
'</div>'
|
||||
);
|
||||
}
|
||||
|
||||
// Cache global (voir triggerLoadAll) — muté directement par les fonctions
|
||||
// triggerInline* ci-dessous, puis ré-affiché + sauvegardé, sans jamais
|
||||
// adopter la réponse du serveur comme nouvel état local (sanitize_dialogue_lines
|
||||
// rejette toute réplique au texte encore vide — juste ajoutée, pas encore
|
||||
// tapée — l'adopter effacerait silencieusement la bulle en cours
|
||||
// d'écriture).
|
||||
var _triggersCache = [];
|
||||
// dialogue_id -> {objectId, dialogueRef} — `dialogueRef` est LA MÊME
|
||||
// référence que dans _triggersCache, muter dialogueRef.lines ici mute
|
||||
// donc aussi le cache (une seule source de vérité). Couvre aussi bien le
|
||||
// dialogue direct d'un déclencheur que ceux embarqués dans ses branches
|
||||
// de condition.
|
||||
var _triggerDialogueRegistry = {};
|
||||
|
||||
function _registerChainDialogues(chain, objectId, dialogueIds) {
|
||||
(chain || []).forEach(function (item) {
|
||||
if (item.type === 'dialogue' && item.dialogue) {
|
||||
_triggerDialogueRegistry[item.dialogue.id] = { objectId: objectId, dialogueRef: item.dialogue };
|
||||
dialogueIds.push(item.dialogue.id);
|
||||
} else if (item.type === 'variable' && item.id) {
|
||||
dialogueIds.push(item.id); // pas de "dialogueRef" (rien à éditer en ligne), juste pour l'id "actif"
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function _registerTriggerDialogues(list) {
|
||||
_triggerDialogueRegistry = {};
|
||||
var dialogueIds = [];
|
||||
list.forEach(function (t) {
|
||||
if (t.condition) {
|
||||
['si_vrai', 'si_faux'].forEach(function (branchKey) {
|
||||
var branch = t.condition[branchKey];
|
||||
if (branch) _registerChainDialogues(branch.chain, t.object_id, dialogueIds);
|
||||
});
|
||||
} else {
|
||||
_registerChainDialogues(t.chain, t.object_id, dialogueIds);
|
||||
}
|
||||
});
|
||||
return dialogueIds;
|
||||
}
|
||||
|
||||
function triggerLoadAll() {
|
||||
fetch('/game/' + GAME_SLUG + '/triggers')
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (list) {
|
||||
_triggersCache = list;
|
||||
var dialogueIds = _registerTriggerDialogues(list);
|
||||
// Le déclencheur actif par défaut est le DERNIER dialogue trouvé —
|
||||
// sauf s'il y en a déjà un actif encore présent, auquel cas on le
|
||||
// garde (cliquer sur une autre carte reste possible, voir
|
||||
// triggerClickObjectCard).
|
||||
if (dialogueIds.indexOf(_triggerActiveDialogueId) === -1) {
|
||||
_triggerActiveDialogueId = dialogueIds[dialogueIds.length - 1] || null;
|
||||
}
|
||||
triggerRenderSlot();
|
||||
});
|
||||
}
|
||||
|
||||
function triggerRenderSlot() {
|
||||
var slot = document.getElementById('triggersSlot');
|
||||
if (!slot) return;
|
||||
slot.innerHTML = _triggersCache.map(function (t) { return triggerChainHtml(t); }).join('');
|
||||
}
|
||||
|
||||
function triggerDeleteTrigger(objectId) {
|
||||
fetch('/game/' + GAME_SLUG + '/triggers/' + objectId, { method: 'DELETE' })
|
||||
.then(function () { triggerLoadAll(); });
|
||||
}
|
||||
|
||||
// ---------- Mutateurs du dialogue INLINE ----------
|
||||
// Chaque fonction retrouve son dialogue via le registre (dialogue_id,
|
||||
// unique par occurrence de "Déclencher ce dialogue") — pas besoin de
|
||||
// connaître l'objet, déjà mémorisé à triggerLoadAll.
|
||||
|
||||
function triggerInlineSaveDialogue(dialogueId) {
|
||||
var entry = _triggerDialogueRegistry[dialogueId];
|
||||
if (!entry) return;
|
||||
fetch('/game/' + GAME_SLUG + '/triggers/' + entry.objectId + '/dialogue', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ dialogue_id: dialogueId, lines: entry.dialogueRef.lines }),
|
||||
}).then(function (r) {
|
||||
if (!r.ok) return r.json().then(function (json) { alert(json.error || "Impossible d'enregistrer."); });
|
||||
});
|
||||
}
|
||||
|
||||
// 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 triggerNextDefaultSpeaker(lastSpeaker) {
|
||||
if (lastSpeaker === 'Joueur') return SCENE_OBJECT_NAMES[0] || 'PNJ';
|
||||
return 'Joueur';
|
||||
}
|
||||
|
||||
function triggerInlineAddBubble(dialogueId) {
|
||||
var entry = _triggerDialogueRegistry[dialogueId];
|
||||
if (!entry) return;
|
||||
var lines = entry.dialogueRef.lines;
|
||||
var lastSpeaker = lines.length ? lines[lines.length - 1].speaker : 'PNJ';
|
||||
lines.push({ type: 'dialogue', speaker: triggerNextDefaultSpeaker(lastSpeaker), text: '' });
|
||||
triggerRenderSlot();
|
||||
triggerInlineSaveDialogue(dialogueId);
|
||||
}
|
||||
|
||||
// "❓ Question" — voir triggerQuestionBubbleHtml ci-dessus. Défauts :
|
||||
// 2 choix vides, la 1ère cochée bonne réponse, récompense "score" à 0 pt
|
||||
// (l'auteur règle le montant dans le header de la bulle).
|
||||
function triggerInlineAddQuestion(dialogueId) {
|
||||
var entry = _triggerDialogueRegistry[dialogueId];
|
||||
if (!entry) return;
|
||||
entry.dialogueRef.lines.push({ type: 'question', text: '', choices: ['', ''], correct_index: 0, reward_type: 'score', reward_amount: 0 });
|
||||
triggerRenderSlot();
|
||||
triggerInlineSaveDialogue(dialogueId);
|
||||
}
|
||||
|
||||
function triggerInlineUpdateQuestionText(dialogueId, index, text) {
|
||||
var entry = _triggerDialogueRegistry[dialogueId];
|
||||
if (!entry) return;
|
||||
entry.dialogueRef.lines[index].text = text;
|
||||
triggerInlineSaveDialogue(dialogueId);
|
||||
}
|
||||
|
||||
// Ajuste le NOMBRE de choix (2 à 4, voir db/dialogue_lines.py::
|
||||
// _MIN_CHOICES/_MAX_CHOICES) : ajoute des choix vides ou retire les
|
||||
// derniers, sans jamais toucher aux choix déjà remplis qui restent dans
|
||||
// la nouvelle taille. Recadre la bonne réponse sur le premier choix si
|
||||
// elle pointait vers un choix retiré.
|
||||
function triggerInlineSetQuestionChoiceCount(dialogueId, index, value) {
|
||||
var entry = _triggerDialogueRegistry[dialogueId];
|
||||
if (!entry) return;
|
||||
var line = entry.dialogueRef.lines[index];
|
||||
var count = Math.max(2, Math.min(4, parseInt(value, 10) || 2));
|
||||
while (line.choices.length < count) line.choices.push('');
|
||||
while (line.choices.length > count) line.choices.pop();
|
||||
if (line.correct_index >= line.choices.length) line.correct_index = 0;
|
||||
triggerRenderSlot();
|
||||
triggerInlineSaveDialogue(dialogueId);
|
||||
}
|
||||
|
||||
function triggerInlineSetQuestionChoiceText(dialogueId, index, choiceIndex, text) {
|
||||
var entry = _triggerDialogueRegistry[dialogueId];
|
||||
if (!entry) return;
|
||||
entry.dialogueRef.lines[index].choices[choiceIndex] = text;
|
||||
triggerInlineSaveDialogue(dialogueId);
|
||||
}
|
||||
|
||||
function triggerInlineSetQuestionCorrectIndex(dialogueId, index, choiceIndex) {
|
||||
var entry = _triggerDialogueRegistry[dialogueId];
|
||||
if (!entry) return;
|
||||
entry.dialogueRef.lines[index].correct_index = choiceIndex;
|
||||
triggerInlineSaveDialogue(dialogueId);
|
||||
}
|
||||
|
||||
function triggerInlineSetQuestionRewardAmount(dialogueId, index, value) {
|
||||
var entry = _triggerDialogueRegistry[dialogueId];
|
||||
if (!entry) return;
|
||||
entry.dialogueRef.lines[index].reward_amount = Math.max(0, parseInt(value, 10) || 0);
|
||||
triggerInlineSaveDialogue(dialogueId);
|
||||
}
|
||||
|
||||
function triggerInlineSetBubbleSpeaker(dialogueId, index, speaker) {
|
||||
var entry = _triggerDialogueRegistry[dialogueId];
|
||||
if (!entry) return;
|
||||
entry.dialogueRef.lines[index].speaker = speaker;
|
||||
triggerRenderSlot();
|
||||
triggerInlineSaveDialogue(dialogueId);
|
||||
}
|
||||
|
||||
function triggerInlineUpdateBubbleText(dialogueId, index, text) {
|
||||
var entry = _triggerDialogueRegistry[dialogueId];
|
||||
if (!entry) return;
|
||||
entry.dialogueRef.lines[index].text = text;
|
||||
triggerInlineSaveDialogue(dialogueId);
|
||||
}
|
||||
|
||||
function triggerInlineDeleteBubble(dialogueId, index) {
|
||||
var entry = _triggerDialogueRegistry[dialogueId];
|
||||
if (!entry) return;
|
||||
entry.dialogueRef.lines.splice(index, 1);
|
||||
triggerRenderSlot();
|
||||
triggerInlineSaveDialogue(dialogueId);
|
||||
}
|
||||
|
||||
// ---------- Modale d'ajout de déclencheur ----------
|
||||
// Étape 1 : choisir l'objet de scène (toutes scènes du jeu confondues,
|
||||
// voir GET .../triggers/objects-catalog — contrairement à l'éditeur de
|
||||
// collision, limité à la scène actuellement ouverte). Étape 2 : le MÊME
|
||||
// assistant "déclencheur -> action" que l'éditeur de collision (voir
|
||||
// openTriggerWizard, static/js/scenes/collision-rules-editor.js).
|
||||
function ensureTriggerModal() {
|
||||
var modal = document.getElementById('questTriggerModal');
|
||||
if (modal) return modal;
|
||||
modal = document.createElement('div');
|
||||
modal.className = 'modal questTriggerModal';
|
||||
modal.id = 'questTriggerModal';
|
||||
modal.innerHTML =
|
||||
'<div class="modal-background" onclick="closeTriggerModal()"></div>' +
|
||||
'<div class="modal-content questFieldsModalContent"><div class="box">' +
|
||||
'<div class="fieldRow" style="align-items:center; justify-content:space-between;">' +
|
||||
'<h2 class="title is-5" style="margin:0;" id="questTriggerModalTitle">Ajouter un déclencheur</h2>' +
|
||||
'<button type="button" class="button is-small" onclick="closeTriggerModal()">✕</button>' +
|
||||
'</div>' +
|
||||
'<div id="questTriggerModalBody"></div>' +
|
||||
'</div></div>';
|
||||
document.body.appendChild(modal);
|
||||
return modal;
|
||||
}
|
||||
|
||||
function openAddTriggerModal() {
|
||||
var modal = ensureTriggerModal();
|
||||
modal.classList.add('is-active');
|
||||
document.getElementById('questTriggerModalTitle').textContent = 'Ajouter un déclencheur';
|
||||
triggerRenderObjectStep();
|
||||
}
|
||||
|
||||
function triggerRenderObjectStep() {
|
||||
var body = document.getElementById('questTriggerModalBody');
|
||||
body.innerHTML =
|
||||
'<p class="hint mt-2 mb-2">Choisis l\'objet de scène qui déclenchera ce dialogue :</p>' +
|
||||
'<div id="questTriggerObjectGrid" class="collisionWizardChoices">Chargement…</div>';
|
||||
fetch('/game/' + GAME_SLUG + '/triggers/objects-catalog')
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (objects) {
|
||||
var grid = document.getElementById('questTriggerObjectGrid');
|
||||
if (!grid) return;
|
||||
if (!objects.length) { grid.innerHTML = '<p class="hint">Aucun objet éligible sur ce jeu.</p>'; return; }
|
||||
grid.innerHTML = objects.map(function (o, i) {
|
||||
return '<button type="button" class="collisionWizardChoice questTriggerObjectChoice" style="animation-delay:' + (i * 40) + 'ms" onclick="triggerSelectObject(' + o.id + ')">' +
|
||||
'<div class="questTriggerObjectThumb">' + collisionRuleThumbHtml(o) + '</div>' +
|
||||
'<span>' + (o.name || '').replace(/</g, '<') + '</span>' +
|
||||
'</button>';
|
||||
}).join('');
|
||||
});
|
||||
}
|
||||
|
||||
// Objet choisi -> enchaîne directement sur l'assistant déclencheur/action
|
||||
// de l'éditeur de collision (même écran, même modale, juste la suite du
|
||||
// parcours) — voir openTriggerWizard.
|
||||
function triggerSelectObject(objectId) {
|
||||
var body = document.getElementById('questTriggerModalBody');
|
||||
openTriggerWizard(objectId, body);
|
||||
}
|
||||
|
||||
// ---------- "+ Ajouter une action" (bout de chaîne d'un déclencheur
|
||||
// DÉJÀ posé) ----------
|
||||
// Demande explicite : "pouvoir modifier un déclencheur pour lui ajouter
|
||||
// des actions" — pas besoin de repasser par la modale de création, on
|
||||
// est déjà DANS ce déclencheur (voir triggerActionChainHtml). Réutilise
|
||||
// les étapes de l'assistant (static/js/scenes/collision-rules-editor.js) :
|
||||
// choix dialogue/variable, puis "Ensuite ?" (Marquer terminé + chaîner
|
||||
// encore), avant d'envoyer le tout via /append-action.
|
||||
function triggerOpenAppendActionModal(objectId, afterId) {
|
||||
var modal = ensureTriggerModal();
|
||||
modal.classList.add('is-active');
|
||||
document.getElementById('questTriggerModalTitle').textContent = 'Ajouter une action';
|
||||
var body = document.getElementById('questTriggerModalBody');
|
||||
// État minimal partagé avec collision-rules-editor.js (mêmes étapes
|
||||
// réutilisées telles quelles, voir renderCollisionWizardChainStep/
|
||||
// renderCollisionWizardVariableStep — elles ne lisent que .bodyEl).
|
||||
_collisionWizard = { bodyEl: body };
|
||||
body.innerHTML =
|
||||
'<p class="hint mb-2">Ajouter...</p>' +
|
||||
'<div class="collisionWizardChoices">' +
|
||||
'<button type="button" class="collisionWizardChoice" style="animation-delay:0ms" onclick="triggerAppendLeaf(' + objectId + ', \'' + afterId + '\', \'dialogue\')">' +
|
||||
'<span>Déclencher un dialogue</span>' +
|
||||
'</button>' +
|
||||
'<button type="button" class="collisionWizardChoice" style="animation-delay:40ms" onclick="triggerAppendLeaf(' + objectId + ', \'' + afterId + '\', \'variable\')">' +
|
||||
'<span>Modifier une variable</span>' +
|
||||
'</button>' +
|
||||
'</div>';
|
||||
}
|
||||
|
||||
function triggerAppendLeaf(objectId, afterId, type) {
|
||||
if (type === 'dialogue') {
|
||||
var leaf = { type: 'dialogue', dialogue: { id: collisionGenerateActionId(), lines: [] } };
|
||||
renderCollisionWizardChainStep(leaf, function (finalLeaf) { triggerSaveAppendedAction(objectId, afterId, finalLeaf); });
|
||||
return;
|
||||
}
|
||||
renderCollisionWizardVariableStep(function (action) {
|
||||
renderCollisionWizardChainStep(action, function (finalLeaf) { triggerSaveAppendedAction(objectId, afterId, finalLeaf); });
|
||||
});
|
||||
}
|
||||
|
||||
function triggerSaveAppendedAction(objectId, afterId, action) {
|
||||
closeTriggerModal();
|
||||
fetch('/game/' + GAME_SLUG + '/triggers/' + objectId + '/append-action', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ after_id: afterId, action: action }),
|
||||
}).then(function (r) {
|
||||
if (!r.ok) return r.json().then(function (json) { alert(json.error || "Impossible d'ajouter cette action."); });
|
||||
}).then(function () { triggerLoadAll(); });
|
||||
}
|
||||
|
||||
function closeTriggerModal() {
|
||||
var modal = document.getElementById('questTriggerModal');
|
||||
if (modal) modal.classList.remove('is-active');
|
||||
}
|
||||
|
||||
// ---------- Point d'entrée ----------
|
||||
// Appelé directement (pas de DOMContentLoaded) : ce fichier est chargé par
|
||||
// un <script src> posé à la fin du contenu de la page — les éléments dont
|
||||
// on a besoin existent déjà à ce stade. Nécessaire aussi pour la
|
||||
// navigation pjax (voir static/pjax.js — runScriptsIn ré-exécute ce
|
||||
// script à chaque page swappée, mais DOMContentLoaded, lui, ne se
|
||||
// redéclenche jamais après le tout premier chargement réel) — sans ça,
|
||||
// l'onglet "Déclencheurs" restait vide après un remplacement de <main>
|
||||
// (ex. suppression d'un objet de scène, voir scene_object_delete.py) qui
|
||||
// ne redéclenche jamais cet événement.
|
||||
function initTriggersTab() {
|
||||
var body = document.getElementById('triggersTabPanelBody');
|
||||
if (!body) return;
|
||||
body.innerHTML = '<div class="questDialogueColumns">' + triggerColumnHtml() + '</div>';
|
||||
triggerLoadAll();
|
||||
}
|
||||
initTriggersTab();
|
||||
+165
-32
@@ -725,7 +725,7 @@ body.builderBody > main.content{ flex:1 1 auto; min-height:0; overflow:hidden; d
|
||||
static/js/play/dialogue-box-controller.js) : header/body/footer,
|
||||
toujours VISIBLE dans l'éditeur (texte d'exemple, pour le positionner/
|
||||
styliser) mais masqué par défaut EN JEU tant qu'aucune règle de
|
||||
collision "quete" n'a de réplique à afficher. */
|
||||
collision "dialogue" n'a de réplique à afficher. */
|
||||
.dialogueBoxWidget{
|
||||
display:flex; flex-direction:column; border-radius:10px; overflow:hidden;
|
||||
box-shadow:0 6px 18px rgba(0,0,0,.35); pointer-events:auto;
|
||||
@@ -740,14 +740,6 @@ body.builderBody > main.content{ flex:1 1 auto; min-height:0; overflow:hidden; d
|
||||
border-radius:6px; padding:5px 12px; font:inherit; font-size:12px; cursor:pointer;
|
||||
}
|
||||
.dialogueBoxNextBtn:hover{ background:rgba(255,255,255,.28); }
|
||||
/* Écran d'offre de quête (voir forgeShowQuestOffer, dialogue-box-controller.js) :
|
||||
Accepter/Refuser remplacent le "Suivant" habituel dans le pied de la
|
||||
boîte — verts/rouges pour rester lisibles sur n'importe quelle couleur
|
||||
de pied choisie par l'auteur (voir "🎨 Style"). */
|
||||
.dialogueBoxAcceptBtn{ background:#2ecc71; border-color:#2ecc71; color:#0b1a10; font-weight:700; }
|
||||
.dialogueBoxAcceptBtn:hover{ background:#3ddb80; }
|
||||
.dialogueBoxDeclineBtn{ background:rgba(224,82,77,.25); border-color:#e0524d; }
|
||||
.dialogueBoxDeclineBtn:hover{ background:rgba(224,82,77,.4); }
|
||||
|
||||
/* Widget "❓ Boîte à quiz" (voir screens/rendering/dialogue_box_style.py,
|
||||
static/js/play/dialogue-box-controller.js) : même squelette que
|
||||
@@ -942,6 +934,14 @@ body.builderBody > main.content{ flex:1 1 auto; min-height:0; overflow:hidden; d
|
||||
.collisionWizardChoice:hover{ transform:translateY(-3px) scale(1.02); border-color:var(--accent); box-shadow:0 8px 18px rgba(0,0,0,.25); }
|
||||
.collisionWizardChoice .emoji{ font-size:22px; }
|
||||
|
||||
/* Étape "condition" de l'assistant (voir renderCollisionWizardConditionStep,
|
||||
collision-rules-editor.js) : variable / opérateur / valeur sur une
|
||||
ligne — les <select>/<input> reprennent déjà le style générique
|
||||
(voir plus haut, input[type=text]/select). */
|
||||
.collisionWizardConditionForm{ display:flex; gap:8px; flex-wrap:wrap; }
|
||||
.collisionWizardConditionForm select{ flex:1.3; min-width:120px; }
|
||||
.collisionWizardConditionForm input{ flex:0.7; min-width:80px; }
|
||||
|
||||
/* Bulle "Appuie sur X" (voir forgeShowCollisionInteractBubble,
|
||||
collision-rules-controller.js) : SŒUR de l'objet dans son parent
|
||||
(.sceneWorld/.sceneUI, déjà position:relative) plutôt qu'ENFANT
|
||||
@@ -956,10 +956,9 @@ body.builderBody > main.content{ flex:1 1 auto; min-height:0; overflow:hidden; d
|
||||
white-space:nowrap; pointer-events:none; z-index:20;
|
||||
}
|
||||
|
||||
/* ---- Éditeur de quêtes (onglet "🗺️ Quêtes" à côté de "🧩 Collision",
|
||||
templates/scene_edit.html + modale de dialogue partagée avec
|
||||
l'assistant "+ Action" de l'éditeur de collision, voir
|
||||
static/js/quests/quest-editor.js) ---- */
|
||||
/* ---- Reliquat de l'ancien onglet "🗺️ Quêtes" (retiré, voir onglet
|
||||
"Déclencheurs" et static/js/triggers/trigger-editor.js) — règles
|
||||
orphelines gardées telles quelles, aucune ne s'applique plus. ---- */
|
||||
.questListRow{ cursor:pointer; }
|
||||
.questPill{ display:inline-flex; align-items:center; border-radius:999px; padding:3px 10px; font-size:11.5px; font-weight:700; }
|
||||
.questPill-score{ background:rgba(242,153,74,.18); color:#f2994a; }
|
||||
@@ -984,33 +983,166 @@ body.builderBody > main.content{ flex:1 1 auto; min-height:0; overflow:hidden; d
|
||||
.questDialogueModal{ z-index:61; }
|
||||
.questDialogueModal .modal-content{ width:100vw; height:100vh; max-width:100vw; max-height:100vh; margin:0; }
|
||||
/* La modale entière est une colonne flex (jamais un scroll global, voir
|
||||
overflow:hidden ici) : le header/la fiche restent fixes en haut, les 3
|
||||
colonnes de répliques se partagent TOUT le reste de la hauteur
|
||||
(demandé explicitement) et défilent CHACUNE indépendamment (voir
|
||||
.questDialogueColumnBubbles plus bas), jamais toute la page. */
|
||||
overflow:hidden ici) : le header reste fixe en haut, le panneau de
|
||||
déclencheurs prend TOUT le reste de la hauteur. */
|
||||
.questDialogueModal .box{ width:100%; height:100%; border-radius:0; overflow:hidden; display:flex; flex-direction:column; }
|
||||
.questDialogueModalContent{ width:100%; height:100%; max-width:100%; }
|
||||
#questDialogueModalBody{ display:flex; flex-direction:column; flex:1; min-height:0; }
|
||||
/* Onglet "Déclencheurs" (templates/scene_edit.html, #triggersTabPanel) —
|
||||
MÊME comportement que #questDialogueModalBody ci-dessus (juste déplacé
|
||||
hors de la modale plein écran dans un onglet, demande explicite :
|
||||
"sans rien toucher au style en place") : colonne flex qui prend tout
|
||||
l'espace du panneau d'onglet actif (voir .builderTabPanel.active). */
|
||||
#triggersTabPanelBody{ display:flex; flex-direction:column; flex:1; min-height:0; }
|
||||
.questDialogueModalHeader{ flex:0 0 auto; margin-bottom:10px; }
|
||||
.questFieldsGrid{ display:grid; grid-template-columns:repeat(auto-fill, minmax(160px, 1fr)); gap:10px; margin-bottom:14px; }
|
||||
.questField{ display:flex; flex-direction:column; gap:4px; font-size:12px; font-weight:600; }
|
||||
.questField-wide{ grid-column:span 2; }
|
||||
.questDialogueColumns{ flex:1; min-height:0; display:grid; grid-template-columns:repeat(3, minmax(220px, 1fr)); gap:14px; }
|
||||
/* La modale de dialogue d'une quête est un GRAPHE DE LOGIQUE lu par
|
||||
déclencheur (demande explicite : "un RH doit le comprendre, un visuel
|
||||
global") : UN SEUL panneau (.questDialogueColumn, 90% de largeur,
|
||||
centré, TOUTE LA HAUTEUR disponible) — les déclencheurs de la quête
|
||||
côte à côte (voir .questTriggersSlot plus bas), chacun avec SON PROPRE
|
||||
dialogue édité EN LIGNE (voir .dialogueBox plus bas), PUIS les 3
|
||||
boutons Réplique/Question/Action TOUJOURS tout en bas — ils ajoutent
|
||||
dans le dialogue du déclencheur ACTIF (voir .questTriggerCol.active),
|
||||
jamais dans une liste séparée. */
|
||||
.questDialogueColumns{ flex:1; min-height:0; display:flex; }
|
||||
.questDialogueColumn{
|
||||
background:rgba(0,0,0,.15); border:1px solid var(--border); border-radius:12px; padding:10px;
|
||||
display:flex; flex-direction:column; gap:8px; height:100%; min-height:0;
|
||||
width:90%; margin:0 auto; height:100%; min-height:0; display:flex; flex-direction:column; gap:8px;
|
||||
background:var(--panel2); border:1px solid var(--border); border-radius:12px; padding:12px;
|
||||
}
|
||||
.questDialogueColumnTitle{ flex:0 0 auto; margin:0 0 2px; font-size:13px; text-align:center; }
|
||||
/* Bulles alignées VERTICALEMENT, centrées dans la colonne, reliées par un
|
||||
trait entre chacune (voir ::before sur .questBubbleRow ci-dessous) —
|
||||
demandé explicitement plutôt qu'un alignement gauche/droite. Seule
|
||||
CETTE zone défile (flex:1 + overflow-y:auto) — le titre de colonne et
|
||||
le bouton "+ Réplique" restent fixes, en haut/en bas. */
|
||||
.questDialogueColumnBubbles{ display:flex; flex-direction:column; align-items:center; flex:1; min-height:0; overflow-y:auto; padding-top:4px; }
|
||||
.questDialogueColumnButtons{ flex:0 0 auto; display:flex; gap:8px; justify-content:center; margin-top:4px; }
|
||||
.questDialogueColumnBody{ display:flex; flex-direction:column; align-items:center; flex:1; min-height:0; overflow-y:auto; gap:14px; padding-top:4px; }
|
||||
.questDialogueColumnButtons{ flex:0 0 auto; display:flex; gap:8px; justify-content:center; flex-wrap:wrap; }
|
||||
.questDialogueAddBtn{ align-self:center; padding:6px 14px; font-size:12px; }
|
||||
.questQuestionAddBtn{ background:linear-gradient(135deg, #e0b93d, #b8890f); }
|
||||
|
||||
/* TOUS les déclencheurs du jeu (voir screens/rendering/
|
||||
trigger_graph.py::list_all_triggers) : réutilise TELLES QUELLES
|
||||
.collisionRuleChain/.collisionRuleLink (collision-rules-editor.js) pour
|
||||
la chaîne de pastilles, juste une petite fiche objet au-dessus. Vit dans le MÊME
|
||||
flux que les répliques (.questDialogueColumnBody ci-dessus), aucun
|
||||
scroll ni cadre séparé ici — plusieurs chaînes possibles côte à côte
|
||||
(flex-wrap). */
|
||||
.questTriggersSlot{ width:100%; display:flex; flex-wrap:wrap; gap:20px; justify-content:center; }
|
||||
.questTriggersSlot:empty::before{ content:"Aucun déclencheur pour l'instant — ajoutes-en un avec le bouton ci-dessous."; color:var(--muted); font-size:13px; }
|
||||
|
||||
/* Carte objet + pastilles empilées VERTICALEMENT, reliées par des flèches
|
||||
(maquette validée avec l'utilisateur) — PAS la chaîne horizontale en
|
||||
chevrons de l'éditeur de collision : deux présentations volontairement
|
||||
différentes pour le même modèle de données sous-jacent. */
|
||||
.questTriggerCol{ position:relative; display:flex; flex-direction:column; align-items:center; width:70%; }
|
||||
/* Modale d'ajout de déclencheur (voir static/js/triggers/trigger-editor.js::
|
||||
openAddTriggerModal) — au-dessus de l'onglet "Déclencheurs", jamais
|
||||
recouverte. */
|
||||
.questTriggerModal{ z-index:62; }
|
||||
.questTriggerDelete{
|
||||
position:absolute; top:-8px; right:2px; width:20px; height:20px; border-radius:50%;
|
||||
background:var(--panel2); border:1px solid var(--border); color:var(--muted, #8b95a7);
|
||||
font-size:10px; display:flex; align-items:center; justify-content:center; cursor:pointer; z-index:2;
|
||||
}
|
||||
.questTriggerDelete:hover{ background:#e0524d; color:#fff; border-color:#e0524d; }
|
||||
.questTriggerObjectCard{
|
||||
width:100%; background:var(--panel2); border:1px solid var(--border); border-radius:12px;
|
||||
padding:10px; display:flex; flex-direction:column; align-items:center; gap:6px; cursor:pointer;
|
||||
}
|
||||
.questTriggerObjectCard img{ width:36px; height:36px; border-radius:8px; object-fit:cover; }
|
||||
.questTriggerObjectCard .questTriggerObjectName{ font-weight:700; font-size:12.5px; text-align:center; }
|
||||
/* Déclencheur ACTIF (voir questClickTriggerObjectCard) : c'est LUI qui
|
||||
reçoit les prochaines répliques/questions ajoutées par les boutons du
|
||||
bas de la colonne — surligné pour que ce soit visible sans avoir à
|
||||
deviner. */
|
||||
.questTriggerCol.active .questTriggerObjectCard{ border-color:#3bb3c9; box-shadow:0 0 0 2px rgba(59,179,201,.35); }
|
||||
/* Accordéon (demande explicite : "ça retracte toute la logique associée") :
|
||||
replié = seule la carte objet reste visible, chevron tourné. */
|
||||
.questTriggerCol.collapsed{ width:auto; min-width:140px; }
|
||||
.questTriggerObjectCard .questTriggerCollapseChevron{ font-size:10px; color:var(--muted); transition:transform .15s ease; }
|
||||
.questTriggerCol.collapsed .questTriggerCollapseChevron{ transform:rotate(-90deg); }
|
||||
.questTriggerConnector{ width:2px; height:16px; background:var(--border); position:relative; flex-shrink:0; }
|
||||
.questTriggerConnector::after{
|
||||
content:""; position:absolute; bottom:0; left:50%; transform:translate(-50%,45%);
|
||||
border:5px solid transparent; border-top-color:var(--border);
|
||||
}
|
||||
/* Flèche menant à une condition — plus grande que les connecteurs
|
||||
ordinaires (demande explicite : "la flèche qui part de interagir vers
|
||||
le losange doit être plus grande"). */
|
||||
.questTriggerConnector.tall{ height:32px; width:3px; }
|
||||
.questTriggerConnector.tall::after{ border-width:7px; }
|
||||
.questTriggerPill{ width:100%; border-radius:100px; padding:8px 14px; font-size:12px; font-weight:700; text-align:center; }
|
||||
.questTriggerPill.trigger{ background:#f2994a; color:#1a1002; }
|
||||
.questTriggerPill.interagir{ background:#d4a72c; color:#1a1002; }
|
||||
.questTriggerPill.quete{ background:#9b59b6; color:#fff; }
|
||||
.questTriggerPill.none{ background:transparent; border:1.5px dashed var(--border); color:var(--muted, #8b95a7); }
|
||||
|
||||
/* Rectangle "dialogue" — même largeur que le reste de la chaîne (100% de
|
||||
son conteneur, comme les pastilles, maquette validée : "le rectangle
|
||||
doit avoir la même width que les autres éléments"). Un bouton "+" en
|
||||
haut à droite ajoute une réplique DIRECTEMENT dedans (voir
|
||||
static/js/triggers/trigger-editor.js::triggerInlineAddBubble) — chaque
|
||||
occurrence de "Déclencher ce dialogue" porte le sien, édité ici même,
|
||||
à l'endroit où la flèche pointe (plus de colonne séparée). */
|
||||
.dialogueBox{
|
||||
width:100%; background:rgba(0,0,0,.18); border:1.5px solid #3bb3c9; border-radius:14px;
|
||||
padding:10px; display:flex; flex-direction:column; gap:8px;
|
||||
}
|
||||
.dialogueBoxHeader{ display:flex; align-items:center; justify-content:space-between; gap:8px; }
|
||||
.dialogueBoxTitle{ font-size:11px; font-weight:800; color:#3bb3c9; text-transform:uppercase; letter-spacing:.04em; }
|
||||
.dialogueBoxActions{ display:flex; gap:4px; }
|
||||
.addReplyBtn{
|
||||
background:#3bb3c9; color:#04222a; border:none; border-radius:999px; padding:5px 14px;
|
||||
font-size:12px; font-weight:800; line-height:1.4; cursor:pointer; flex-shrink:0;
|
||||
}
|
||||
.addReplyBtn:hover{ filter:brightness(1.1); }
|
||||
.addQuestionBtn{ background:linear-gradient(135deg, #e0b93d, #b8890f); color:#1a1002; }
|
||||
|
||||
/* Feuille "Modifier une variable" (voir screens/rendering/collision_rules.py
|
||||
::"variable") — même famille visuelle que .dialogueBox (rectangle
|
||||
pleine largeur), bordure jaune pour la distinguer d'un dialogue au
|
||||
premier coup d'œil, pas de bulles dedans. */
|
||||
.questVariableAction{ border-color:#e0b93d; }
|
||||
.questVariableActionLabel{ margin:0; font-size:13px; color:var(--text); }
|
||||
/* "+ Ajouter une action" en bout de chaîne (demande explicite : "pouvoir
|
||||
modifier un déclencheur pour lui ajouter des actions") — même largeur
|
||||
que le reste de la chaîne, discret (pointillés) tant qu'on ne survole
|
||||
pas. */
|
||||
.questTriggerAddChainedActionBtn{
|
||||
width:100%; background:transparent; border:1.5px dashed var(--border); color:var(--muted, #8b95a7);
|
||||
}
|
||||
.questTriggerAddChainedActionBtn:hover{ border-color:var(--accent); color:var(--accent); }
|
||||
|
||||
/* Losange "condition" — lecture ENTIÈREMENT VERTICALE (demande explicite :
|
||||
"il faut tout mettre verticalement", le losange lui-même ne change
|
||||
pas) : le losange reste petit et centré, la branche qui MÈNE à cette
|
||||
quête descend tout droit en premier (flèche verte ou rouge selon
|
||||
Vrai/Faux) vers "Déclencher ce dialogue" (pleine largeur, comme un
|
||||
déclencheur sans condition) puis son rectangle de dialogue, PUIS
|
||||
l'autre branche descend à son tour vers "Ne rien faire" — jamais deux
|
||||
branches côte à côte. */
|
||||
.questCondColumn{ width:100%; display:flex; flex-direction:column; align-items:center; }
|
||||
/* Un VRAI losange (carré tourné à 45°, pas un rectangle aplati) — voir
|
||||
demande explicite "je veut un vrai losange comme avant". */
|
||||
.questCondDiamond{
|
||||
width:150px; height:150px; margin:16px 0; transform:rotate(45deg);
|
||||
background:#3bb3c9; color:#04222a; display:flex; align-items:center; justify-content:center; border-radius:16%;
|
||||
box-shadow:0 4px 14px rgba(59,179,201,.35);
|
||||
}
|
||||
.questCondDiamond span{ transform:rotate(-45deg); font-size:11.5px; font-weight:800; text-align:center; line-height:1.3; padding:0 12px; }
|
||||
/* Flèches Vrai/Faux plus grandes — demande explicite "c'est trop petit
|
||||
trop tassé". */
|
||||
.questCondArrowWrap{ position:relative; display:flex; flex-direction:column; align-items:center; width:100%; height:68px; }
|
||||
.questCondArrowWrap svg{ width:34px; height:100%; overflow:visible; }
|
||||
.questCondArrowLabel{
|
||||
position:absolute; left:calc(50% + 26px); top:14px; z-index:2; border-radius:999px; padding:6px 16px;
|
||||
font-size:12.5px; font-weight:800; white-space:nowrap;
|
||||
}
|
||||
.questCondArrowLabel.yes{ background:rgba(46,204,113,.18); color:#2ecc71; border:1px solid rgba(46,204,113,.4); }
|
||||
.questCondArrowLabel.no{ background:rgba(224,82,77,.18); color:#e0524d; border:1px solid rgba(224,82,77,.4); }
|
||||
|
||||
.questTriggerObjectThumb{ width:40px; height:40px; border-radius:8px; overflow:hidden; background:var(--panel2); display:flex; align-items:center; justify-content:center; }
|
||||
.questTriggerObjectThumb img{ width:100%; height:100%; object-fit:cover; }
|
||||
.questTriggerObjectChoice{ align-items:center; }
|
||||
.questTriggerObjectChoice.is-selected{ border-color:var(--accent); box-shadow:0 0 0 2px var(--accent); }
|
||||
|
||||
.questBubbleRow{ display:flex; justify-content:center; width:100%; position:relative; }
|
||||
.questBubbleRow:not(:first-child)::before{
|
||||
content:''; position:absolute; top:-14px; left:50%; transform:translateX(-50%);
|
||||
@@ -1029,14 +1161,15 @@ body.builderBody > main.content{ flex:1 1 auto; min-height:0; overflow:hidden; d
|
||||
.questBubble-pnj .questBubbleBody{ background:rgba(47,111,237,.16); }
|
||||
.questBubble-joueur .questBubbleHeader{ background:#1e9e57; }
|
||||
.questBubble-joueur .questBubbleBody{ background:rgba(46,204,113,.16); }
|
||||
/* "❓ Question" (voir questQuestionBubbleHtml, static/js/quests/
|
||||
quest-editor.js) — bulle JAUNE distincte des répliques de dialogue,
|
||||
/* "❓ Question" (voir triggerQuestionBubbleHtml, static/js/triggers/
|
||||
trigger-editor.js) — bulle JAUNE distincte des répliques de dialogue,
|
||||
même chaîne/alignement vertical (.questBubbleRow). */
|
||||
.questBubble-question .questBubbleHeader{ background:#b8890f; flex-wrap:wrap; row-gap:4px; }
|
||||
.questBubble-question .questBubbleBody{ background:rgba(224,185,61,.14); }
|
||||
.questQuestionHeaderField{
|
||||
display:inline-flex; align-items:center; gap:4px; font-size:11px; font-weight:700; color:#fff; white-space:nowrap;
|
||||
display:inline-flex; align-items:center; gap:6px; font-size:11px; font-weight:700; color:#fff; white-space:nowrap;
|
||||
}
|
||||
.questQuestionHeaderFieldLabel{ font-weight:700; text-transform:uppercase; font-size:10px; letter-spacing:.02em; opacity:.85; }
|
||||
.questQuestionHeaderField input{
|
||||
width:44px; background:rgba(0,0,0,.25); border:1px solid rgba(255,255,255,.3); border-radius:4px;
|
||||
color:#fff; font:inherit; font-size:11px; padding:2px 4px;
|
||||
@@ -1196,7 +1329,7 @@ code{ background:var(--panel2); border:1px solid var(--border); border-radius:4p
|
||||
.builderTabBtn.active{ color:var(--accent); border-bottom-color:var(--accent); font-weight:600; }
|
||||
|
||||
/* overflow-y:auto : contrairement à "screen" (voir .canvasFrame plus haut,
|
||||
qui défile LUI-MÊME), "collision-rules"/"quests"/"events" n'ont pas de
|
||||
qui défile LUI-MÊME), "triggers"/"events" n'ont pas de
|
||||
sous-conteneur dédié au défilement — sans ça, une liste plus haute que
|
||||
l'espace disponible restait simplement coupée par .builderCanvasArea
|
||||
(overflow:hidden), au lieu de défiler. */
|
||||
|
||||
Reference in New Issue
Block a user