Nouvel espace "🗺️ Quêtes" (game-wide, comme les événements personnalisés) : liste de cartes + "+ Nouvelle quête", chaque carte ouvrant une modale d'arbre de dialogue en bulles de théâtre ("speaker: texte"), une colonne par statut de quête (nouvelle/en cours/terminée) puisque le dialogue joué dépend de l'état de la quête au moment où le joueur parle au PNJ. Bulles alternées joueur/PNJ, couleur différente selon qui parle. L'action "Déclencher une quête" de l'éditeur de collision n'est plus un champ texte libre : elle ouvre directement CETTE MÊME modale (picker de quêtes existantes + création à la volée), et finalise la règle avec l'id numérique de la quête choisie — collision_rules.py migré en conséquence (quete_id devient un entier, comme evenement_id). Corrige aussi la miniature d'objet de la carte "🧩 Collision", 3× plus grande (120px) comme demandé. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
266 lines
13 KiB
JavaScript
266 lines
13 KiB
JavaScript
// ---------- É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).
|
|
|
|
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',
|
|
};
|
|
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) },
|
|
];
|
|
var action = rule.action;
|
|
while (action.type === 'interagir' && action.sub_action) {
|
|
links.push({ kind: action.sub_action.type, text: collisionRuleActionSummary(action.sub_action) });
|
|
action = action.sub_action;
|
|
}
|
|
var linksHtml = links.map(function (link, i) {
|
|
return '<span class="collisionRuleLink" data-kind="' + link.kind + '" style="animation-delay:' + (i * 60) + 'ms">' +
|
|
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>'
|
|
);
|
|
}
|
|
|
|
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">' + obj.thumbnail_html + '</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>'
|
|
);
|
|
}
|
|
|
|
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" ----------
|
|
// É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").
|
|
var _collisionWizard = null;
|
|
|
|
function openCollisionRuleModal(objectId) {
|
|
_collisionWizard = { objectId: objectId, trigger: null, perimetrePx: COLLISION_DEFAULT_PERIMETRE_PX };
|
|
document.getElementById('collisionRuleModal').classList.add('is-active');
|
|
renderCollisionWizardTriggerStep();
|
|
}
|
|
|
|
function closeCollisionRuleModal() {
|
|
document.getElementById('collisionRuleModal').classList.remove('is-active');
|
|
_collisionWizard = null;
|
|
}
|
|
|
|
function collisionWizardBodyEl() {
|
|
return document.getElementById('collisionRuleModalBody');
|
|
}
|
|
|
|
// Étape 1 — déclencheur : collision directe, ou périmètre (px réglable,
|
|
// valeur par défaut COLLISION_DEFAULT_PERIMETRE_PX).
|
|
// Sépare "emoji" et "libellé" d'un texte "🗺️ Déclencher une quête" pour
|
|
// les afficher sur deux lignes dans une carte-choix (voir .collisionWizardChoice).
|
|
function collisionSplitEmojiLabel(text) {
|
|
var m = text.match(/^(\S+)\s+(.*)$/);
|
|
return m ? { emoji: m[1], label: m[2] } : { emoji: '', label: text };
|
|
}
|
|
|
|
function renderCollisionWizardTriggerStep() {
|
|
var collision = collisionSplitEmojiLabel(COLLISION_TRIGGER_LABELS.collision);
|
|
var perimetre = collisionSplitEmojiLabel(COLLISION_TRIGGER_LABELS.perimetre);
|
|
collisionWizardBodyEl().innerHTML =
|
|
'<p class="hint mb-2">Cette règle se déclenche...</p>' +
|
|
'<div class="collisionWizardChoices">' +
|
|
'<button type="button" class="collisionWizardChoice" style="animation-delay:0ms" onclick="collisionWizardChooseTrigger(\'collision\')">' +
|
|
'<span class="emoji">' + collision.emoji + '</span><span>' + collision.label + '</span>' +
|
|
'</button>' +
|
|
'<button type="button" class="collisionWizardChoice" style="animation-delay:40ms" onclick="collisionWizardChooseTrigger(\'perimetre\')">' +
|
|
'<span class="emoji">' + perimetre.emoji + '</span><span>' + perimetre.label + '</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);
|
|
}
|
|
|
|
// Étape 2 (et, pour "interagir", étape 3 en second niveau — `nested`
|
|
// distingue les deux, "interagir" n'étant proposé qu'au premier niveau,
|
|
// voir screens/rendering/collision_rules.py::_MAX_ACTION_DEPTH) :
|
|
// quelle action exécuter.
|
|
function renderCollisionWizardActionStep(nested) {
|
|
var types = nested ? COLLISION_ACTION_TYPES.filter(function (t) { return t !== 'interagir'; }) : COLLISION_ACTION_TYPES;
|
|
var buttons = types.map(function (type, i) {
|
|
var split = collisionSplitEmojiLabel(COLLISION_ACTION_LABELS[type]);
|
|
return '<button type="button" class="collisionWizardChoice" style="animation-delay:' + (i * 40) + 'ms" onclick="collisionWizardChooseAction(\'' + type + '\', ' + (nested ? 'true' : 'false') + ')">' +
|
|
'<span class="emoji">' + split.emoji + '</span><span>' + split.label + '</span>' +
|
|
'</button>';
|
|
}).join('');
|
|
collisionWizardBodyEl().innerHTML =
|
|
'<p class="hint mb-2">' + (nested ? 'Une fois la touche pressée...' : 'Alors...') + '</p>' +
|
|
'<div class="collisionWizardChoices">' + buttons + '</div>';
|
|
}
|
|
|
|
function collisionWizardFinalizeAction(action, nested) {
|
|
if (nested) {
|
|
// Deuxième niveau ("interagir" -> sous-action) : referme la maille
|
|
// sur l'action de premier niveau, déjà mémorisée sur le wizard.
|
|
_collisionWizard.pendingInteragir.sub_action = action;
|
|
collisionWizardSaveRule(_collisionWizard.pendingInteragir);
|
|
return;
|
|
}
|
|
if (action.type === 'interagir') {
|
|
// "interagir" n'est jamais final : une carte supplémentaire s'emboîte
|
|
// pour choisir CE QUI se passe une fois la touche pressée.
|
|
_collisionWizard.pendingInteragir = action;
|
|
renderCollisionWizardActionStep(true);
|
|
return;
|
|
}
|
|
collisionWizardSaveRule(action);
|
|
}
|
|
|
|
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);
|
|
}
|
|
|
|
// 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.
|
|
function collisionWizardChooseAction(type, nested) {
|
|
if (type === 'attaque' || type === 'interagir') {
|
|
collisionWizardFinalizeAction({ type: type }, nested);
|
|
return;
|
|
}
|
|
if (type === 'quete') {
|
|
renderQuestPickerStep(function (quest) {
|
|
collisionWizardFinalizeAction({ type: 'quete', quete_id: quest.id }, 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 class="emoji">📣</span><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>';
|
|
}
|
|
}
|
|
|
|
document.addEventListener('DOMContentLoaded', function () {
|
|
if (typeof COLLISION_RULE_OBJECTS !== 'undefined') renderCollisionRuleCards();
|
|
});
|