Your browser lacks required capabilities. Please upgrade it or switch to another to continue.
Loading…
<<run (function () {
/* ── Game version ──────────────────────────────────────── */
window.GAME_VERSION = 0.1;
UIBar.stow(true);
/* ── Language ──────────────────────────────────────────── */
if (State.variables.lang === undefined)
State.variables.lang = "en";
/* ── Player name ───────────────────────────────────────── */
if (State.variables.playerName === undefined)
State.variables.playerName = "Devon";
/* ── Player resources ──────────────────────────────────── */
if (State.variables.endurance === undefined)
State.variables.endurance = 100;
if (State.variables.devotionEssence === undefined)
State.variables.devotionEssence = 1000;
/* divineEssences: keyed by divinity id */
if (State.variables.divineEssences === undefined)
State.variables.divineEssences = {};
if (State.variables.foughtDivinities === undefined)
State.variables.foughtDivinities = [];
/* ── Roster & team ─────────────────────────────────────── */
/* team: [divinityId, servantId, servantId] (null = empty slot) */
if (State.variables.roster === undefined)
State.variables.roster = ["hera", "ellie", "sirena", "kendra", "alex"];
if (State.variables.team === undefined)
State.variables.team = ["hera", "kendra", "sirena"];
/* teamDivRole: rôle choisi pour le slot divinity (slot 0) */
if (State.variables.teamDivRole === undefined)
State.variables.teamDivRole = "defensive";
/* linkLevels: servant link %, keyed by servant id */
if (State.variables.linkLevels === undefined)
State.variables.linkLevels = {};
/* unlockedLinkCards: array of card ids unlocked via link level */
if (State.variables.unlockedLinkCards === undefined)
State.variables.unlockedLinkCards = [];
/* ── Ladder progress ───────────────────────────────────── */
/* ladderProgress[ladderId][enemyId] = win count */
if (State.variables.ladderProgress === undefined)
State.variables.ladderProgress = {};
/* ── Character XP (non-NPC servants) ──────────────────── */
/* charXP[id] = total cumulative XP earned */
if (State.variables.charXP === undefined)
State.variables.charXP = {};
/* ── Recruitment unlocks ───────────────────────────────── */
if (State.variables.unlocked === undefined)
State.variables.unlocked = []; /* unlockedAccess: set of location keys paid once for permanent access */
if (State.variables.unlockedAccess === undefined)
State.variables.unlockedAccess = {};
/* ── Active combat context ─────────────────────────────── */
if (State.variables.combatContext === undefined)
State.variables.combatContext = null;
/* ── Serialized combat engine state (for save/load mid-combat) ── */
if (State.variables.combatState === undefined)
State.variables.combatState = null;
/* ── Dungeon progress ──────────────────────────────────────── */
/* dungeonProgress[dungeonId] = { step: <int>, completed: <bool> } */
/* step = index of the next step to execute (advances on step win) */
if (State.variables.dungeonProgress === undefined)
State.variables.dungeonProgress = {};
/* activeDungeonId: set while a dungeon run is in progress */
if (State.variables.activeDungeonId === undefined)
State.variables.activeDungeonId = null;
/* ── Story time ───────────────────────────────────────── */
if (State.variables.day === undefined)
State.variables.day = 1;
/* ── Settings ──────────────────────────────────────────── */
if (State.variables.settings === undefined)
State.variables.settings = { typewriter: false, sfw: false };
if (State.variables.settings.sfw === undefined)
State.variables.settings.sfw = false;
/* ── Event flags ───────────────────────────────────── */
/* Persistent named flags/counters. Use GameEvents JS API */
/* or the setevent/hasevent/incevent/clearevent/notevent macros */
if (State.variables.events === undefined)
State.variables.events = {};
}())>>
/* ── Global modal backdrop-click-to-close ────────────────────
Defined here ([script]) so it works in every passage.
─────────────────────────────────────────────────────────── */<div id="story-banner">
<div id="sidebar-version">v<<= (window.GAME_VERSION || 0.1).toFixed(1)>></div>
<img src="media/img/logos/logo.webp" id="sidebar-logo" alt="WEE">
</div><svg style="position:absolute;width:0;height:0;overflow:hidden" aria-hidden="true">
<defs>
<filter id="rough-card" x="-15%" y="-15%" width="130%" height="130%">
<feTurbulence type="fractalNoise" baseFrequency="0.065 0.05" numOctaves="2" seed="9" result="noise"/>
<feDisplacementMap in="SourceGraphic" in2="noise" scale="3" xChannelSelector="R" yChannelSelector="G"/>
</filter>
</defs>
</svg><div id="story-caption" style="border-top: 1px solid var(--gold-dark)">
<div class="caption-team">
<<for _tId range $team>>
<<set _tData to (window.DB_Characters && window.DB_Characters[_tId]) || (window.DB_Divinities && window.DB_Divinities[_tId])>>
<<if _tData>>
<div class="caption-team-slot">
<img @src="_tData.portrait" class="caption-team-portrait" @title="_tData.nameKey[$lang]" alt="">
<span class="caption-team-name"><<= _tData.nameKey[$lang]>></span>
</div>
<</if>>
<</for>>
</div>
<div class="caption-section-title"><<= $lang === "en" ? "Stamina" : "Endurance">></div>
<div class="caption-endurance">
<div class="caption-endurance-bar" @style="'width:' + Math.max(0, Math.min(100, $endurance)) + '%'"></div>
<span class="caption-endurance-value">$endurance / 100</span>
</div>
<div id="caption-essence-block">
<div class="caption-section-title"><<= $lang === "en" ? "Essences" : "Essences">></div>
<div class="caption-resource">
<img src="media/img/logos/devotion_essence.webp" class="caption-icon" title="Devotion Essence">
<span>$devotionEssence</span>
</div>
<!-- Show divine essence only if divinity is owned or its dungeon is completed -->
<<for _divId, _divData range (window.DB_Divinities || {})>>
<<if $roster.includes(_divId)>>
<div class="caption-resource">
<img @src="_divData.icon" class="caption-icon" @title="_divData.nameKey[$lang]">
<span><<= $divineEssences[_divId] || 0>></span>
</div>
<</if>>
<</for>>
</div>
</div>
<<set _settLabel to ($lang === "en" ? "\u2699 Settings" : "\u2699 Param\u00e8tres")>><<button _settLabel>><<run window.openSettingsModal()>><</button>>
<a id="caption-patreon" href="https://www.patreon.com/c/Moltes" target="_blank" rel="noopener">
<img src="media/img/icons/patreon.webp" alt="Patreon"> <<= $lang === "fr" ? "Soutenir sur Patreon" : "Support on Patreon">>
</a><nav id="story-menu">
<<set _teamLabel to "👥 " + ($lang === "fr" ? "Équipe" : "Team")>>
<<set _ladderLabel to "⚔ " + ($lang === "fr" ? "Échelle" : "Ladder")>>
<<link _teamLabel "TeamManagement">><</link>>
<<link _ladderLabel "Ladder">><</link>>
<<if Config.debug>>
<<link "🛠 DevTest" "DevTest">><</link>>
<</if>>
</nav><<run (function () {
var bar = document.getElementById('ui-bar');
var story = document.getElementById('story');
if (tags().includes('no_panel')) {
if (bar) bar.style.setProperty('display', 'none', 'important');
if (story) story.style.setProperty('margin-left', '0', 'important');
return;
}
if (bar) bar.style.removeProperty('display');
UIBar.unstow(true);
if (!window._panelSync && bar) {
var sideW = getComputedStyle(document.documentElement).getPropertyValue('--sidebar-w').trim() || '20rem';
window._panelSync = function () {
var s = document.getElementById('story');
var toggle = document.getElementById('ui-bar-toggle');
/* SugarCube 2.30 uses class 'stowed' (not 'ui-bar-stowed') */
var stowed = bar.classList.contains('stowed');
if (s) {
/* When open : story starts after sidebar (margin-left: sidebar-w, margin-right: 0)
When stowed: same content width, centered (margin-left/right: sidebar-w / 2) */
s.style.setProperty('margin-left', stowed ? 'calc(var(--sidebar-w) / 2)' : 'var(--sidebar-w)', 'important');
s.style.setProperty('margin-right', stowed ? 'calc(var(--sidebar-w) / 2)' : '0', 'important');
}
if (toggle) toggle.style.cssText = stowed
? 'position:fixed;top:.75rem;left:.4rem;bottom:auto;right:auto;' +
'border-radius:0 8px 8px 0;width:28px;height:40px;z-index:300;'
: '';
};
new MutationObserver(window._panelSync)
.observe(bar, { attributes: true, attributeFilter: ['class'] });
}
if (window._panelSync) window._panelSync();
}())>><div class="start-screen">
<img src="media/img/logos/logo.webp" alt="When Eternity Ends" class="start-banner">
<div class="start-lang-picker">
<select id="start-lang-select" class="start-lang-select"></select>
</div>
<div class="start-content">
<div class="start-intro">
<<if $lang === "fr">>
<p>Bienvenue dans <em>When Eternity Ends</em>, un RPG narratif (pour adultes) vous plongeant dans l'univers des dieux… et de leur possible extinction. Mais tout n'est pas encore joué et votre rôle sera peut-être plus important que vous ne le pensez.</p>
<<else>>
<p>Welcome to <em>When Eternity Ends</em>, an adult narrative RPG plunging you into the world of the gods… and their possible extinction. But the game is not over yet, and your role may be more important than you think.</p>
<</if>>
</div>
<div class="start-name-wrap">
<label class="start-name-label" for="start-name-input">
<<if $lang === "fr">>Votre nom :<<else>>Your name :<</if>>
</label>
<input id="start-name-input" class="start-name-input" type="text"
maxlength="24" placeholder="Devon">
</div>
<div class="start-buttons">
<<set _ageLabel to ($lang === "fr" ? "✔ Je confirme avoir plus de 18 ans" : "✔ I confirm I am 18 or older")>>
<<button _ageLabel "HadesIntro_Accident">><</button>>
</div><!-- .start-buttons -->
</div><!-- .start-content -->
</div><!-- .start-screen -->
<<script>>
(function () {
$(document).one(':passagedisplay', function () {
var input = document.getElementById("start-name-input");
if (input) {
input.value = State.variables.playerName || "Devon";
input.addEventListener("input", function () {
var val = this.value.trim();
State.variables.playerName = val.length > 0 ? val : "Devon";
});
}
var sel = document.getElementById("start-lang-select");
if (sel) {
var langs = [{v:"en", l:"🇬🇧 English"}, {v:"fr", l:"🇫🇷 Français"}];
var cur = State.variables.lang || "en";
langs.forEach(function (o) {
var opt = document.createElement("option");
opt.value = o.v;
opt.textContent = o.l;
if (o.v === cur) opt.selected = true;
sel.appendChild(opt);
});
sel.addEventListener("change", function () {
State.variables.lang = this.value;
Engine.play("Start");
});
}
});
}());
<</script>><div id="devtest-screen">
<div id="devtest-villa-shortcut">
<<set _villaLbl to "🏛 " + ($lang === "fr" ? "La Villa" : "The Villa")>>
<<button _villaLbl "VillaHub">><</button>>
<<button "🔍 State Inspector" "StateInspector">><</button>>
</div>
<h2>🛠 Dev Test Panel</h2>
<section>
<h3>Resources</h3>
<div id="dev-resource-btns"></div>
</section>
<section>
<h3>Roster</h3>
<button id="dev-unlock-all">
<<= $lang === "fr" ? "Débloquer tous les personnages" : "Unlock All Characters">>
</button>
<button id="dev-make-purchasable">
<<= $lang === "fr" ? "🔓 Rendre achetables les personnages manquants" : "🔓 Make Missing Chars Purchasable">>
</button>
<button id="dev-team-hera">
<<= $lang === "fr" ? "Équipe Héra (Héra + Ellie + Siréna)" : "Hera Team (Hera + Ellie + Sirena)">>
</button>
<button id="dev-go-team"><<= $lang === "fr" ? "Gérer l'équipe" : "Team Management">></button>
<p><<= $lang === "fr" ? "Possédés" : "Owned">> : <em id="dev-owned-list"><<= $roster.join(", ") || "—">></em></p>
</section>
<section>
<h3>Quick Combat</h3>
<p><<= $lang === "fr" ? "Équipe" : "Team">> : <em><<= $team.filter(Boolean).join(", ") || "—">></em></p>
<button id="dev-super-fight"><<= $lang === "fr" ? "⚡ Combattre (super équipe)" : "⚡ Fight (super team)">></button>
<div id="dev-combat-btns"></div>
</section>
<section>
<h3><<= $lang === "fr" ? "Donjons" : "Dungeons">></h3>
<div id="dev-dungeon-btns"></div>
</section>
<section>
<h3>Ladder Progress</h3>
<button id="dev-go-ladder"><<= $lang === "fr" ? "Aller au Ladder" : "Go to Ladder">></button>
<button id="dev-reset-ladder"><<= $lang === "fr" ? "Réinitialiser l'Échelle" : "Reset Ladder">></button>
<button id="dev-unlock-ladder"><<= $lang === "fr" ? "Débloquer tous les combats" : "Unlock All Fights">></button>
<pre id="dev-ladder-json"><<= JSON.stringify($ladderProgress, null, 2)>></pre>
</section>
<section>
<h3>Ritual Tests</h3>
<div id="dev-ritual-btns"></div>
</section>
<section>
<h3><<= $lang === "fr" ? "Narration" : "Narration">></h3>
<button id="dev-narr-demo">
<<= $lang === "fr" ? "▶ Lancer la démo de narration" : "▶ Launch Narration Demo">>
</button>
<button id="dev-narr-dialog">
<<= $lang === "fr" ? "▶ Test Dialogue (Hermès / Ellie)" : "▶ Test Dialog (Hermes / Ellie)">>
</button>
</section>
<section>
<h3><<= $lang === "fr" ? "Passages histoire" : "Story Passages">></h3>
<button id="dev-visite-april">
<<= $lang === "fr" ? "▶ Visite d'April (Jour 3)" : "▶ April's Visit (Day 3)">>
</button>
<button id="dev-hades-intro">
<<= $lang === "fr" ? "▶ Prologue Hadès (séquence complète)" : "▶ Hades Prologue (full sequence)">>
</button>
</section>
<section>
<h3>Card Gallery</h3>
<<set _galleryLbl to ($lang === "fr" ? "🃏 Ouvrir la galerie" : "🃏 Open Gallery")>>
<<button _galleryLbl "CardGallery">><</button>>
</section>
</div>
<<script>>
(function () {
var lang = State.variables.lang;
$(document).one(':passagedisplay', function () {
/* ── Resource buttons ─────────────────────────── */
var resEl = document.getElementById("dev-resource-btns");
var devBtn = document.createElement("button");
devBtn.textContent = "+100 Devotion";
devBtn.onclick = function () {
State.variables.devotionEssence += 100;
Engine.show();
};
resEl.appendChild(devBtn);
Object.keys(window.DB_Divinities || {}).forEach(function (divId) {
var d = DB_Divinities[divId];
var btn = document.createElement("button");
btn.textContent = "+50 " + d.nameKey[lang];
btn.onclick = function () {
if (!State.variables.divineEssences[divId])
State.variables.divineEssences[divId] = 0;
State.variables.divineEssences[divId] += 50;
if (!State.variables.foughtDivinities.includes(divId))
State.variables.foughtDivinities.push(divId);
Engine.show();
};
resEl.appendChild(btn);
});
/* ── Go to Team Management ─────────────────────── */
document.getElementById("dev-go-team").onclick = function () {
Engine.play("TeamManagement");
};
/* ── Make missing chars purchasable (1 ladder win each) ── */
document.getElementById("dev-make-purchasable").onclick = function () {
var prog = State.variables.ladderProgress;
var roster = State.variables.roster;
/* Map sourceChar → { ladderId, enemyId } from DB_EnemyDefs */
var charEnemy = {};
Object.keys(DB_Enemies).forEach(function (ladderId) {
var seq = DB_Enemies[ladderId].sequence;
seq.forEach(function (enemyId) {
var def = DB_EnemyDefs[enemyId];
if (def && def.sourceChar && !charEnemy[def.sourceChar])
charEnemy[def.sourceChar] = { ladderId: ladderId, enemyId: enemyId };
});
});
/* For each non-owned servant, ensure at least 1 win on its ladder entry */
Object.keys(DB_Characters).forEach(function (id) {
if (DB_Characters[id].npc) return;
if (roster.includes(id)) return; /* already owned */
var entry = charEnemy[id];
if (!entry) return; /* no ladder entry found */
if (!prog[entry.ladderId]) prog[entry.ladderId] = {};
if ((prog[entry.ladderId][entry.enemyId] || 0) < 1)
prog[entry.ladderId][entry.enemyId] = 1;
});
State.variables.ladderProgress = prog;
Engine.show();
};
/* ── Unlock all ───────────────────────────────── */
document.getElementById("dev-unlock-all").onclick = function () {
var all = Object.keys(DB_Characters).filter(function (id) { return !DB_Characters[id].npc; })
.concat(Object.keys(DB_Divinities));
all.forEach(function (id) {
if (!State.variables.roster.includes(id)) State.variables.roster.push(id);
});
Engine.show();
};
/* ── Quick team preset ────────────────────────── */
document.getElementById("dev-team-hera").onclick = function () {
["hera", "ellie", "sirena"].forEach(function (id) {
if (!State.variables.roster.includes(id)) State.variables.roster.push(id);
});
State.variables.team = ["hera", "ellie", "sirena"];
Engine.show();
};
/* ── Quick combat buttons ─────────────────────── */
/* Super team fight (for testing combat end) */
document.getElementById("dev-super-fight").onclick = function () {
["hera", "kendra", "ellie"].forEach(function (id) {
if (!State.variables.roster.includes(id)) State.variables.roster.push(id);
var def = (window.DB_Characters && window.DB_Characters[id])
|| (window.DB_Divinities && window.DB_Divinities[id]);
if (def) { def.hp = 999; def.maxHp = 999; }
});
State.variables.team = ["hera", "kendra", "ellie"];
window._devSuperTeam = true; /* signals combat to apply godmode atk buff */
State.variables.combatContext = {
ladderId : null,
enemyId : "hera_royal_guard_enemy",
enemyIds : ["hera_royal_guard_enemy", "hera_priestess_enemy", "hera_archer_enemy"],
backgroundId: "poseidon_terrasse"
};
Engine.play("Combat");
};
/* ── DB_Dungeons buttons (super team) ───────────────────── */
var dungeonEl = document.getElementById("dev-dungeon-btns");
Object.keys(window.DB_Dungeons || {}).forEach(function (dungeonId) {
var cfg = DB_Dungeons[dungeonId];
var btn = document.createElement("button");
btn.textContent = "🏑 " + cfg.nameKey[lang] + " (⚡)";
btn.onclick = function () {
/* Super team setup */
["hera", "kendra", "ellie"].forEach(function (id) {
if (!State.variables.roster.includes(id)) State.variables.roster.push(id);
var def = (window.DB_Characters && window.DB_Characters[id])
|| (window.DB_Divinities && window.DB_Divinities[id]);
if (def) { def.hp = 999; def.maxHp = 999; }
});
State.variables.team = ["hera", "kendra", "ellie"];
window._devSuperTeam = true;
/* Reset dungeon progress so it restarts from step 0 */
var prog = State.variables.dungeonProgress;
prog[dungeonId] = { step: 0 };
State.variables.dungeonProgress = prog;
State.variables.activeDungeonId = dungeonId;
Engine.play("DungeonAdvance");
};
dungeonEl.appendChild(btn);
});
/* ── Hermes dungeon — one button per combat ─── */
(function () {
var DUNGEON_ID = "hermes_dungeon";
var def = window.DB_Dungeons && window.DB_Dungeons[DUNGEON_ID];
if (!def) return;
function makeSuperTeam() {
["hera", "kendra", "ellie"].forEach(function (id) {
if (!State.variables.roster.includes(id)) State.variables.roster.push(id);
var d = (window.DB_Characters && window.DB_Characters[id])
|| (window.DB_Divinities && window.DB_Divinities[id]);
if (d) { d.hp = 999; d.maxHp = 999; }
});
State.variables.team = ["hera", "kendra", "ellie"];
window._devSuperTeam = true;
}
def.steps.forEach(function (step, idx) {
if (step.type !== "combat") return;
var combatDef = window.DB_Combats && window.DB_Combats[step.combatId];
var label = combatDef ? (combatDef.nameKey[lang] || step.combatId) : step.combatId;
var btn = document.createElement("button");
btn.textContent = "⚔ " + label + " (⚡)";
btn.onclick = function () {
makeSuperTeam();
var prog = State.variables.dungeonProgress;
prog[DUNGEON_ID] = { step: idx };
State.variables.dungeonProgress = prog;
State.variables.activeDungeonId = DUNGEON_ID;
Engine.play("DungeonAdvance");
};
dungeonEl.appendChild(btn);
});
})();
/* ── DB_Combats buttons (training, scenario combats) ─── */
var combatEl = document.getElementById("dev-combat-btns");
Object.keys(window.DB_Combats || {}).forEach(function (combatId) {
var cfg = DB_Combats[combatId];
var btn = document.createElement("button");
btn.textContent = "⚔ " + cfg.nameKey[lang];
btn.onclick = function () {
var team = State.variables.team.filter(Boolean);
if (team.length < 1) {
showLinkError(this, lang === "fr" ? "Assemblez une équipe d'abord." : "Build a team first."); return;
}
State.variables.combatContext = { combatId: combatId };
Engine.play("Combat");
};
combatEl.appendChild(btn);
});
/* ── Go to Ladder ───────────────────────────── */
document.getElementById("dev-go-ladder").onclick = function () {
Engine.play("Ladder");
};
/* ── Reset ladder ────────────────────────────── */
document.getElementById("dev-reset-ladder").onclick = function () {
State.variables.ladderProgress = {};
document.getElementById("dev-ladder-json").textContent = "{}";
};
/* ── Unlock all ladder fights ────────────────── */
document.getElementById("dev-unlock-ladder").onclick = function () {
var prog = State.variables.ladderProgress;
Object.keys(DB_Enemies).forEach(function (ladderId) {
var seq = DB_Enemies[ladderId].sequence;
if (!prog[ladderId]) prog[ladderId] = {};
seq.forEach(function (enemyId) {
var required = (DB_EnemyDefs[enemyId] && DB_EnemyDefs[enemyId].winsRequired) || 1;
prog[ladderId][enemyId] = required;
});
});
document.getElementById("dev-ladder-json").textContent =
JSON.stringify(State.variables.ladderProgress, null, 2);
};
/* ── Ritual buttons ─────────────────────────── */
var ritualEl = document.getElementById("dev-ritual-btns");
Object.keys(window.DB_Rituals || {}).forEach(function (ritualId) {
var cfg = DB_Rituals[ritualId];
var btn = document.createElement("button");
btn.textContent = (lang === "fr" ? "Rituel" : "Ritual") + " – " + cfg.nameKey[lang];
btn.onclick = function () {
State.variables.currentRitual = ritualId;
Engine.play("DevotionRitual");
};
ritualEl.appendChild(btn);
});
/* ── Narration demo ──────────────────────────── */
document.getElementById("dev-narr-demo").onclick = function () {
Engine.play("NarrDemo");
};
document.getElementById("dev-narr-dialog").onclick = function () {
Engine.play("TestDialog");
};
/* ── Visite April ────────────────────────────────────── */
document.getElementById("dev-visite-april").onclick = function () {
Engine.play("VisiteApril");
};
/* ── Prologue Hadès (super team) ────────────────────── */
document.getElementById("dev-hades-intro").onclick = function () {
["hera", "kendra", "ellie"].forEach(function (id) {
if (!State.variables.roster.includes(id)) State.variables.roster.push(id);
var def = (window.DB_Characters && window.DB_Characters[id])
|| (window.DB_Divinities && window.DB_Divinities[id]);
if (def) { def.hp = 999; def.maxHp = 999; }
});
State.variables.team = ["hera", "kendra", "ellie"];
window._devSuperTeam = true;
var prog = State.variables.dungeonProgress;
prog["hades_intro"] = { step: 0 };
State.variables.dungeonProgress = prog;
State.variables.activeDungeonId = "hades_intro";
Engine.play("DungeonAdvance");
};
}); /* end :passagedisplay */
}());
<</script>><div id="cardgallery-screen">
<div id="devtest-villa-shortcut">
<<set _backLbl to ($lang === "fr" ? "← Retour" : "← Back")>>
<<button _backLbl "DevTest">><</button>>
</div>
<h2>🃏 <<= $lang === "fr" ? "Galerie des cartes" : "Card Gallery">></h2>
<div id="devtest-cards" class="card-grid"></div>
</div>
<<script>>
(function () {
var lang = State.variables.lang;
$(document).one(':passagedisplay', function () {
var grid = document.getElementById("devtest-cards");
grid.innerHTML = "";
Object.keys(DB_Cards).forEach(function (id) {
grid.appendChild(CardUI.buildCardElement(DB_Cards[id], lang, false));
});
});
}());
<</script>><div id="inspector-screen">
<div id="inspector-header">
<<button "← DevTest" "DevTest">><</button>>
<h2>🔍 State Inspector</h2>
<button id="inspector-refresh-btn">↺ Refresh</button>
</div>
<div id="inspector-toolbar">
<input id="inspector-filter" type="search" placeholder="Filter keys…" autocomplete="off">
<button id="inspector-expand-all">Expand all</button>
<button id="inspector-collapse-all">Collapse all</button>
<button id="inspector-copy-btn">📋 Copy JSON</button>
</div>
<div id="inspector-body"></div>
</div>
<<script>>
$(document).one(':passagedisplay', function () {
var body = document.getElementById('inspector-body');
var filterEl = document.getElementById('inspector-filter');
var refreshBtn= document.getElementById('inspector-refresh-btn');
var expandBtn = document.getElementById('inspector-expand-all');
var collapseBtn=document.getElementById('inspector-collapse-all');
var copyBtn = document.getElementById('inspector-copy-btn');
/* ── Render ──────────────────────────────────────────── */
function renderValue(val) {
if (val === null) return '<span class="si-null">null</span>';
if (val === undefined) return '<span class="si-undef">undefined</span>';
if (typeof val === 'boolean')
return '<span class="si-bool">' + val + '</span>';
if (typeof val === 'number')
return '<span class="si-num">' + val + '</span>';
if (typeof val === 'string')
return '<span class="si-str">"' + _esc(val) + '"</span>';
if (Array.isArray(val))
return renderArray(val);
if (typeof val === 'object')
return renderObject(val);
return '<span>' + _esc(String(val)) + '</span>';
}
function renderArray(arr) {
if (arr.length === 0)
return '<span class="si-empty">[ ]</span>';
var id = 'si-' + Math.random().toString(36).slice(2);
var preview = '[' + arr.length + ']';
var inner = arr.map(function (v, i) {
return '<div class="si-row"><span class="si-key">' + i + '</span>'
+ '<span class="si-colon">:</span>' + renderValue(v) + '</div>';
}).join('');
return '<span class="si-toggle" data-target="' + id + '">' + preview + '</span>'
+ '<div class="si-block" id="' + id + '">' + inner + '</div>';
}
function renderObject(obj) {
var keys = Object.keys(obj);
if (keys.length === 0)
return '<span class="si-empty">{ }</span>';
var id = 'si-' + Math.random().toString(36).slice(2);
var preview = '{' + keys.length + '}';
var inner = keys.map(function (k) {
return '<div class="si-row"><span class="si-key">' + _esc(k) + '</span>'
+ '<span class="si-colon">:</span>' + renderValue(obj[k]) + '</div>';
}).join('');
return '<span class="si-toggle" data-target="' + id + '">' + preview + '</span>'
+ '<div class="si-block" id="' + id + '">' + inner + '</div>';
}
function _esc(s) {
return String(s)
.replace(/&/g,'&').replace(/</g,'<')
.replace(/>/g,'>').replace(/"/g,'"');
}
function build(filter) {
var vars = State.variables;
var keys = Object.keys(vars).sort();
if (filter) {
var f = filter.toLowerCase();
keys = keys.filter(function (k) { return k.toLowerCase().indexOf(f) >= 0; });
}
if (keys.length === 0) {
body.innerHTML = '<p class="si-empty">No variables match.</p>';
return;
}
body.innerHTML = keys.map(function (k) {
return '<div class="si-var-row">'
+ '<span class="si-var-key">$' + _esc(k) + '</span>'
+ '<div class="si-var-val">' + renderValue(vars[k]) + '</div>'
+ '</div>';
}).join('');
/* Toggle click */
body.querySelectorAll('.si-toggle').forEach(function (btn) {
btn.addEventListener('click', function () {
var target = document.getElementById(btn.getAttribute('data-target'));
if (target) target.classList.toggle('si-collapsed');
});
});
}
/* Initial render */
build('');
/* Filter */
filterEl.addEventListener('input', function () { build(filterEl.value); });
/* Refresh */
refreshBtn.addEventListener('click', function () { build(filterEl.value); });
/* Expand / Collapse all */
expandBtn.addEventListener('click', function () {
body.querySelectorAll('.si-block').forEach(function (el) {
el.classList.remove('si-collapsed');
});
});
collapseBtn.addEventListener('click', function () {
body.querySelectorAll('.si-block').forEach(function (el) {
el.classList.add('si-collapsed');
});
});
/* Copy */
copyBtn.addEventListener('click', function () {
try {
var json = JSON.stringify(State.variables, null, 2);
navigator.clipboard.writeText(json).then(function () {
copyBtn.textContent = '✅ Copied!';
setTimeout(function () { copyBtn.textContent = '📋 Copy JSON'; }, 1800);
});
} catch (e) { copyBtn.textContent = '❌ Failed'; }
});
});
<</script>><<script>>
State.variables.narratCtx = {
onEnd: "DevTest",
sequence: [
{
type : "narration",
bg : "hermes_palace",
text : {
fr: "Dans les couloirs dorés du palais d'Hermès, l'air vibre d'une énergie électrique. Le messager des dieux attend, son caducée tournoyant entre ses doigts.",
en: "In the golden corridors of Hermes's palace, the air hums with electric energy. The messenger of the gods waits, his caduceus spinning between his fingers."
}
},
{
type : "dialogue",
character: "hermes",
side : "right",
bg : "hermes_throne",
text : {
fr: "Ah, enfin ! Je commençais à croire que tu avais renoncé. Tu sais que le temps est une ressource rare — même pour les immortels.",
en: "Ah, finally! I was starting to think you had given up. You know time is a rare resource — even for immortals."
}
},
{
type : "dialogue",
character: "ellie",
side : "left",
bg : "hermes_throne",
text : {
fr: "Je ne renonce jamais. J'avais juste besoin de comprendre ce que tu voulais vraiment de moi.",
en: "I never give up. I just needed to understand what you really wanted from me."
}
},
{
type : "dialogue",
character: "hermes",
side : "right",
bg : "hermes_throne",
text : {
fr: "Directe. J'aime ça. Très bien, voilà la vérité : Poséidon prépare quelque chose. Quelque chose de grand. Et Héra ne veut pas l'entendre.",
en: "Direct. I like that. Very well, here is the truth: Poseidon is planning something. Something big. And Hera doesn't want to hear about it."
}
},
{
type : "dialogue",
character: "kendra",
side : "left",
bg : "hermes_throne",
text : {
fr: "Poseidon… j'ai entendu ce nom dans mes visions. Des flots noirs qui engloutissent l'Olympe. Ce n'est pas un mythe.",
en: "Poseidon… I have heard that name in my visions. Dark tides swallowing Olympus. This is no myth."
}
},
{
type : "dialogue",
character: "hermes",
side : "right",
bg : "hermes_throne",
text : {
fr: "La mortelle aux yeux de prophétesse. Intéressant. Alors tu sais déjà ce qu'il faut faire.",
en: "The mortal with the seer's eyes. Interesting. Then you already know what must be done."
}
},
{
type : "dialogue",
character: "ellie",
side : "left",
bg : "hermes_palace",
text : {
fr: "D'accord. Mais si je fais ça, tu me dois une faveur. Une vraie. Pas un de tes tours de passe-passe.",
en: "Alright. But if I do this, you owe me a favour. A real one. Not one of your tricks."
}
},
{
type : "dialogue",
character: "sirena",
side : "left",
bg : "hermes_palace",
text : {
fr: "Je les ai vus depuis les profondeurs. Les courants changent. La mer elle-même retient son souffle.",
en: "I have seen them from the depths. The currents are changing. Even the sea holds its breath."
}
},
{
type : "dialogue",
character: "hermes",
side : "right",
bg : "hermes_palace",
text : {
fr: "Ha ! Tu négocies avec un dieu. J'adore ça. Marché conclu.",
en: "Ha! You're negotiating with a god. I love it. Deal."
}
},
{
type : "dialogue",
character: "hera",
side : "right",
bg : "hera_throne",
text : {
fr: "Hermès. Tu t'es encore mêlé de ce qui ne te regardait pas.",
en: "Hermes. You have meddled again in matters that are not your concern."
}
},
{
type : "dialogue",
character: "kendra",
side : "left",
bg : "hera_throne",
text : {
fr: "Grande Héra, nous ne cherchons pas à vous offenser. Nous cherchons à protéger ce qui reste.",
en: "Great Hera, we do not seek to offend you. We seek to protect what remains."
}
},
{
type : "dialogue",
character: "hera",
side : "right",
bg : "hera_throne",
text : {
fr: "Soit. Je vous accorde mon aide. Mais le prix sera élevé… pour toutes.",
en: "So be it. I grant you my aid. But the price will be steep… for all of you."
}
},
{
type : "narration",
bg : "golden_entrance",
text : {
fr: "La porte s'ouvre d'elle-même. La mission commence.",
en: "The door opens on its own. The mission begins."
}
}
]
};
<</script>>
<<goto "Narration">><div id="team-management">
<!-- ── Top bar : back + title ── -->
<div id="team-top-bar">
<div class="screen-nav">
<<set _backLabel to ($lang === "fr" ? "← Retour" : "← Back")>>
<<button _backLabel "VillaHub">><</button>>
</div>
<h2><<= $lang === "fr" ? "Gestion de l'Équipe" : "Team Management">></h2>
</div>
<!-- ── Two-column body ── -->
<div id="team-body">
<!-- Left panel : current team -->
<div id="team-left-panel">
<section id="team-slots">
<h3><<= $lang === "fr" ? "Équipe Actuelle" : "Current Team">></h3>
<div id="team-slot-list" class="slot-list"></div>
</section>
</div>
<!-- Right panel : character roster -->
<div id="team-right-panel">
<section id="div-roster-section">
<h3><<= $lang === "fr" ? "Divinités" : "Divinities">></h3>
<div id="div-roster" class="char-grid"></div>
</section>
<section id="serv-roster-section">
<h3><<= $lang === "fr" ? "Serviteurs" : "Servants">></h3>
<div id="serv-roster" class="char-grid"></div>
</section>
</div>
</div>
<!-- Card modal -->
<div id="card-modal" class="modal hidden" role="dialog" aria-modal="true">
<div class="modal-inner">
<button class="modal-close" onclick="document.getElementById('card-modal').classList.add('hidden')" aria-label="Close">✕</button>
<h3 id="modal-char-name"></h3>
<div id="modal-card-list" class="card-grid"></div>
</div>
</div>
<!-- Recruit modal -->
<div id="recruit-modal" class="modal hidden" role="dialog" aria-modal="true">
<div class="modal-inner">
<button class="modal-close" onclick="document.getElementById('recruit-modal').classList.add('hidden')" aria-label="Close">✕</button>
<div id="recruit-modal-content"></div>
</div>
</div>
</div>
<<script>>
(function () {
var lang = State.variables.lang;
var team = State.variables.team;
var roster = State.variables.roster;
/* ── Helpers ──────────────────────────────────────── */
window.TeamUI = {
/* Build a character roster card */
buildCharCard: function (data, id, owned) {
var roleIcons = { offensive: "⚔", defensive: "🛡", support: "✦" };
var roleIcon = roleIcons[data.role] || "●";
/* Format recruit cost for display (hidden when already owned) */
var costHtml = "";
if (data.recruitCost && !owned) {
costHtml = '<div class="char-cost">';
Object.keys(data.recruitCost).forEach(function (k) {
var have = k === "devotionEssence"
? State.variables.devotionEssence
: (State.variables.divineEssences[k] || 0);
var need = data.recruitCost[k];
var canPay = owned || have >= need;
var icon = k === "devotionEssence"
? '<img src="media/img/logos/devotion_essence.webp" class="cost-icon" alt="🔥">'
: (window.DB_Divinities && window.DB_Divinities[k]
? '<img src="' + window.DB_Divinities[k].icon + '" class="cost-icon" alt="' + k + '">'
: k);
var cls = (!owned && !canPay) ? ' cost-missing' : '';
costHtml += '<span class="cost-tag' + cls + '">' + icon + ' ' + need + '</span>';
});
costHtml += '</div>';
}
/* Build role display string and action buttons before innerHTML */
var rolesDisplay = data.roles
? data.roles.map(function(r) { return (roleIcons[r]||'') + ' ' + r; }).join(' / ')
: (roleIcon + ' ' + (data.role||''));
var actBtns;
if (owned) {
if (team.includes(id)) {
actBtns = '<button disabled class="btn-in-team">' + (lang === "fr" ? "✓ En équipe" : "✓ In team") + '</button>';
} else {
actBtns = '<button onclick="TeamUI.addToTeam(this,\'' + id + '\')">'
+ (lang === "fr" ? "+ Ajouter" : "+ Add") + '</button>';
}
} else {
actBtns = '<button class="btn-recruit" onclick="TeamUI.openRecruit(\'' + id + '\')">'
+ (lang === "fr" ? "⚷ Recruter" : "⚷ Recruit") + '</button>';
}
actBtns += '<button onclick="TeamUI.openCards(\'' + id + '\')">'
+ (lang === "fr" ? "🂠 Cartes" : "🂠 Cards") + '</button>';
var card = document.createElement("div");
card.className = "char-card" + (owned ? " owned" : " locked");
card.dataset.id = id;
/* Level / XP display for owned non-NPC servants and divinities */
var levelHtml = '';
if (owned && !data.npc && (data.tag === 'Servant' || data.tag === 'Divine')) {
var XP_TABLE = [0, 100, 250, 450, 700, 1000, 1350, 1750, 2200, 2700];
var _cxpRaw = State.variables.charXP && State.variables.charXP[id];
var _baseLv = (_cxpRaw === undefined) ? (data.level || 1) : 1;
/* If no XP recorded yet, pin cxp to the floor of the base level */
var cxp = (_cxpRaw !== undefined) ? _cxpRaw : XP_TABLE[Math.max(0, _baseLv - 1)];
var clv = _baseLv;
for (var xi = 1; xi < XP_TABLE.length; xi++) { if (cxp >= XP_TABLE[xi]) clv = xi + 1; else break; }
if (clv < _baseLv) clv = _baseLv;
var xpForNext = clv < 10 ? XP_TABLE[clv] : XP_TABLE[9];
var xpBase = clv >= 2 ? XP_TABLE[clv - 1] : 0;
var xpPct = clv >= 10 ? 100 : Math.round((cxp - xpBase) / (xpForNext - xpBase) * 100);
if (xpPct < 0) xpPct = 0;
levelHtml = '<div class="char-level">';
levelHtml += '<span class="char-level-badge">Lv.' + clv + (clv >= 10 ? ' MAX' : '') + '</span>';
levelHtml += '<div class="char-xp-bar-wrap"><div class="char-xp-bar" style="width:' + xpPct + '%"></div></div>';
levelHtml += '<span class="char-xp-pct">' + (clv >= 10 ? 'MAX' : (cxp - xpBase) + '/' + (xpForNext - xpBase) + ' XP') + '</span>';
levelHtml += '</div>';
} else if (data.npc) {
var fixedLv = data.level || 1;
levelHtml = '<div class="char-level"><span class="char-level-badge char-level-fixed">Lv.' + fixedLv + '</span></div>';
}
card.innerHTML =
'<img src="' + data.portrait.replace('media/img/avatars/', 'media/img/characters/') + '" class="char-portrait" alt="' + data.nameKey[lang] + '">'
+ '<div class="char-info">'
+ '<span class="char-name">' + data.nameKey[lang] + '</span>'
+ '<div class="char-rarity-role">'
+ '<span class="char-rarity ' + (data.rarity || "") + '">' + ((data.rarity || data.tag) || "") + '</span>'
+ '<span class="char-role">' + rolesDisplay + '</span>'
+ '</div>'
+ levelHtml
+ (owned && data.tag === 'Servant'
? '<div class="char-link">'
+ '<span class="char-link-label">' + (lang === 'fr' ? 'Lien' : 'Link') + '</span>'
+ '<div class="char-link-bar-wrap"><div class="char-link-bar" style="width:' + (State.variables.linkLevels[id] || 0) + '%"></div></div>'
+ '<span class="char-link-pct">' + (State.variables.linkLevels[id] || 0) + '%</span>'
+ '</div>'
: '')
+ '</div>'
+ costHtml
+ '<div class="char-actions">' + actBtns + '</div>';
return card;
},
/* Render the 3-slot team panel */
renderTeamSlots: function () {
var el = document.getElementById("team-slot-list");
if (!el) return;
var roleIcons = { offensive: "⚔", defensive: "🛡", support: "✦" };
var labels = lang === "fr"
? ["Divinité", "Serviteur 1", "Serviteur 2"]
: ["Divinity", "Servant 1", "Servant 2"];
el.innerHTML = "";
for (var i = 0; i < 3; i++) {
var charId = team[i];
var slot = document.createElement("div");
slot.className = "team-slot";
if (charId) {
var c = DB_Characters[charId] || DB_Divinities[charId];
var rarityClass = c.rarity ? " " + c.rarity : "";
var displayRole = (i === 0 && c.roleData)
? (State.variables.teamDivRole || c.role) : (c.role || "");
var roleIcon = roleIcons[displayRole] || "";
/* Level / XP block for slot */
var slotLevelHtml = '';
var isNpcChar = DB_Characters[charId] && (DB_Characters[charId].npc || false);
if (!isNpcChar) {
var XP_TAB2 = [0, 100, 250, 450, 700, 1000, 1350, 1750, 2200, 2700];
var _scxpRaw = State.variables.charXP && State.variables.charXP[charId];
var _sBaseLv = (_scxpRaw === undefined) ? (c.level || 1) : 1;
/* Pin to floor of base level if no XP recorded yet */
var scxp = (_scxpRaw !== undefined) ? _scxpRaw : XP_TAB2[Math.max(0, _sBaseLv - 1)];
var sclv = _sBaseLv;
for (var xi2 = 1; xi2 < XP_TAB2.length; xi2++) { if (scxp >= XP_TAB2[xi2]) sclv = xi2 + 1; else break; }
if (sclv < _sBaseLv) sclv = _sBaseLv;
var sxpF = sclv < 10 ? XP_TAB2[sclv] : XP_TAB2[9];
var sxpB = sclv >= 2 ? XP_TAB2[sclv - 1] : 0;
var sxpP = sclv >= 10 ? 100 : Math.round((scxp - sxpB) / (sxpF - sxpB) * 100);
if (sxpP < 0) sxpP = 0;
slotLevelHtml = '<div class="char-level">'
+ '<span class="char-level-badge">Lv.' + sclv + (sclv >= 10 ? ' MAX' : '') + '</span>'
+ '<div class="char-xp-bar-wrap"><div class="char-xp-bar" style="width:' + sxpP + '%"></div></div>'
+ '<span class="char-xp-pct">' + (sclv >= 10 ? 'MAX' : (scxp - sxpB) + '/' + (sxpF - sxpB) + ' XP') + '</span>'
+ '</div>';
} else {
var fixedLv2 = (c.level || 1);
slotLevelHtml = '<div class="char-level"><span class="char-level-badge char-level-fixed">Lv.' + fixedLv2 + '</span></div>';
}
/* Role switcher for divinities in slot 0 */
var roleSwitcherHtml = '';
if (i === 0 && c.roleData) {
var curRole = State.variables.teamDivRole || c.role;
roleSwitcherHtml = '<div class="slot-role-switcher">';
c.roles.forEach(function(r) {
var rIcon = roleIcons[r] || '';
if (r === curRole) {
roleSwitcherHtml += '<button class="role-btn active" disabled>' + rIcon + ' ' + r + '</button>';
} else {
roleSwitcherHtml += '<button class="role-btn" onclick="TeamUI.switchDivRole(\'' + r + '\')">'
+ rIcon + ' ' + r + '</button>';
}
});
roleSwitcherHtml += '</div>';
}
slot.innerHTML =
'<img src="' + c.portrait.replace('media/img/avatars/', 'media/img/characters/') + '" class="char-portrait" alt="' + c.nameKey[lang] + '">'
+ '<div class="slot-body">'
+ '<span class="char-name">' + c.nameKey[lang] + '</span>'
+ '<div class="char-rarity-role">'
+ '<span class="char-rarity' + rarityClass + '">' + ((c.rarity || c.tag) || "") + '</span>'
+ '<span class="char-role">' + roleIcon + ' ' + displayRole + '</span>'
+ '</div>'
+ slotLevelHtml
+ roleSwitcherHtml
+ (DB_Characters[charId]
? '<div class="char-link">'
+ '<span class="char-link-label">' + (lang === 'fr' ? 'Lien' : 'Link') + '</span>'
+ '<div class="char-link-bar-wrap"><div class="char-link-bar" style="width:' + (State.variables.linkLevels[charId] || 0) + '%"></div></div>'
+ '<span class="char-link-pct">' + (State.variables.linkLevels[charId] || 0) + '%</span>'
+ '</div>'
: '')
+ '<button class="slot-remove" onclick="TeamUI.removeFromTeam(' + i + ')">✕ ' + (lang === "fr" ? "Retirer" : "Remove") + '</button>'
+ '</div>';
} else {
slot.innerHTML = '<span class="slot-empty">' + labels[i] + '</span>';
}
el.appendChild(slot);
}
},
/* Add character to team (roleOverride = role choisi pour les divinités) */
addToTeam: function (el, id, roleOverride) {
var data = DB_Characters[id] || DB_Divinities[id];
/* Divinity already in team: just switch role */
if (team.includes(id) && data.tag === 'Divine' && roleOverride) {
State.variables.teamDivRole = roleOverride;
TeamUI.renderTeamSlots();
TeamUI.renderRosters();
return;
}
if (team.includes(id)) {
showLinkError(el, lang === "fr" ? "Ce personnage est déjà dans l'équipe." : "This character is already in the team."); return;
}
var slot = (data.tag === "Divine") ? 0 : (team[1] ? 2 : 1);
if (slot > 2 || team[slot]) {
showLinkError(el, lang === "fr" ? "Emplacement occupé." : "Slot occupied."); return;
}
team[slot] = id;
State.variables.team = team;
if (data.tag === 'Divine') {
State.variables.teamDivRole = roleOverride || (data.roles && data.roles[0]) || data.role;
}
TeamUI.renderTeamSlots();
TeamUI.renderRosters();
},
/* Remove character from team slot */
removeFromTeam: function (slot) {
State.variables.team[slot] = null;
team = State.variables.team;
TeamUI.renderTeamSlots();
TeamUI.renderRosters();
},
/* Switch active role for the divinity in slot 0 */
switchDivRole: function (role) {
State.variables.teamDivRole = role;
TeamUI.renderTeamSlots();
TeamUI.renderRosters();
},
/* Open card list modal for a character */
openCards: function (id) {
var data = DB_Characters[id] || DB_Divinities[id];
document.getElementById("modal-char-name").textContent = data.nameKey[lang];
var list = document.getElementById("modal-card-list");
list.innerHTML = "";
if (data.roleData) {
/* Divinité multi-rôles : afficher les cartes groupées par rôle */
Object.keys(data.roleData).forEach(function (r) {
var sep = document.createElement("div");
sep.className = "card-section-label";
sep.textContent = (lang === "fr" ? "Rôle : " : "Role: ") + r;
list.appendChild(sep);
data.roleData[r].cardPool.forEach(function (cardId) {
var card = DB_Cards[cardId];
if (card) list.appendChild(CardUI.buildCardElement(card, lang, false));
});
});
} else {
data.cardPool.forEach(function (cardId) {
var card = DB_Cards[cardId];
if (card) list.appendChild(CardUI.buildCardElement(card, lang, false));
});
}
/* ── Link bonus cards ── */
var unlocked = State.variables.unlockedLinkCards || [];
var lc = data.linkCards;
if (lc) {
var linkCardIds = Object.values(lc).filter(function (cid) { return unlocked.includes(cid); });
if (linkCardIds.length > 0) {
var sep = document.createElement("div");
sep.className = "card-section-label";
sep.textContent = lang === "fr" ? "✦ Cartes de lien" : "✦ Link cards";
list.appendChild(sep);
linkCardIds.forEach(function (cardId) {
var card = DB_Cards[cardId];
if (card) list.appendChild(CardUI.buildCardElement(card, lang, false));
});
}
}
document.getElementById("card-modal").classList.remove("hidden");
},
/* Open recruit confirmation modal */
openRecruit: function (id) {
var data = DB_Characters[id] || DB_Divinities[id];
var cost = data.recruitCost || {};
var canAfford = Object.keys(cost).every(function (k) {
return k === "devotionEssence"
? State.variables.devotionEssence >= cost[k]
: (State.variables.divineEssences[k] || 0) >= cost[k];
});
var costHtml = Object.keys(cost).map(function (k) {
var have = k === "devotionEssence"
? State.variables.devotionEssence
: (State.variables.divineEssences[k] || 0);
var need = cost[k];
var icon = k === "devotionEssence"
? '<img src="media/img/logos/devotion_essence.webp" class="cost-icon" alt="">'
: (window.DB_Divinities && window.DB_Divinities[k]
? '<img src="' + window.DB_Divinities[k].icon + '" class="cost-icon" alt="' + k + '">'
: k);
var cls = have < need ? ' style="color:#e05050"' : '';
return '<span' + cls + '>' + icon + ' ' + need + ' / ' + have + '</span>';
}).join(" ");
document.getElementById("recruit-modal-content").innerHTML =
'<h3 class="recruit-name">' + (lang === "fr" ? "Recruter " : "Recruit ") + data.nameKey[lang] + '</h3>'
+ '<div class="recruit-cost">' + costHtml + '</div>'
+ (canAfford
? '<button class="recruit-confirm" onclick="TeamUI.recruit(\'' + id + '\')" >'
+ (lang === "fr" ? "✔ Confirmer" : "✔ Confirm") + '</button>'
: '<p class="error recruit-error">'
+ (lang === "fr" ? "Ressources insuffisantes." : "Not enough resources.")
+ '</p>');
document.getElementById("recruit-modal").classList.remove("hidden");
},
/* Execute recruitment */
recruit: function (id) {
var data = DB_Characters[id] || DB_Divinities[id];
var cost = data.recruitCost || {};
Object.keys(cost).forEach(function (k) {
if (k === "devotionEssence") {
State.variables.devotionEssence -= cost[k];
} else {
if (!State.variables.divineEssences[k]) State.variables.divineEssences[k] = 0;
State.variables.divineEssences[k] -= cost[k];
}
});
if (!State.variables.roster.includes(id)) State.variables.roster.push(id);
document.getElementById("recruit-modal").classList.add("hidden");
/* Refresh StoryCaption so essence counters update in real time */
if (typeof UIBar !== "undefined" && UIBar.update) UIBar.update();
/* Update only the card that changed: swap its buttons from Recruit to Add */
var oldCard = document.querySelector('.char-card[data-id="' + id + '"]');
if (oldCard) {
var newCard = TeamUI.buildCharCard(data, id, true);
newCard.dataset.id = id;
oldCard.parentNode.replaceChild(newCard, oldCard);
}
}
};
/* ── Populate rosters ─────────────────────────────── */
$(document).one(':passagedisplay', function () {
TeamUI.renderTeamSlots();
TeamUI.renderRosters();
});
TeamUI.renderRosters = function () {
var divGrid = document.getElementById("div-roster");
var servGrid = document.getElementById("serv-roster");
if (!divGrid || !servGrid) return;
divGrid.innerHTML = "";
servGrid.innerHTML = "";
var prog = State.variables.ladderProgress || {};
var fought = State.variables.foughtDivinities || [];
/* Build a set of servant ids beaten at least once on the ladder */
var beatenOnLadder = {};
Object.keys(DB_EnemyDefs).forEach(function (enemyId) {
var def = DB_EnemyDefs[enemyId];
if (!def.sourceChar) return;
Object.keys(prog).forEach(function (ladderId) {
if ((prog[ladderId][enemyId] || 0) >= 1) beatenOnLadder[def.sourceChar] = true;
});
});
/* Build a set of divinity ids whose dungeon has been completed */
var dungeonUnlockedDivinity = {};
var dungeonProg = State.variables.dungeonProgress || {};
Object.keys(window.DB_Dungeons || {}).forEach(function (dId) {
var dDef = DB_Dungeons[dId];
if (dDef.unlocksDivinity && dungeonProg[dId] && dungeonProg[dId].completed)
dungeonUnlockedDivinity[dDef.unlocksDivinity] = true;
});
Object.keys(DB_Divinities).forEach(function (id) {
var owned = roster.includes(id);
/* A divinity is visible only if owned or its unlocking dungeon is completed */
var unlockedByDungeon = Object.keys(window.DB_Dungeons || {}).some(function (dId) {
return DB_Dungeons[dId].unlocksDivinity === id
&& dungeonProg[dId] && dungeonProg[dId].completed;
});
if (owned || unlockedByDungeon)
divGrid.appendChild(TeamUI.buildCharCard(DB_Divinities[id], id, owned));
});
Object.keys(DB_Characters).forEach(function (id) {
if (DB_Characters[id].npc) return; /* PNJ exclus */
var owned = roster.includes(id);
var divKey = DB_Characters[id].divinity;
if (owned || beatenOnLadder[id] || dungeonUnlockedDivinity[divKey])
servGrid.appendChild(TeamUI.buildCharCard(DB_Characters[id], id, owned));
});
};
}());
<</script>><div id="ladder-screen">
<h2><<= $lang === "fr" ? "Échelle" : "Ladder">></h2>
<div id="ladder-list"></div>
</div>
<<script>>
(function () {
var lang = State.variables.lang;
var prog = State.variables.ladderProgress;
var unlocked = State.variables.unlocked;
/* ── Global LadderUI (called from dynamic onclick) ── */
window.LadderUI = {
fight: function (el, ladderId, enemyId) {
var team = State.variables.team.filter(Boolean);
if (team.length < 1) {
showLinkError(el, lang === "fr" ? "Assemblez d'abord une équipe." : "Set up a team first.");
return;
}
State.variables.combatContext = {
ladderId : ladderId,
enemyId : enemyId,
enemyIds : [enemyId],
minionIds : DB_EnemyDefs[enemyId].minionIds || [],
backgroundId: DB_EnemyDefs[enemyId].background || DB_Enemies[ladderId].background
};
Engine.play("Combat");
},
recruit: function (el, charId, ladderId, enemyId) {
var def = DB_EnemyDefs[enemyId];
var cost = def.recruitCost || {};
var canAfford = Object.keys(cost).every(function (k) {
return k === "devotionEssence"
? State.variables.devotionEssence >= cost[k]
: (State.variables.divineEssences[k] || 0) >= cost[k];
});
if (!canAfford) {
showLinkError(el, lang === "fr" ? "Ressources insuffisantes." : "Not enough resources."); return;
}
Object.keys(cost).forEach(function (k) {
if (k === "devotionEssence") {
State.variables.devotionEssence -= cost[k];
} else {
if (!State.variables.divineEssences[k]) State.variables.divineEssences[k] = 0;
State.variables.divineEssences[k] -= cost[k];
}
});
if (!State.variables.roster.includes(charId)) State.variables.roster.push(charId);
if (!State.variables.unlocked.includes(charId)) State.variables.unlocked.push(charId);
Engine.show();
}
};
/* ── Render each ladder ─────────────────────────── */
$(document).one(':passagedisplay', function () {
var list = document.getElementById("ladder-list");
list.innerHTML = "";
Object.keys(DB_Enemies).forEach(function (ladderId) {
var ladder = DB_Enemies[ladderId];
var section = document.createElement("div");
section.className = "ladder-section";
var title = document.createElement("h3");
title.textContent = ladder.nameKey[lang];
section.appendChild(title);
var grid = document.createElement("div");
grid.className = "lc-grid";
section.appendChild(grid);
var seq = ladder.sequence;
for (var i = 0; i < seq.length; i++) {
var enemyId = seq[i];
var def = DB_EnemyDefs[enemyId];
if (!def) continue;
/* Lock until previous enemy has enough wins */
var accessible = (i === 0);
if (!accessible && prog[ladderId]) {
accessible = (prog[ladderId][seq[i - 1]] || 0) >= def.winsRequired;
}
var wins = prog[ladderId] ? (prog[ladderId][enemyId] || 0) : 0;
var completed = wins >= def.winsRequired;
var canRecruit = def.sourceChar && completed && !unlocked.includes(def.sourceChar);
/* Portrait from characters/ folder (capitalize first letter), fallback to battle */
var charKey = def.sourceChar || "";
var portrait = charKey
? "media/img/characters/" + charKey.charAt(0).toUpperCase() + charKey.slice(1) + ".webp"
: def.battle;
var badgeHtml = completed
? '<span class="lc-badge lc-done">✔ ' + (lang === "fr" ? "Vaincu" : "Defeated") + '</span>'
: '<span class="lc-badge lc-wins">' + wins + ' / ' + def.winsRequired + '</span>';
var actionHtml = !accessible
? '<div class="lc-lock">🔒</div>'
: completed
? '<button class="lc-btn lc-replay" onclick="LadderUI.fight(this,\'' + ladderId + '\',\'' + enemyId + '\')">'
+ (lang === "fr" ? "↺ Rejouer" : "↺ Replay") + '</button>'
: '<button class="lc-btn lc-fight" onclick="LadderUI.fight(this,\'' + ladderId + '\',\'' + enemyId + '\')">'
+ (lang === "fr" ? "⚔ Combat" : "⚔ Fight") + '</button>';
var card = document.createElement("div");
card.className = "lc-card" + (accessible ? "" : " lc-locked") + (completed ? " lc-complete" : "");
card.innerHTML =
'<div class="lc-portrait-wrap">'
+ '<div class="lc-portrait" style="background-image:url(\'' + portrait + '\')"></div>'
+ '<div class="lc-info">'
+ '<span class="lc-name">' + def.nameKey[lang] + '</span>'
+ badgeHtml
+ '</div>'
+ '</div>'
+ actionHtml;
grid.appendChild(card);
}
list.appendChild(section);
});
}); /* end :passagedisplay */
}());
<</script>><<script>>
(function () {
State.variables.events.visitAprilDone = true;
State.variables.narratCtx = {
onEnd: 'VillaHub',
sequence: [
{
type: 'narration',
bg: 'villa_room',
text: {
fr: "Je me réveille dans des draps soyeux, le soleil brille à travers les arbres visibles depuis la baie vitrée. Comment aurais-je pu rêver d'une meilleure vie ! Je n'ai plus à me soucier du boulot ou des factures et j'ai accès à tous les plaisirs de la vie. Ce doit être ça, le paradis, finalement. Je me laisse aller à mes pensées quand une douce sonnerie retentit dans la villa. Ce doit être la sonnette de la porte d'entrée. Je m'habille et je vais ouvrir.",
en: "I wake up in silky sheets, sunlight streaming through the trees visible beyond the bay window. How could I have ever dreamed of a better life? No more worrying about work or bills, with access to every pleasure life has to offer. Maybe this is what paradise truly feels like. I'm lost in my thoughts when a soft chime rings through the villa. Must be the front doorbell. I get dressed and go to answer it."
}
},
{
type: 'image',
src : 'media/img/story/2april_visit.webp'
},
{
type: 'narration',
text: {
fr: "Une jeune femme à l'air assuré se tient devant la porte.",
en: "A young woman with a confident air stands at the door."
}
},
{
type : 'dialogue',
character: 'me',
side : 'right',
text: {
fr: "Bonjour. Puis-je vous aider ?",
en: "Hello. Can I help you?"
}
},
{
type : 'dialogue',
character: 'april_human',
side : 'left',
text: {
fr: "Bonjour. Je suis une amie de la famille, j'habite pas loin d'ici. On m'a dit qu'un petit nouveau était arrivé il y a peu, donc je suis venue me présenter. Je vois que je ne me suis pas trompée. Je m'appelle April, ravie de te rencontrer. Et toi ?",
en: "Hello. I'm a family friend, I live not far from here. I heard a newcomer had arrived recently, so I came to introduce myself. Looks like I was right. My name is April, lovely to meet you. And yours?"
}
},
{
type : 'dialogue',
character: 'me',
side : 'right',
text: {
fr: "Je m'appelle $playerName. Je suis hébergé ici juste pour quelque temps.",
en: "My name is $playerName. I'm just staying here for a little while."
}
},
{
type : 'dialogue',
character: 'april_human',
side : 'left',
text: {
fr: "Merveilleux ! Puis-je entrer ?",
en: "Wonderful! May I come in?"
}
},
{
type : 'dialogue',
character: 'me',
side : 'right',
text: {
fr: "Euh… oui, bien sûr. Voulez-vous quelque chose à boire ?",
en: "Uh… yes, of course. Would you like something to drink?"
}
},
{
type : 'dialogue',
character: 'april_human',
side : 'left',
text: {
fr: "Non merci, pas tout de suite. J'aimerais en savoir un peu plus sur vous, en fait.",
en: "No thank you, not just yet. I'd actually like to learn a little more about you."
}
},
{
type : 'dialogue',
character: 'me',
side : 'right',
text: {
fr: "Sur moi ? Il n'y a pas grand-chose à dire. Je rends des services à l'hôte de la maison. Disons que je suis une sorte d'homme à tout faire.",
en: "About me? There's not much to say. I run errands for the host of the house. Let's just say I'm a kind of handyman."
}
},
{
type : 'dialogue',
character: 'april_human',
side : 'left',
text: {
fr: "Moi, j'ai l'impression que ça va un peu plus loin que ça…",
en: "I have a feeling it goes a little further than that…"
}
},
{
type: 'image',
src : 'media/img/story/2april_visit2.webp'
},
{
type: 'narration',
text: {
fr: "Suite à ces paroles, quelque chose d'étrange se passe dans le regard de cette April. Ses yeux émettent une lumière blanche si intense que je me sens comme un lapin devant des phares de voiture en pleine nuit, immobile.",
en: "After those words, something strange happens in April's eyes. They emit a white light so intense that I feel like a deer caught in headlights in the dead of night, frozen."
}
},
{
type : 'dialogue',
character: 'april_human',
side : 'left',
text: {
fr: "Je pense que tu as plus de choses à me dire, et je suis une experte pour aller chercher des informations. Laisse-moi te montrer mes talents d'enquêtrice.",
en: "I think you have a lot more to tell me, and I'm an expert at getting information. Let me show you my interrogation skills."
}
},
{
type: 'narration',
text: {
fr: "En l'espace d'une seconde, je me retrouve nu sur un lit. Elle est également nue et continue de me parler.",
en: "In the span of a second, I find myself lying naked on a bed. She is equally undressed and continues speaking to me."
}
},
{
type: 'video',
src : 'media/video/story/2april_visit1.mp4'
},
{
type : 'dialogue',
character: 'april_human',
side : 'left',
text: {
fr: "Alors, monsieur $playerName. Quel est votre secret ? Je sais que les personnes qui habitent ici n'invitent pas des gens comme ça. Surtout des gens dans votre genre. Vous devez avoir quelque chose de spécial.",
en: "So, Mister $playerName. What's your secret? I know the people who live here don't just invite anyone. Especially someone like you. You must have something special."
}
},
{
type: 'narration',
text: {
fr: "J'ai l'impression de ne plus avoir le contrôle sur mon corps, je peux seulement parler.",
en: "I feel like I've lost control of my body — I can only speak."
}
},
{
type : 'dialogue',
character: 'me',
side : 'right',
text: {
fr: "Je vous ai dit tout ce que j'avais à dire.",
en: "I've told you everything I have to say."
}
},
{
type : 'dialogue',
character: 'april_human',
side : 'left',
text: {
fr: "Bien, bien… Je vais devoir aller chercher les informations moi-même, alors. Redresse-toi et prends-moi maintenant.",
en: "Very well… I'll have to find the information myself, then. Stand up and take me now."
}
},
{
type: 'narration',
text: {
fr: "Mon corps lui obéit plus que moi.",
en: "My body obeys her more than it obeys me."
}
},
{
type: 'video',
src : 'media/video/story/2april_visit2.mp4'
},
{
type : 'dialogue',
character: 'april_human',
side : 'left',
text: {
fr: "Je vois ! L'énergie circule très rapidement en toi. C'est très intéressant. Héra ne fait jamais rien par hasard. Ma maîtresse sera amusée par ces informations.",
en: "I see! Energy flows through you very quickly. Very interesting. Hera never does anything by chance. My mistress will be amused by this information."
}
},
{
type: 'video',
src : 'media/video/story/2april_visit3.mp4'
},
{
type : 'dialogue',
character: 'april_human',
side : 'left',
text: {
fr: "C'est très agréable, mais finissons maintenant cet entretien ! Je ne veux pas donner toutes mes essences non plus. En plus, je sens que les autres arrivent.",
en: "This is very pleasant, but let's bring this interview to a close. I don't want to give away all my essence either. Besides, I can sense the others coming."
}
},
{
type: 'narration',
text: {
fr: "Elle se lève et sort de la chambre en me laissant comme ça. Les membres de mon corps semblent de nouveau répondre et je tente de m'élancer derrière elle. Je fais quelques pas dans le salon et je m'effondre sur le sol.",
en: "She gets up and leaves the room, leaving me just like that. My body seems to respond again and I try to rush after her. I take a few steps into the living room and collapse to the floor."
}
},
{
type: 'image',
src : 'media/img/story/2april_visit_awake.webp'
},
{
type : 'dialogue',
character: 'ellie_human',
side : 'left',
text: {
fr: "$playerName ? Réveille-toi. Ça va ? Nous avons senti une présence intruse dans la maison et nous sommes venues. Que s'est-il passé ?",
en: "$playerName? Wake up. Are you okay? We sensed an intruder's presence in the house and came right away. What happened?"
}
},
{
type : 'dialogue',
character: 'me',
side : 'right',
text: {
fr: "C'était une fille… avec des yeux blancs… elle voulait savoir ce que je faisais ici.",
en: "It was a girl… with white eyes… she wanted to know what I was doing here."
}
},
{
type : 'dialogue',
character: 'sirena_human',
side : 'left',
text: {
fr: "Ça ressemble à du Hermès tout craché. C'est pas bon pour nous.",
en: "That sounds exactly like Hermes. That's not good for us."
}
},
{
type: 'image',
src : 'media/img/story/2hera_deg.webp'
},
{
type : 'dialogue',
character: 'hera_human',
side : 'left',
text: {
fr: "Ah, Hermès ! Encore et toujours ! Elle ne peut pas s'empêcher de mettre son nez partout et de créer le désordre, juste pour s'amuser ! Mais cette fois-ci, nous ne pouvons pas la laisser faire. On doit aller la confronter directement chez elle avant qu'elle ne fasse circuler l'information et que mon plan tombe à l'eau.",
en: "Ah, Hermes! Same as always! She just can't help meddling in everything and stirring up chaos for the sheer fun of it! But this time, we can't let her get away with it. We need to confront her directly in her own domain before she spreads the word and my plan falls apart."
}
},
{
type : 'dialogue',
character: 'hera_human',
side : 'left',
text: {
fr: "Préparez-vous bien, les filles ! Nous allons au palais d'Hermès. Toi aussi tu viens avec nous, $playerName — nous allons avoir besoin de ta réserve de puissance et tu seras de toute façon plus en sécurité avec nous.",
en: "Get ready, girls! We're heading to the Palace of Hermes. You're coming with us too, $playerName — we'll need your power reserve, and you'll be safer with us either way."
}
}
]
};
}());
<</script>>
<<goto "Narration">><<if $day >= 3 && !$events.visitAprilDone>><<goto "VisiteApril">><</if>>
<div id="villa-hub-screen">
<div id="villa-hub-bg"></div>
<div id="villa-hub-content">
<h2 class="villa-hub-title">
<<= $lang === "fr" ? "La Villa" : "The Villa">>
</h2>
<<if $dungeonProgress.hermes_dungeon && $dungeonProgress.hermes_dungeon.completed>>
<div id="villa-end-of-demo">
<<if $lang === "fr">>
🎉 Vous avez terminé tout le contenu disponible dans cette version (v<<= window.GAME_VERSION >>).<br>Merci d'avoir joué — la suite arrive bientôt !
<<else>>
🎉 You've completed all available content in this version (v<<= window.GAME_VERSION >>).<br>Thanks for playing — more is coming soon!
<</if>>
</div>
<</if>>
<div id="villa-hub-grid">
<!-- Rest card -->
<div class="villa-hub-card" id="villa-card-rest">
<div class="villa-hub-card-img" style="background-image: url('media/img/backgrounds/villa_room.webp')">
<div class="villa-hub-card-veil"></div>
</div>
<div class="villa-hub-card-label">
<span class="villa-hub-card-icon">🌙</span>
<<= $lang === "fr" ? "Se reposer" : "Rest">>
</div>
</div>
<!-- Team Management card -->
<div class="villa-hub-card" id="villa-card-team">
<div class="villa-hub-card-img" style="background-image: url('media/img/backgrounds/villa_meeting.webp')">
<div class="villa-hub-card-veil"></div>
</div>
<div class="villa-hub-card-label">
<span class="villa-hub-card-icon">⚔</span>
<<= $lang === "fr" ? "Gestion de l'équipe" : "Team Management">>
</div>
</div>
<!-- Rituals card -->
<div class="villa-hub-card" id="villa-card-rituals">
<div class="villa-hub-card-img" style="background-image: url('media/img/backgrounds/villa_autel.webp')">
<div class="villa-hub-card-veil"></div>
</div>
<div class="villa-hub-card-label">
<span class="villa-hub-card-icon">✦</span>
<<= $lang === "fr" ? "Rituels" : "Rituals">>
</div>
</div>
<!-- Training card -->
<div class="villa-hub-card" id="villa-card-training">
<div class="villa-hub-card-img" style="background-image: url('media/img/backgrounds/training_room.webp')">
<div class="villa-hub-card-veil"></div>
<div class="villa-hub-card-cost">⚡ 30 • <img src="media/img/logos/devotion_essence.webp" class="cost-icon" alt="essence"> 50</div>
</div>
<div class="villa-hub-card-label">
<span class="villa-hub-card-icon">⚔</span>
<<= $lang === "fr" ? "Entraînement" : "Training">>
</div>
</div>
<!-- Temple d'Hermès dungeon card — visible après la visite d'April -->
<<if $events.visitAprilDone && (!$dungeonProgress.hermes_dungeon || !$dungeonProgress.hermes_dungeon.completed)>>
<div class="villa-hub-card" id="villa-card-hermes-dungeon">
<div class="villa-hub-card-img" style="background-image: url('media/img/backgrounds/hermes_entrance.webp')">
<div class="villa-hub-card-veil"></div>
<<if !$unlockedAccess.hermes_palace>>
<div class="villa-hub-card-cost"><img src="media/img/logos/devotion_essence.webp" class="cost-icon" alt="essence"> 500</div>
<<else>>
<div class="villa-hub-card-cost villa-hub-card-unlocked">✓ <<= $lang === "fr" ? "Déverrouillé" : "Unlocked">></div>
<</if>>
</div>
<div class="villa-hub-card-label">
<span class="villa-hub-card-icon">🏑</span>
<<= $lang === "fr" ? "Palais d'Hermès" : "Palace of Hermes">>
</div>
</div>
<</if>>
</div>
</div>
</div>
<<script>>
(function () {
var lang = State.variables.lang;
$(document).one(':passagedisplay', function () {
/* ── Training ──────────────────────────────────── */
document.getElementById("villa-card-training").addEventListener("click", function () {
if (State.variables.endurance < 30) {
showLinkError(this, lang === "fr" ? "Énergie insuffisante (30 requis)." : "Not enough energy (30 required).");
return;
}
if (State.variables.devotionEssence < 50) {
showLinkError(this, lang === "fr" ? "Essences insuffisantes (50 requis)." : "Not enough essence (50 required).");
return;
}
var team = State.variables.team.filter(Boolean);
if (team.length < 1) {
showLinkError(this, lang === "fr" ? "Assemblez une équipe d'abord." : "Build a team first.");
return;
}
State.variables.endurance -= 30;
State.variables.devotionEssence -= 50;
/* Build dynamic pool from all NPCs linked to owned divinities */
var _roster = State.variables.roster || [];
var _npcPool = Object.keys(window.DB_NPCs || {}).filter(function (npcId) {
var npc = DB_NPCs[npcId];
return npc && npc.divinity && _roster.includes(npc.divinity);
});
/* Fallback to default Hera pool if nothing found */
if (_npcPool.length < 2) {
_npcPool = ["hera_royal_guard", "hera_soldier", "hera_archer", "hera_priestess"];
}
State.variables.combatContext = {
combatId : "training",
trainingPool : _npcPool
};
State.variables.combatState = null;
Engine.play("Combat");
});
/* ── Rituals ───────────────────────────────────── */
document.getElementById("villa-card-rituals").addEventListener("click", function () {
Engine.play("VillaRituals");
});
/* ── Rest ──────────────────────────────────────── */
document.getElementById("villa-card-rest").addEventListener("click", function () {
State.variables.endurance = 100;
State.variables.day = (State.variables.day || 1) + 1;
Engine.play("VillaHub");
});
/* ── Team Management ─────────────────────────── */
document.getElementById("villa-card-team").addEventListener("click", function () {
Engine.play("TeamManagement");
});
/* ── Temple d'Hermès (donjon) ────────────────── */
var hermesDungeonCard = document.getElementById("villa-card-hermes-dungeon");
if (hermesDungeonCard) hermesDungeonCard.addEventListener("click", function () {
var team = State.variables.team.filter(Boolean);
if (team.length < 1) {
showLinkError(this, lang === "fr" ? "Assemblez une équipe d'abord." : "Build a team first.");
return;
}
/* One-time unlock: pay 500 devotion essence on first visit */
if (!State.variables.unlockedAccess.hermes_palace) {
if (State.variables.devotionEssence < 500) {
showLinkError(this, lang === "fr"
? "Accès verrouillé. Il faut 500 essences de dévotion pour ouvrir le Palais d'Hermès."
: "Locked. You need 500 devotion essence to unlock the Palace of Hermes.");
return;
}
State.variables.devotionEssence -= 500;
State.variables.unlockedAccess.hermes_palace = true;
}
var prog = State.variables.dungeonProgress;
if (!prog["hermes_dungeon"]) prog["hermes_dungeon"] = { step: 0 };
State.variables.dungeonProgress = prog;
State.variables.activeDungeonId = "hermes_dungeon";
Engine.play("DungeonAdvance");
});
});
}());
<</script>><div id="villa-rituals-screen">
<div id="villa-rituals-bg"></div>
<div id="villa-rituals-content">
<h2 class="villa-hub-title">
<<= $lang === "fr" ? "✦ Rituels" : "✦ Rituals">>
</h2>
<p class="villa-rituals-subtitle">
<<= $lang === "fr" ? "Choisissez un personnage possédé pour invoquer son rituel." : "Choose a possessed character to invoke their ritual.">>
</p>
<div id="villa-rituals-grid"></div>
<div id="villa-hub-back">
<<if $lang === "fr">><<button "← La Villa" "VillaHub">><</button>><<else>><<button "← The Villa" "VillaHub">><</button>><</if>>
</div>
</div>
</div>
<<script>>
(function () {
var lang = State.variables.lang;
var roster = State.variables.roster;
$(document).one(':passagedisplay', function () {
var grid = document.getElementById("villa-rituals-grid");
roster.forEach(function (charId) {
var ritual = window.DB_Rituals && window.DB_Rituals[charId];
if (!ritual) return; /* skip roster entries with no ritual */
var charDef = window.DB_Characters && window.DB_Characters[charId];
if (!charDef || charDef.npc) return; /* divinités et PNJ exclus */
var tile = document.createElement("div");
tile.className = "villa-ritual-tile";
var portrait = charDef.portrait || ritual.icon || "";
var img = document.createElement("div");
img.className = "villa-ritual-portrait";
if (portrait) img.style.backgroundImage = "url('" + portrait + "')";
/* possessed shimmer overlay */
var shimmer = document.createElement("div");
shimmer.className = "villa-ritual-shimmer";
var lbl = document.createElement("div");
lbl.className = "villa-ritual-name";
lbl.innerHTML = (ritual.nameKey[lang] || charId)
+ ' <span class="villa-hub-card-cost">\u26a1 30</span>';
tile.appendChild(img);
tile.appendChild(shimmer);
tile.appendChild(lbl);
tile.addEventListener("click", (function (cId) {
return function () {
if (State.variables.endurance < 30) {
showLinkError(this, lang === "fr" ? "Énergie insuffisante (30 requis)." : "Not enough energy (30 required).");
return;
}
State.variables.endurance -= 30;
State.variables.currentRitual = cId;
Engine.play("DevotionRitual");
};
}(charId)));
grid.appendChild(tile);
});
if (!grid.childElementCount) {
grid.innerHTML = "<p class='villa-empty'>"
+ (lang === "fr"
? "Aucun personnage possédé ne dispose d'un rituel pour l'instant."
: "No owned character has a ritual yet.")
+ "</p>";
}
});
}());
<</script>><<script>>
(function () {
State.variables.narratCtx = {
onEnd: 'DungeonAdvance',
sequence: [
{
type: 'narration',
bg : 'hermes_entrance',
text: {
fr: "Nous entrons tous dans le portail qu'Héra vient d'ouvrir. Une fois passé, une lumière blanche et aveuglante m'oblige à mettre les mains devant les yeux. Une fois habitué à cette luminosité, je me retrouve devant un palais immense à l'effigie du dieu de la mythologie. Une jeune femme souriante nous accueille.",
en: "We all step through the portal Hera has just opened. Once through, a blinding white light forces me to shield my eyes. When I finally adjust to the brightness, I find myself standing before an enormous palace bearing the likeness of the god of mythology. A smiling young woman greets us."
}
},
{
type: 'image',
src : 'media/img/story/3karlee_dungeaon.webp'
},
{
type : 'dialogue',
character: 'karlee',
side : 'left',
text: {
fr: "Pas un pas de plus. Je ne peux laisser entrer personne sans bonne raison.",
en: "Not another step. I can't let anyone in without good reason."
}
},
{
type : 'dialogue',
character: 'hera',
side : 'left',
text: {
fr: "Laisse-nous passer, nous devons parler à ta maîtresse.",
en: "Let us through — we need to speak with your mistress."
}
},
{
type : 'dialogue',
character: 'karlee',
side : 'left',
text: {
fr: "Ma maîtresse est très occupée en ce moment. Il se passe apparemment des choses étranges dans certains royaumes et elle aime bien être renseignée. Je ne peux donc pas vous laisser passer.",
en: "My mistress is very busy right now. Strange things are apparently happening in some realms and she likes to stay informed. I'm afraid I can't let you through."
}
},
{
type : 'dialogue',
character: 'hera',
side : 'left',
text: {
fr: "C'est un cas de force majeure. Nous passerons quoi qu'il arrive.",
en: "This is an emergency. We will get through no matter what."
}
},
{
type : 'dialogue',
character: 'karlee',
side : 'left',
text: {
fr: "Très bien. Vous allez devoir nous affronter, mes sœurs et moi. Je ne pourrai sûrement pas vous battre, mais ça donnera le temps aux autres de se préparer.",
en: "Very well. You'll have to face my sisters and me. I probably won't be able to beat you, but it'll buy the others time to get ready."
}
}
]
};
}());
<</script>>
<<goto "Narration">><<script>>
(function () {
State.variables.narratCtx = {
onEnd: 'DungeonAdvance',
bg : 'hermes_palace_hall',
sequence: [
{
type: 'image',
src : 'media/img/story/3kendall_dungeaon.webp'
},
{
type : 'dialogue',
character: 'kendall',
side : 'left',
text: {
fr: "La salle du trône est encore loin. Est-ce que vous aurez assez de puissance pour y parvenir ?",
en: "The throne room is still a long way off. Will you even have enough strength left to make it?"
}
},
{
type : 'dialogue',
character: 'hera',
side : 'left',
text: {
fr: "Je t'assure que rien ne nous arrêtera !",
en: "I assure you, nothing will stop us!"
}
}
]
};
}());
<</script>>
<<goto "Narration">><<script>>
(function () {
State.variables.narratCtx = {
onEnd: 'DungeonAdvance',
bg : 'hermes_palace_hall',
sequence: [
{
type: 'image',
src : 'media/img/story/3jane_dungeon.webp'
},
{
type : 'dialogue',
character: 'jane',
side : 'left',
text: {
fr: "Quelle belle brochette d'aventuriers ! Mais je crois que ça se termine ici pour vous — je ne laisserai personne importuner ma maîtresse.",
en: "What a fine bunch of adventurers. But I'm afraid this is as far as you go — I won't let anyone disturb my mistress."
}
}
]
};
}());
<</script>>
<<goto "Narration">><<script>>
(function () {
State.variables.narratCtx = {
onEnd: 'DungeonAdvance',
bg : 'hermes_palace_hall',
sequence: [
{
type: 'image',
src : 'media/img/story/3april_donjon.webp'
},
{
type : 'dialogue',
character: 'april',
side : 'left',
text: {
fr: "Coucou $playerName, tu m'as manqué ? Ne le prends pas mal — il n'y avait rien de personnel. Je ne suis pas qu'experte en renseignements, je sais aussi me défendre.",
en: "Hey $playerName, did you miss me? Don't take it personally — it was nothing personal. I'm not just an expert in intelligence gathering, I can hold my own in a fight too."
}
}
]
};
}());
<</script>>
<<goto "Narration">><<script>>
(function () {
State.variables.narratCtx = {
onEnd: 'DungeonAdvance',
bg : 'hermes_throne',
sequence: [
{
type: 'image',
src : 'media/img/story/3hermes_dungeon.webp'
},
{
type : 'dialogue',
character: 'hermes',
side : 'left',
text: {
fr: "Ah, bonjour Héra ! Tu sais bien qu'on ne débarque pas chez les gens à l'improviste !",
en: "Ah, hello Hera! You know perfectly well you don't just show up at someone's door unannounced!"
}
},
{
type : 'dialogue',
character: 'hera',
side : 'left',
text: {
fr: "Comme tu l'as fait chez moi avec ta servante, tu veux dire ?",
en: "Like you did at my place with your handmaiden, you mean?"
}
},
{
type : 'dialogue',
character: 'hermes',
side : 'left',
text: {
fr: "Il est normal de s'intéresser à ses semblables. Qui sait si ton nouveau plan ne mettra pas en danger les royaumes ? Je suis obligée de garder un œil sur tout ce qui se passe.",
en: "It's only natural to take an interest in one's peers. Who's to say your new plan won't put the realms at risk? I'm obliged to keep an eye on everything that goes on."
}
},
{
type : 'dialogue',
character: 'hera',
side : 'left',
text: {
fr: "Tu parles ! Tu aimes surtout semer la zizanie là où tu le peux. Je suis venue te convaincre que je fais ça pour nous toutes, pour notre survie. Tu sais bien que notre existence est en danger.",
en: "Please! You just love stirring up trouble wherever you can. I came here to convince you that I'm doing this for all of us — for our survival. You know as well as I do that our existence is at stake."
}
},
{
type : 'dialogue',
character: 'hermes',
side : 'left',
text: {
fr: "Peut-être… Dans ce cas, je te propose un défi ! Si tu arrives à me toucher, je m'engage à t'aider dans ton plan. Mais si tu perds, tu quitteras ce palais et tu me laisseras m'amuser comme bon me semble !",
en: "Perhaps… In that case, I propose a challenge! If you manage to land a hit on me, I'll commit to helping with your plan. But if you lose, you'll leave this palace and let me do as I please!"
}
},
{
type : 'dialogue',
character: 'hera',
side : 'left',
text: {
fr: "Je crois que je n'ai pas le choix…",
en: "I don't think I have much of a choice…"
}
},
{
type : 'dialogue',
character: 'kendall',
side : 'left',
text: {
fr: "Nous sommes là, maîtresse !",
en: "We're right here, mistress!"
}
},
{
type : 'dialogue',
character: 'jane',
side : 'left',
text: {
fr: "Nous ne laisserons personne vous atteindre.",
en: "We won't let anyone get to you."
}
},
{
type : 'dialogue',
character: 'hermes',
side : 'left',
text: {
fr: "Très bien ! Commençons alors !",
en: "Very well! Let's begin!"
}
}
]
};
}());
<</script>>
<<goto "Narration">><<script>>
(function () {
State.variables.narratCtx = {
onEnd: 'DungeonAdvance',
bg : 'hermes_throne',
sequence: [
{
type: 'image',
src : 'media/img/story/3hermes_dungeon_end.webp'
},
{
type : 'dialogue',
character: 'hermes',
side : 'left',
text: {
fr: "Je dois dire que j'étais mal renseignée sur ta puissance — félicitations ! Je vais t'aider, mes talents de renseignement te seront sûrement utiles. On risque même de bien s'amuser !",
en: "I must admit I was poorly informed about your power — congratulations! I'll help you. My intelligence skills will surely come in handy. We might even have a good time!"
}
}
]
};
}());
<</script>>
<<goto "Narration">><<script>>
(function () {
State.variables.narratCtx = {
onEnd: 'HadesIntro_Passage',
bg : 'background',
sequence: [
{
type: 'image',
src : 'media/img/story/1car_night.webp'
},
{
type: 'narration',
text: {
fr: "Encore une putain de journée de travail qui finit tard… Ce n'est pas un mauvais travail en soi, mais les journées sont répétitives en plus de ne me laisser aucun temps pour ma vie personnelle. Résultat : un salaire à peine convenable, pas de petite amie et une fatigue lancinante dont je n'arrive pas à me débarrasser.\nTout absorbé par mes pensées déprimantes, ma concentration faiblit. Je me ressaisis juste à temps pour voir un cerf planté au milieu de la route déserte. Il a le regard fixe et semble ne pas être décidé à bouger. Pour une raison que je ne comprends pas, je suis comme hypnotisé par son regard étrangement conscient. Ce n'est qu'à la dernière minute que je donne un brusque coup de volant pour ne pas le heurter.",
en: "Another damn late night at work... It's not a bad job as such, but the days are repetitive and leave me no time for my personal life. The result: a barely decent salary, no girlfriend, and a nagging exhaustion I can't shake.\nLost in my gloomy thoughts, my concentration falters. I snap back just in time to see a deer standing in the middle of the empty road. It stares ahead, seemingly unwilling to move. For some reason I can't explain, I feel almost hypnotised by its strangely aware gaze. It's only at the last second that I wrench the wheel to avoid it."
}
},
{
type: 'image',
src : 'media/img/story/1car_accident.webp'
},
{
type: 'narration',
text: {
fr: "Ma voiture fait une embardée et je perds le contrôle du véhicule. Lancé à cette vitesse, la barrière de sécurité ne résiste pas. Je m'entends dire cette phrase stupide : « c'est pour ça que je paye des impôts ? ». Le temps semble ralentir. Je vois toute ma vie défiler devant mes yeux : mon enfance, mes parents, mon ex petite amie… Même si je n'ai jamais été vraiment satisfait de ma vie, elle m'apparaît à ce moment-là comme la chose la plus précieuse qui soit.\nJe vois les rochers se rapprocher dangereusement à travers le pare-brise. Je ferme les yeux devant cette vision d'horreur, mon cœur est prêt à lâcher à tout moment, qu'on en finisse ! Et… rien. Enfin, pas rien exactement : je me sens comme flotter dans une chaleur insupportable. J'ouvre les yeux.",
en: "My car swerves and I lose control of the vehicle. At this speed, the crash barrier doesn't stand a chance. I hear myself blurt out something stupid: \"That's what I pay taxes for?\" Time seems to slow down. My whole life flashes before my eyes: my childhood, my parents, my ex-girlfriend... Even if I was never truly satisfied with my life, in that moment it feels like the most precious thing in the world.\nI see the rocks rushing toward me through the windshield. I squeeze my eyes shut at the horrifying sight, my heart ready to give out at any moment — just let it end! And... nothing. Well, not exactly nothing: I feel as though I'm floating in unbearable heat. I open my eyes."
}
},
{
type: 'image',
src : 'media/img/story/1fire_tunnel.webp'
},
{
type: 'narration',
text: {
fr: "Sous mes pieds, un tourbillon de flammes digne des visions les plus apocalyptiques. Où suis-je ? Et surtout, où vais-je atterrir ? Un décor se dessine et grandit peu à peu au bout de ce tunnel infernal. Peut-on mourir plusieurs fois ? Suis-je encore dans ma voiture en train d'halluciner ? Impact.",
en: "Beneath my feet, a vortex of flames worthy of the most apocalyptic visions. Where am I? And more importantly, where am I going to land? A scene takes shape and grows at the far end of this infernal tunnel. Can you die more than once? Am I still in my car, hallucinating? Impact."
}
}
]
};
}());
<</script>>
<<goto "Narration">><<script>>
(function () {
State.variables.narratCtx = {
onEnd: 'HadesIntro_Sauvetage',
bg : 'hades_realm',
sequence: [
{
type: 'narration',
text: {
fr: "Je suis face contre terre sur un sol dur comme de la pierre. Mes membres me font souffrir mais je semble être encore entier. Je fais un effort pour redresser la tête.",
en: "I am lying face down on a floor as hard as stone. My limbs ache, but I seem to still be in one piece. I make an effort to lift my head."
}
},
{
type: 'image',
src : 'media/img/story/1hades_realm_arrival.webp'
},
{
type: 'narration',
text: {
fr: "Qu'est-ce que c'est ? Une blague ? Qui serait assez tordu pour mettre en place une telle mise en scène ? L'air est suffocant, je peine à respirer. Tout sent la mort ici. Des sortes d'émanations humanoïdes semblent errer çà et là. On se croirait dans un vieux conte mythologique.\nQuoi qu'il en soit, je ne peux pas rester indéfiniment, il faut que je bouge. Il semble y avoir de la lumière qui émane de ce château. Je n'ai pas d'autre choix, je dois tenter ma chance par là, même si je suis quasi sûr de faire de mauvaises rencontres.\nJe me dirige donc vers le château péniblement. Arrivé au pied de l'édifice, j'aperçois une entrée discrète sur le côté. Je la rejoins en me disant que ce sera toujours plus prudent que d'entrer par l'entrée principale. À l'intérieur, je marche au moins une demi-heure dans le même décor : des couloirs lugubres, des torches et quelques cellules vides.\nAlors que je commence à désespérer de ne trouver aucune aide en ces murs, une voix douce et féminine me fait faire un bond.",
en: "What is this? Some kind of joke? Who would be twisted enough to stage something like this? The air is suffocating, I can barely breathe. Everything here smells of death. Humanoid-like emanations seem to drift here and there. It feels like something out of an old mythological tale.\nBe that as it may, I can't stay here indefinitely — I need to move. There seems to be light coming from that castle. I have no other choice; I have to take my chances over there, even if I'm almost certain I'll run into trouble.\nI make my way toward the castle with great difficulty. At the foot of the building, I spot a discreet entrance on the side. I head for it, telling myself it will always be safer than going through the main entrance. Inside, I walk for at least half an hour through the same scenery: gloomy corridors, torches, and a few empty cells.\nJust as I begin to lose hope of finding any help within these walls, a soft, feminine voice makes me jump."
}
},
{
type: 'image',
src : 'media/img/story/1hades_castle_inside.webp'
},
{
type : 'dialogue',
character: 'yurizan',
side : 'left',
text: {
fr: "Il est rare d'entendre des gens respirer par ici. Je me disais bien que quelque chose ne tournait pas rond !",
en: "It's rare to hear anyone breathing around here. I knew something was off!"
}
},
{
type : 'dialogue',
character: 'me',
side : 'right',
text: {
fr: "Ah ! Dieu merci ! Je ne suis pas seul ! Je ne comprends pas ce que je fais là, est-ce que vous pourriez m'aider ?",
en: "Ah! Thank God! I'm not alone! I don't understand what I'm doing here — could you help me?"
}
},
{
type : 'dialogue',
character: 'yurizan',
side : 'left',
text: {
fr: "Je dois dire que je ne comprends pas non plus ce que vous faites là. D'habitude, cet endroit est réservé à une certaine forme de… clientèle.",
en: "I must say I don't understand what you're doing here either. Usually, this place is reserved for a certain kind of… clientele."
}
},
{
type : 'dialogue',
character: 'me',
side : 'right',
text: {
fr: "Clientèle ? Peut-être y a-t-il un responsable ici qui pourrait m'éclairer ?",
en: "Clientele? Perhaps there's someone in charge here who could shed some light on this?"
}
},
{
type : 'dialogue',
character: 'yurizan',
side : 'left',
text: {
fr: "Un responsable ? Hi hi. Oui, nous avons une « responsable ». Suivez-moi. De toute façon, c'est à elle de décider de votre sort ici.",
en: "Someone in charge? Heh heh. Yes, we do have a \"person in charge\". Follow me. In any case, it's up to her to decide your fate here."
}
},
{
type : 'dialogue',
character: 'me',
side : 'right',
text: {
fr: "Très bien, je vous suis.",
en: "Very well, I'll follow you."
}
},
{
type: 'narration',
text: {
fr: "Après une longue succession de couloirs, nous entrons dans une vaste salle semblable à une cathédrale, mais plutôt à la gloire de la mort.",
en: "After a long succession of corridors, we enter a vast hall resembling a cathedral, though one dedicated rather to the glory of death."
}
},
{
type: 'image',
src : 'media/img/story/1hades_encounter.webp'
},
{
type: 'narration',
text: {
fr: "Une femme au regard glaçant lève les yeux d'une sorte de grimoire alors que nous nous arrêtons à quelques mètres d'elle. La pièce semble disparaître quand elle commence à prendre la parole.",
en: "A woman with an ice-cold gaze looks up from what appears to be a grimoire as we stop a few metres away from her. The room seems to fade away as she begins to speak."
}
},
{
type : 'dialogue',
character: 'hades',
side : 'left',
text: {
fr: "Voici donc la cause de tout ce raffut dans ma demeure ? Je peux entendre sa respiration depuis l'autre bout du royaume.",
en: "So this is the cause of all this commotion in my home? I can hear its breathing from the other end of the realm."
}
},
{
type : 'dialogue',
character: 'yurizan',
side : 'left',
text: {
fr: "Oui, maîtresse Hadès. Je l'ai trouvé en train d'errer dans les couloirs aux alentours des cachots.",
en: "Yes, mistress Hades. I found him wandering the corridors near the dungeon cells."
}
},
{
type : 'dialogue',
character: 'me',
side : 'right',
text: {
fr: "Hadès ? C'est un jeu de rôle, c'est ça ? Je ne crois pas que…",
en: "Hades? This is some kind of role-playing thing, right? I don't think…"
}
},
{
type : 'dialogue',
character: 'hades',
side : 'left',
text: {
fr: "Silence, mortel ! La mort n'est pas un jeu ! C'est une machine qui ne doit souffrir d'aucun dysfonctionnement, et voilà que j'en vois un juste devant moi. Pas de vivant chez les morts, c'est la règle. Heureusement, il y a une solution à chaque problème. Gardes ! Jetez-le dans la fosse, cela devrait régler cette déconvenue.",
en: "Silence, mortal! Death is not a game! It is a machine that must suffer no malfunction, and yet here I see one standing right before me. No living among the dead — that is the rule. Fortunately, there is a solution to every problem. Guards! Throw it into the pit. That should settle this inconvenience."
}
},
{
type : 'dialogue',
character: 'yurizan',
side : 'left',
text: {
fr: "Mais maîtresse, peut-être devrions-nous comprendre pourquoi il…",
en: "But mistress, perhaps we should try to understand why he…"
}
},
{
type : 'dialogue',
character: 'hades',
side : 'left',
text: {
fr: "J'ai dit ! Exécution.",
en: "I said! See it done."
}
},
{
type: 'image',
src : 'media/img/story/1hades_gards.webp'
},
{
type: 'narration',
text: {
fr: "Deux mains froides et dures me sortent de mon état de sidération. Je tente de résister mais je n'arrive même pas à faire bouger d'un centimètre un de leurs bras enserrant mes poignets comme des étaux. Je hurle, jette des regards en arrière en espérant que quelqu'un va sonner la fin de la farce. Je vois dans le regard sincère et inquiet de la jeune femme du couloir que ce ne sera pas le cas.",
en: "Two cold, hard hands drag me out of my daze. I try to resist but I can't move their arms so much as a centimetre — they grip my wrists like vices. I scream, glance back desperately hoping someone will call an end to this farce. In the sincere, worried eyes of the young woman from the corridor, I can see that no one will."
}
},
{
type: 'image',
src : 'media/img/story/1hades_realm_fall.webp'
},
{
type: 'narration',
text: {
fr: "Nous arrivons au bord d'un gouffre immense, où quelques âmes semblent se diriger. Malgré ma dernière tentative pour me dégager, je suis précipité sans aucune hésitation, la tête la première. Au bord du désespoir, je me mets à rire nerveusement en me demandant combien de fois vais-je encore tomber aujourd'hui. Je ne vois plus d'issue cette fois ; j'espère que je ne vais pas me retrouver dans un endroit pire que celui-ci après ma mort.",
en: "We reach the edge of an immense chasm, toward which a few souls seem to be drifting. Despite my last attempt to break free, I am hurled in headfirst without a moment's hesitation. On the verge of despair, I let out a nervous laugh, wondering how many more times I can fall today. I see no way out this time; I can only hope I won't end up somewhere worse than this after I die."
}
}
]
};
}());
<</script>>
<<goto "Narration">><<script>>
(function () {
State.variables.narratCtx = {
onEnd: 'HadesIntro_Gardes',
bg : 'hera_palace',
sequence: [
{
type: 'image',
src : 'media/img/story/1hera_save_portal.webp'
},
{
type: 'narration',
text: {
fr: "Alors que je pensais être arrivé à la fin de mon histoire, une fissure lumineuse commence à se dessiner en dessous de moi. Un portail semble s'ouvrir sur une vision aux antipodes de mon lieu actuel. L'espoir renaît en moi et j'actionne tous les muscles de mon corps pour ne pas rater cette planche de salut. L'effort est extrême, la panique est à son maximum et je suis comme aspiré par le portail de lumière. Je perds connaissance…",
en: "Just as I thought my story had reached its end, a crack of light begins to form beneath me. A portal seems to open onto a vision utterly unlike where I am now. Hope surges back through me, and I strain every muscle in my body to not miss this lifeline. The effort is immense, the panic overwhelming, and I am pulled into the portal of light as if by suction. I lose consciousness…"
}
},
{
type : 'dialogue',
character: 'ellie',
side : 'left',
text: {
fr: "Il est mort, vous croyez ?",
en: "Do you think he's dead?"
}
},
{
type : 'dialogue',
character: 'kendra',
side : 'left',
text: {
fr: "Dis pas de bêtises, tu vois bien qu'il respire.",
en: "Don't be silly, you can clearly see he's breathing."
}
},
{
type : 'dialogue',
character: 'alex',
side : 'left',
text: {
fr: "On devrait peut-être faire quelque chose…",
en: "Maybe we should do something…"
}
},
{
type : 'dialogue',
character: 'sirena',
side : 'left',
text: {
fr: "Laissons-le juste reprendre connaissance, ce n'est qu'un humain, il n'est pas habitué à ce genre d'épreuve.",
en: "Let's just let him come around. He's only human — he's not used to this kind of ordeal."
}
},
{
type : 'dialogue',
character: 'hera',
side : 'left',
text: {
fr: "Poussez-vous, les filles, je m'en occupe.",
en: "Step aside, girls, I'll handle this."
}
},
{
type: 'narration',
bg : 'hera_palace',
text: {
fr: "Je reprends conscience petit à petit, les voix que j'entends sont mélodieuses et l'air est frais et parfumé. Je tente d'ouvrir les yeux mais la lumière présente ici est trop intense. Une silhouette se détache de cette bouillie blanche.",
en: "I regain consciousness gradually. The voices I hear are melodious, and the air is cool and fragrant. I try to open my eyes, but the light is too intense. A silhouette emerges from the white haze."
}
},
{
type: 'image',
src : 'media/img/story/1hera_encounter.webp'
},
{
type : 'dialogue',
character: 'hera',
side : 'left',
text: {
fr: "Bonjour $playername, bienvenue dans mon royaume. Tu es en sécurité maintenant.",
en: "Hello $playername, welcome to my realm. You are safe now."
}
},
{
type : 'dialogue',
character: 'me',
side : 'right',
text: {
fr: "Euh, bonjour… Je dois vous remercier, j'imagine, mais j'ai du mal à comprendre ce qui se passe ici.",
en: "Uh, hello… I suppose I should thank you, but I'm finding it hard to understand what's going on here."
}
},
{
type : 'dialogue',
character: 'hera',
side : 'left',
text: {
fr: "C'est normal, aucun mortel n'a encore fait ce que tu as fait, et c'est bien pour cela que ton cas m'intéresse au plus haut point.",
en: "That's understandable. No mortal has ever done what you have done, and that is precisely why your case interests me so greatly."
}
},
{
type : 'dialogue',
character: 'me',
side : 'right',
text: {
fr: "Mortel ? Et pourquoi j'ai l'impression de connaître votre visage ? Qui êtes-vous ?",
en: "Mortal? And why do I feel like I recognise your face? Who are you?"
}
},
{
type : 'dialogue',
character: 'hera',
side : 'left',
text: {
fr: "Je suis Héra, ce que vous, les humains, appelez une divinité. De notre point de vue, nous sommes simplement une forme de vie différente de la vôtre. Si mon apparence te dit quelque chose, c'est tout à fait normal — je vais rapidement t'expliquer les choses pour que tu les assimiles vite.\nVois mon espèce comme vivant sur un autre plan que le vôtre. Nous ne nous nourrissons pas de matière comme vous, mais plutôt de sentiments, comme vous les appelleriez. Pour être plus précis, nous vivons grâce à la dévotion des autres espèces, comme les humains.",
en: "I am Hera — what you humans call a divinity. From our perspective, we are simply a different form of life from yours. If my appearance seems familiar, that is entirely normal; I will explain things quickly so you can take it all in.\nThink of my kind as living on a different plane from yours. We do not feed on matter as you do, but rather on feelings, as you would call them. To be more precise, we live off the devotion of other species, such as humans."
}
},
{
type : 'dialogue',
character: 'hera',
side : 'left',
text: {
fr: "Pendant des milliers d'années, les humains étaient dévoués aux divinités et, bien que nous puissions vivre éternellement, nous pouvons mourir si nous venions à manquer de cette énergie provenant de la dévotion. Par souci de survie, nous nous sommes adaptées à l'époque et avons trouvé une autre source : les actrices porno. Elles suscitent une attention quasi religieuse de la part de nombreux humains sur la planète et nous avons décidé de canaliser cette énergie pour nous en nourrir. C'est pour cela que nous descendons sur Terre pour maintenir cette adoration via les médias appropriés.",
en: "For thousands of years, humans were devoted to divinities, and while we can live forever, we can die if we were to run out of the energy that devotion provides. Out of a need to survive, we adapted to the times and found another source: porn actresses. They command a near-religious attention from many humans across the planet, and we decided to channel that energy to sustain ourselves. That is why we come down to Earth to keep that adoration alive through the appropriate media."
}
},
{
type : 'dialogue',
character: 'me',
side : 'right',
text: {
fr: "C'est l'histoire la plus folle que j'aie entendue de ma vie ! Quel rapport avec moi ? Et qui sont ces filles avec vous ? Des divinités aussi ?",
en: "That's the craziest thing I've ever heard! What does any of this have to do with me? And who are these girls with you? Are they divinities too?"
}
},
{
type: 'image',
src : 'media/img/story/1servants_welcome.webp'
},
{
type : 'dialogue',
character: 'hera',
side : 'left',
text: {
fr: "Oh non, ce sont mes servantes ; elles me permettent de récolter assez de dévotion pour maintenir encore ce royaume. Je te présente, de gauche à droite : Alex, Ellie, Sirena et Kendra.",
en: "Oh no, they are my servants; they allow me to gather enough devotion to keep this realm going. Let me introduce them, from left to right: Alex, Ellie, Sirena, and Kendra."
}
},
{
type : 'dialogue',
character: 'alex',
side : 'left',
text: {
fr: "Bonjour $playername, ravie de faire ta connaissance.",
en: "Hello $playername, lovely to meet you."
}
},
{
type : 'dialogue',
character: 'ellie',
side : 'left',
text: {
fr: "J'espère que ta tête ne te fait pas trop souffrir, tu es très pâle.",
en: "I hope your head isn't hurting too much — you're very pale."
}
},
{
type : 'dialogue',
character: 'sirena',
side : 'left',
text: {
fr: "Il s'en sortira, il doit encore être sous le choc.",
en: "He'll be fine, he must still be in shock."
}
},
{
type : 'dialogue',
character: 'kendra',
side : 'left',
text: {
fr: "Ce n'est plus un enfant, arrêtez de le materner comme ça !",
en: "He's not a child anymore, stop coddling him like that!"
}
},
{
type: 'narration',
bg : 'hera_palace',
text: {
fr: "Tout cela n'a aucun sens : des divinités, des actrices porno, des royaumes… Il est temps que je me réveille. Pourtant, tout cela me semble tellement réel. Je suis encore en train d'essayer de démêler toutes les pensées qui se bousculent dans ma tête quand je sens un halo de chaleur gonfler dans mon dos…",
en: "None of this makes any sense — divinities, porn actresses, realms… It's time I woke up. And yet it all feels so real. I am still trying to untangle the jumble of thoughts crowding my mind when I feel a halo of warmth swelling at my back…"
}
}
]
};
}());
<</script>>
<<goto "Narration">><<script>>
(function () {
State.variables.narratCtx = {
onEnd: 'HadesIntro_Combat',
bg : 'hera_palace',
sequence: [
{
type: 'image',
src : 'media/img/story/1veronica_arrival.webp'
},
{
type: 'narration',
bg : 'hera_palace',
text: {
fr: "Je me retourne et vois apparaître une femme d'une grande beauté ténébreuse via un portail de feu. Elle est suivie par des gardes similaires à ceux qui m'ont jeté dans la fosse.",
en: "I turn around and see a woman of striking dark beauty step through a portal of fire. She is followed by guards similar to those who threw me into the pit."
}
},
{
type : 'dialogue',
character: 'veronica',
side : 'left',
text: {
fr: "Héra ! Ma maîtresse avait raison, c'est bien toi qui es derrière cet affront ! Je viens chercher l'humain. Tu sais comme ma maîtresse est intransigeante sur les règles : pas de vivant dans le royaume d'Hadès. Alors imagine un vivant qui en sort… vivant. Elle est furieuse.",
en: "Hera! My mistress was right — it is you behind this affront! I have come for the human. You know how uncompromising my mistress is about the rules: no living being in the realm of Hades. Now imagine a living one who escapes from it… alive. She is furious."
}
},
{
type : 'dialogue',
character: 'hera',
side : 'left',
text: {
fr: "Il n'ira nulle part, il est sous ma responsabilité maintenant. Pars en paix et présente mes excuses à ta maîtresse.",
en: "He is going nowhere — he is under my protection now. Go in peace and convey my apologies to your mistress."
}
},
{
type : 'dialogue',
character: 'veronica',
side : 'left',
text: {
fr: "Tu la connais et tu sais bien ce qui pourrait m'arriver si je revenais les mains vides. Si tu ne consens pas à me le livrer, je devrais utiliser la force. Bien que tu sois une divinité et moi une servante, je sais que tu as dû utiliser beaucoup d'énergie pour ouvrir un portail dans le royaume d'Hadès. Donne-le-moi maintenant, ce sera ma dernière proposition.",
en: "You know her, and you know full well what could happen to me if I returned empty-handed. If you refuse to hand him over, I will have to use force. Even though you are a divinity and I am merely a servant, I know you must have spent a great deal of energy opening a portal into Hades' realm. Hand him over now — that is my final offer."
}
},
{
type : 'dialogue',
character: 'hera',
side : 'left',
text: {
fr: "Dans ce cas, le temps des choix est terminé.",
en: "In that case, the time for choices is over."
}
}
]
};
}());
<</script>>
<<goto "Narration">><<script>>
(function () {
State.variables.narratCtx = {
onEnd: 'HadesIntro_Reveil',
bg : 'hera_palace',
sequence: [
{
type: 'image',
src : 'media/img/story/1veronica_flee.webp'
},
{
type : 'dialogue',
character: 'veronica',
side : 'left',
text: {
fr: "Tu as gagné pour cette fois, mais tu sais qu'Hadès ne te laissera jamais tranquille tant que l'ordre des choses ne sera pas rétabli. Profite de cette maigre victoire — la prochaine risque de te coûter cher.",
en: "You have won this time, but you know Hades will never leave you in peace until the order of things is restored. Enjoy this small victory — the next one may cost you dearly."
}
},
{
type : 'dialogue',
character: 'hera',
side : 'left',
text: {
fr: "Nous verrons à ce moment-là. Passe le bonjour à Hadès de ma part.",
en: "We shall see when that time comes. Give my regards to Hades."
}
},
{
type: 'narration',
bg : 'hera_palace',
text: {
fr: "Je n'ai pas participé au combat mais je me sens tout à coup très affaibli. Je m'effondre sur le sol, presque inconscient.",
en: "I took no part in the fight, yet I suddenly feel utterly drained. I collapse to the floor, barely conscious."
}
},
{
type : 'dialogue',
character: 'hera',
side : 'left',
text: {
fr: "Pas de panique $playername, c'est normal. Tu dois récupérer. Je vais te renvoyer chez toi pour que tu te reposes. Une fois cela fait, je viendrai te retrouver — nous avons de grandes choses à accomplir ensemble.",
en: "Don't panic $playername, this is normal. You need to recover. I am going to send you home so you can rest. Once you have, I will come find you — we have great things to accomplish together."
}
},
{
type: 'image',
src : 'media/img/story/1hera_revive.webp'
},
{
type: 'narration',
bg : 'hera_palace',
text: {
fr: "Elle tend la main vers moi et des millions d'étincelles en jaillissent. Je me sens bien et commence à fermer les yeux, content de pouvoir enfin avoir l'occasion de me reposer…",
en: "She reaches her hand toward me and a million sparks burst from it. A sense of well-being washes over me and I begin to close my eyes, relieved to finally have the chance to rest…"
}
}
]
};
}());
<</script>>
<<goto "Narration">><<script>>
(function () {
State.variables.narratCtx = {
onEnd: 'VillaHub',
bg : 'background',
sequence: [
{
type: 'image',
src : 'media/img/story/1home_bed.webp'
},
{
type: 'narration',
text: {
fr: "Des chants d'oiseaux, un filet de lumière à travers les rideaux : c'est le matin. J'ouvre les yeux, je suis allongé en travers de mon lit. Je le savais ! Ce ne pouvait être qu'un rêve ! L'accumulation de fatigue doit être la cause de tout ce délire onirique. Je suis sûrement rentré chez moi hier complètement épuisé et je me suis effondré sur le lit. Des dieux et du porno, quelle histoire absurde ! La sonnette retentit. Qui ça peut bien être à cette heure-ci ? Je me lève et me dirige vers la porte.",
en: "Birdsong, a sliver of light through the curtains: it's morning. I open my eyes — I'm sprawled across my bed. I knew it! It could only have been a dream! The buildup of exhaustion must be the cause of all that delirious rambling. I must have come home yesterday completely drained and collapsed on the bed. Gods and porn — what an absurd story! The doorbell rings. Who on earth could it be at this hour? I get up and head for the door."
}
},
{
type: 'image',
src : 'media/img/story/1hera_door.webp'
},
{
type: 'narration',
text: {
fr: "J'ouvre et un éclair me paralyse instantanément. Ce n'est pas possible !",
en: "I open the door and a lightning bolt freezes me on the spot. This can't be real!"
}
},
{
type : 'dialogue',
character: 'hera_human',
side : 'left',
text: {
fr: "Bonjour $playername, j'espère que tu as bien dormi. Comme prévu, me voilà.",
en: "Good morning $playername, I hope you slept well. As planned, here I am."
}
},
{
type : 'dialogue',
character: 'me',
side : 'right',
text: {
fr: "Mais… qu'est-ce que…",
en: "But… what the…"
}
},
{
type : 'dialogue',
character: 'hera_human',
side : 'left',
text: {
fr: "Non, ce n'était pas un rêve, et oui, tout était vrai. J'ai beaucoup de choses à te dire mais je te propose de venir plutôt chez moi, c'est important pour la suite.",
en: "No, it was not a dream, and yes, everything was real. I have a lot to tell you, but I suggest you come to my place instead — it matters for what comes next."
}
},
{
type : 'dialogue',
character: 'me',
side : 'right',
text: {
fr: "Et si je refuse ?",
en: "And if I refuse?"
}
},
{
type : 'dialogue',
character: 'hera_human',
side : 'left',
text: {
fr: "Je pense que tu es trop curieux pour ne pas au moins écouter ce que j'ai à dire. Allez, suis-moi, ma voiture nous attend.",
en: "I think you're too curious not to at least hear what I have to say. Come on, follow me — my car is waiting."
}
},
{
type: 'narration',
text: {
fr: "L'envie de savoir le fin mot de cette histoire est trop forte. Qu'est-ce que je risque, de toute façon ? Au premier problème, je cours. J'attrape des affaires pour m'habiller et je la suis dehors.",
en: "The urge to get to the bottom of this is too strong. What do I have to lose anyway? At the first sign of trouble, I run. I grab some things to get dressed and follow her outside."
}
},
{
type: 'image',
src : 'media/img/story/1hera_car.webp'
},
{
type : 'dialogue',
character: 'hera_human',
side : 'left',
text: {
fr: "Allez, monte, nous avons un peu de route. Je t'expliquerai tout à la villa.",
en: "Come on, get in — we have a bit of a drive. I'll explain everything at the villa."
}
},
{
type : 'dialogue',
character: 'me',
side : 'right',
text: {
fr: "Très bien, mais pas d'embrouilles !",
en: "Alright, but no funny business!"
}
},
{
type : 'dialogue',
character: 'hera_human',
side : 'left',
text: {
fr: "Aucune, je te le garantis.",
en: "None whatsoever, I promise you that."
}
},
{
type: 'narration',
text: {
fr: "Une fois dans la voiture, le chauffeur referme la porte grâce à un bouton sur le tableau de bord et il démarre. Nous roulons un bon moment, en silence. Je regarde par la fenêtre : nous nous dirigeons en dehors de la ville.",
en: "Once inside the car, the driver closes the door using a button on the dashboard and sets off. We drive for quite a while in silence. I look out the window — we are heading out of the city."
}
},
{
type: 'image',
src : 'media/img/story/1villa_discover.webp'
},
{
type: 'narration',
text: {
fr: "Nous roulons ensuite à travers la forêt un bon moment, jusqu'à nous engager sur un sentier discret. Au bout de celui-ci, la forêt s'écarte, révélant une villa immense. La voiture s'arrête devant l'entrée et nous nous retrouvons devant la porte d'entrée.",
en: "We then drive through the forest for quite some time before eventually turning onto a discreet path. At the end of it, the trees part to reveal an immense villa. The car stops in front of the entrance and we find ourselves at the front door."
}
},
{
type : 'dialogue',
character: 'hera',
side : 'left',
text: {
fr: "Bienvenue chez nous. Entre et assieds-toi. Je vais pouvoir tout t'expliquer calmement.",
en: "Welcome to our home. Come in and have a seat. I'll be able to explain everything calmly."
}
},
{
type : 'dialogue',
character: 'me',
side : 'right',
text: {
fr: "D'accord.",
en: "Alright."
}
},
{
type: 'image',
src : 'media/img/story/1hera_seat_sofa.webp'
},
{
type : 'dialogue',
character: 'hera',
side : 'left',
text: {
fr: "Bon, voilà. Je sais que tout ceci te paraît un peu fou et je peux le comprendre. Je t'ai déjà expliqué qui nous sommes mais je n'ai pas encore abordé le sujet qui te concerne.\nComme tu le sais, nous dépendons de la dévotion pour survivre, mais malheureusement nous sommes nombreux dans ce cas et le gâteau se réduit de jour en jour. En plus de l'arrivée de l'IA dans cette industrie, nous subissons d'énormes pertes sur la puissance que nous récoltons. Nous ne pouvons actuellement consommer cette énergie qu'en direct et nous n'avons aucun moyen de la stocker efficacement.",
en: "Right, here's the thing. I know all of this must seem a little crazy to you, and I can understand that. I've already explained who we are, but I haven't yet addressed the matter that concerns you.\nAs you know, we depend on devotion to survive, but unfortunately there are many of us in this situation and the pie is shrinking day by day. On top of the arrival of AI in this industry, we are suffering enormous losses in the power we harvest. We can currently only consume this energy in real time, and we have no way of storing it efficiently."
}
},
{
type: 'image',
src : 'media/img/story/1explanation.webp'
},
{
type : 'dialogue',
character: 'hera',
side : 'left',
text: {
fr: "Jusqu'à ta venue dans nos royaumes. Comme tu l'as remarqué, nous n'avons pas d'hommes dans nos rangs, or les essences de dévotion ne peuvent se transmettre que par l'acte sexuel.\nCela ne peut fonctionner avec un humain normal, mais toi, tu as pu traverser notre plan d'existence en restant en vie. C'est une chance inespérée pour la survie de notre espèce — nous allons pouvoir réunir assez de puissance en un point, sans perte, pour assurer notre avenir.",
en: "Until your arrival in our realms. As you may have noticed, we have no men in our ranks, yet devotion essences can only be transmitted through the sexual act.\nThis cannot work with an ordinary human, but you — you were able to pass through our plane of existence while remaining alive. This is an unexpected blessing for the survival of our species. We will be able to gather enough power in one place, without loss, to secure our future."
}
},
{
type : 'dialogue',
character: 'me',
side : 'right',
text: {
fr: "Holà, que je comprenne bien… Vous voulez vous servir de moi comme pile de stockage ?",
en: "Whoa, let me get this straight… You want to use me as a storage battery?"
}
},
{
type : 'dialogue',
character: 'hera',
side : 'left',
text: {
fr: "En quelque sorte, mais crois-moi, ce sera plutôt un plaisir pour toi. Bien sûr, nous prendrons en charge tout ce dont tu as besoin pour vivre, et toi tu n'auras qu'à prendre du bon temps.",
en: "In a manner of speaking, but trust me, it will be more of a pleasure for you than anything else. Of course, we will take care of everything you need to live, and all you'll have to do is enjoy yourself."
}
},
{
type: 'narration',
bg : 'hera_palace',
text: {
fr: "Que répondre à ça ! Quel homme pourrait simplement refuser une telle opportunité ? Peu importe si je suis devenu fou ou pas, il faut au moins que je teste ça une fois dans ma vie.",
en: "What is there to say to that! What man could simply turn down such an opportunity? Whether I've lost my mind or not, I have to at least try this once in my life."
}
},
{
type : 'dialogue',
character: 'me',
side : 'right',
text: {
fr: "Bon, d'accord, mais je veux bien tester pour une semaine pour voir s'il n'y a pas une arnaque là-dessous.",
en: "Alright, fine, but I want to try it for a week first to see if there's not some catch to all this."
}
},
{
type : 'dialogue',
character: 'hera',
side : 'left',
text: {
fr: "Très bien ! Maintenant je vais devoir activer ta capacité d'absorption d'essences. Enlève tes vêtements, nous allons commencer le rituel.",
en: "Very well! Now I will need to activate your essence absorption ability. Take your clothes off — we are going to begin the ritual."
}
},
{
type : 'dialogue',
character: 'me',
side : 'right',
text: {
fr: "Euh… bon, d'accord.",
en: "Uh… alright then."
}
},
{
type: 'video',
src : 'media/video/story/1hera_ritual.mp4'
},
{
type : 'dialogue',
character: 'hera',
side : 'left',
text: {
fr: "Hum, c'est parfait, je sens déjà le pouvoir vibrer dans ma main. Je ne me suis pas trompée, tu es le bon candidat. Maintenant, il faut que tu te taises pendant le rituel — concentre-toi sur tes sensations.",
en: "Hmm, perfect — I can already feel the power vibrating in my hand. I was not wrong about you; you are the right candidate. Now you must stay silent during the ritual — focus on your sensations."
}
},
{
type: 'video',
src : 'media/video/story/1hera_ritual2.mp4'
},
{
type : 'dialogue',
character: 'hera',
side : 'left',
text: {
fr: "Il est important que tu mentalises le lien avec ta partenaire, que tu essaies de te synchroniser avec elle.",
en: "It is important that you mentally focus on the bond with your partner and try to synchronise with her."
}
},
{
type: 'video',
src : 'media/video/story/1hera_ritual3.mp4'
},
{
type : 'dialogue',
character: 'hera',
side : 'left',
text: {
fr: "Garde toujours le contrôle de toi-même pour ne perdre aucune essence de dévotion. C'est important !",
en: "Always keep control of yourself so as not to lose a single devotion essence. This is important!"
}
},
{
type: 'video',
src : 'media/video/story/1hera_ritual4.mp4'
},
{
type : 'dialogue',
character: 'hera',
side : 'left',
text: {
fr: "Accélère pour aller chercher le plus d'essences possible !",
en: "Speed up — go and gather as many essences as you can!"
}
},
{
type: 'video',
src : 'media/video/story/1hera_ritual5.mp4'
},
{
type : 'dialogue',
character: 'hera',
side : 'left',
text: {
fr: "Allez ! Tu y es presque ! Il faut que tu ailles jusqu'au bout pour ne pas perdre la totalité des essences collectées !",
en: "Go on! You're almost there! You must go all the way to avoid losing all the essences you have collected!"
}
},
{
type: 'video',
src : 'media/video/story/1hera_ritual6.mp4'
},
{
type : 'dialogue',
character: 'hera',
side : 'left',
text: {
fr: "Voilà ! C'était très bien ! J'ai senti que tu serais l'homme de la situation. Ton pouvoir est maintenant activé.",
en: "There! That was very well done! I sensed you would be the right man for this. Your power is now activated."
}
},
{
type: 'narration',
bg : 'hera_palace',
text: {
fr: "Mon Dieu ! Je n'ai jamais rien connu de similaire, c'était… divin ! Je pense qu'une semaine dans cette maison ne pourra pas me faire de mal.",
en: "My God! I have never experienced anything like it — it was… divine! I think a week in this house can only do me good."
}
},
{
type : 'dialogue',
character: 'hera',
side : 'left',
text: {
fr: "N'hésite pas à aller voir mes servantes pour récolter plus d'essences. Accompagne-les aussi pour leurs entraînements — elles pourront puiser en toi l'énergie pour activer leurs pouvoirs. Allez, je te laisse, j'ai des choses à faire. La maison est à toi, prends tes marques. À plus tard.",
en: "Don't hesitate to go and see my servants to gather more essences. Also accompany them during their training sessions — they will be able to draw on your energy to activate their powers. Right, I'll leave you to it — I have things to do. The house is yours, make yourself at home. See you later."
}
},
{
type : 'dialogue',
character: 'me',
side : 'right',
text: {
fr: "À plus tard.",
en: "See you later."
}
}
]
};
}());
<</script>>
<<goto "Narration">><<script>>
(function () {
State.variables.combatContext = {
combatId : "hades_trio",
onVictory : "HadesIntro_FuiteVeronica",
onDefeat : "HadesIntro_Gardes"
};
}());
<</script>>
<<goto "Combat">><div id="combat-screen">
<!-- ── COMBAT AREA (≈ 2/3 screen) ───────────────── -->
<div id="combat-area">
<div id="combat-bg"></div>
<div id="card-art-preview"></div>
<div id="combat-teams">
<div id="player-side" class="team-side"></div>
<div id="enemy-card-display" class="hidden"></div>
<div id="enemy-side" class="team-side enemy-team"></div>
</div>
</div>
<!-- ── HAND AREA (≈ 1/3 screen) ─────────────────── -->
<div id="hand-area">
<button id="combat-log-btn" title="<<= $lang === 'fr' ? 'Historique' : 'History'>>">📜</button>
<div id="deck-counter">
<img src="media/img/logos/pile.webp" class="hand-icon" alt="Deck">
<span id="deck-count">0</span>
</div>
<div id="player-hand"></div>
<div id="hand-right">
<div id="discard-counter">
<img src="media/img/logos/defausse.webp" class="hand-icon" alt="Discard">
<span id="discard-count">0</span>
</div>
<button id="flee-btn" class="flee-btn" title="<<= $lang === 'fr' ? 'Abandonner le combat' : 'Abandon combat'>>">
<<= $lang === "fr" ? "✗ Fuir" : "✗ Flee">>
</button>
</div>
</div>
<!-- ── COMBAT HUD ─────────────────────────────────── -->
<div id="combat-hud">
<div id="combat-location"></div>
<div class="combat-hud-row">
<span id="ap-display">⚡ <span id="ap-count">3</span></span>
<button id="end-turn-btn">
<<= $lang === "fr" ? "Fin du Tour" : "End Turn">>
</button>
<span id="turn-display">
<<= $lang === "fr" ? "Tour" : "Turn">> <span id="turn-num">1</span>
</span>
</div>
</div>
<!-- ── COMBAT LOG MODAL ───────────────────────────── -->
<div id="combat-log-modal" class="modal hidden">
<div class="modal-inner" id="combat-log-inner">
<button class="modal-close" id="combat-log-close">✕</button>
<h3 id="combat-log-title"><<= $lang === 'fr' ? 'Historique du combat' : 'Combat History'>></h3>
<div id="combat-log-list"></div>
</div>
</div>
<!-- ── TARGET SELECTION OVERLAY ──────────────────── -->
<div id="target-overlay" class="hidden" role="alertdialog">
<button id="cancel-target-btn">
<<= $lang === "fr" ? "Annuler" : "Cancel">>
</button>
</div>
<!-- ── RESULT OVERLAY ────────────────────────────── -->
<div id="result-overlay" class="hidden" role="alertdialog">
<div id="result-box"> <div id="result-icon"></div> <h2 id="result-title"></h2>
<div id="result-rewards"></div>
<div id="result-link-gains"></div>
<button id="result-continue-btn"></button>
</div>
</div>
</div>
<<script>>
(function () {
var lang = State.variables.lang;
var ctx = State.variables.combatContext || {};
var team = State.variables.team.filter(Boolean);
var enemyIds = ctx.enemyIds || [];
var _isSuperTeam = !!window._devSuperTeam; /* capture before combat init resets it */
if (team.length === 0) { Engine.play("TeamManagement"); return; }
/* ── If this combat imposes a playerTeam, save the real team so it can be
restored after the combat ends (victory, defeat, or flee). ── */
var _combatDefImposed = ctx.combatId && window.DB_Combats && window.DB_Combats[ctx.combatId];
if (_combatDefImposed && _combatDefImposed.playerTeam) {
State.variables._savedTeam = State.variables.team.slice();
State.variables._savedDivRole = State.variables.teamDivRole;
}
/* ── Target selection state ──────────────────────── */
var _pendingCard = null;
var _targetSide = null;
var _prevHandIds = []; /* track drawn cards for fly-in animation */
var _prevHp = { player: [], enemy: [] }; /* track HP+shield for shake animation */
var _lastLogSerial = 0; /* track processed log entries for float text (uses engine logTotal) */
/* ══════════════════════════════════════════════════
CombatUI — défini AVANT CombatEngine.init pour que
les boutons HTML ne lèvent jamais «CombatUI is not
defined» même si init échoue.
══════════════════════════════════════════════════ */
window.CombatUI = {
/* Full re-render after any state change */
render: function () {
if (!document.getElementById("player-side")) return; /* passage left */
var s = CombatEngine.getState();
/* Store the state as a JSON *string* so SugarCube serializes it verbatim.
Storing a nested object risks SugarCube's deep-clone mangling the data;
a string is atomic and round-trips perfectly through any save/load. */
try { State.variables.combatState = JSON.stringify(s); } catch(e) { State.variables.combatState = null; }
/* Snapshot HP before re-rendering so renderSide can detect hits */
var snapPlayer = s.player.characters.map(function (c) { return { hp: c.hp, shield: c.shield }; });
var snapEnemy = s.enemy.characters.map(function (c) { return { hp: c.hp, shield: c.shield }; });
CombatUI.renderSide("player-side", s.player.characters, false, _prevHp.player);
CombatUI.renderSide("enemy-side", s.enemy.characters, true, _prevHp.enemy);
_prevHp.player = snapPlayer;
_prevHp.enemy = snapEnemy;
CombatUI.renderHand(s.player.hand);
document.getElementById("deck-count").textContent = s.player.deck.length;
document.getElementById("discard-count").textContent = s.player.discard.length;
document.getElementById("ap-count").textContent = s.actionPoints;
document.getElementById("turn-num").textContent = s.turn;
if (s.phase === "end") CombatUI.showResult(s);
CombatUI.processNewLogs();
},
/* Render one team side */
renderSide: function (elId, chars, flip, prevHps) {
prevHps = prevHps || [];
var el = document.getElementById(elId);
if (!el) return; /* passage left mid-animation */
/* ── If the combatant count changed, do a full rebuild ── */
var existingWraps = el.querySelectorAll(".combatant");
var needsRebuild = existingWraps.length !== chars.length;
if (needsRebuild) {
el.innerHTML = "";
}
chars.forEach(function (c, idx) {
var hpPct = Math.round((c.hp / c.maxHp) * 100);
var roleIcons = { defensive: '🛡', offensive: '⚔', support: '✨' };
var roleIcon = (c.role && roleIcons[c.role]) ? '<span class="combatant-role-icon">' + roleIcons[c.role] + '</span>' : '';
var lvlBadge = '<span class="combatant-level-badge">Lv.' + (c.level || 1) + '</span>';
/* ── PATCH existing element (animates HP bar via CSS transition) ── */
if (!needsRebuild) {
var wrap = existingWraps[idx];
/* Update alive/dead class */
wrap.className = "combatant"
+ (c.alive ? "" : " dead")
+ (window.DB_Divinities && DB_Divinities[c.id] ? " divine" : "");
/* Update status icons */
var statusEl = wrap.querySelector(".status-icons");
if (statusEl) statusEl.outerHTML = CombatUI.statusIcons(c);
/* Update HP fill — width change triggers CSS transition */
var fill = wrap.querySelector(".hp-fill");
if (fill) {
fill.style.width = hpPct + '%';
if (hpPct <= 30) fill.classList.add("hp-low");
else fill.classList.remove("hp-low");
}
/* Update HP text */
var hpTxt = wrap.querySelector(".hp-text");
if (hpTxt) hpTxt.textContent = c.hp + '/' + c.maxHp;
/* Update shield badge */
var shieldBadge = wrap.querySelector(".shield-badge");
if (c.shield) {
if (shieldBadge) {
shieldBadge.textContent = '🛡' + c.shield;
} else {
var hpBar = wrap.querySelector(".hp-bar-wrap");
if (hpBar) {
var sb = document.createElement("span");
sb.className = "shield-badge";
sb.textContent = '🛡' + c.shield;
hpBar.appendChild(sb);
}
}
} else if (shieldBadge) {
shieldBadge.parentNode.removeChild(shieldBadge);
}
/* Sprite animations handled below (shared path) */
var sprite = wrap.querySelector(".battle-sprite");
var prev = prevHps[idx];
if (prev !== undefined && sprite) {
var hpDrop = c.hp < prev.hp;
var shieldDrop = c.shield < prev.shield;
var hpGain = c.hp > prev.hp;
var shieldGain = c.shield > prev.shield;
if (hpDrop || shieldDrop) {
sprite.classList.add("shake-hit", "glow-hit");
setTimeout(function () { sprite.classList.remove("shake-hit", "glow-hit"); }, 480);
}
if (hpGain) { sprite.classList.add("glow-heal"); setTimeout(function () { sprite.classList.remove("glow-heal"); }, 750); }
if (shieldGain) { sprite.classList.add("glow-shield"); setTimeout(function () { sprite.classList.remove("glow-shield"); }, 750); }
}
return; /* skip full-build path */
}
/* ── FULL BUILD (first render or team size changed) ── */
var wrap = document.createElement("div");
wrap.className = "combatant"
+ (c.alive ? "" : " dead")
+ (window.DB_Divinities && DB_Divinities[c.id] ? " divine" : "");
wrap.dataset.side = flip ? "enemy" : "player";
wrap.dataset.index = idx;
wrap.innerHTML =
CombatUI.statusIcons(c)
+ '<span class="combatant-name">' + roleIcon + c.nameKey[lang] + lvlBadge + '</span>'
+ '<div class="hp-bar-wrap">'
+ '<span class="hp-heart">♥</span>'
+ '<div class="hp-bar"><div class="hp-fill' + (hpPct <= 30 ? ' hp-low' : '') + '" style="width:' + hpPct + '%"></div>'
+ '<span class="hp-text">' + c.hp + '/' + c.maxHp + '</span></div>'
+ (c.shield ? '<span class="shield-badge">🛡' + c.shield + '</span>' : '')
+ '</div>'
+ '<img src="' + c.battle + '" class="battle-sprite' + (flip ? " flipped" : "") + '" alt="' + c.nameKey[lang] + '">';
if (flip && c.alive) {
wrap.onclick = function () { CombatUI.selectTarget("enemy", idx); };
} else if (!flip && c.alive) {
wrap.onclick = function () { CombatUI.selectTarget("player", idx); };
}
/* ── Drop zone on combatant ── */
(function (capturedIdx, capturedSide) {
wrap.addEventListener('dragover', function (e) {
if (!wrap.classList.contains('drop-target')) return;
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
wrap.classList.add('drop-hover');
});
wrap.addEventListener('dragleave', function () {
wrap.classList.remove('drop-hover');
});
wrap.addEventListener('drop', function (e) {
e.preventDefault();
wrap.classList.remove('drop-hover');
var handIdx = parseInt(e.dataTransfer.getData('text/plain'), 10);
if (isNaN(handIdx)) return;
/* Set _pendingCard so selectTarget can consume it */
_pendingCard = handIdx;
CombatUI.selectTarget(capturedSide, capturedIdx);
});
})(idx, flip ? 'enemy' : 'player');
el.appendChild(wrap);
var prev = prevHps[idx];
var sprite = wrap.querySelector(".battle-sprite");
if (prev !== undefined && sprite) {
var hpDrop = c.hp < prev.hp;
var shieldDrop = c.shield < prev.shield;
var hpGain = c.hp > prev.hp;
var shieldGain = c.shield > prev.shield;
/* Shake + red glow on any damage */
if (hpDrop || shieldDrop) {
sprite.classList.add("shake-hit", "glow-hit");
setTimeout(function () {
sprite.classList.remove("shake-hit", "glow-hit");
}, 480);
}
/* Heal aura — green/yellow filter glow */
if (hpGain) {
sprite.classList.add("glow-heal");
setTimeout(function () { sprite.classList.remove("glow-heal"); }, 750);
}
/* Shield aura — blue filter glow */
if (shieldGain) {
sprite.classList.add("glow-shield");
setTimeout(function () { sprite.classList.remove("glow-shield"); }, 750);
}
}
});
},
/* ── Stat → icon mapping (add entries here to parameterise) ── */
statusIconMap: {
atk : { img: "media/img/icons/strength.webp", label: "ATK" },
spd : { img: "media/img/icons/speed.webp", label: "SPD" },
dodge : { img: "media/img/icons/speed.webp", label: "DODGE" },
taunt : { img: "media/img/icons/protect.webp", label: "TAUNT" },
stun : { img: "media/img/icons/stun.webp", label: "STUN" }
/* Add more: key : { img: "path.webp", label: "LABEL" } */
},
/* Build buff/debuff icons string */
statusIcons: function (c) {
var map = CombatUI.statusIconMap;
var html = '<div class="status-icons">';
c.buffs.forEach(function (b) {
var m = map[b.stat];
var img = m ? '<img src="' + m.img + '" class="sicon-img" alt="' + (m.label || b.stat) + '">' : '▲';
var title = (m ? m.label : b.stat) + ' ×' + b.value + ' (' + b.duration + 't)';
html += '<span class="status-icon buff" title="' + title + '">' + img + '<span class="sicon-dur">' + b.duration + '</span></span>';
});
c.debuffs.forEach(function (d) {
var m = map[d.stat];
var img = m ? '<img src="' + m.img + '" class="sicon-img sicon-debuff" alt="' + (m.label || d.stat) + '">' : '▼';
var title = (m ? m.label : d.stat) + ' ×' + d.value + ' (' + d.duration + 't)';
html += '<span class="status-icon debuff" title="' + title + '">' + img + '<span class="sicon-dur">' + d.duration + '</span></span>';
});
return html + '</div>';
},
/* Process new log entries and show float texts (dodge, etc.) */
processNewLogs: function () {
var s = CombatEngine.getState();
var log = s.log;
/* Use monotonic logTotal counter so log.shift() trimming never desynchs the index */
var total = s.logTotal || log.length; /* fallback for old saves */
var newCount = total - _lastLogSerial;
_lastLogSerial = total;
var from = Math.max(0, log.length - newCount);
for (var i = from; i < log.length; i++) {
var entry = log[i];
var pIdx = s.player.characters.findIndex(function (c) { return c.id === entry.target; });
var eIdx = s.enemy.characters.findIndex(function (c) { return c.id === entry.target; });
if (entry.type === 'dodge') {
if (pIdx >= 0) CombatUI.showFloatText('player', pIdx, 'DODGE!', 'float-dodge');
if (eIdx >= 0) CombatUI.showFloatText('enemy', eIdx, 'DODGE!', 'float-dodge');
} else if (entry.type === 'damage') {
var sz = entry.amount > 30 ? '1.9rem' : entry.amount > 15 ? '1.4rem' : '1.05rem';
if (pIdx >= 0) CombatUI.showFloatText('player', pIdx, '-' + entry.amount, 'float-damage', sz);
if (eIdx >= 0) CombatUI.showFloatText('enemy', eIdx, '-' + entry.amount, 'float-damage', sz);
} else if (entry.type === 'shield_hit') {
if (pIdx >= 0) CombatUI.showFloatText('player', pIdx, '-' + entry.amount + '🛡', 'float-shield-hit', '1rem');
if (eIdx >= 0) CombatUI.showFloatText('enemy', eIdx, '-' + entry.amount + '🛡', 'float-shield-hit', '1rem');
} else if (entry.type === 'heal') {
var hsz = entry.amount > 20 ? '1.5rem' : '1.1rem';
if (pIdx >= 0) CombatUI.showFloatText('player', pIdx, '+' + entry.amount, 'float-heal', hsz);
if (eIdx >= 0) CombatUI.showFloatText('enemy', eIdx, '+' + entry.amount, 'float-heal', hsz);
} else if (entry.type === 'shield_gain') {
if (pIdx >= 0) CombatUI.showFloatText('player', pIdx, '+' + entry.amount + '🛡', 'float-shield-gain', '1rem');
if (eIdx >= 0) CombatUI.showFloatText('enemy', eIdx, '+' + entry.amount + '🛡', 'float-shield-gain', '1rem'); } else if (entry.type === 'buff') {
var bLabel = (CombatUI.statusIconMap[entry.stat] && CombatUI.statusIconMap[entry.stat].label) || entry.stat.toUpperCase();
var bText = '▲ ' + bLabel;
if (pIdx >= 0) CombatUI.showFloatText('player', pIdx, bText, 'float-buff', '1rem');
if (eIdx >= 0) CombatUI.showFloatText('enemy', eIdx, bText, 'float-buff', '1rem');
} else if (entry.type === 'debuff') {
var dLabel = (CombatUI.statusIconMap[entry.stat] && CombatUI.statusIconMap[entry.stat].label) || entry.stat.toUpperCase();
var dText = '▼ ' + dLabel;
if (pIdx >= 0) CombatUI.showFloatText('player', pIdx, dText, 'float-debuff', '1rem');
if (eIdx >= 0) CombatUI.showFloatText('enemy', eIdx, dText, 'float-debuff', '1rem');
} else if (entry.type === 'stun') {
if (pIdx >= 0) CombatUI.showFloatText('player', pIdx, '⚡ STUN', 'float-stun', '1rem');
if (eIdx >= 0) CombatUI.showFloatText('enemy', eIdx, '⚡ STUN', 'float-stun', '1rem');
} else if (entry.type === 'cleanse') {
if (pIdx >= 0) CombatUI.showFloatText('player', pIdx, '✦ CLEANSE', 'float-cleanse', '1rem');
if (eIdx >= 0) CombatUI.showFloatText('enemy', eIdx, '✦ CLEANSE', 'float-cleanse', '1rem');
} else if (entry.type === 'crit') {
var critText = entry.healCrit ? '⚡ CRIT!' : '⚡ CRIT!';
if (eIdx >= 0) CombatUI.showFloatText('enemy', eIdx, critText, 'float-crit', '1.6rem');
if (pIdx >= 0) CombatUI.showFloatText('player', pIdx, critText, 'float-crit', '1.6rem');
}
}
},
/* Show a floating text label above a combatant */
showFloatText: function (side, index, text, cssClass, fontSize) {
var sideEl = document.getElementById(side === 'enemy' ? 'enemy-side' : 'player-side');
if (!sideEl) return;
var wrap = sideEl.children[index];
if (!wrap) return;
var el = document.createElement('div');
el.className = 'float-text ' + (cssClass || '');
el.textContent = text;
if (fontSize) el.style.fontSize = fontSize;
wrap.appendChild(el);
/* Remove after animation */
el.addEventListener('animationend', function () {
if (el.parentNode) el.parentNode.removeChild(el);
});
},
/* Render player hand */
renderHand: function (hand) {
var el = document.getElementById("player-hand");
/* Detect newly drawn cards (ids not found in previous hand pool) */
var prevPool = _prevHandIds.slice();
var drawnSlots = []; /* DOM child indices for fly-in */
hand.forEach(function (card, idx) {
if (!card) return;
var pos = prevPool.indexOf(card.id);
if (pos === -1) {
drawnSlots.push(hand.slice(0, idx).filter(Boolean).length);
} else {
prevPool.splice(pos, 1);
}
});
_prevHandIds = hand.filter(Boolean).map(function (c) { return c.id; });
el.innerHTML = "";
hand.forEach(function (card, idx) {
if (!card) return;
var cardEl = CardUI.buildCardElement(card, lang, true, idx);
var _cs = CombatEngine.getState();
if (_cs.phase === 'player' && _cs.actionPoints >= card.cost) {
cardEl.classList.add('card-playable');
}
/* ── Drag support ── */
cardEl.setAttribute('draggable', 'true');
cardEl.addEventListener('dragstart', function (e) {
var s = CombatEngine.getState();
if (s.phase !== 'player') { e.preventDefault(); return; }
if (s.actionPoints < card.cost) { e.preventDefault(); return; }
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/plain', String(idx));
cardEl.classList.add('card-dragging');
/* Show valid drop zones */
var needsTarget = card.effects.some(function (e2) {
return e2.target === 'enemy' || e2.target === 'ally';
});
if (needsTarget) {
var side = card.effects.some(function (e2) { return e2.target === 'enemy'; })
? 'enemy' : 'player';
var allEls = Array.from(document.querySelectorAll(".combatant[data-side='" + side + "']"));
var tauntEl = (side === 'enemy') ? allEls.find(function (el2) {
var ci = parseInt(el2.dataset.index, 10);
var c2 = CombatEngine.getState().enemy.characters[ci];
return c2 && c2.alive && c2.buffs && c2.buffs.some(function (b) { return b.stat === 'taunt'; });
}) : null;
(tauntEl ? [tauntEl] : allEls).forEach(function (el2) { el2.classList.add('drop-target'); });
} else {
document.getElementById('combat-area').classList.add('drop-target-area');
}
});
cardEl.addEventListener('dragend', function () {
cardEl.classList.remove('card-dragging');
document.querySelectorAll('.drop-target').forEach(function (el2) { el2.classList.remove('drop-target'); });
var ca = document.getElementById('combat-area');
if (ca) ca.classList.remove('drop-target-area');
});
el.appendChild(cardEl);
});
/* Animate newly drawn cards flying in from the deck */
drawnSlots.forEach(function (domIdx, i) {
var cardEl = el.children[domIdx];
if (cardEl) CombatUI.animateCardFromDeck(cardEl, i * 80);
});
/* Fit all cards within the available width */
CombatUI.scaleHandArea();
},
/* Adjust card overlap so all cards fit horizontally.
Height is now driven purely by CSS (clamp on .game-card + #hand-area),
so no zoom or height manipulatiBon is needed here. */
scaleHandArea: function () {
var handEl = document.getElementById("player-hand");
var areaEl = document.getElementById("hand-area");
var deckEl = document.getElementById("deck-counter");
var rightEl = document.getElementById("hand-right");
if (!handEl || !areaEl) return;
/* Reset any previous inline styles from old implementation */
areaEl.style.zoom = "";
areaEl.style.height = "";
areaEl.style.flexBasis = "";
var cards = Array.from(handEl.querySelectorAll(".game-card"));
var n = cards.length;
if (n === 0) return;
var cardW = cards[0].offsetWidth || 180;
/* Measure the actual widths of the flanking elements */
var deckW = deckEl ? deckEl.offsetWidth : 95;
var rightW = rightEl ? rightEl.offsetWidth : 95;
/* gap + padding on both sides (0.5rem * 2 ≈ 16px, gap 0.4rem * 2 ≈ 13px) */
var gutters = 30;
/* Available width for the hand itself */
var available = areaEl.offsetWidth - deckW - rightW - gutters;
if (available <= 0) return;
/* Minimum overlap: keep at least 30px of each card visible */
var minVisible = 30;
/* Natural total width with zero overlap */
var natural = cardW * n;
/* How much total overlap is needed? */
var totalOverlap = Math.max(0, natural - available);
/* Overlap per card gap (n-1 gaps) */
var overlapEach = n > 1 ? Math.ceil(totalOverlap / (n - 1)) : 0;
/* Cap: never hide more than (cardW - minVisible) per card */
overlapEach = Math.min(overlapEach, cardW - minVisible);
cards.forEach(function (c, i) {
c.style.marginLeft = (i === 0 ? "0" : "-" + overlapEach + "px");
});
},
/* Fly a card from the deck to its hand position */
animateCardFromDeck: function (cardEl, delay) {
var deckEl = document.getElementById("deck-counter");
var deckRect = deckEl.getBoundingClientRect();
var cardRect = cardEl.getBoundingClientRect();
var tx = (deckRect.left + deckRect.width / 2) - (cardRect.left + cardRect.width / 2);
var ty = (deckRect.top + deckRect.height / 2) - (cardRect.top + cardRect.height / 2);
var clone = cardEl.cloneNode(true);
clone.style.cssText = [
'position:fixed',
'left:' + cardRect.left + 'px',
'top:' + cardRect.top + 'px',
'width:' + cardRect.width + 'px',
'height:' + cardRect.height + 'px',
'margin:0', 'padding:0',
'pointer-events:none',
'z-index:9999',
'transform:translate(' + tx + 'px,' + ty + 'px) scale(0.05)',
'opacity:0',
'transition:none',
'transform-origin:center center',
'will-change:transform,opacity'
].join(';');
document.body.appendChild(clone);
cardEl.style.visibility = 'hidden';
function doAnimate() {
clone.style.transition = 'transform 0.42s cubic-bezier(.2,.8,.4,1), opacity 0.35s ease';
clone.style.transform = 'translate(0,0) scale(1)';
clone.style.opacity = '1';
function onEnd(e) {
if (e.propertyName !== 'transform') return;
clone.removeEventListener('transitionend', onEnd);
document.body.removeChild(clone);
cardEl.style.visibility = '';
}
clone.addEventListener('transitionend', onEnd);
}
if (delay) {
setTimeout(function () {
requestAnimationFrame(function () { requestAnimationFrame(doAnimate); });
}, delay);
} else {
requestAnimationFrame(function () { requestAnimationFrame(doAnimate); });
}
},
/* Fly a card from hand to the discard pile, then call callback */
animateCardToDiscard: function (handIndex, callback) {
var hand = document.getElementById("player-hand");
var cardEl = hand.children[handIndex];
if (!cardEl) { callback(); return; }
var cardRect = cardEl.getBoundingClientRect();
var discardEl = document.getElementById("discard-counter");
var discardRect = discardEl.getBoundingClientRect();
/* Fixed clone that flies over everything */
var clone = cardEl.cloneNode(true);
clone.style.cssText = [
'position:fixed',
'left:' + cardRect.left + 'px',
'top:' + cardRect.top + 'px',
'width:' + cardRect.width + 'px',
'height:' + cardRect.height + 'px',
'margin:0', 'padding:0',
'pointer-events:none',
'z-index:9999',
'transition:transform 0.42s cubic-bezier(.4,0,.8,1), opacity 0.42s ease',
'transform-origin:center center',
'will-change:transform,opacity'
].join(';');
document.body.appendChild(clone);
cardEl.style.visibility = 'hidden'; /* hide original during flight */
/* Target: centre of discard icon */
var tx = (discardRect.left + discardRect.width / 2) - (cardRect.left + cardRect.width / 2);
var ty = (discardRect.top + discardRect.height / 2) - (cardRect.top + cardRect.height / 2);
/* Double rAF to ensure the browser paints before the transition starts */
requestAnimationFrame(function () {
requestAnimationFrame(function () {
clone.style.transform = 'translate(' + tx + 'px,' + ty + 'px) scale(0.05)';
clone.style.opacity = '0';
});
});
function onEnd(e) {
if (e.propertyName !== 'transform') return;
clone.removeEventListener('transitionend', onEnd);
document.body.removeChild(clone);
callback();
}
clone.addEventListener('transitionend', onEnd);
},
/* Play an animated GIF hit-effect on one or more combatants
side : "enemy" | "player"
indices : array of data-index values
gifUrl : path to the GIF (e.g. "media/img/effects/hit.gif")
duration : ms to display (default 900) — should match GIF length */
playHitEffect: function (side, indices, gifUrl, duration) {
duration = duration || 900;
var sideId = side === "enemy" ? "enemy-side" : "player-side";
var sideEl = document.getElementById(sideId);
if (!sideEl) return;
indices.forEach(function (idx) {
var wrap = sideEl.querySelector('.combatant[data-index="' + idx + '"]');
if (!wrap) return;
var img = document.createElement("img");
/* Append timestamp to force GIF restart on each play */
img.src = gifUrl + "?r=" + Date.now();
img.className = "hit-effect-overlay";
img.style.setProperty("--hit-dur", duration + "ms");
img.alt = "";
wrap.appendChild(img);
setTimeout(function () {
if (img.parentNode) img.parentNode.removeChild(img);
}, duration + 100);
});
},
/* Player clicks a card in hand */
selectCard: function (handIndex) {
var s = CombatEngine.getState();
if (s.phase !== "player") return;
var card = s.player.hand[handIndex];
if (!card || s.actionPoints < card.cost) return;
/* If another card was already waiting for a target, cancel it first */
if (_pendingCard !== null) {
CombatUI.cancelTarget();
}
var needsTarget = card.effects.some(function (e) {
return e.target === "enemy" || e.target === "ally";
});
if (needsTarget) {
_pendingCard = handIndex;
_targetSide = card.effects.some(function (e) { return e.target === "enemy"; })
? "enemy" : "player";
document.getElementById("target-overlay").classList.remove("hidden");
/* If targeting enemies and one has taunt, only that one is targetable */
var targetableCombatants;
if (_targetSide === "enemy") {
var allEnemyEls = Array.from(document.querySelectorAll(".combatant[data-side='enemy']"))
.filter(function (el) { return !el.classList.contains("dead"); });
var tauntEl = allEnemyEls.find(function (el) {
var idx = parseInt(el.dataset.index, 10);
var c = CombatEngine.getState().enemy.characters[idx];
return c && c.alive && c.buffs && c.buffs.some(function (b) { return b.stat === "taunt"; });
});
targetableCombatants = tauntEl ? [tauntEl] : allEnemyEls;
} else {
targetableCombatants = Array.from(document.querySelectorAll(".combatant[data-side='player']"))
.filter(function (el) { return !el.classList.contains("dead"); });
}
targetableCombatants.forEach(function (el) { el.classList.add("targetable"); });
} else {
/* Compute AoE / self target indices BEFORE playCard mutates state */
var aoeIndices = null, aoeSide = null;
if (card.hitEffect) {
var firstEff = card.effects[0];
if (firstEff) {
if (firstEff.target === "allEnemies") {
aoeSide = "enemy";
aoeIndices = s.enemy.characters.reduce(function (a, c, i) {
if (c.alive) a.push(i); return a;
}, []);
} else if (firstEff.target === "allAllies") {
aoeSide = "player";
aoeIndices = s.player.characters.reduce(function (a, c, i) {
if (c.alive) a.push(i); return a;
}, []);
} else if (firstEff.target === "self") {
aoeSide = "player";
var selfIdx = s.player.characters.findIndex(function (c) { return c.alive; });
aoeIndices = selfIdx >= 0 ? [selfIdx] : [];
}
}
}
CombatUI.animateCardToDiscard(handIndex, function () {
_lastLogSerial = CombatEngine.getState().logTotal || 0;
var result = CombatEngine.playCard(handIndex, null, null);
if (card.hitEffect && aoeSide && aoeIndices && aoeIndices.length) {
CombatUI.playHitEffect(aoeSide, aoeIndices, card.hitEffect, card.hitEffectDuration);
}
CombatUI.animateCaster(card, function () {
if (typeof result !== "string") CombatUI.render();
});
});
}
},
/* Player clicks a combatant as target */
selectTarget: function (side, index) {
if (_pendingCard === null) return;
document.getElementById("target-overlay").classList.add("hidden");
document.querySelectorAll(".combatant").forEach(function (el) {
el.classList.remove("targetable");
});
var pendingIdx = _pendingCard;
_pendingCard = null;
var card = CombatEngine.getState().player.hand[pendingIdx];
CombatUI.animateCardToDiscard(pendingIdx, function () {
_lastLogSerial = CombatEngine.getState().logTotal || 0;
var result = CombatEngine.playCard(pendingIdx, side, index);
if (card.hitEffect) {
CombatUI.playHitEffect(side, [index], card.hitEffect, card.hitEffectDuration);
}
CombatUI.animateCaster(card, function () {
if (typeof result !== "string") CombatUI.render();
});
});
},
/* Animate the caster (character linked to the played card) lurching forward */
animateCaster: function (card, onDone) {
if (!card || !card.character) { if (onDone) onDone(); return; }
var s = CombatEngine.getState();
var charIdx = s.player.characters.findIndex(function (c) { return c.id === card.character; });
if (charIdx === -1) { if (onDone) onDone(); return; }
var playerEl = document.getElementById("player-side");
if (!playerEl) { if (onDone) onDone(); return; }
var wrap = playerEl.children[charIdx];
if (!wrap) { if (onDone) onDone(); return; }
var _casterDone = false;
function _casterFinish() {
if (_casterDone) return;
_casterDone = true;
wrap.classList.remove("lunge-forward");
if (onDone) onDone();
}
wrap.classList.add("lunge-forward");
wrap.addEventListener("animationend", _casterFinish, { once: true });
/* Fallback: if animationend never fires (CSS disabled / very slow), still call onDone */
setTimeout(_casterFinish, 750);
},
/* Show the card an enemy is playing, then hide after 1s */
showEnemyCard: function (cardDef) {
var el = document.getElementById('enemy-card-display');
if (!el) return;
el.innerHTML = '';
if (cardDef) {
/* Build a custom horizontal banner */
var charData = (window.DB_Characters && window.DB_Characters[cardDef.character])
|| (window.DB_Divinities && window.DB_Divinities[cardDef.character]);
var charName = charData ? charData.nameKey[lang] : '';
var descText = cardDef.descKey ? cardDef.descKey[lang] : '';
var roleIcons = { defensive: '🛡', offensive: '⚔', support: '✨' };
var roleIcon = (cardDef.role && roleIcons[cardDef.role]) ? roleIcons[cardDef.role] + ' ' : '';
var banner = document.createElement('div');
banner.className = 'enemy-card-banner rarity-' + (cardDef.rarity || 'common');
banner.innerHTML =
'<img class="ecb-art" src="' + (window.CardUI ? window.CardUI.cardImage(cardDef) : cardDef.image) + '" alt="">'
+ '<div class="ecb-body">'
+ '<div class="ecb-header">'
+ '<span class="ecb-cost">' + cardDef.cost + '</span>'
+ '<span class="ecb-name">' + cardDef.nameKey[lang] + '</span>'
+ (charName ? '<span class="ecb-char">' + roleIcon + charName + '</span>' : '')
+ '</div>'
+ '<div class="ecb-desc">' + descText + '</div>'
+ '</div>';
el.appendChild(banner);
}
el.classList.remove('hidden', 'enemy-card-hide');
el.classList.add('enemy-card-show');
clearTimeout(CombatUI._enemyCardTimer);
CombatUI._enemyCardTimer = setTimeout(function () {
el.classList.remove('enemy-card-show');
el.classList.add('enemy-card-hide');
setTimeout(function () {
el.classList.add('hidden');
el.classList.remove('enemy-card-hide');
}, 350);
}, 2100);
},
/* Open combat log modal */
showLog: function () {
var s = CombatEngine.getState();
var logs = s.log || [];
var list = document.getElementById('combat-log-list');
var modal = document.getElementById('combat-log-modal');
if (!list || !modal) return;
list.innerHTML = '';
/* Build a name lookup for all combatants */
function resolveName(id) {
var def = (window.DB_Characters && window.DB_Characters[id])
|| (window.DB_Divinities && window.DB_Divinities[id])
|| (window.DB_EnemyDefs && window.DB_EnemyDefs[id]);
return def ? def.nameKey[lang] : id;
}
/* Summarise card effects into short tags */
function effectTags(effects) {
if (!effects || !effects.length) return '';
var parts = [];
effects.forEach(function (e) {
switch (e.type) {
case 'damage': parts.push('<span class="clog-tag clog-tag-dmg">\u2694 ' + e.value + '</span>'); break;
case 'heal': parts.push('<span class="clog-tag clog-tag-heal">\u2665 +' + e.value + '</span>'); break;
case 'shield': parts.push('<span class="clog-tag clog-tag-shield">\ud83d\udee1 +' + e.value + '</span>'); break;
case 'draw': parts.push('<span class="clog-tag clog-tag-util">+' + e.value + ' ' + (lang === 'fr' ? 'carte' : 'card') + (e.value > 1 ? 's' : '') + '</span>'); break;
case 'discard': parts.push('<span class="clog-tag clog-tag-util">-' + e.value + ' ' + (lang === 'fr' ? 'carte' : 'card') + (e.value > 1 ? 's' : '') + '</span>'); break;
case 'stun': parts.push('<span class="clog-tag clog-tag-debuff">\u26a1 ' + (lang === 'fr' ? '\u00c9tourdi' : 'Stun') + '</span>'); break;
case 'atk': parts.push('<span class="clog-tag clog-tag-buff">\u2694 ATK\xd7' + e.value + '</span>'); break;
case 'spd': parts.push('<span class="clog-tag clog-tag-buff">\u26a1 SPD\xd7' + e.value + '</span>'); break;
case 'dodge': parts.push('<span class="clog-tag clog-tag-buff">\ud83d\udca8 Dodge</span>'); break;
case 'taunt': parts.push('<span class="clog-tag clog-tag-buff">\ud83d\udee1 Taunt</span>'); break;
}
});
return parts.join(' ');
}
/* Group entries by turn */
var byTurn = {};
logs.forEach(function (entry) {
if (!byTurn[entry.turn]) byTurn[entry.turn] = [];
byTurn[entry.turn].push(entry);
});
var turns = Object.keys(byTurn).map(Number).sort(function (a, b) { return b - a; }); /* newest first */
turns.forEach(function (turn) {
var header = document.createElement('div');
header.className = 'clog-turn-header';
header.textContent = (lang === 'fr' ? 'Tour ' : 'Turn ') + turn;
list.appendChild(header);
byTurn[turn].forEach(function (entry) {
var row = document.createElement('div');
var cardDef, cardName, casterName, charName, tags, desc;
if (entry.type === 'card_played') {
cardDef = window.DB_Cards && window.DB_Cards[entry.card];
cardName = cardDef ? cardDef.nameKey[lang] : entry.card;
casterName = cardDef && cardDef.character ? resolveName(cardDef.character) : '';
tags = cardDef ? effectTags(cardDef.effects) : '';
desc = cardDef && cardDef.descKey ? cardDef.descKey[lang] : '';
row.className = 'clog-row clog-player';
row.innerHTML =
'<div class="clog-main">'
+ '<span class="clog-side clog-side-player">\u25b6</span>'
+ (casterName ? '<span class="clog-char">' + casterName + '</span><span class="clog-sep">\u203a</span>' : '')
+ '<span class="clog-card">' + cardName + '</span>'
+ (tags ? '<span class="clog-tags">' + tags + '</span>' : '')
+ '</div>'
+ (desc ? '<div class="clog-desc">' + desc + '</div>' : '');
} else if (entry.type === 'enemy_played') {
cardDef = window.DB_Cards && window.DB_Cards[entry.card];
cardName = cardDef ? cardDef.nameKey[lang] : entry.card;
charName = resolveName(entry.enemy);
tags = cardDef ? effectTags(cardDef.effects) : '';
desc = cardDef && cardDef.descKey ? cardDef.descKey[lang] : '';
row.className = 'clog-row clog-enemy';
row.innerHTML =
'<div class="clog-main">'
+ '<span class="clog-side clog-side-enemy">\u25c0</span>'
+ '<span class="clog-char">' + charName + '</span>'
+ '<span class="clog-sep">\u203a</span>'
+ '<span class="clog-card">' + cardName + '</span>'
+ (tags ? '<span class="clog-tags">' + tags + '</span>' : '')
+ '</div>'
+ (desc ? '<div class="clog-desc">' + desc + '</div>' : '');
} else if (entry.type === 'enemy_stunned') {
charName = resolveName(entry.enemy);
row.className = 'clog-row clog-status';
row.innerHTML =
'<div class="clog-main">'
+ '<span class="clog-side">\u26a1</span>'
+ '<span class="clog-char">' + charName + '</span>'
+ '<span class="clog-sep">\u2014</span>'
+ '<span class="clog-card">' + (lang === 'fr' ? '\u00c9tourdi, passe son tour' : 'Stunned, skips turn') + '</span>'
+ '</div>';
} else if (entry.type === 'dodge') {
var dodgerName = resolveName(entry.target);
var dodgeType = entry.guaranteed
? (lang === 'fr' ? 'Esquive garantie' : 'Guaranteed dodge')
: (lang === 'fr' ? 'Esquive (vitesse)' : 'Speed dodge');
row.className = 'clog-row clog-status';
row.innerHTML =
'<div class="clog-main">'
+ '<span class="clog-side">\ud83d\udca8</span>'
+ '<span class="clog-char">' + dodgerName + '</span>'
+ '<span class="clog-sep">\u2014</span>'
+ '<span class="clog-card">' + dodgeType + '</span>'
+ '</div>';
} else if (entry.type === 'crit') {
var critTargetName = resolveName(entry.target);
var critCasterName = entry.caster ? resolveName(entry.caster) : '';
var critLabel = entry.healCrit
? (lang === 'fr' ? 'Soin Critique' : 'Critical Heal')
: (lang === 'fr' ? 'Coup Critique' : 'Critical Hit');
row.className = 'clog-row clog-crit-row';
row.innerHTML =
'<div class="clog-main">'
+ '<span class="clog-side">\u26a1</span>'
+ (critCasterName ? '<span class="clog-char">' + critCasterName + '</span><span class="clog-sep">\u203a</span>' : '')
+ '<span class="clog-card clog-crit-label">' + critLabel + '</span>'
+ '<span class="clog-sep">\u2192</span>'
+ '<span class="clog-char">' + critTargetName + '</span>'
+ '</div>';
} else {
return; /* skip unknown entries */
}
list.appendChild(row);
});
});
if (list.children.length === 0) {
var empty = document.createElement('div');
empty.className = 'clog-empty';
empty.textContent = lang === 'fr' ? 'Aucune action pour l\u0027instant.' : 'No actions yet.';
list.appendChild(empty);
}
modal.classList.remove('hidden');
},
/* Cancel target selection */
cancelTarget: function () {
_pendingCard = null;
document.getElementById("target-overlay").classList.add("hidden");
document.querySelectorAll(".combatant").forEach(function (el) {
el.classList.remove("targetable");
});
},
/* End player turn button */
endTurn: function () {
/* Cancel any pending target selection before ending the turn */
if (_pendingCard !== null) CombatUI.cancelTarget();
var actions = CombatEngine.peekEnemyActions();
var btn = document.getElementById("end-turn-btn");
btn.disabled = true;
CombatEngine.endPlayerTurn(); /* tick player buffs, set phase=enemy */
var ANIM_MS = 650; /* animation duration */
var PAUSE_MS = 1800; /* pause between enemies after render */
function runNext(i) {
if (i >= actions.length) {
/* all enemies done — wrap up turn;
btn.disabled = false is in finally so it ALWAYS runs */
try {
var result = CombatEngine.finishEnemyTurn();
if (typeof result !== "string") CombatUI.render();
} catch (e) {
console.error("[DD] finishEnemyTurn/render error:", e);
} finally {
btn.disabled = false;
}
return;
}
var enemyIdx = actions[i].enemyIdx;
var enemyEl = document.getElementById("enemy-side");
var wrap = enemyEl ? enemyEl.children[enemyIdx] : null;
/* Show the card this enemy is about to play.
Uses peekNextEnemyCard so the preview matches the situational AI choice. */
var _previewId = CombatEngine.peekNextEnemyCard(enemyIdx);
var _previewDef = _previewId && window.DB_Cards && window.DB_Cards[_previewId];
CombatUI.showEnemyCard(_previewDef || null);
/* Guard: afterAnim must only run once per enemy, even if both
animationend and the fallback timeout fire (browser render lag). */
var _animDone = false;
function afterAnim() {
if (_animDone) return;
_animDone = true;
/* apply this enemy's action — errors must not break the chain */
try {
CombatEngine.runSingleEnemyAction(enemyIdx);
CombatUI.render();
} catch (e) {
console.error("[DD] afterAnim error (enemy " + enemyIdx + "):", e);
}
/* pause then move to next enemy — scheduled OUTSIDE the try so it
always runs even if the block above threw */
setTimeout(function () { runNext(i + 1); }, PAUSE_MS);
}
if (wrap) {
wrap.classList.add("lunge-forward-enemy");
wrap.addEventListener("animationend", function () {
wrap.classList.remove("lunge-forward-enemy");
afterAnim();
}, { once: true });
/* safety fallback if animationend never fires */
setTimeout(function () {
if (wrap.classList.contains("lunge-forward-enemy")) {
wrap.classList.remove("lunge-forward-enemy");
}
afterAnim(); /* idempotent — safe to call even if animationend already fired */
}, ANIM_MS + 100);
} else {
afterAnim();
}
}
runNext(0);
},
/* Show victory / defeat overlay and grant rewards */
showResult: function (s) {
var overlay = document.getElementById("result-overlay");
var titleEl = document.getElementById("result-title");
var rewardsEl = document.getElementById("result-rewards");
var linkGainsEl = document.getElementById("result-link-gains");
var continueBtn = document.getElementById("result-continue-btn");
overlay.classList.remove("hidden");
/* ── Combat background inside the modal box ── */
var _bgId = (ctx.combatId && window.DB_Combats && window.DB_Combats[ctx.combatId])
? window.DB_Combats[ctx.combatId].backgroundId
: (ctx.backgroundId || "default");
overlay.style.background = "";
document.getElementById("result-box").style.setProperty(
"--result-bg", "url('media/img/backgrounds/" + _bgId + ".webp')"
);
var isVilla = !ctx.ladderId && !ctx.dungeonId;
/* Default: defeat in dungeon/story → onDefeat, otherwise hub/ladder */
var nextPassage = ctx.dungeonId ? (ctx.onDefeat || "VillaHub")
: ctx.onDefeat ? ctx.onDefeat
: isVilla ? "VillaHub"
: "Ladder";
continueBtn.textContent = lang === "fr" ? "Continuer" : "Continue";
continueBtn.onclick = function () {
State.variables.combatState = null;
/* Restore the real team if this combat had an imposed playerTeam */
if (State.variables._savedTeam) {
State.variables.team = State.variables._savedTeam;
State.variables.teamDivRole = State.variables._savedDivRole;
State.variables._savedTeam = null;
State.variables._savedDivRole = null;
}
Engine.play(nextPassage);
};
if (s.result === "victory") {
titleEl.textContent = lang === "fr" ? "Victoire !" : "Victory!";
titleEl.className = "result-victory";
document.getElementById("result-icon").className = "result-icon-victory";
if (!s.rewardsGiven) {
/* ── Ladder rewards ── */
var lId = ctx.ladderId;
var eId = ctx.enemyId;
var html = "";
if (lId && eId) {
var def = DB_EnemyDefs[eId];
var prog = State.variables.ladderProgress;
if (!prog[lId]) prog[lId] = {};
if (!prog[lId][eId]) prog[lId][eId] = 0;
prog[lId][eId]++;
State.variables.ladderProgress = prog;
Object.keys(def.rewards || {}).forEach(function (k) {
var v = def.rewards[k];
if (k === "devotionEssence") {
State.variables.devotionEssence += v;
html += '<span>+' + v + ' Devotion Essence</span> ';
} else {
if (!State.variables.divineEssences[k]) State.variables.divineEssences[k] = 0;
State.variables.divineEssences[k] += v;
if (!State.variables.foughtDivinities.includes(k))
State.variables.foughtDivinities.push(k);
html += '<span>+' + v + ' ' + k + ' Essence</span> ';
}
});
}
rewardsEl.innerHTML = html;
/* ── Divine essence gains from defeated enemies ── */
(function () {
var ESSENCE_BY_TYPE = { npc: 1, servant: 2, divinity: 5 };
/* Determine type + divinity key for a combatant id */
function _enemyEssInfo(cid) {
var eDef = window.DB_EnemyDefs && window.DB_EnemyDefs[cid];
if (eDef) {
var srcIsDivinity = eDef.sourceChar
&& window.DB_Divinities && window.DB_Divinities[eDef.sourceChar];
var srcIsNPC = !srcIsDivinity && eDef.sourceChar
&& ((window.DB_NPCs && window.DB_NPCs[eDef.sourceChar])
|| (window.DB_Minions && window.DB_Minions[eDef.sourceChar]));
var type = srcIsDivinity ? "divinity" : (srcIsNPC ? "npc" : "servant");
return { divinity: eDef.divinity, type: type };
}
if (window.DB_Divinities && window.DB_Divinities[cid])
return { divinity: cid, type: "divinity" };
var cDef = window.DB_Characters && window.DB_Characters[cid];
if (cDef) return { divinity: cDef.divinity, type: "servant" };
var nDef = (window.DB_NPCs && window.DB_NPCs[cid])
|| (window.DB_Minions && window.DB_Minions[cid]);
if (nDef) return { divinity: nDef.divinity, type: "npc" };
return null;
}
var gains = {};
s.enemy.characters.forEach(function (c) {
var info = _enemyEssInfo(c.id);
if (!info || !info.divinity) return;
gains[info.divinity] = (gains[info.divinity] || 0) + (ESSENCE_BY_TYPE[info.type] || 1);
});
var divEss = State.variables.divineEssences;
var essHtml = '';
Object.keys(gains).forEach(function (divKey) {
var gain = gains[divKey];
if (!divEss[divKey]) divEss[divKey] = 0;
divEss[divKey] += gain;
if (!State.variables.foughtDivinities.includes(divKey))
State.variables.foughtDivinities.push(divKey);
var divData = window.DB_Divinities && window.DB_Divinities[divKey];
var divName = divData ? (divData.nameKey[lang] || divKey) : divKey;
var iconHtml = (divData && divData.icon)
? '<img src="' + divData.icon + '" class="result-ess-icon" alt="">'
: '';
essHtml += '<div class="result-ess-pill">'
+ iconHtml
+ '<span class="result-ess-amount">+' + gain + '</span>'
+ '<span class="result-ess-name">' + divName + '</span>'
+ '</div>';
});
State.variables.divineEssences = divEss;
if (essHtml) {
var essTitleTxt = lang === 'fr' ? 'Essences divines' : 'Divine Essences';
rewardsEl.innerHTML += '<div class="result-ess-section">'
+ '<div class="result-ess-title">' + essTitleTxt + '</div>'
+ '<div class="result-ess-row">' + essHtml + '</div>'
+ '</div>';
}
})();
/* ── XP + Link gain — combined, one compact row per character ── */
var XP_TABLE = [0, 100, 250, 450, 700, 1000, 1350, 1750, 2200, 2700];
function xpToLevel(xp) {
var lv = 1;
for (var xi = 1; xi < XP_TABLE.length; xi++) {
if (xp >= XP_TABLE[xi]) lv = xi + 1; else break;
}
return lv;
}
/* ── XP formula: base (enemy mean level) ± diff vs player mean level + boss bonus ── */
var _enemyChars = s.enemy.characters;
var _enemyMeanLevel = _enemyChars.length
? _enemyChars.reduce(function (sum, e) { return sum + (e.level || 1); }, 0) / _enemyChars.length
: 1;
var _fightingTeamForLevel = (_combatDef && _combatDef.playerTeam)
? _combatDef.playerTeam.filter(Boolean)
: State.variables.team.filter(Boolean);
var _playerMeanLevel = _fightingTeamForLevel.length
? _fightingTeamForLevel.reduce(function (sum, id) {
var xp = State.variables.charXP[id];
var baseDb = (window.DB_Characters && window.DB_Characters[id])
|| (window.DB_Divinities && window.DB_Divinities[id]);
return sum + (xp !== undefined ? xpToLevel(xp) : ((baseDb && baseDb.level) || 1));
}, 0) / _fightingTeamForLevel.length
: 1;
var _levelDiff = _enemyMeanLevel - _playerMeanLevel;
var _baseXP = Math.round(40 + _enemyMeanLevel * 20);
var _diffBonus = Math.round(_levelDiff * 25);
var _bossBonus = (ctx.combatId && window.DB_Combats && window.DB_Combats[ctx.combatId]
&& window.DB_Combats[ctx.combatId].boss) ? 200 : 0;
var _xpGainAmt = _isSuperTeam ? 250 : Math.max(20, _baseXP + _diffBonus + _bossBonus);
var _linkGainAmt = _isSuperTeam ? 50 : 15;
var charXP = State.variables.charXP;
var linkLevels = State.variables.linkLevels;
var newlyUnlocked = [];
var charHtml = '';
/* Use imposed playerTeam if the combat has one, otherwise fall back to $team */
var _combatDef = ctx.combatId && window.DB_Combats && window.DB_Combats[ctx.combatId];
var _fightingTeamIds = (_combatDef && _combatDef.playerTeam)
? _combatDef.playerTeam.filter(Boolean)
: State.variables.team.filter(Boolean);
_fightingTeamIds.forEach(function (id) {
var charDef = (window.DB_Characters && window.DB_Characters[id])
|| (window.DB_Divinities && window.DB_Divinities[id]);
if (!charDef || charDef.npc) return;
/* XP — if no XP recorded yet, seed from DB base level so level never drops */
if (charXP[id] === undefined) {
var _baseLevel = (charDef.level) || 1;
charXP[id] = _baseLevel >= 2 ? XP_TABLE[_baseLevel - 1] : 0;
}
var prevXP = charXP[id];
var nextXP = prevXP + _xpGainAmt;
charXP[id] = nextXP;
var prevLv = xpToLevel(prevXP);
var nextLv = xpToLevel(nextXP);
var capXP = XP_TABLE[XP_TABLE.length - 1];
var xpForNext = nextLv < 10 ? XP_TABLE[nextLv] : capXP;
var xpBase = nextLv >= 2 ? XP_TABLE[nextLv - 1] : 0;
var xpPct = nextLv >= 10 ? 100 : Math.round((nextXP - xpBase) / (xpForNext - xpBase) * 100);
var lvUpHtml = (nextLv > prevLv)
? '<span class="result-lvup-badge">\u2605 Lv.' + nextLv + '!</span>'
: '';
/* Link (servants only) */
var isDiv = !(window.DB_Characters && window.DB_Characters[id]);
var linkCellHtml = '<td class="rt-link"><span class="rt-none">—</span></td>';
if (!isDiv) {
var lPrev = linkLevels[id] || 0;
var lNext = Math.min(100, lPrev + _linkGainAmt);
var lGain = lNext - lPrev;
if (lGain > 0) {
linkLevels[id] = lNext;
var lc = window.DB_Characters[id].linkCards;
if (lc) {
[50, 100].forEach(function (thr) {
if (lc[thr] && lPrev < thr && lNext >= thr) {
var cardId = lc[thr];
if (!State.variables.unlockedLinkCards.includes(cardId)) {
State.variables.unlockedLinkCards.push(cardId);
newlyUnlocked.push({ servantId: id, cardId: cardId, threshold: thr });
}
}
});
}
}
var lDisplay = linkLevels[id] !== undefined ? linkLevels[id] : lPrev;
var gainLabel = lGain > 0 ? '<span class="result-link-gain">+' + lGain + '%</span>' : '';
linkCellHtml = '<td class="rt-link"><span class="result-link-gain">+' + lGain + '%</span></td>';
}
var name = charDef.nameKey[lang] || id;
charHtml += '<tr>'
+ '<td class="rt-name">' + name + '</td>'
+ '<td class="rt-xp"><span class="result-xp-gain">+' + _xpGainAmt + ' XP</span></td>'
+ '<td class="rt-level">' + lvUpHtml + '</td>'
+ linkCellHtml
+ '</tr>';
});
State.variables.charXP = charXP;
State.variables.linkLevels = linkLevels;
if (charHtml) {
var thPerso = lang === 'fr' ? 'Personnage' : 'Character';
var thLien = lang === 'fr' ? 'Lien' : 'Link';
rewardsEl.innerHTML += '<table class="result-table"><thead><tr><th>' + thPerso + '</th><th>XP</th><th>Niv.</th><th>' + thLien + '</th></tr></thead><tbody>' + charHtml + '</tbody></table>';
}
linkGainsEl.innerHTML = '';
/* ── Show newly unlocked link cards ── */
if (newlyUnlocked.length > 0) {
var cardHtml = '<div class="result-link-cards-title">'
+ (lang === "fr" ? "Nouvelle(s) carte(s) débloquée(s) !" : "New card(s) unlocked!") + '</div>';
newlyUnlocked.forEach(function (u) {
var card = window.DB_Cards && window.DB_Cards[u.cardId];
if (!card) return;
var cardName = card.nameKey[lang] || u.cardId;
var cardDesc = card.descKey[lang] || "";
var rarityLabel = { common: "Commune", rare: "Rare", epic: "Épique" };
var rarityEn = { common: "Common", rare: "Rare", epic: "Epic" };
var rLbl = lang === "fr" ? (rarityLabel[card.rarity] || card.rarity) : (rarityEn[card.rarity] || card.rarity);
var servantName = window.DB_Characters[u.servantId].nameKey[lang] || u.servantId;
cardHtml += '<div class="result-link-card rarity-' + card.rarity + '">'
+ '<img class="result-link-card-img" src="' + (window.CardUI ? window.CardUI.cardImage(card) : card.image) + '" onerror="this.style.display=\'none\'">'
+ '<div class="result-link-card-info">'
+ '<div class="result-link-card-name">' + cardName + '</div>'
+ '<div class="result-link-card-meta">' + servantName + ' · ' + rLbl + ' · Lien ' + u.threshold + '%</div>'
+ '<div class="result-link-card-desc">' + cardDesc + '</div>'
+ '</div></div>';
});
rewardsEl.innerHTML += cardHtml;
}
/* ── Dungeon: advance to next step ── */
if (ctx.dungeonId) {
var _dProg = State.variables.dungeonProgress || {};
if (!_dProg[ctx.dungeonId]) _dProg[ctx.dungeonId] = { step: 0 };
_dProg[ctx.dungeonId].step++;
State.variables.activeDungeonId = ctx.dungeonId;
State.variables.dungeonProgress = _dProg;
}
/* Persist rewards-applied flag so a save/load can't re-apply them */
s.rewardsGiven = true;
try { State.variables.combatState = JSON.stringify(s); } catch(e) { State.variables.combatState = null; }
} /* end !s.rewardsGiven */
/* Always navigate to DungeonAdvance on victory, even when reloaded */
if (ctx.dungeonId) nextPassage = "DungeonAdvance";
else if (ctx.onVictory) nextPassage = ctx.onVictory;
} else {
titleEl.textContent = lang === "fr" ? "Défaite..." : "Defeat...";
titleEl.className = "result-defeat";
document.getElementById("result-icon").className = "result-icon-defeat";
rewardsEl.innerHTML = "";
linkGainsEl.innerHTML = "";
/* ── Dungeon/story: keep step, redirect to onDefeat ── */
if (ctx.dungeonId) nextPassage = ctx.onDefeat || "VillaHub";
/* ── allowRetry: replace Continue with Retry ── */
var _combatDef = ctx.combatId && window.DB_Combats && window.DB_Combats[ctx.combatId];
if (_combatDef && _combatDef.allowRetry) {
continueBtn.textContent = lang === "fr" ? "Rejouer" : "Retry";
continueBtn.onclick = function () { State.variables.combatState = null; Engine.play("Combat"); };
}
}
}
};
/* ── Sort team: role first (support→offensive→defensive), divinities in front of servants within same role ─── */
var _roleOrder = { support: 0, offensive: 1, defensive: 2 };
var _teamOrig = team.slice();
team = team.slice().sort(function (a, b) {
var defA = DB_Characters[a] || DB_Divinities[a];
var defB = DB_Characters[b] || DB_Divinities[b];
/* For divinities with multiple roles, use the active teamDivRole */
var roleA = (DB_Divinities[a] && defA && defA.roleData)
? (State.variables.teamDivRole || defA.role) : (defA && defA.role);
var roleB = (DB_Divinities[b] && defB && defB.roleData)
? (State.variables.teamDivRole || defB.role) : (defB && defB.role);
var ra = roleA !== undefined ? (_roleOrder[roleA] !== undefined ? _roleOrder[roleA] : 9) : 9;
var rb = roleB !== undefined ? (_roleOrder[roleB] !== undefined ? _roleOrder[roleB] : 9) : 9;
if (ra !== rb) return ra - rb;
/* Same role: servant (0) before divinity (1) → divinity closer to front */
var grpA = DB_Divinities[a] ? 1 : 0;
var grpB = DB_Divinities[b] ? 1 : 0;
if (grpA !== grpB) return grpA - grpB;
return _teamOrig.indexOf(a) - _teamOrig.indexOf(b);
});
/* ══ Initialise combat engine ══════════════════════════════
If a saved combat snapshot exists (save/load mid-combat),
restore it. Otherwise start fresh.
══════════════════════════════════════════════════════════ */
try {
/* combatState is stored as a JSON string so SC serializes it verbatim */
var _rawCombat = State.variables.combatState;
var _savedCombat = (typeof _rawCombat === 'string') ? JSON.parse(_rawCombat)
: (_rawCombat && typeof _rawCombat === 'object') ? _rawCombat
: null;
if (_savedCombat) {
CombatEngine.restoreState(_savedCombat);
} else if (ctx.combatId) {
CombatEngine.initFromCombatDef(ctx.combatId, team);
} else {
CombatEngine.init(team, enemyIds, ctx.backgroundId || "default");
}
/* Dev: apply godmode atk buff to all player characters */
if (window._devSuperTeam) {
window._devSuperTeam = false;
CombatEngine.getState().player.characters.forEach(function (c) {
c.buffs.push({ stat: "atk", value: 50, duration: 9999 });
});
}
} catch (e) {
console.error("CombatEngine.init failed:", e);
}
/* ── Initial render (DOM ready) ─────────────────── */
$(document).one(':passagedisplay', function () {
var _bgId = ctx.combatId
? ((window.DB_Combats && DB_Combats[ctx.combatId] && DB_Combats[ctx.combatId].backgroundId) || "default")
: (ctx.backgroundId || "default");
document.getElementById("combat-bg").style.backgroundImage =
"url('media/img/backgrounds/" + _bgId + ".webp')";
/* ── Location label ── */
var _locEl = document.getElementById("combat-location");
var _locDef = ctx.combatId && window.DB_Combats && window.DB_Combats[ctx.combatId];
if (_locDef && _locDef.locationKey) {
_locEl.textContent = _locDef.locationKey[lang] || "";
_locEl.style.display = "block";
}
document.getElementById("end-turn-btn").addEventListener("click", function () {
CombatUI.endTurn();
});
document.getElementById("cancel-target-btn").addEventListener("click", function () {
CombatUI.cancelTarget();
});
document.getElementById("flee-btn").addEventListener("click", function () {
State.variables.combatState = null;
if (State.variables._savedTeam) {
State.variables.team = State.variables._savedTeam;
State.variables.teamDivRole = State.variables._savedDivRole;
State.variables._savedTeam = null;
State.variables._savedDivRole = null;
}
Engine.play(ctx.onDefeat || "VillaHub");
});
document.getElementById("combat-log-btn").addEventListener("click", function () {
CombatUI.showLog();
});
document.getElementById("combat-log-close").addEventListener("click", function () {
document.getElementById("combat-log-modal").classList.add("hidden");
});
document.getElementById("combat-log-modal").addEventListener("click", function (e) {
if (e.target === this) this.classList.add("hidden");
});
/* ── Drop zone on combat-area (cards with no target: AoE, self, etc.) ── */
var combatArea = document.getElementById('combat-area');
combatArea.addEventListener('dragover', function (e) {
if (!combatArea.classList.contains('drop-target-area')) return;
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
combatArea.classList.add('drop-hover-area');
});
combatArea.addEventListener('dragleave', function (e) {
if (!combatArea.contains(e.relatedTarget)) {
combatArea.classList.remove('drop-hover-area');
}
});
combatArea.addEventListener('drop', function (e) {
e.preventDefault();
combatArea.classList.remove('drop-hover-area');
combatArea.classList.remove('drop-target-area');
var handIdx = parseInt(e.dataTransfer.getData('text/plain'), 10);
if (isNaN(handIdx)) return;
CombatUI.selectCard(handIdx);
});
CombatUI.render();
/* Re-adjust card overlap on resize */
window.addEventListener("resize", function () { CombatUI.scaleHandArea(); });
requestAnimationFrame(function () {
CombatUI.scaleHandArea();
requestAnimationFrame(function () { CombatUI.scaleHandArea(); });
});
});
}());
<</script>><!-- ═══════════════════════════════════════════════════════
DEVOTION RITUAL – Mini-jeu type pêche (Stardew Valley)
═══════════════════════════════════════════════════════ -->
<div id="ritual-screen">
<!-- Écran de confirmation de démarrage -->
<div id="ritual-start-overlay">
<div id="ritual-start-inner">
<img id="ritual-start-icon" src="" alt="">
<h2 id="ritual-start-title"></h2>
<p id="ritual-start-subtitle"><<= $lang === "fr" ? "Invoquez la divinité et accomplissez le rituel" : "Invoke the divinity and complete the ritual">></p>
<button id="ritual-start-btn"><<= $lang === "fr" ? "Commencer le Rituel" : "Begin the Ritual">></button>
</div>
</div>
<!-- Rangée du haut : vidéo + jauges -->
<div id="ritual-top" class="hidden">
<!-- Lecteur vidéo -->
<div id="ritual-video-wrap">
<video id="ritual-video" autoplay playsinline></video>
<!-- Feedback "dans la zone" -->
<div id="ritual-in-zone-fx" class="hidden"></div>
<!-- Gains flottants d'essence -->
<div id="ritual-gain-fx"></div>
</div>
<!-- Jauge de jeu (curseur joueur + zone cible) -->
<div id="ritual-gauge-wrap">
<div id="ritual-gauge">
<div id="ritual-target-zone"></div>
<div id="ritual-cursor"><span id="ritual-cursor-combo" class="hidden"></span></div>
</div>
</div>
<!-- Jauge globale de progression -->
<div id="ritual-global-wrap">
<div id="ritual-global-gauge">
<div id="ritual-global-fill"></div>
<div id="ritual-global-marker"></div>
</div>
</div>
</div>
<!-- Rangée du bas : stats + bouton -->
<div id="ritual-bottom" class="hidden">
<div id="ritual-essence-display">
<img src="media/img/logos/devotion_essence.webp" class="ritual-icon" alt="">
<span id="ritual-essence-counter">0</span>
<span id="ritual-essence-delta"></span>
</div>
<div id="ritual-accuracy-display">
<span id="ritual-accuracy-label"><<= $lang === "fr" ? "Précision" : "Accuracy">></span>
<span id="ritual-accuracy-value">0%</span>
</div>
<!-- Bouton actif pendant le jeu -->
<button id="ritual-action-btn">
<<= $lang === "fr" ? "Maintenir / [Espace] pour monter" : "Hold / [Space] to rise">>
</button>
<!-- Récompense de Lien (affichée en fin de rituel servant) -->
<div id="ritual-link-reward" class="hidden"></div>
<!-- Bouton de fin (caché pendant le jeu) -->
<button id="ritual-continue-btn" class="hidden">
<<= $lang === "fr" ? "Continuer" : "Continue">>
</button>
</div>
</div>
<!-- ═══════════════════════════════════════════════════════
SCRIPT – logique dans js/ritual_engine.js
═══════════════════════════════════════════════════════ -->
<<script>>
$(document).one(':passagedisplay', function () { window.RitualEngine.start(); });
<</script>><div id="narr-screen">
<!-- Arriere-plan fixe (change avec chaque beat) -->
<div id="narr-fixed-bg"></div>
<!-- Colonne de beats accumules -->
<div id="narr-scroll"></div>
<!-- Pied de page : progression + avancer -->
<div id="narr-footer">
<div id="narr-progress-dots"></div>
<div id="narr-continue">▼ <<= $lang === 'fr' ? 'Cliquer pour continuer' : 'Click to continue'>></div>
<button id="narr-skip-btn" title="Skip">⏭ Skip</button>
</div>
</div>
<<script>>
(function () {
var lang = State.variables.lang || 'en';
var ctx = State.variables.narratCtx || {};
var seq = ctx.sequence || [];
var onEnd = ctx.onEnd || 'DevTest';
if (seq.length === 0) { Engine.play(onEnd); return; }
var _cur = -1;
/* On save/load: fast-forward silently to the beat that was active at save time */
var _restoreUntil = (ctx.curBeat !== undefined) ? ctx.curBeat : -1;
/* helpers */
function txt(obj) {
if (!obj) return '';
return typeof obj === 'string' ? obj : (obj[lang] || obj['en'] || '');
}
function charName(beat) {
var def = (window.DB_Characters && window.DB_Characters[beat.character])
|| (window.DB_Divinities && window.DB_Divinities[beat.character]);
if (def) return (def.nameKey ? def.nameKey[lang] : '') || def.name || beat.character || '';
return txt(beat.name) || beat.character || '';
}
function scrollBottom() {
var s = document.getElementById('narr-scroll');
if (s) s.scrollTop = s.scrollHeight;
}
function updateDots() {
var dots = document.getElementById('narr-progress-dots');
if (!dots) return;
dots.innerHTML = '';
seq.forEach(function (_, i) {
var d = document.createElement('span');
d.className = 'narr-dot'
+ (i === _cur ? ' active' : i < _cur ? ' done' : '');
dots.appendChild(d);
});
}
function setFooter(state) {
var hint = document.getElementById('narr-continue');
var footer = document.getElementById('narr-footer');
var skipBtn = document.getElementById('narr-skip-btn');
if (!hint || !footer) return;
var prev = document.getElementById('narr-end-btn');
if (prev) prev.remove();
if (state === 'end') {
hint.style.visibility = 'hidden';
if (skipBtn) skipBtn.style.visibility = 'hidden';
var btn = document.createElement('button');
btn.id = 'narr-end-btn';
btn.textContent = (lang === 'fr' ? 'Continuer' : 'Continue') + ' \u2192';
btn.onclick = function () {
if (State.variables.narratCtx) State.variables.narratCtx.curBeat = undefined;
Engine.play(onEnd);
};
footer.appendChild(btn);
} else if (state === 'video') {
hint.style.visibility = 'hidden';
if (skipBtn) skipBtn.style.visibility = 'hidden';
} else if (state === 'next') {
hint.style.visibility = 'visible';
hint.textContent = lang === 'fr' ? '\u25bc Cliquer pour continuer' : '\u25bc Click to continue';
if (skipBtn) skipBtn.style.visibility = 'visible';
} else {
hint.style.visibility = 'hidden';
if (skipBtn) skipBtn.style.visibility = 'visible';
}
}
function resolveSCVars(text) {
/* Replace $varname SugarCube variable references with their current values.
Lookup is case-insensitive to handle $playername -> State.variables.playerName etc. */
var vars = State.variables;
var lowerMap = {};
Object.keys(vars).forEach(function (k) { lowerMap[k.toLowerCase()] = k; });
return text.replace(/\$([a-zA-Z_][a-zA-Z0-9_]*)/g, function (match, name) {
var key = lowerMap[name.toLowerCase()];
if (key !== undefined && vars[key] !== null) return String(vars[key]);
return match;
});
}
function typeText(el, text, onDone) {
if (!el) { if (onDone) onDone(); return; }
el.textContent = resolveSCVars(text);
scrollBottom();
if (onDone) onDone();
}
/* afterBeat: wait for click, or auto-advance when typewriter setting is off
or when we are fast-forwarding to restore a saved beat position */
function afterBeat(idx) {
if (idx >= seq.length - 1) {
setFooter('end');
var _sa = (State.variables && State.variables.settings) || {};
if (_sa.typewriter !== false) {
setTimeout(scrollBottom, 80);
}
return;
}
var s = (State.variables && State.variables.settings) || {};
if (s.typewriter === false || idx < _restoreUntil) {
appendBeat(idx + 1);
} else {
setFooter('next');
setTimeout(scrollBottom, 80);
}
}
function appendBeat(idx) {
if (idx >= seq.length) {
updateDots();
setFooter('end');
return;
}
_cur = idx;
/* Persist current beat so a save/load can resume here */
if (State.variables.narratCtx) State.variables.narratCtx.curBeat = idx;
var beat = seq[idx];
var scroll = document.getElementById('narr-scroll');
if (!scroll) return;
/* update fixed background */
if (beat.bg) {
var bg = document.getElementById('narr-fixed-bg');
if (bg) bg.style.backgroundImage =
"url('media/img/backgrounds/" + beat.bg + ".webp')";
}
updateDots();
var div = document.createElement('div');
div.className = 'narr-beat narr-beat-' + beat.type;
/* NARRATION */
if (beat.type === 'narration') {
var p = document.createElement('p');
p.className = 'narr-text-content';
div.appendChild(p);
scroll.appendChild(div);
scrollBottom();
typeText(p, txt(beat.text), function () { afterBeat(idx); });
/* DIALOGUE */
} else if (beat.type === 'dialogue') {
div.dataset.side = beat.side || 'left';
var pw = document.createElement('div');
pw.className = 'narr-portrait-wrap';
var imgEl = document.createElement('img');
/* Prefer avatars/ folder resolved from character id */
imgEl.src = beat.character
? 'media/img/avatars/' + beat.character + '.webp'
: (beat.portrait || '');
imgEl.alt = charName(beat);
var nm = document.createElement('div');
nm.className = 'narr-speaker-name';
nm.textContent = charName(beat);
pw.appendChild(imgEl);
pw.appendChild(nm);
var bub = document.createElement('div');
bub.className = 'narr-speech-bubble';
var bp = document.createElement('p');
bp.className = 'narr-speech-text';
bub.appendChild(bp);
div.appendChild(pw);
div.appendChild(bub);
scroll.appendChild(div);
scrollBottom();
typeText(bp, txt(beat.text), function () { afterBeat(idx); });
/* IMAGE */
} else if (beat.type === 'image') {
var im = document.createElement('img');
im.src = beat.src || '';
div.appendChild(im);
if (beat.caption) {
var cap = document.createElement('div');
cap.className = 'narr-caption';
cap.textContent = txt(beat.caption);
div.appendChild(cap);
}
scroll.appendChild(div);
afterBeat(idx);
/* VIDEO */
} else if (beat.type === 'video') {
var vid = document.createElement('video');
vid.src = beat.src || '';
vid.setAttribute('playsinline', '');
vid.setAttribute('webkit-playsinline', '');
vid.controls = true;
vid.muted = true;
vid.pause();
div.appendChild(vid);
scroll.appendChild(div);
vid.load();
/* Auto-play/pause and unmute based on 75% visibility */
if (typeof IntersectionObserver !== 'undefined') {
(function (v) {
var obs = new IntersectionObserver(function (entries) {
var visible = entries[0].isIntersecting;
v.muted = !visible;
if (visible) {
v.play().catch(function () {});
} else {
v.pause();
}
}, { threshold: 0.75 });
obs.observe(div);
$(document).one(':passageleave', function () { obs.disconnect(); v.pause(); });
}(vid));
} else {
vid.play().catch(function () {});
}
afterBeat(idx);
} else {
scroll.appendChild(div);
afterBeat(idx);
}
}
function advance() {
appendBeat(_cur + 1);
}
function skipAll() {
if (State.variables.narratCtx) State.variables.narratCtx.curBeat = undefined;
Engine.play(onEnd);
}
$(document).one(':passagedisplay', function () {
var screen = document.getElementById('narr-screen');
if (!screen) return;
/* Tag screen for instant mode so CSS suppresses animations */
var _sboot = (State.variables && State.variables.settings) || {};
if (_sboot.typewriter === false) {
screen.classList.add('narr-instant');
}
screen.addEventListener('click', function (e) {
if (e.target.id === 'narr-end-btn') return;
if (e.target.id === 'narr-skip-btn') return;
if (e.target.closest && e.target.closest('.narr-beat-video')) return;
advance();
});
var skipBtn = document.getElementById('narr-skip-btn');
if (skipBtn) skipBtn.addEventListener('click', function (e) { e.stopPropagation(); skipAll(); });
function onNarrKey(e) {
if (e.key === ' ' || e.key === 'Enter' || e.key === 'ArrowRight') {
e.preventDefault();
advance();
}
}
document.addEventListener('keydown', onNarrKey);
/* Clean up when leaving passage so the listener doesn't persist */
$(document).one(':passageleave', function () {
document.removeEventListener('keydown', onNarrKey);
});
/* Apply global background if defined at ctx level */
if (ctx.bg) {
var initBgEl = document.getElementById('narr-fixed-bg');
if (initBgEl) initBgEl.style.backgroundImage =
"url('media/img/backgrounds/" + ctx.bg + ".webp')";
}
appendBeat(0);
/* After synchronous instant render, scroll to top */
var _s0 = (State.variables && State.variables.settings) || {};
if (_s0.typewriter === false) {
var sc0 = document.getElementById('narr-scroll');
if (sc0) sc0.scrollTop = 0;
}
});
}());
<</script>><<script>>
State.variables.narratCtx = {
onEnd: "DevTest",
sequence: [
{
type : "narration",
bg : "hera_palace",
text : {
fr: "Au commencement, il y avait le silence.\nLes dieux régnaient sans être vus, leurs desseins aussi impénétrables que la nuit des temps.",
en: "In the beginning, there was silence.\nThe gods reigned unseen, their designs as impenetrable as the darkness of ages."
}
},
{
type : "image",
src : "media/img/backgrounds/golden_entrance.webp",
caption: {
fr: "La Porte Dorée de l'Olympe",
en: "The Golden Gate of Olympus"
}
},
{
type : "dialogue",
character: "hera",
side : "right",
bg : "hera_throne",
text : {
fr: "Tu oses franchir les portes de mon palais, mortelle ? Sais-tu ce que tu risques ?",
en: "You dare cross the gates of my palace, mortal? Do you know what you risk?"
}
},
{
type : "dialogue",
character: "ellie",
side : "left",
bg : "hera_throne",
text : {
fr: "Je n'ai pas le choix, déesse. L'éternité touche à sa fin, et toi seule peux m'aider.",
en: "I have no choice, goddess. Eternity is drawing to a close, and only you can help me."
}
},
{
type : "dialogue",
character: "hera",
side : "right",
bg : "hera_throne",
text : {
fr: "Prouve ta valeur. Seul le combat révèle la véritable nature d'une âme.",
en: "Prove your worth. Only battle reveals the true nature of a soul."
}
},
{
type: "video",
src : "media/video/hermes/rit1.mp4"
},
{
type : "narration",
bg : "hermes_palace",
text : {
fr: "Le destin est en marche. Il n'y a plus de retour possible. L'heure du jugement approche.",
en: "Fate is set in motion. There is no turning back. The hour of judgment draws near."
}
}
]
};
<</script>>
<<goto "Narration">><<script>>
(function () {
/* Default destination — overridden below */
State.temporary.dungeonNext = 'VillaHub';
var id = State.variables.activeDungeonId;
if (!id || !window.DB_Dungeons || !DB_Dungeons[id]) { return; }
var def = DB_Dungeons[id];
var prog = State.variables.dungeonProgress;
if (!prog[id]) prog[id] = { step: 0 };
var step = prog[id].step;
/* ── All steps completed → dungeon victory ── */
if (step >= def.steps.length) {
prog[id].completed = true;
State.variables.dungeonProgress = prog;
/* Auto-recruit the divinity unlocked by this dungeon */
if (def.unlocksDivinity && !State.variables.roster.includes(def.unlocksDivinity))
State.variables.roster.push(def.unlocksDivinity);
/* Fire event so any listener can react */
$(document).trigger('dungeon:complete', [{ dungeonId: id }]);
State.temporary.dungeonNext = def.onVictory || 'VillaHub';
return;
}
var stepDef = def.steps[step];
if (stepDef.type === 'combat') {
State.variables.combatContext = {
combatId : stepDef.combatId,
dungeonId: id,
onDefeat : def.onDefeat || 'VillaHub'
};
State.temporary.dungeonNext = 'Combat';
} else if (stepDef.type === 'narration') {
/* Increment step now so re-entry skips replaying narration */
prog[id].step++;
State.variables.dungeonProgress = prog;
State.temporary.dungeonNext = stepDef.passage;
}
}());
<</script>>
<<goto _dungeonNext>>