Your browser lacks required capabilities. Please upgrade it or switch to another to continue.
Loading…
<<run (function () {
/* ── Game version ──────────────────────────────────────── */
window.GAME_VERSION = "0.2.0";
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.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 = {};
/* divineFlames: forge stat 0-100 % */
if (State.variables.divineFlames === undefined)
State.variables.divineFlames = 0;
/* trackingPower: Artemis tracking stat 0-100 % */
if (State.variables.trackingPower === undefined)
State.variables.trackingPower = 0;
/* unlockedLinkCards: array of card ids unlocked via link level */
if (State.variables.unlockedLinkCards === undefined)
State.variables.unlockedLinkCards = [];
/* ── 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;
if (State.variables.hermesDoneDay === undefined)
State.variables.hermesDoneDay = 0;
/* ── Settings ──────────────────────────────────────────── */
if (State.variables.settings === undefined)
State.variables.settings = { typewriter: false, sfw: false, muted: false, volume: 0.2, musicEnabled: true };
if (State.variables.settings.typewriter === undefined) State.variables.settings.typewriter = false;
if (State.variables.settings.sfw === undefined) State.variables.settings.sfw = false;
if (State.variables.settings.muted === undefined) State.variables.settings.muted = false;
if (State.variables.settings.volume === undefined) State.variables.settings.volume = 0.2;
if (State.variables.settings.musicEnabled === undefined) State.variables.settings.musicEnabled = true;
/* ── 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 = {};
/* ── Survival mode ─────────────────────────────────────── */
/* wounds[charId] = [woundId, ...] persistent across combats */
if (State.variables.wounds === undefined)
State.variables.wounds = {};
/* survivalHp[charId] = current HP carried between combats */
if (State.variables.survivalHp === undefined)
State.variables.survivalHp = {};
/* survivalApBonus: extra AP granted at next combat start */
if (State.variables.survivalApBonus === undefined)
State.variables.survivalApBonus = 0;
/* survivalTempCards: card ids injected into deck next combat */
if (State.variables.survivalTempCards === undefined)
State.variables.survivalTempCards = [];
/* survivalEventId: event to display in SurvivalEvent passage */
if (State.variables.survivalEventId === undefined)
State.variables.survivalEventId = null;
/* ── Deck state (deckbuilder) ───────────────────────────── */
/* decks[charId] = max 5 card ids used in combat */
/* cardPools[charId]= all owned/available card ids */
if (State.variables.deckState === undefined) {
State.variables.deckState = { selectedCharId: null, decks: {}, cardPools: {} };
}
/* Migration: fill decks/cardPools for already-recruited characters missing from deckState */
(function () {
var dsSt = State.variables.deckState;
if (!dsSt) return;
var roster = State.variables.roster || [];
roster.forEach(function (id) {
/* Divinity */
var div = window.DB_Divinities && DB_Divinities[id];
if (div) {
if (!dsSt.cardPools[id]) {
var all = [];
if (div.roleData) {
Object.keys(div.roleData).forEach(function (r) {
(div.roleData[r].cardPool || []).forEach(function (cid) {
if (all.indexOf(cid) === -1) all.push(cid);
});
});
Object.keys(div.roleData).forEach(function (r) {
var k = id + '_' + r;
if (!dsSt.decks[k] || dsSt.decks[k].length === 0)
dsSt.decks[k] = (div.roleData[r].cardPool || []).slice(0, 5);
});
} else {
all = (div.cardPool || []).slice();
if (!dsSt.decks[id] || dsSt.decks[id].length === 0)
dsSt.decks[id] = all.slice(0, 5);
}
dsSt.cardPools[id] = all;
}
return;
}
/* Servant */
var ch = window.DB_Characters && DB_Characters[id];
if (ch && ch.npc) return;
if (ch && !dsSt.cardPools[id]) {
var pool = (ch.cardPool || []).slice();
dsSt.cardPools[id] = pool;
if (!dsSt.decks[id] || dsSt.decks[id].length === 0)
dsSt.decks[id] = pool.slice(0, 5);
}
});
}());
}())>>
/* ── 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")>></div>
<img src="media/img/logos/logo.webp" id="sidebar-logo" alt="WEE">
<button id="sound-mute-btn" title="Mute / Unmute" onclick="window.SoundManager && SoundManager.toggleMute()"><<= window.SoundManager && SoundManager.isMuted() ? '🔇' : '🔊'>></button>
</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 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>>
<<set _helpLabel to ($lang === "en" ? "\u2753 Help" : "\u2753 Aide")>><<button _helpLabel>><<run window.openHelpModal()>><</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")>>
<<link _teamLabel "TeamManagement">><</link>>
</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>><<set $lang to "fr">>
<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>
<button id="dev-lvlup-team">
<<= $lang === "fr" ? "⬆ Niveau + (non-divinités de l'équipe)" : "⬆ Level Up (non-divinities in team)">>
</button>
<p id="dev-team-levels"></p>
<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>Ritual Tests</h3>
<div id="dev-ritual-btns"></div>
</section>
<section> <h3><<= $lang === "fr" ? "Forge (mini-jeu)" : "Forge (mini-game)">></h3>
<div id="dev-forge-btns"></div>
</section>
<section>
<h3><<= $lang === "fr" ? "Mode Survie" : "Survival Mode">></h3>
<label id="dev-survival-super-label" style="display:inline-flex;align-items:center;gap:6px;margin-bottom:8px;cursor:pointer;font-size:.9rem;">
<input type="checkbox" id="dev-survival-super-toggle">
<<= $lang === "fr" ? "⚡ Super équipe (Héra / Kendra / Ellie, 999 HP)" : "⚡ Super team (Hera / Kendra / Ellie, 999 HP)">>
</label>
<div id="dev-survival-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);
var flamesBtn = document.createElement("button");
flamesBtn.textContent = "+50 Flammes divines";
flamesBtn.onclick = function () {
State.variables.divineFlames = Math.min(100, (State.variables.divineFlames || 0) + 50);
Engine.show();
};
resEl.appendChild(flamesBtn);
var trackBtn = document.createElement("button");
trackBtn.textContent = "+50 Puissance de pistage";
trackBtn.onclick = function () {
State.variables.trackingPower = Math.min(100, (State.variables.trackingPower || 0) + 50);
Engine.show();
};
resEl.appendChild(trackBtn);
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 (dungeon completion) ── */
document.getElementById("dev-make-purchasable").onclick = function () {
var roster = State.variables.roster;
/* ── Divinities: mark unlock dungeon completed + ensure essence ── */
var divProg = State.variables.dungeonProgress;
var divFought = State.variables.foughtDivinities;
var divEss = State.variables.divineEssences;
/* Build map: divinity id → dungeon id that unlocks it */
var divToDungeon = {};
Object.keys(window.DB_Dungeons || {}).forEach(function (dId) {
var dDef = DB_Dungeons[dId];
if (dDef.unlocksDivinity) divToDungeon[dDef.unlocksDivinity] = dId;
});
Object.keys(window.DB_Divinities || {}).forEach(function (divId) {
var div = DB_Divinities[divId];
if (roster.includes(divId)) return; /* already owned */
/* Ensure enough essence to recruit */
var cost = div.recruitCost || {};
if (cost.devotionEssence &&
State.variables.devotionEssence < cost.devotionEssence)
State.variables.devotionEssence = cost.devotionEssence;
Object.keys(cost).forEach(function (k) {
if (k === "devotionEssence") return;
if (!divEss[k]) divEss[k] = 0;
if (divEss[k] < cost[k]) divEss[k] = cost[k];
});
/* Add to foughtDivinities */
if (!divFought.includes(divId)) divFought.push(divId);
/* Mark its unlock dungeon as completed → makes it visible in TeamManagement */
var dId = divToDungeon[divId];
if (dId) {
if (!divProg[dId]) divProg[dId] = {};
divProg[dId].completed = true;
}
});
State.variables.dungeonProgress = divProg;
State.variables.foughtDivinities = divFought;
State.variables.divineEssences = divEss;
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();
};
/* ── Level up non-divinities in current team ───── */
var XP_TABLE = [0, 100, 250, 450, 700, 1000, 1350, 1750, 2200, 2700, 3250, 3850, 4500, 5200, 5950, 6750, 7600, 8500, 9450, 10450];
function _xpToLevel(xp) {
var level = 1;
for (var i = 1; i < XP_TABLE.length; i++) {
if (xp >= XP_TABLE[i]) level = i + 1;
else break;
}
return level;
}
function _refreshTeamLevels() {
var el = document.getElementById("dev-team-levels");
if (!el) return;
var charXP = State.variables.charXP || {};
var team = State.variables.team.filter(Boolean);
var parts = team.map(function (id) {
var isDivinity = !!(window.DB_Divinities && window.DB_Divinities[id]);
var lv = isDivinity ? "✦" : ("Lv" + _xpToLevel(charXP[id] || 0));
return id + " " + lv;
});
el.innerHTML = "<em>" + (parts.join(" · ") || "—") + "</em>";
}
document.getElementById("dev-lvlup-team").onclick = function () {
var team = State.variables.team.filter(Boolean);
var changed = false;
team.forEach(function (id) {
if (window.DB_Divinities && window.DB_Divinities[id]) return; /* skip divinities */
if (!State.variables.charXP) State.variables.charXP = {};
var currentXP = State.variables.charXP[id] || 0;
var currentLevel = _xpToLevel(currentXP);
if (currentLevel >= XP_TABLE.length) return; /* already max level */
State.variables.charXP[id] = XP_TABLE[currentLevel]; /* XP_TABLE[currentLevel] = threshold for level+1 */
changed = true;
});
if (!changed) alert(lang === "fr" ? "Pas de personnage non-divinité dans l'équipe (ou déjà niveau max)." : "No non-divinity in team (or already max level).");
_refreshTeamLevels();
Engine.show();
};
_refreshTeamLevels();
/* ── 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 = {
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 && !(cfg.playerTeam && cfg.playerTeam.length)) {
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);
});
/* ── 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);
});
/* ── Forge buttons ───────────────────────────── */
var forgeEl = document.getElementById("dev-forge-btns");
Object.keys(window.DB_Forge || {}).forEach(function (forgeId) {
var cfg = DB_Forge[forgeId];
var btn = document.createElement("button");
btn.textContent = (cfg.nameKey ? (cfg.nameKey[lang] || cfg.nameKey.en) : forgeId);
btn.onclick = function () {
State.variables.forgeContext = forgeId;
Engine.play("ForgeGame");
};
forgeEl.appendChild(btn);
});
/* ── Survival dungeon buttons ──────────────────── */
var survivalEl = document.getElementById("dev-survival-btns");
var survivalSuper = document.getElementById("dev-survival-super-toggle");
/* Helper: apply super team if toggle is checked, otherwise use current $team */
function _survivalSetupTeam() {
if (survivalSuper && survivalSuper.checked) {
["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;
return true;
}
var team = State.variables.team.filter(Boolean);
if (!team.length) {
alert(lang === "fr" ? "Assemblez une équipe d'abord (panneau Roster)." : "Build a team first (Roster section).");
return false;
}
return true;
}
/* Helper: reset survival state and launch dungeon from step */
function _launchSurvivalDungeon(dungeonId, step) {
if (!_survivalSetupTeam()) return;
var prog = State.variables.dungeonProgress;
prog[dungeonId] = { step: step, survivalProcessed: false };
State.variables.dungeonProgress = prog;
State.variables.wounds = {};
State.variables.survivalHp = {};
State.variables.survivalApBonus = 0;
State.variables.survivalTempCards = [];
State.variables.survivalEventId = null;
State.variables.activeDungeonId = dungeonId;
Engine.play("DungeonAdvance");
}
(function () {
var DUNGEON_ID = "misty_hq";
var def = window.DB_Dungeons && window.DB_Dungeons[DUNGEON_ID];
if (!def) return;
/* Button: full dungeon from step 0 */
var btnFull = document.createElement("button");
btnFull.textContent = lang === "fr" ? "🔫 QG de Misty (début)" : "🔫 Misty's HQ (start)";
btnFull.onclick = function () { _launchSurvivalDungeon(DUNGEON_ID, 0); };
survivalEl.appendChild(btnFull);
/* One button per step (narration + combat) */
var stepIcons = { narration: "📜", combat: "⚔" };
def.steps.forEach(function (step, idx) {
var label;
if (step.type === "combat") {
var combatDef = window.DB_Combats && window.DB_Combats[step.combatId];
label = combatDef ? (combatDef.nameKey[lang] || step.combatId) : step.combatId;
} else {
label = step.passage;
}
var icon = stepIcons[step.type] || "▶";
var btn = document.createElement("button");
btn.textContent = icon + " " + label + " (étape " + idx + ")";
btn.onclick = function () { _launchSurvivalDungeon(DUNGEON_ID, idx); };
survivalEl.appendChild(btn);
});
/* Button: jump directly to SurvivalEvent passage (picks a random event) */
var btnEvent = document.createElement("button");
btnEvent.textContent = lang === "fr" ? "🎲 Test Événement Survie" : "🎲 Test Survival Event";
btnEvent.onclick = function () {
if (window.SurvivalEngine) {
State.variables.survivalEventId = SurvivalEngine.randomSurvivalEvent();
State.variables.activeDungeonId = DUNGEON_ID;
}
Engine.play("SurvivalEvent");
};
survivalEl.appendChild(btnEvent);
})();
/* ── 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="cg-filters">
<div class="cg-filter-group">
<span class="cg-filter-label"><<= $lang === "fr" ? "Personnage" : "Character">></span>
<div id="cg-char-btns" class="cg-btn-row"></div>
</div>
<div class="cg-filter-group">
<span class="cg-filter-label"><<= $lang === "fr" ? "Rareté" : "Rarity">></span>
<div id="cg-rarity-btns" class="cg-btn-row">
<button class="cg-filter-btn active" data-rarity=""><<= $lang === "fr" ? "Toutes" : "All">></button>
<button class="cg-filter-btn" data-rarity="common"><<= $lang === "fr" ? "Commune" : "Common">></button>
<button class="cg-filter-btn" data-rarity="rare"><<= $lang === "fr" ? "Rare" : "Rare">></button>
<button class="cg-filter-btn" data-rarity="epic"><<= $lang === "fr" ? "Épique" : "Epic">></button>
</div>
</div>
<div class="cg-filter-group">
<span class="cg-filter-label"><<= $lang === "fr" ? "Rôle" : "Role">></span>
<div id="cg-role-btns" class="cg-btn-row">
<button class="cg-filter-btn active" data-role=""><<= $lang === "fr" ? "Tous" : "All">></button>
<button class="cg-filter-btn" data-role="offensive">⚔ <<= $lang === "fr" ? "Offensif" : "Offensive">></button>
<button class="cg-filter-btn" data-role="defensive">🛡 <<= $lang === "fr" ? "Défensif" : "Defensive">></button>
<button class="cg-filter-btn" data-role="support">✨ <<= $lang === "fr" ? "Soutien" : "Support">></button>
</div>
</div>
<span id="cg-count" class="cg-count"></span>
</div>
<div id="devtest-cards" class="card-grid"></div>
</div>
<<script>>
(function () {
var lang = State.variables.lang;
$(document).one(':passagedisplay', function () {
/* ── Build card list once ── */
var allCards = Object.keys(DB_Cards).map(function (id) { return DB_Cards[id]; });
/* ── Build pool/link sets across all characters ── */
var _poolSet = {};
var _linkSet = {};
var _allChars = Object.assign({}, window.DB_Characters || {}, window.DB_Divinities || {});
Object.keys(_allChars).forEach(function (cid) {
var ch = _allChars[cid];
if (ch.cardPool) ch.cardPool.forEach(function (id) { _poolSet[id] = true; });
if (ch.linkCards) Object.keys(ch.linkCards).forEach(function (lvl) { _linkSet[ch.linkCards[lvl]] = true; });
});
/* ── Collect unique chars in order (divinities first, then servants) ── */
var charOrder = [];
var seen = {};
allCards.forEach(function (c) {
var key = c.character || null;
if (!seen[key]) { seen[key] = true; charOrder.push(key); }
});
/* sort: divinities first */
charOrder.sort(function (a, b) {
if (a === null && b === null) return 0;
if (a === null) return 1;
if (b === null) return -1;
var aDiv = !!(window.DB_Divinities && DB_Divinities[a]);
var bDiv = !!(window.DB_Divinities && DB_Divinities[b]);
if (aDiv && !bDiv) return -1;
if (!aDiv && bDiv) return 1;
return a.localeCompare(b);
});
/* ── Active filters state ── */
var filters = { char: "", rarity: "", role: "" };
/* ── Resolve display name for a char id ── */
function charName(id) {
if (!id) return lang === "fr" ? "Générique" : "Generic";
var def = (window.DB_Characters && DB_Characters[id])
|| (window.DB_Divinities && DB_Divinities[id]);
return def ? def.nameKey[lang] : id;
}
/* ── Build character filter buttons ── */
var charRow = document.getElementById("cg-char-btns");
var allCharBtn = document.createElement("button");
allCharBtn.className = "cg-filter-btn active";
allCharBtn.dataset.char = "";
allCharBtn.textContent = lang === "fr" ? "Tous" : "All";
charRow.appendChild(allCharBtn);
charOrder.forEach(function (id) {
var btn = document.createElement("button");
btn.className = "cg-filter-btn";
if (id && window.DB_Divinities && DB_Divinities[id]) btn.classList.add("cg-btn-divinity");
btn.dataset.char = id || "";
btn.textContent = charName(id);
charRow.appendChild(btn);
});
/* ── Generic filter-group toggle (single-select) ── */
function bindFilterGroup(rowId, prop) {
document.getElementById(rowId).addEventListener("click", function (e) {
var btn = e.target.closest(".cg-filter-btn");
if (!btn || !this.contains(btn)) return;
this.querySelectorAll(".cg-filter-btn").forEach(function (b) { b.classList.remove("active"); });
btn.classList.add("active");
filters[prop] = btn.dataset[prop] || "";
render();
});
}
bindFilterGroup("cg-char-btns", "char");
bindFilterGroup("cg-rarity-btns", "rarity");
bindFilterGroup("cg-role-btns", "role");
/* ── Render ── */
var grid = document.getElementById("devtest-cards");
var countEl = document.getElementById("cg-count");
function render() {
var visible = allCards.filter(function (c) {
if (filters.char && (c.character || "") !== filters.char) return false;
if (filters.rarity && c.rarity !== filters.rarity) return false;
if (filters.role && c.role !== filters.role) return false;
return true;
});
grid.innerHTML = "";
visible.forEach(function (c) {
var el;
try { el = CardUI.buildCardElement(c, lang, false); }
catch(e) { console.error('[Gallery] buildCardElement THREW for card', c.id, e); return; }
if (!el) { console.error('[Gallery] buildCardElement returned null for card', c.id, c); return; }
if (_poolSet[c.id] || _linkSet[c.id]) {
var badge = document.createElement('div');
badge.className = 'cg-pool-badge' + (_linkSet[c.id] ? ' cg-link-badge' : '');
badge.textContent = _linkSet[c.id] ? '\ud83d\udd17' : '\ud83c\udccf';
badge.title = _linkSet[c.id]
? (lang === 'fr' ? 'Carte de lien' : 'Link card')
: (lang === 'fr' ? 'Dans le cardPool' : 'In cardPool');
el.style.position = 'relative';
el.appendChild(badge);
}
grid.appendChild(el);
});
countEl.textContent = visible.length + " / " + allCards.length;
}
render();
});
}());
<</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">><<if !$events.meetHephaistos>><<goto "MeetHephaistos">><</if>>
<div id="villa-hub-screen">
<div id="villa-hub-bg" style="background-image: url('media/img/backgrounds/forge_interior.webp')"></div>
<div id="villa-hub-content">
<h2 class="villa-hub-title">
<<= $lang === "fr" ? "Atelier d'Héphaïstos" : "Hephaestus's Workshop">>
</h2>
<!-- Flammes divines -->
<div class="divine-flames-wrap">
<span class="divine-flames-label">
<img src="media/img/logos/pure_flame.webp" class="stat-label-icon" alt=""> <<= $lang === "fr" ? "Flammes divines" : "Divine Flames">>
<span class="divine-flames-pct"><<= $divineFlames || 0>>%</span>
</span>
<div class="divine-flames-track">
<div class="divine-flames-fill" id="hub-flames-fill" style="width: 0%"></div>
</div>
</div>
<div id="villa-hub-grid">
<!-- Accès forge -->
<div class="villa-hub-card" id="atelier-card-forge">
<div class="villa-hub-card-img" style="background-image: url('media/img/backgrounds/forge_interior.webp')">
<div class="villa-hub-card-veil"></div>
<div class="villa-hub-card-cost" id="forge-ess-cost"><img src="media/img/logos/all_div_ess.webp" class="stat-label-icon" alt=""> 5</div>
</div>
<div class="villa-hub-card-label">
<<= $lang === "fr" ? "Alimenter la forge" : "Feed the Forge">>
<select id="forge-ess-select" class="forge-ess-select"><!-- peuplé par JS --></select>
</div>
<div id="forge-ess-error" class="forge-ess-error hidden"></div>
</div>
<!-- Cellules — accès unique contre 50 flammes divines -->
<div class="villa-hub-card" id="atelier-card-cells">
<div class="villa-hub-card-img" style="background-image: url('media/img/backgrounds/cells_base.webp')">
<div class="villa-hub-card-veil"></div>
<<if !$events.cellsUnlocked>>
<div class="villa-hub-card-cost"><img src="media/img/logos/pure_flame.webp" class="stat-label-icon" alt=""> 50</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>
<<if $events.cellsUnlocked>>
<<= $lang === "fr" ? "Cellules" : "Cells">>
<<else>>
<<= $lang === "fr" ? "Activer les cellules" : "Activate Cells">>
<</if>>
</div>
</div>
<!-- Retour villa -->
<div class="villa-hub-card" id="atelier-card-back">
<div class="villa-hub-card-img" style="background-image: url('media/img/backgrounds/villa_outside.webp')">
<div class="villa-hub-card-veil"></div>
</div>
<div class="villa-hub-card-label">
<<= $lang === "fr" ? "Retour à la villa" : "Back to the villa">>
</div>
</div>
</div>
</div>
</div>
<<script>>
(function () {
$(document).one(':passagedisplay', function () {
var lang = State.variables.lang || 'en';
var COST = 5;
/* ── Build essence select ── */
var essences = State.variables.divineEssences || {};
var roster = State.variables.roster || [];
var sel = document.getElementById('forge-ess-select');
var errEl = document.getElementById('forge-ess-error');
var divOptions = roster.filter(function (id) {
return window.DB_Divinities && DB_Divinities[id];
});
if (divOptions.length === 0) divOptions = Object.keys(window.DB_Divinities || {});
divOptions.forEach(function (divId) {
var def = DB_Divinities[divId];
var count = essences[divId] || 0;
var name = def && def.nameKey ? (def.nameKey[lang] || def.nameKey.en) : divId;
var opt = document.createElement('option');
opt.value = divId;
opt.textContent = name + ' (' + count + ')';
if (count < COST) opt.disabled = true;
sel.appendChild(opt);
});
/* Auto-select first with enough */
var firstValid = divOptions.find(function (id) { return (essences[id] || 0) >= COST; });
if (firstValid) sel.value = firstValid;
sel.addEventListener('click', function (e) { e.stopPropagation(); });
sel.addEventListener('change', function () { errEl.classList.add('hidden'); });
/* Animate the flames bar from 0 → target on entry */
var fill = document.getElementById('hub-flames-fill');
var pct = Math.min(State.variables.divineFlames || 0, 100);
if (fill) {
requestAnimationFrame(function () {
requestAnimationFrame(function () { fill.style.width = pct + '%'; });
});
}
/* ── Forge card click ── */
document.getElementById('atelier-card-forge').addEventListener('click', function () {
var chosenDiv = sel.value;
var curEss = (State.variables.divineEssences || {})[chosenDiv] || 0;
if (!chosenDiv) {
errEl.textContent = lang === 'fr' ? 'Sélectionnez une divinité.' : 'Select a divinity.';
errEl.classList.remove('hidden'); return;
}
if (curEss < COST) {
var def = window.DB_Divinities && DB_Divinities[chosenDiv];
var name = def && def.nameKey ? (def.nameKey[lang] || def.nameKey.en) : chosenDiv;
errEl.textContent = lang === 'fr'
? 'Pas assez d’essences ' + name + ' (' + curEss + '/' + COST + ')'
: 'Not enough ' + name + ' essence (' + curEss + '/' + COST + ')';
errEl.classList.remove('hidden'); return;
}
if (!State.variables.divineEssences) State.variables.divineEssences = {};
State.variables.divineEssences[chosenDiv] = curEss - COST;
State.variables.forgeContext = 'hephaestus';
Engine.play('ForgeGame');
});
/* ── Cellules card click ── */
document.getElementById('atelier-card-cells').addEventListener('click', function () {
if (!State.variables.events.cellsUnlocked) {
var flames = State.variables.divineFlames || 0;
if (flames < 50) {
showLinkError(this, lang === 'fr'
? 'Flammes divines insuffisantes (50 requises).'
: 'Not enough divine flames (50 required).');
return;
}
State.variables.divineFlames = flames - 50;
GameEvents.set('cellsUnlocked');
}
Engine.play('Cellules');
});
/* ── Back card click ── */
document.getElementById('atelier-card-back').addEventListener('click', function () {
Engine.play('VillaHub');
});
});
}());
<</script>><<if !$events.MeetArtemis>><<goto "MeetArtemis">><</if>>
<div id="villa-hub-screen">
<div id="villa-hub-bg" style="background-image: url('media/img/backgrounds/artemis_forest.webp')"></div>
<div id="villa-hub-content">
<h2 class="villa-hub-title">
<<= $lang === "fr" ? "Cabane d'Artémis" : "Artemis's Cabin">>
</h2>
<!-- Pouvoir de pistage -->
<div class="divine-flames-wrap">
<span class="divine-flames-label">
<img src="media/img/logos/track_power.webp" class="stat-label-icon" alt=""> <<= $lang === "fr" ? "Pouvoir de pistage" : "Tracking Power">>
<span class="divine-flames-pct"><<= $trackingPower || 0>>%</span>
</span>
<div class="divine-flames-track">
<div class="divine-flames-fill" id="hub-tracking-fill" style="width: 0%"></div>
</div>
</div>
<div id="villa-hub-grid">
<!-- Partir en chasse -->
<div class="villa-hub-card" id="scene-card-artemis-hunt">
<div class="villa-hub-card-img" style="background-image: url('media/img/backgrounds/artemis_forest.webp')">
<div class="villa-hub-card-veil"></div>
</div>
<div class="villa-hub-card-label">
<<= $lang === "fr" ? "Rituel d'Artémis" : "Artemis's Ritual">>
</div>
</div>
<!-- Retour villa -->
<div class="villa-hub-card" id="scene-card-artemis-back">
<div class="villa-hub-card-img" style="background-image: url('media/img/backgrounds/villa_outside.webp')">
<div class="villa-hub-card-veil"></div>
</div>
<div class="villa-hub-card-label">
<<= $lang === "fr" ? "Retour à la villa" : "Back to the villa">>
</div>
</div>
</div>
</div>
</div>
<<script>>
(function () {
$(document).one(':passagedisplay', function () {
/* Animate tracking bar from 0 → target on entry */
var fill = document.getElementById('hub-tracking-fill');
var pct = Math.min(State.variables.trackingPower || 0, 100);
if (fill) {
requestAnimationFrame(function () {
requestAnimationFrame(function () { fill.style.width = pct + '%'; });
});
}
document.getElementById("scene-card-artemis-hunt").addEventListener("click", function () {
State.variables.forgeContext = 'artemis_tracking';
Engine.play("ForgeGame");
});
document.getElementById("scene-card-artemis-back").addEventListener("click", function () {
Engine.play("VillaHub");
});
});
}());
<</script>><div id="villa-hub-screen">
<div id="villa-hub-bg" style="background-image: url('media/img/backgrounds/cells.webp')"></div>
<div id="villa-hub-content">
<h2 class="villa-hub-title">
<<= $lang === "fr" ? "Cellules" : "Cells">>
</h2>
<div id="villa-hub-grid">
<!-- Harpie emprisonnée -->
<<if $dungeonProgress.misty_hq && $dungeonProgress.misty_hq.completed>>
<div class="villa-hub-card" id="cells-card-harpy" style="cursor:default; pointer-events:none;">
<div class="villa-hub-card-img" style="background-image: url('media/img/story/4harpy_prisonner.webp')">
<div class="villa-hub-card-veil"></div>
</div>
<div class="villa-hub-card-label">
<<= $lang === "fr" ? "La Harpie (captive)" : "The Harpy (captive)">>
</div>
</div>
<</if>>
<!-- Retour à l'atelier -->
<div class="villa-hub-card" id="cells-card-back">
<div class="villa-hub-card-img" style="background-image: url('media/img/backgrounds/forge_outside.webp')">
<div class="villa-hub-card-veil"></div>
</div>
<div class="villa-hub-card-label">
<<= $lang === "fr" ? "Retour à l'atelier" : "Back to the workshop">>
</div>
</div>
</div>
</div>
</div>
<<script>>
(function () {
$(document).one(':passagedisplay', function () {
document.getElementById('cells-card-back').addEventListener('click', function () {
Engine.play('AtelierHephaistos');
});
});
}());
<</script>><<if !$deckState || !$deckState.selectedCharId>><<goto "TeamManagement">><</if>>
<div id="deck-management">
<!-- ── Header ── -->
<div id="dm-header">
<div class="screen-nav">
<button id="dm-back-btn" class="dm-back-btn"></button>
</div>
<h2 id="dm-char-title"> </h2>
<p id="dm-warn" class="dm-warn dm-warn-hidden"></p>
</div>
<!-- ── Body ── -->
<div id="dm-body">
<!-- Left: portrait + stats + mechanics -->
<div id="dm-left">
<div id="dm-portrait-wrap"></div>
<div id="dm-stats"></div>
<div id="dm-mechanics"></div>
</div>
<!-- Right: deck + pool -->
<div id="dm-right">
<div id="dm-role-tabs" class="dm-role-tabs dm-hidden"></div>
<section id="dm-deck-section">
<h3 id="dm-deck-title"> </h3>
<div id="dm-deck" class="dm-deck-grid"></div>
<p id="dm-deck-full" class="dm-full-msg dm-full-hidden"></p>
</section>
<section id="dm-pool-section">
<h3 id="dm-pool-title"> </h3>
<div id="dm-pool" class="card-grid"></div>
</section>
</div>
</div>
</div>
<<script>>
(function () {
'use strict';
var lang = State.variables.lang || 'en';
var ds = State.variables.deckState;
console.log('[DM] init: CardUI=', window.CardUI, 'deckState=', ds);
if (!ds) { console.error('[DM] deckState is falsy, aborting'); return; }
var charId = ds.selectedCharId;
console.log('[DM] charId=', charId, 'DB_Cards keys=', window.DB_Cards && Object.keys(DB_Cards).slice(0,5));
if (!charId) { console.error('[DM] no selectedCharId, aborting'); return; }
var data = (window.DB_Divinities && DB_Divinities[charId])
|| (window.DB_Characters && DB_Characters[charId]);
if (!data) { Engine.play('TeamManagement'); return; }
var DECK_MAX = 5;
var isDiv = !!(data.roleData);
var roleList = isDiv ? (data.roles || Object.keys(data.roleData)) : [];
var selRole = (roleList.length > 0)
? (State.variables.teamDivRole && roleList.indexOf(State.variables.teamDivRole) !== -1
? State.variables.teamDivRole : roleList[0])
: null;
/* ── Deck key (role-aware for divinities) ─────────────────── */
function deckKey() {
return (isDiv && selRole) ? (charId + '_' + selRole) : charId;
}
/* ── Pool for current selection ───────────────────────────── */
function currentPool() {
var pool;
if (isDiv && selRole && data.roleData[selRole])
pool = data.roleData[selRole].cardPool || [];
else
pool = ds.cardPools[charId] || [];
if (isDiv && selRole) {
pool = pool.filter(function (cid) {
var card = window.DB_Cards && DB_Cards[cid];
return card && card.role === selRole;
});
}
return pool;
}
/* ── Lazy-init ────────────────────────────────────────────── */
if (!ds.cardPools[charId] || ds.cardPools[charId].length === 0) {
var allCards = [];
if (data.roleData) {
Object.keys(data.roleData).forEach(function (r) {
(data.roleData[r].cardPool || []).forEach(function (cid) {
if (allCards.indexOf(cid) === -1) allCards.push(cid);
});
});
} else {
allCards = (data.cardPool || []).slice();
}
ds.cardPools[charId] = allCards;
}
if (isDiv) {
roleList.forEach(function (r) {
var k = charId + '_' + r;
if (!ds.decks[k] || ds.decks[k].length === 0) {
var rolePool = (data.roleData[r].cardPool || []).filter(function (cid) {
var card = window.DB_Cards && DB_Cards[cid];
return card && card.role === r;
});
ds.decks[k] = rolePool.slice(0, DECK_MAX);
}
});
} else if (!ds.decks[charId] || ds.decks[charId].length === 0) {
ds.decks[charId] = (ds.cardPools[charId] || []).slice(0, DECK_MAX);
}
/* Merge link cards */
var ulc = State.variables.unlockedLinkCards || [];
if (data.linkCards) {
Object.values(data.linkCards).forEach(function (cid) {
if (ulc.indexOf(cid) !== -1 && ds.cardPools[charId].indexOf(cid) === -1)
ds.cardPools[charId].push(cid);
});
}
/* ── Header title ─────────────────────────────────────────── */
var titleEl = document.getElementById('dm-char-title');
if (titleEl) titleEl.textContent = data.nameKey[lang];
/* ── Stat computation ─────────────────────────────────────── */
function computeDeckStats() {
var deck = ds.decks[deckKey()] || [];
var activeRole = selRole || data.role;
var XP_TAB = [0, 100, 250, 450, 700, 1000, 1350, 1750, 2200, 2700];
var cxpRaw = State.variables.charXP && State.variables.charXP[charId];
var baseLv = (cxpRaw === undefined) ? (data.level || 1) : 1;
var cxp = (cxpRaw !== undefined) ? cxpRaw : XP_TAB[Math.max(0, baseLv - 1)];
var clv = baseLv;
for (var xi = 1; xi < XP_TAB.length; xi++) { if (cxp >= XP_TAB[xi]) clv = xi + 1; else break; }
if (clv < baseLv) clv = baseLv;
var lm = 1 + (clv - 1) * 0.1;
var atk = 0, def = 0, heal = 0;
deck.forEach(function (cid) {
var card = window.DB_Cards && DB_Cards[cid];
if (!card) return;
(card.effects || []).forEach(function (eff) {
var w = (eff.target === 'allEnemies' || eff.target === 'allAllies') ? 1.5 : 1.0;
var v = (eff.value || 0) * lm * w;
if (eff.type === 'damage') atk += v;
if (eff.type === 'shield') def += v;
if (eff.type === 'heal') heal += v;
if (eff.type === 'taunt') def += 20 * lm;
});
});
var hp = data.maxHp;
if (data.roleData && activeRole && data.roleData[activeRole] && data.roleData[activeRole].maxHp)
hp = data.roleData[activeRole].maxHp;
return { atk: Math.round(atk), def: Math.round(def), heal: Math.round(heal), hp: hp, role: activeRole, lv: clv };
}
/* ── Left panel ───────────────────────────────────────────── */
function renderLeftPanel() {
var portWrap = document.getElementById('dm-portrait-wrap');
if (portWrap && !portWrap.querySelector('img')) {
var src = (data.portrait || data.battle || '').replace('media/img/avatars/', 'media/img/characters/');
if (src) {
var img = document.createElement('img');
img.src = src; img.className = 'dm-portrait'; img.alt = data.nameKey[lang];
portWrap.appendChild(img);
}
}
var statsEl = document.getElementById('dm-stats');
if (!statsEl) return;
var s = computeDeckStats();
var deck = ds.decks[deckKey()] || [];
var roleIcons = { offensive: '⚔', defensive: '🛡', support: '✶' };
var rIcon = (s.role && roleIcons[s.role]) || '●';
var deckLabel = lang === 'fr'
? deck.length + ' / ' + DECK_MAX + ' carte' + (deck.length > 1 ? 's' : '')
: deck.length + ' / ' + DECK_MAX + ' card' + (deck.length !== 1 ? 's' : '');
statsEl.innerHTML =
'<div class="dm-char-name">' + data.nameKey[lang] + '</div>'
+ '<div class="dm-char-role">' + rIcon + ' ' + (s.role || '') + ' — Lv.' + s.lv + '</div>'
+ '<div class="char-stat-profile dm-char-stats">'
+ (s.hp ? '<span class="csp-hp">❤ ' + s.hp + '</span>' : '')
+ '<span class="csp-def">🛡 ' + s.def + '</span>'
+ '<span class="csp-atk">⚔ ' + s.atk + '</span>'
+ (s.heal > 0 ? '<span class="csp-heal">✶ ' + s.heal + '</span>' : '')
+ '</div>'
+ '<div class="dm-deck-count">' + deckLabel + '</div>';
}
/* ── Mechanics panel ──────────────────────────────────────── */
function renderMechanics() {
var el = document.getElementById('dm-mechanics');
if (!el) return;
var activeRole = selRole || data.role;
/* Role-aware pool: changes with the active role tab for divinities */
var allCardIds = currentPool();
/* Collect unique tag IDs referenced by the cards */
var usedTags = {};
var hasBurst = false;
var roleTagMap = (window.RoleTagMap) || { offensive: 'fury', defensive: 'guard', support: 'grace' };
allCardIds.forEach(function (cid) {
var card = window.DB_Cards && DB_Cards[cid];
if (!card) return;
(card.effects || []).forEach(function (eff) {
if (eff.type === 'applyTag' && eff.tag) usedTags[eff.tag] = true;
if (eff.scaleTag && eff.scaleTag.tag) { usedTags[eff.scaleTag.tag] = true; hasBurst = true; }
if (eff.type === 'generateRoleTag' || eff.type === 'consumeRoleTag') {
var r = card.role || activeRole;
var rt = roleTagMap[r];
if (rt) usedTags[rt] = true;
if (eff.type === 'consumeRoleTag') hasBurst = true;
}
});
});
if (hasBurst) usedTags['__burst__'] = true;
/* Tag → icon file mapping */
var tagIconFile = {
authority : 'media/img/icons/authority.webp',
tempo : 'media/img/icons/tempo.webp',
threshold_reduction: 'media/img/icons/protect.webp',
momentum : 'media/img/icons/momentum.webp',
fury : 'media/img/icons/fury.webp',
guard : 'media/img/icons/guard.webp',
grace : 'media/img/icons/grace.webp',
relay : 'media/img/icons/relay.webp',
__burst__ : 'media/img/icons/burst.webp'
};
/* Synthetic entry for burst mechanic */
var burstMeta = {
__burst__: {
nameKey: { en: 'Burst', fr: 'Burst' },
descKey: {
en: 'Consumes tag stacks to unleash a powerful bonus effect.',
fr: 'Consomme des stacks de tag pour d\u00e9clencher un effet bonus puissant.'
},
stackable: false
}
};
var tags = window.DB_Tags || {};
var tagIds = Object.keys(usedTags).filter(function (tid) {
return tid === '__burst__' ? true : !!tags[tid];
});
var html = '';
html += '<div class="dm-mech-title">' + (lang === 'fr' ? 'M\u00e9caniques de tags' : 'Tag Mechanics') + '</div>';
if (tagIds.length === 0) {
html += '<div class="dm-mech-empty">' + (lang === 'fr' ? 'Aucun tag sp\u00e9cifique.' : 'No specific tags.') + '</div>';
} else {
tagIds.forEach(function (tid) {
var t = tags[tid] || burstMeta[tid];
var src = tagIconFile[tid];
var name = t.nameKey ? (t.nameKey[lang] || t.nameKey.en) : tid;
var desc = t.descKey ? (t.descKey[lang] || t.descKey.en) : '';
html += '<div class="dm-mech-tag">';
html += '<div class="dm-mech-tag-head">';
if (src) {
html += '<img class="dm-mech-tag-icon" src="' + src + '" alt="' + name + '">';
} else {
html += '<span class="dm-mech-tag-icon dm-mech-tag-icon-fallback">\u25cf</span>';
}
html += '<span class="dm-mech-tag-name">' + name + '</span>';
if (t.stackable) html += '<span class="dm-mech-tag-stack">' + (lang === 'fr' ? 'cumulable' : 'stackable') + '</span>';
html += '</div>';
if (desc) html += '<div class="dm-mech-tag-desc">' + desc + '</div>';
html += '</div>';
});
}
el.innerHTML = html;
}
/* ── Tooltip cleanup (innerHTML removal bypasses mouseleave) ── */
function cleanTips() {
document.querySelectorAll('.card-tip').forEach(function (t) {
if (t.parentNode) t.parentNode.removeChild(t);
});
}
/* ── Deck completeness check ──────────────────────────────── */
function allDecksComplete() {
if (isDiv) {
return roleList.every(function (r) {
var d = ds.decks[charId + '_' + r] || [];
return d.length >= DECK_MAX;
});
}
return (ds.decks[charId] || []).length >= DECK_MAX;
}
function incompleteRoles() {
if (!isDiv) return null;
return roleList.filter(function (r) {
return (ds.decks[charId + '_' + r] || []).length < DECK_MAX;
});
}
function showWarn(msg) {
var w = document.getElementById('dm-warn');
if (!w) return;
w.textContent = msg;
w.classList.remove('dm-warn-hidden');
clearTimeout(showWarn._t);
showWarn._t = setTimeout(function () {
w.classList.add('dm-warn-hidden');
}, 3500);
}
/* ── Role tabs ────────────────────────────────────────────── */
function renderRoleTabs() {
var tabsEl = document.getElementById('dm-role-tabs');
if (!tabsEl) return;
if (!isDiv || roleList.length < 2) { tabsEl.classList.add('dm-hidden'); return; }
tabsEl.classList.remove('dm-hidden');
tabsEl.innerHTML = '';
var roleIcons = { offensive: '⚔', defensive: '🛡', support: '✶' };
roleList.forEach(function (r) {
var btn = document.createElement('button');
btn.className = 'dm-role-tab' + (r === selRole ? ' dm-role-tab-active' : '');
btn.textContent = (roleIcons[r] || '') + ' ' + r;
btn.onclick = function () {
selRole = r;
renderRoleTabs();
renderDeck();
renderPool();
renderLeftPanel();
renderMechanics();
};
tabsEl.appendChild(btn);
});
}
/* ── Deck section ─────────────────────────────────────────── */
function renderDeck() {
cleanTips();
var deckEl = document.getElementById('dm-deck');
var headEl = document.getElementById('dm-deck-title');
var fullMsg = document.getElementById('dm-deck-full');
if (!deckEl) return;
var deck = ds.decks[deckKey()] || [];
if (headEl) headEl.textContent = (lang === 'fr' ? 'Deck' : 'Deck') + ' (' + deck.length + ' / ' + DECK_MAX + ')';
if (fullMsg) {
if (deck.length >= DECK_MAX) {
fullMsg.textContent = lang === 'fr'
? '✦ Deck complet — cliquez une carte pour la retirer.'
: '✦ Deck full — click a card to remove it.';
fullMsg.classList.remove('dm-full-hidden');
} else {
fullMsg.textContent = '';
fullMsg.classList.add('dm-full-hidden');
}
}
deckEl.innerHTML = '';
deck.forEach(function (cid) {
var card = window.DB_Cards && DB_Cards[cid];
if (!card) { console.warn('[DM] renderDeck: no card for cid', cid); return; }
if (!window.CardUI) { console.error('[DM] renderDeck: CardUI undefined for cid', cid); return; }
var cardEl;
try {
cardEl = CardUI.buildCardElement(card, lang, false);
} catch(e) { console.error('[DM] renderDeck: buildCardElement THREW for', cid, e); return; }
if (!cardEl) { console.error('[DM] renderDeck: buildCardElement returned null/undefined for', cid, card); return; }
cardEl.classList.add('dm-deck-card');
cardEl.title = lang === 'fr' ? 'Cliquer pour retirer du deck' : 'Click to remove from deck';
(function (id) { cardEl.onclick = function () { removeCard(id); }; }(cid));
deckEl.appendChild(cardEl);
});
for (var i = deck.length; i < DECK_MAX; i++) {
var slot = document.createElement('div');
slot.className = 'dm-empty-slot';
slot.textContent = lang === 'fr' ? '+ Vide' : '+ Empty';
deckEl.appendChild(slot);
}
renderLeftPanel();
}
/* ── Pool section ─────────────────────────────────────────── */
function renderPool() {
cleanTips();
var poolEl = document.getElementById('dm-pool');
var headEl = document.getElementById('dm-pool-title');
if (!poolEl) return;
var deck = ds.decks[deckKey()] || [];
var pool = currentPool().filter(function (cid) { return deck.indexOf(cid) === -1; });
var deckFull = deck.length >= DECK_MAX;
if (headEl) headEl.textContent = lang === 'fr'
? 'Cartes disponibles (' + pool.length + ')'
: 'Available Cards (' + pool.length + ')';
console.log('[DM] renderPool: pool=', pool, 'deck=', deck, 'deckKey=', deckKey());
poolEl.innerHTML = '';
pool.forEach(function (cid) {
var card = window.DB_Cards && DB_Cards[cid];
if (!card) { console.warn('[DM] renderPool: no card for cid', cid); return; }
if (!window.CardUI) { console.error('[DM] renderPool: CardUI undefined for cid', cid); return; }
var cardEl;
try {
cardEl = CardUI.buildCardElement(card, lang, false);
} catch(e) { console.error('[DM] renderPool: buildCardElement THREW for', cid, e); return; }
if (!cardEl) { console.error('[DM] renderPool: buildCardElement returned null/undefined for', cid, card); return; }
console.log('[DM] renderPool: cardEl for', cid, '=', cardEl);
cardEl.style.position = 'relative';
if (deckFull) {
cardEl.classList.add('dm-unavailable');
cardEl.title = lang === 'fr' ? 'Deck complet' : 'Deck full';
} else {
cardEl.style.cursor = 'pointer';
cardEl.title = lang === 'fr' ? 'Cliquer pour ajouter au deck' : 'Click to add to deck';
(function (id) { cardEl.onclick = function () { addCard(id); }; }(cid));
}
poolEl.appendChild(cardEl);
});
}
/* ── Add / remove ─────────────────────────────────────────── */
function addCard(cid) {
var key = deckKey();
var deck = ds.decks[key] = ds.decks[key] || [];
if (deck.length >= DECK_MAX || deck.indexOf(cid) !== -1) return;
deck.push(cid);
renderDeck(); renderPool();
}
function removeCard(cid) {
var key = deckKey();
var deck = ds.decks[key] || [];
var idx = deck.indexOf(cid);
if (idx === -1) return;
deck.splice(idx, 1);
renderDeck(); renderPool();
}
/* ── Initial render ───────────────────────────────────────── */
$(document).one(':passagedisplay', function () {
renderRoleTabs();
renderLeftPanel();
renderMechanics();
renderDeck();
renderPool();
/* Wire back button with deck validation */
var backBtn = document.getElementById('dm-back-btn');
if (backBtn) {
backBtn.textContent = lang === 'fr' ? '← Retour' : '← Back';
backBtn.onclick = function () {
if (!allDecksComplete()) {
var missing = incompleteRoles();
var msg;
if (missing && missing.length > 0) {
var roleIcons = { offensive: '⚔', defensive: '🛡', support: '✶' };
var names = missing.map(function (r) { return (roleIcons[r] || '') + ' ' + r; }).join(', ');
msg = lang === 'fr'
? '⚠ Deck incomplet pour\u00a0: ' + names + '. Ajoutez ' + DECK_MAX + ' cartes par rôle.'
: '⚠ Incomplete deck for: ' + names + '. Add ' + DECK_MAX + ' cards per role.';
} else {
msg = lang === 'fr'
? '⚠ Deck incomplet\u00a0— ajoutez ' + DECK_MAX + ' cartes avant de quitter.'
: '⚠ Deck incomplete — add ' + DECK_MAX + ' cards before leaving.';
}
showWarn(msg);
/* Shake the deck section to draw attention */
var deckSec = document.getElementById('dm-deck-section');
if (deckSec) {
deckSec.classList.remove('dm-shake');
void deckSec.offsetWidth; /* reflow to restart animation */
deckSec.classList.add('dm-shake');
}
return;
}
cleanTips();
Engine.play('TeamManagement');
};
}
});
/* ── Cleanup on leave ──────────────────────────────────────── */
$(document).one(':passagehide', function () { cleanTips(); });
}());
<</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">><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 ──────────────────────────────────────── */
/* Calcule la puissance offensive / défensive / soins depuis le card pool au niveau actuel */
function _calcStatProfile(charId, role) {
var XP_TAB = [0, 100, 250, 450, 700, 1000, 1350, 1750, 2200, 2700];
var db = (window.DB_Divinities && DB_Divinities[charId]) || (window.DB_Characters && DB_Characters[charId]);
if (!db) return { atk: 0, def: 0, heal: 0 };
/* Résoudre le niveau courant depuis l'XP */
var _cxpRaw = State.variables.charXP && State.variables.charXP[charId];
var _baseLv = (_cxpRaw === undefined) ? (db.level || 1) : 1;
var cxp = (_cxpRaw !== undefined) ? _cxpRaw : XP_TAB[Math.max(0, _baseLv - 1)];
var clv = _baseLv;
for (var xi = 1; xi < XP_TAB.length; xi++) { if (cxp >= XP_TAB[xi]) clv = xi + 1; else break; }
if (clv < _baseLv) clv = _baseLv;
var levelMult = 1 + (clv - 1) * 0.1;
/* Résoudre le card pool pour le rôle actif */
var pool = (role && db.roleData && db.roleData[role] && db.roleData[role].cardPool)
? db.roleData[role].cardPool : (db.cardPool || []);
var atk = 0, def = 0, heal = 0;
pool.forEach(function(cid) {
var card = window.DB_Cards && DB_Cards[cid];
if (!card) return;
card.effects.forEach(function(eff) {
var isAoe = (eff.target === 'allEnemies' || eff.target === 'allAllies');
var w = isAoe ? 1.5 : 1.0;
var v = (eff.value || 0) * levelMult * w;
if (eff.type === 'damage') atk += v;
if (eff.type === 'shield') def += v;
if (eff.type === 'heal') heal += v;
if (eff.type === 'taunt') def += 20 * levelMult;
});
});
return { atk: Math.round(atk), def: Math.round(def), heal: Math.round(heal) };
}
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.manageDecks(\'' + id + '\')">'
+ (lang === "fr" ? "🂠 Deck" : "🂠 Deck") + '</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, 3250, 3850, 4500, 5200, 5950, 6750, 7600, 8500, 9450, 10450];
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 < 20 ? XP_TABLE[clv] : XP_TABLE[19];
var xpBase = clv >= 2 ? XP_TABLE[clv - 1] : 0;
var xpPct = clv >= 20 ? 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 >= 20 ? ' 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 >= 20 ? '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>';
}
var _displayMaxHp = data.maxHp;
if (data.roleData) {
var _activeRole = State.variables.teamDivRole || data.role;
if (data.roleData[_activeRole] && data.roleData[_activeRole].maxHp)
_displayMaxHp = data.roleData[_activeRole].maxHp;
}
var roleIcons2 = { offensive: '⚔', defensive: '🛡', support: '✶' };
var statProfileHtml;
if (data.roleData) {
/* Divinité : une ligne de stats par rôle */
statProfileHtml = '<div class="char-stat-profile char-stat-multi">';
var _roleList = data.roles || Object.keys(data.roleData);
_roleList.forEach(function(r) {
var _rHp = (data.roleData[r] && data.roleData[r].maxHp) || data.maxHp;
var _rSp = _calcStatProfile(id, r);
statProfileHtml += '<div class="csp-role-row">'
+ '<span class="csp-role-label">' + (roleIcons2[r] || r.charAt(0).toUpperCase()) + '</span>'
+ '<span class="csp-divider"></span>'
+ (_rHp ? '<span class="csp-hp">❤ ' + _rHp + '</span>' : '')
+ '<span class="csp-def">🛡 ' + _rSp.def + '</span>'
+ '<span class="csp-atk">⚔ ' + _rSp.atk + '</span>'
+ (_rSp.heal > 0 ? '<span class="csp-heal">✶ ' + _rSp.heal + '</span>' : '<span class="csp-heal csp-zero">✶ 0</span>')
+ '</div>';
});
statProfileHtml += '</div>';
} else {
/* Serviteur : stats simples du seul rôle */
var _sp = _calcStatProfile(id, data.role);
statProfileHtml = '<div class="char-stat-profile">'
+ (_displayMaxHp ? '<span class="csp-hp">❤ ' + _displayMaxHp + '</span>' : '')
+ '<span class="csp-def">🛡 ' + _sp.def + '</span>'
+ '<span class="csp-atk">⚔ ' + _sp.atk + '</span>'
+ (_sp.heal > 0 ? '<span class="csp-heal">✶ ' + _sp.heal + '</span>' : '<span class="csp-heal csp-zero">✶ 0</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
+ statProfileHtml
+ (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>';
}
var _slotMaxHp = c.maxHp;
if (i === 0 && c.roleData) {
var _curRoleHp = State.variables.teamDivRole || c.role;
if (c.roleData[_curRoleHp] && c.roleData[_curRoleHp].maxHp)
_slotMaxHp = c.roleData[_curRoleHp].maxHp;
}
var _slotSp = _calcStatProfile(charId, displayRole);
var slotStatHtml = '<div class="char-stat-profile">'
+ (_slotMaxHp ? '<span class="csp-hp">❤ ' + _slotMaxHp + '</span>' : '')
+ '<span class="csp-def">🛡 ' + _slotSp.def + '</span>'
+ '<span class="csp-atk">⚔ ' + _slotSp.atk + '</span>'
+ (_slotSp.heal > 0 ? '<span class="csp-heal">✶ ' + _slotSp.heal + '</span>' : '<span class="csp-heal csp-zero">✶ 0</span>')
+ '</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
+ slotStatHtml
+ 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 (data.tag === "Divine" && team[0] && team[0] !== id) {
/* Swap out the existing divinity for the new one */
team[0] = null;
}
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();
},
/* Navigate to deck management passage for a character */
manageDecks: function (id) {
if (!State.variables.deckState) {
State.variables.deckState = { selectedCharId: null, decks: {}, cardPools: {} };
}
State.variables.deckState.selectedCharId = id;
Engine.play('DeckManagement');
},
/* 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);
/* Init deckState for newly recruited character if not already present */
var _ds = State.variables.deckState;
if (_ds && !_ds.cardPools[id]) {
var _allCards = [];
if (data.roleData) {
Object.keys(data.roleData).forEach(function (r) {
(data.roleData[r].cardPool || []).forEach(function (cid) {
if (_allCards.indexOf(cid) === -1) _allCards.push(cid);
});
});
} else {
_allCards = (data.cardPool || []).slice();
}
_ds.cardPools[id] = _allCards;
_ds.decks[id] = _allCards.slice(0, 5);
}
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 fought = State.variables.foughtDivinities || [];
/* 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 || dungeonUnlockedDivinity[divKey])
servGrid.appendChild(TeamUI.buildCharCard(DB_Characters[id], id, owned));
});
};
}());
<</script>><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.misty_hq && $dungeonProgress.misty_hq.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">
<!-- Story cards -->
<<if !$events.visitAprilDone>>
<div class="villa-hub-card story-card" id="villa-story-april">
<div class="villa-hub-card-img" style="background-image: url('media/img/backgrounds/villa_room.webp')">
<div class="villa-hub-card-veil"></div>
<div class="story-card-badge"><<= $lang === "fr" ? "Histoire" : "Story">></div>
</div>
<div class="villa-hub-card-label">
<<= $lang === "fr" ? "Une visiteuse" : "A Visitor">>
</div>
</div>
<</if>>
<<if $dungeonProgress.hermes_dungeon && $dungeonProgress.hermes_dungeon.completed && !$events.apparitionApril>>
<div class="villa-hub-card story-card" id="villa-story-april-apparition">
<div class="villa-hub-card-img" style="background-image: url('media/img/backgrounds/villa_inside.webp')">
<div class="villa-hub-card-veil"></div>
<div class="story-card-badge"><<= $lang === "fr" ? "Histoire" : "Story">></div>
</div>
<div class="villa-hub-card-label">
<<= $lang === "fr" ? "Apparition d'April" : "April's Apparition">>
</div>
</div>
<</if>>
<!-- 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"><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"><img src="media/img/logos/dungeon.webp" class="stat-label-icon" alt=""></span>
<<= $lang === "fr" ? "Palais d'Hermès" : "Palace of Hermes">>
</div>
</div>
<</if>>
<!-- QG de Misty — donjon survie, visible après visite de l'atelier -->
<<if $events.meetHephaistos && (!$dungeonProgress.misty_hq || !$dungeonProgress.misty_hq.completed)>>
<div class="villa-hub-card" id="villa-card-misty-hq">
<div class="villa-hub-card-img" style="background-image: url('media/img/backgrounds/city.webp')">
<div class="villa-hub-card-veil"></div>
<div class="card-team-req" style="flex-direction:column;align-items:center;gap:.3rem;width:100%;box-sizing:border-box;padding:0 .5rem;">
<div style="display:flex;gap:.45rem;align-items:flex-end;justify-content:center;">
<div class="card-team-req-entry">
<div class="card-team-req-avatar <<= $team.includes('ellie') ? 'present' : 'missing'>>" style="background-image:url('media/img/avatars/ellie.webp')"></div>
<span class="card-team-req-name">Ellie</span>
</div>
<div class="card-team-req-entry">
<div class="card-team-req-avatar <<= $team.includes('artemis') ? 'present' : 'missing'>>" style="background-image:url('media/img/avatars/artemis.webp')"></div>
<span class="card-team-req-name">Artemis</span>
</div>
<div class="card-team-req-entry">
<div class="card-team-req-avatar <<= $team.includes('april') ? 'present' : 'missing'>>" style="background-image:url('media/img/avatars/april.webp')"></div>
<span class="card-team-req-name">April</span>
</div>
</div>
<div class="card-team-req-row">
<div class="card-team-req-avatar <<= $trackingPower >= 50 ? 'present' : 'missing'>>" style="background-image:url('media/img/logos/track_power.webp');width:24px;height:24px;flex-shrink:0;"></div>
<span id="misty-req-tracking" class="card-team-req-name"><<= $lang === 'fr' ? 'Pistage 50 %' : 'Tracking 50%'>></span>
</div>
<div class="card-team-req-row">
<div class="card-team-req-avatar <<= $events.cellsUnlocked ? 'present' : 'missing'>>" style="font-size:.95rem;display:flex;align-items:center;justify-content:center;width:24px;height:24px;flex-shrink:0;">🔒</div>
<span id="misty-req-cells" class="card-team-req-name"><<= $lang === 'fr' ? 'Cellules activées' : 'Cells activated'>></span>
</div>
</div>
</div>
<div class="villa-hub-card-label">
<span class="villa-hub-card-icon"><img src="media/img/logos/dungeon.webp" class="stat-label-icon" alt=""></span>
<<= $lang === "fr" ? "QG de la harpie" : "Harpy's Hideout">>
</div>
</div>
<</if>>
<!-- Cabane d'Artémis — après explication de Héra, accessible en permanence -->
<<if $events.explicationHera>>
<div class="villa-hub-card" id="villa-card-artemis-cabin">
<div class="villa-hub-card-img" style="background-image: url('media/img/backgrounds/artemis_forest.webp')">
<div class="villa-hub-card-veil"></div>
</div>
<div class="villa-hub-card-label">
<<= $lang === "fr" ? "Cabane d'Artémis" : "Artemis's Cabin">>
</div>
</div>
<</if>>
<!-- Atelier d'Héphaïstos — apparaît après la rencontre avec Héphaïstos -->
<<if $events.MeetArtemis>>
<div class="villa-hub-card" id="villa-card-atelier">
<div class="villa-hub-card-img" style="background-image: url('media/img/backgrounds/forge_outside.webp')">
<div class="villa-hub-card-veil"></div>
</div>
<div class="villa-hub-card-label">
<<= $lang === "fr" ? "Atelier d'Héphaïstos" : "Hephaestus's Workshop">>
</div>
</div>
<</if>>
</div>
</div>
</div>
<<script>>
(function () {
var lang = State.variables.lang;
$(document).one(':passagedisplay', function () {
/* ── Team requirement helper ──────────────────────────────── */
function applyTeamRequirement(cardEl, requiredIds, onSuccess) {
if (!cardEl) return;
var team = (State.variables.team || []).filter(Boolean);
var allPresent = requiredIds.every(function (id) { return team.includes(id); });
if (!allPresent) cardEl.classList.add('team-locked');
cardEl.addEventListener('click', function () {
var currentTeam = (State.variables.team || []).filter(Boolean);
var missing = requiredIds.filter(function (id) { return !currentTeam.includes(id); });
if (missing.length > 0) {
var names = missing.map(function (id) {
var def = (window.DB_Characters && DB_Characters[id]) || (window.DB_Divinities && DB_Divinities[id]);
var rl = lang === 'fr' ? 'fr' : 'en';
return def && def.nameKey ? (def.nameKey[rl] || def.nameKey.en) : id;
});
showLinkError(cardEl,
lang === 'fr'
? 'Requis dans l’équipe : ' + names.join(', ')
: 'Required in team: ' + names.join(', ')
);
return;
}
onSuccess();
});
}
/* ── Training ──────────────────────────────────── */
document.getElementById("villa-card-training").addEventListener("click", function () {
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.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");
});
/* ── 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");
});
/* ── QG de Misty (donjon) ───────────────────── */
var _mistyCard = document.getElementById('villa-card-misty-hq');
applyTeamRequirement(
_mistyCard,
['ellie', 'artemis', 'april'],
function () {
if ((State.variables.trackingPower || 0) < 50) {
showLinkError(_mistyCard,
lang === 'fr'
? 'Puissance de pistage insuffisante (50 % requis).'
: 'Tracking power too low (50% required).');
return;
}
if (!State.variables.events.cellsUnlocked) {
showLinkError(_mistyCard,
lang === 'fr'
? 'Les cellules ne sont pas encore activées (Atelier d’Héphaïstos).'
: 'Cells not yet activated (Hephaestus\'s Workshop).');
return;
}
State.variables.trackingPower = (State.variables.trackingPower || 0) - 50;
var prog = State.variables.dungeonProgress;
if (!prog['misty_hq']) prog['misty_hq'] = { step: 0 };
State.variables.dungeonProgress = prog;
State.variables.activeDungeonId = 'misty_hq';
Engine.play('DungeonAdvance');
}
);
if (_mistyCard && (State.variables.trackingPower || 0) < 50)
_mistyCard.classList.add('team-locked');
if (_mistyCard && !State.variables.events.cellsUnlocked)
_mistyCard.classList.add('team-locked');
/* ── Misty req labels color ──────────────────── */
var MET = 'color:#6fcf7a;text-shadow:0 0 6px rgba(111,207,122,.7),0 1px 4px #000';
var UNMET = 'color:#ff8080;text-shadow:0 0 6px rgba(255,80,80,.6),0 1px 4px #000';
var _trackEl = document.getElementById('misty-req-tracking');
var _cellEl = document.getElementById('misty-req-cells');
if (_trackEl) _trackEl.style.cssText += (State.variables.trackingPower || 0) >= 50 ? MET : UNMET;
if (_cellEl) _cellEl.style.cssText += State.variables.events.cellsUnlocked ? MET : UNMET;
/* ── Cabane d'Artémis ────────────────────────── */
var artemisCabinCard = document.getElementById("villa-card-artemis-cabin");
if (artemisCabinCard) artemisCabinCard.addEventListener("click", function () {
Engine.play("CabaneArtemis");
});
/* ── Atelier d'Héphaïstos ────────────────────── */
var atelierCard = document.getElementById("villa-card-atelier");
if (atelierCard) atelierCard.addEventListener("click", function () {
Engine.play("AtelierHephaistos");
});
/* ── Story cards ─────────────────────────────── */
var storyAprilCard = document.getElementById("villa-story-april");
if (storyAprilCard) storyAprilCard.addEventListener("click", function () {
Engine.play("VisiteApril");
});
var storyAprilApparitionCard = document.getElementById("villa-story-april-apparition");
if (storyAprilApparitionCard) storyAprilApparitionCard.addEventListener("click", function () {
Engine.play("ApparitionApril");
});
});
}());
<</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);
tile.appendChild(img);
tile.appendChild(shimmer);
tile.appendChild(lbl);
tile.addEventListener("click", (function (cId) {
return function () {
State.variables.currentRitual = cId;
State.variables.forgeContext = null; /* prevent forge mini-game from overriding the ritual config */
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 () {
GameEvents.set('apparitionApril');
State.variables.narratCtx = {
onEnd: 'ExplicationHera',
bg : 'villa_inside',
sequence: [
{
type: 'narration',
text: {
fr: "La villa est calme en ce début d'après-midi. Héra et Hermès discutent activement dans le salon. Cela fait une semaine que je suis dans cette villa et j'ai déjà l'impression que cela fait des mois. Ce lieu a quelque chose de particulier, comme hors du temps. Ce qui ne m'étonnerait qu'à moitié vu le contexte…\nJ'entends des bruits légers contre la porte d'entrée — j'ai l'impression d'être le seul à les entendre. Je vais ouvrir la porte.",
en: "The villa is quiet in the early afternoon. Hera and Hermes are deep in conversation in the drawing room. I've been here for a week and already it feels like months. There is something peculiar about this place — as if it exists outside of time. Which wouldn't surprise me half as much, given the circumstances.\nI hear faint tapping at the front door — I seem to be the only one who notices it. I go to open it."
}
},
{
type: 'image',
src : 'media/img/story/4april_disapear.webp'
},
{
type : 'dialogue',
character: 'april_human',
side : 'left',
text: {
fr: "$playerName… il faut… que je… parle à…",
en: "$playerName… I need to… talk to…"
}
},
{
type : 'dialogue',
character: 'me',
side : 'right',
text: {
fr: "April ? Que t'arrive-t-il ? Héra ! Hermès ! Je crois qu'il y a un problème !",
en: "April? What's happening to you? Hera! Hermes! I think there's a problem!"
}
},
{
type : 'dialogue',
character: 'hera_human',
side : 'left',
text: {
fr: "Que se passe-t-il ?",
en: "What's going on?"
}
},
{
type : 'dialogue',
character: 'hermes_human',
side : 'left',
text: {
fr: "April ! Faites-la entrer et allongez-la ! Vite, avant qu'il ne soit trop tard !",
en: "April! Get her inside and lay her down! Quickly, before it's too late!"
}
},
{
type: 'image',
src : 'media/img/story/4hermes_bless.webp'
},
{
type: 'narration',
text: {
fr: "Hermès semble transférer de son énergie dans le corps d'April. Elle retrouve peu à peu consistance et finit par se relever.",
en: "Hermes appears to channel some of his energy into April's body. She gradually regains substance and eventually manages to stand."
}
},
{
type : 'dialogue',
character: 'april_human',
side : 'left',
text: {
fr: "Merci, maîtresse…",
en: "Thank you, my lady…"
}
},
{
type : 'dialogue',
character: 'hermes_human',
side : 'left',
text: {
fr: "C'était moins une !",
en: "That was a close call!"
}
},
{
type : 'dialogue',
character: 'me',
side : 'right',
text: {
fr: "Mais je pensais que vous ne pouviez pas mourir sur Terre — que si votre corps mourait ici, votre esprit retournait là d'où vous veniez.",
en: "But I thought you couldn't die on Earth — that if your body died here, your spirit would simply return to where you came from."
}
},
{
type : 'dialogue',
character: 'hera_human',
side : 'left',
text: {
fr: "C'est bien le cas, mais tu sais aussi que nous dépendons de l'énergie de dévotion. Incarner un corps ici demande de maintenir beaucoup de ressources à l'intérieur de celui-ci. Si le corps meurt, notre esprit repart bien, mais les ressources sont perdues.",
en: "That is true, but you also know that we depend on devotion energy. Inhabiting a body here requires a great deal of resources to be maintained within it. If the body dies, our spirit does return — but those resources are lost."
}
},
{
type : 'dialogue',
character: 'hermes_human',
side : 'left',
text: {
fr: "Mais ce n'est pas exactement le corps d'April qui a été attaqué ici. April ? As-tu suffisamment récupéré pour nous raconter ce qui s'est passé ?",
en: "But it isn't exactly April's body that was attacked here. April? Have you recovered enough to tell us what happened?"
}
},
{
type : 'dialogue',
character: 'april_human',
side : 'left',
text: {
fr: "Oui, maîtresse. J'étais en train d'effectuer la mission de renseignement que vous m'aviez confiée. J'ai essayé de suivre les perturbations d'énergie et je me suis retrouvée dans un entrepôt en ville.\nMalheureusement, il s'agissait d'un piège. Mais pas un simple piège. S'il s'était agi de simples humains, j'aurais pu m'en sortir. Mais là, il s'agissait de la Harpie.",
en: "Yes, my lady. I was carrying out the intelligence mission you had assigned me. I followed the energy disturbances and found myself in a warehouse in the city.\nUnfortunately, it was a trap — but not a simple one. Had it been ordinary humans, I could have escaped. But this was the Harpy."
}
},
{
type : 'dialogue',
character: 'hera_human',
side : 'left',
text: {
fr: "La Harpie ? Mais cela fait des siècles qu'elle n'avait pas donné signe de vie ! Je t'en prie, continue.",
en: "The Harpy? She hasn't been heard from in centuries! Please, go on."
}
},
{
type : 'dialogue',
character: 'april_human',
side : 'left',
text: {
fr: "Nous avons commencé à nous affronter. Mais quelque chose clochait : elle était beaucoup trop puissante pour une simple créature. Je n'ai pas pu lutter longtemps et elle a commencé à me saisir et à aspirer les essences de mon corps. C'est là que j'ai utilisé les réserves qui me restaient pour me téléporter ici…",
en: "We began to fight. But something was wrong — she was far too powerful for a mere creature. I couldn't hold on for long, and she started grabbing me and draining the essence from my body. That's when I used what reserves I had left to teleport here…"
}
},
{
type : 'dialogue',
character: 'hera_human',
side : 'left',
text: {
fr: "Très bien, nous en savons assez maintenant. Tu peux aller te reposer. Il faut que je réfléchisse à un plan. $playerName, viens avec moi dans le salon, s'il te plaît.",
en: "Very well, we know enough now. You may go rest. I need to think through a plan. $playerName, come with me to the drawing room, please."
}
}
]
};
}());
<</script>>
<<goto "Narration">>
/* ── Explication de Héra – la Harpie ──────────────────────────── */<<script>>
(function () {
GameEvents.set('explicationHera');
State.variables.narratCtx = {
onEnd: 'VillaHub',
bg : 'villa_inside',
sequence: [
{
type: 'narration',
text: {
fr: "Nous nous dirigeons vers le salon et nous nous asseyons. Elle commence à parler, l'air inquiet.",
en: "We make our way to the drawing room and sit down. She begins to speak, visibly worried."
}
},
{
type: 'image',
src : 'media/img/story/4hera_explanation.webp'
},
{
type : 'dialogue',
character: 'hera_human',
side : 'left',
text: {
fr: "Ce que je craignais est en train de se passer — l'offensive a commencé…",
en: "What I feared is coming to pass — the offensive has begun…"
}
},
{
type : 'dialogue',
character: 'me',
side : 'right',
text: {
fr: "L'offensive ? Mais qui est cette Harpie d'abord ? Ne me dis pas que c'est une femme avec des griffes et des ailes !",
en: "The offensive? But who is this Harpy in the first place? Don't tell me she's literally a woman with claws and wings!"
}
},
{
type : 'dialogue',
character: 'hera_human',
side : 'left',
text: {
fr: "Tu veux que je te rappelle avec qui tu habites maintenant ? À vrai dire, elle n'est pas vraiment des nôtres, mais elle est de notre espèce. C'est une créature indépendante. Mais là n'est pas le problème. Il semblerait qu'elle en veuille à notre existence et je ne sais pas encore bien pourquoi.\nNous devons la retrouver pour tirer cela au clair.",
en: "Do you need me to remind you who you're living with now? In truth, she isn't exactly one of us, but she is of our kind. She is an independent creature. But that isn't the issue. It appears she has designs against our existence, and I don't yet know why.\nWe need to find her and get to the bottom of this."
}
},
{
type : 'dialogue',
character: 'me',
side : 'right',
text: {
fr: "Mais comment faire ?",
en: "But how do we go about it?"
}
},
{
type : 'dialogue',
character: 'hera_human',
side : 'left',
text: {
fr: "Il y a des personnes que je ne t'ai pas encore présentées et qui habitent sur le terrain de la villa. Je vais te présenter Artémis — suis-moi. Ses talents nous seront nécessaires pour retrouver la Harpie.",
en: "There are people I haven't introduced you to yet who live on the villa grounds. I'm going to introduce you to Artemis — follow me. Her talents will be essential for tracking down the Harpy."
}
}
]
};
}());
<</script>>
<<goto "Narration">>
/* ── Cabane d'Artémis ──────────────────────────────────────────── */<<script>>
(function () {
GameEvents.set('MeetArtemis');
/* Recruit Artemis on first meeting */
if (!State.variables.roster.includes('artemis')) {
State.variables.roster.push('artemis');
}
State.variables.narratCtx = {
onEnd: 'CabaneArtemis',
bg : 'artemis_forest',
sequence: [
{
type: 'narration',
text: {
fr: "En sortant de la villa, je me rends compte que je n'avais pas encore mis les pieds dehors depuis que je suis là. Nous empruntons un petit sentier que je n'avais pas aperçu en arrivant. Il débouche sur une cabane dans une clairière isolée.",
en: "Stepping outside the villa, I realise this is the first time I've set foot outdoors since I arrived. We follow a narrow path I hadn't noticed when I came. It opens onto a cabin in a secluded clearing."
}
},
{
type : 'dialogue',
character: 'hera_human',
side : 'left',
text: {
fr: "Artémis est plutôt solitaire, mais elle est fiable et déterminée. Elle te permettra de développer un peu plus tes capacités.",
en: "Artemis is something of a loner, but she is reliable and determined. She will help you develop your abilities further."
}
},
{
type: 'image',
src : 'media/img/story/4artemis_meet.webp'
},
{
type : 'dialogue',
character: 'artemis_human',
side : 'left',
text: {
fr: "Laisse-moi d'abord en juger par moi-même, si tu le permets.",
en: "Let me be the judge of that myself, if you don't mind."
}
},
{
type : 'dialogue',
character: 'hera_human',
side : 'left',
text: {
fr: "Bonjour, Artémis. Cela va de soi — c'est pour cela que je l'ai amené ici.",
en: "Good day, Artemis. Of course — that is precisely why I brought him here."
}
},
{
type : 'dialogue',
character: 'artemis_human',
side : 'left',
text: {
fr: "Tu es donc cet humain si « spécial », celui qui va nous sortir de toute cette merde ?",
en: "So you're this supposedly \"special\" human — the one who's going to get us out of all this mess?"
}
},
{
type : 'dialogue',
character: 'me',
side : 'right',
text: {
fr: "Euh, oui… enfin…",
en: "Uh, yes… well…"
}
},
{
type : 'dialogue',
character: 'hera_human',
side : 'left',
text: {
fr: "Asseyons-nous autour du feu pour nous détendre, si vous le voulez bien.",
en: "Let us sit by the fire and take a moment to relax, if you don't mind."
}
},
{
type: 'image',
src : 'media/img/story/4artemis_hera_conversation.webp'
},
{
type : 'dialogue',
character: 'artemis_human',
side : 'left',
text: {
fr: "Désolée pour mon côté direct, mais la situation est devenue compliquée pour nous. En quoi puis-je vous être utile ?",
en: "I'm sorry for being blunt, but things have become complicated for us. How can I be of help?"
}
},
{
type : 'dialogue',
character: 'hera_human',
side : 'left',
text: {
fr: "Je vais être directe : on nous attaque. Et curieusement, pas par celle à laquelle je pensais en premier lieu. Il s'agit de la Harpie. Nous devons la retrouver.",
en: "I'll be straight with you: we are under attack. And curiously, not from the one I first suspected. It is the Harpy. We need to find her."
}
},
{
type : 'dialogue',
character: 'artemis_human',
side : 'left',
text: {
fr: "La Harpie ? C'est une créature — ça ne devrait pas être difficile de s'en occuper.",
en: "The Harpy? She's a creature — that shouldn't be too hard to deal with."
}
},
{
type : 'dialogue',
character: 'hera_human',
side : 'left',
text: {
fr: "Auparavant, oui. Mais certains paramètres ont changé. Nous sommes plus faibles aujourd'hui et il semblerait qu'elle ait trouvé un moyen d'augmenter sa puissance. Je veux que tu apprennes à $playerName le don d'essences. Il faut qu'il puisse te transférer assez de puissance pour que tu puisses retrouver la trace de cette créature.",
en: "In the past, yes. But certain things have changed. We are weaker now, and it seems she has found a way to increase her power. I want you to teach $playerName the essence gift. He needs to be able to transfer enough power to you so that you can track down this creature."
}
},
{
type : 'dialogue',
character: 'artemis_human',
side : 'left',
text: {
fr: "Très bien. Mais si on met la main dessus, qu'est-ce qu'on devra en faire ?",
en: "Very well. But if we do find her, what are we supposed to do with her?"
}
},
{
type : 'dialogue',
character: 'hera_human',
side : 'left',
text: {
fr: "Capturez-la et ramenez-la. Je dois aller voir Héphaïstos pour le reste. Rejoins-moi à l'atelier derrière la villa quand tu auras terminé, $playerName.",
en: "Capture her and bring her back. I need to go see Hephaestus about the rest. Meet me at the workshop behind the villa when you're done, $playerName."
}
}
]
};
}());
<</script>>
<<goto "Narration">>
/* ── Atelier d'Héphaïstos ──────────────────────────────────────── */<<script>>
(function () {
GameEvents.set('meetHephaistos');
State.variables.narratCtx = {
onEnd: 'AtelierHephaistos',
bg : 'forge_interior',
sequence: [
{
type: 'narration',
text: {
fr: "Arrivée devant l'atelier, je frappe timidement à la porte.",
en: "Arriving at the workshop, I knock tentatively at the door."
}
},
{
type: 'image',
src : 'media/img/story/4hephaistus_meet.webp'
},
{
type : 'dialogue',
character: 'hephaistus',
side : 'left',
text: {
fr: "Ah, te voilà enfin ! Héra est passée depuis un moment. Entre !",
en: "Ah, there you are! Hera stopped by a while ago. Come in!"
}
},
{
type : 'dialogue',
character: 'me',
side : 'right',
text: {
fr: "Bonjour, je viens pour…",
en: "Hello, I'm here about…"
}
},
{
type : 'dialogue',
character: 'hephaistus',
side : 'left',
text: {
fr: "Oui, bla bla bla, je sais — pas besoin de me la refaire ! Je suis déjà au courant de la situation. Quand on veut attraper des bestioles et qu'on ne sait pas quoi en faire, qui vient-on chercher ? Bibi. Suis-moi, je vais te faire faire un peu le tour de l'atelier pour t'expliquer mon rôle ici.",
en: "Yes, yes, I know — no need to spell it out! I'm already up to speed. When you want to catch creatures and don't know what to do with them, who do you come looking for? Yours truly. Follow me, I'll show you around the workshop and explain what I do here."
}
},
{
type : 'dialogue',
character: 'hephaistus',
side : 'left',
text: {
fr: "Comme tu t'en doutes, ce n'est pas un atelier comme les autres. Ici, je peux créer toutes sortes de choses, pourvu que j'aie l'énergie nécessaire. Voilà la forge divine. Quand il s'agit de créer des objets puissants, elle est indispensable.\nMalheureusement, je suis assez limité ici — mais tu vas peut-être pouvoir m'aider. Cette forge est alimentée en essences divines, plus difficiles à obtenir que celles de dévotion. Mais apparemment, tu serais en mesure de m'aider.",
en: "As you might expect, this is no ordinary workshop. Here I can craft all manner of things, as long as I have the energy to do so. Behold the divine forge. When it comes to creating powerful items, it is indispensable.\nUnfortunately, I am rather limited here — but you may be able to help me. This forge runs on divine essences, which are harder to come by than devotion essences. But apparently, you might be in a position to help."
}
},
{
type : 'dialogue',
character: 'hephaistus',
side : 'left',
text: {
fr: "Héra m'a dit que tu avais la capacité passive d'extraire des essences divines des personnes vaincues au combat. Cela tombe très bien, car j'ai moi-même le talent d'en extraire efficacement les matières premières. Viens, je vais te montrer quelque chose.",
en: "Hera told me you have the passive ability to extract divine essences from those you defeat in combat. That works out perfectly, because I myself have the talent to efficiently draw out the raw materials. Come, I want to show you something."
}
},
{
type: 'narration',
text: {
fr: "Je le suis dans un large escalier qui descend dans le sol.",
en: "I follow him down a wide staircase leading underground."
}
},
{
type: 'image',
src : 'media/img/story/4hephaistos_prison.webp'
},
{
type : 'dialogue',
character: 'hephaistus',
side : 'left',
text: {
fr: "Voici l'hôtel tout confort qui va accueillir les éventuelles créatures que l'on voudrait mettre hors d'état de nuire. Il annule leurs pouvoirs tout en leur fournissant de quoi vivre en attendant de savoir ce qu'on pourra faire d'elles.\nElles sont actuellement hors service, mais si j'alimente suffisamment la forge au-dessus, elle pourra fournir l'énergie nécessaire pour les activer. Repasse me voir quand tu auras accumulé suffisamment d'essences divines, de n'importe quel type.",
en: "Here is the five-star accommodation for any creatures we'd like to put out of commission. It nullifies their powers while keeping them alive until we figure out what to do with them.\nThey're currently offline, but if I feed the forge enough power above, it can supply the energy to activate them. Come back when you've accumulated enough divine essences of any type."
}
}
]
};
}());
<</script>>
<<goto "Narration">><<script>>
(function () {
GameEvents.set('goToHarpyHQ');
State.variables.narratCtx = {
onEnd: 'DungeonAdvance',
bg : 'villa_inside',
sequence: [
{
type: 'narration',
text: {
fr: "Artémis arrive d'un pas déterminé dans le salon.",
en: "Artemis strides into the drawing room with purpose."
}
},
{
type : 'dialogue',
character: 'artemis_human',
side : 'left',
text: {
fr: "C'est bon — tout est prêt et j'ai repéré où était la Harpie. Préparez-vous, nous y allons !",
en: "We're good — everything is ready and I've located the Harpy. Get ready, we're moving out!"
}
},
{
type : 'dialogue',
character: 'ellie_human',
side : 'left',
text: {
fr: "Vous aurez besoin de mon bouclier — ça risque d'être plus difficile qu'on ne le pense.",
en: "You'll need my shield — this could be harder than we think."
}
},
{
type : 'dialogue',
character: 'artemis_human',
side : 'left',
text: {
fr: "Très bien.",
en: "Fine by me."
}
},
{
type : 'dialogue',
character: 'april_human',
side : 'left',
text: {
fr: "Je viens aussi ! Je l'ai déjà affrontée et je sais à quoi m'attendre !",
en: "I'm coming too! I've already faced her — I know what to expect!"
}
},
{
type : 'dialogue',
character: 'me',
side : 'right',
text: {
fr: "Tu es sûre que tu es en état de le faire ?",
en: "Are you sure you're up for it?"
}
},
{
type : 'dialogue',
character: 'april_human',
side : 'left',
text: {
fr: "Je suis complètement rechargée et j'ai un compte à régler.",
en: "I'm fully recharged and I have a score to settle."
}
},
{
type : 'dialogue',
character: 'artemis_human',
side : 'left',
text: {
fr: "Ça me va. $playerName, toi aussi tu viens. Nous devons récupérer le maximum d'énergie lors de cette mission.",
en: "Works for me. $playerName, you're coming too. We need to harvest as much energy as we can during this mission."
}
},
{
type : 'dialogue',
character: 'me',
side : 'right',
text: {
fr: "D'accord…",
en: "All right…"
}
},
{
type: 'image',
src : 'media/img/story/4artemis_portal.webp'
},
{
type: 'narration',
text: {
fr: "Artémis tend la main et un portail s'ouvre sur une ruelle sombre de la ville.",
en: "Artemis reaches out and a portal opens onto a dark city alley."
}
},
{
type : 'dialogue',
character: 'artemis_human',
side : 'left',
text: {
fr: "Allez, on y va !",
en: "Let's go!"
}
},
{
type: 'image',
src : 'media/img/story/4street.webp'
},
{
type: 'narration',
text: {
fr: "Nous traversons le portail et le changement soudain de température me coupe le souffle. L'air semble vicié et m'empêche de respirer correctement. La ruelle est déserte, mais une silhouette se détache du décor.",
en: "We step through the portal and the sudden change in temperature knocks the breath out of me. The air feels stale and makes it hard to breathe properly. The alley is deserted, but a figure detaches itself from the shadows."
}
},
{
type: 'image',
src : 'media/img/story/4encounter_street.webp'
},
{
type : 'dialogue',
character: 'enforcer',
side : 'left',
text: {
fr: "Je pense que vous vous êtes trompées de quartier, les filles. Vous n'avez rien à faire ici.",
en: "I think you've got the wrong neighbourhood, ladies. You've got no business here."
}
},
{
type : 'dialogue',
character: 'artemis_human',
side : 'left',
text: {
fr: "Moi je pense que nous sommes exactement au bon endroit. Nous cherchons la Harpie.",
en: "I think we're exactly where we need to be. We're looking for the Harpy."
}
},
{
type : 'dialogue',
character: 'enforcer',
side : 'left',
text: {
fr: "Le boss ne reçoit personne. Dégagez, ou on va devenir beaucoup moins aimables.",
en: "The boss isn't seeing anyone. Clear out, or we're going to get a lot less friendly."
}
},
{
type : 'dialogue',
character: 'artemis_human',
side : 'left',
text: {
fr: "Moi ça me va !",
en: "Works for me!"
}
}
]
};
}());
<</script>>
<<goto "Narration">>
/* ── Rencontre avec la Harpie ──────────────────────────────────── */<<script>>
(function () {
GameEvents.set('meetHarpy');
State.variables.narratCtx = {
onEnd: 'DungeonAdvance',
bg : 'city',
sequence: [
{
type: 'narration',
text: {
fr: "Nous arrivons enfin devant la porte du bureau de ce fameux « boss ». Artémis hésite quelques instants, à l'écoute. Quand elle prend enfin la décision d'ouvrir la porte, une voix rauque et féminine s'élève depuis l'intérieur de la pièce.",
en: "We finally reach the door to the so-called \"boss's\" office. Artemis hesitates for a moment, listening. Just as she decides to open it, a husky female voice rises from inside."
}
},
{
type : 'dialogue',
character: 'harpy',
side : 'left',
text: {
fr: "Eh bien, ça prend du temps ! Vous allez enfin l'ouvrir, cette porte ?",
en: "Well, took you long enough! Are you finally going to open that door?"
}
},
{
type: 'image',
src : 'media/img/story/4harpy_meet.webp'
},
{
type: 'narration',
text: {
fr: "Artémis ouvre avec prudence. Nous nous retrouvons face à une femme qui pourrait presque être une caricature de Tony Montana au féminin.",
en: "Artemis opens it cautiously. We come face to face with a woman who could almost be a female caricature of Tony Montana."
}
},
{
type : 'dialogue',
character: 'harpy',
side : 'left',
text: {
fr: "Artémis ! Toujours aussi méfiante, à ce que je vois. Il y a des choses qui ne changent pas. Et d'autres…",
en: "Artemis! Still as suspicious as ever, I see. Some things never change. Others, though…"
}
},
{
type : 'dialogue',
character: 'artemis_human',
side : 'left',
text: {
fr: "Je ne suis pas là pour faire des politesses. Nous voulons des explications concernant l'attaque de notre groupe.",
en: "I'm not here for pleasantries. We want an explanation for the attack on our group."
}
},
{
type : 'dialogue',
character: 'harpy',
side : 'left',
text: {
fr: "Oh, je vois qu'elle a pu s'en sortir, finalement. Coucou, toi.",
en: "Oh, I see she managed to get away after all. Hello there."
}
},
{
type : 'dialogue',
character: 'april_human',
side : 'left',
text: {
fr: "Tu ne me fais pas peur. Tu vas répondre de tes actes !",
en: "You don't scare me. You're going to answer for what you did!"
}
},
{
type : 'dialogue',
character: 'harpy',
side : 'left',
text: {
fr: "Tu as bien raison — ce sont les actes qui comptent ! Le monde a changé depuis notre âge d'or. La crainte que j'inspirais aux humains me suffisait amplement à cette époque. Mais voilà, le monde a changé, il faut savoir prendre des décisions pour survivre. Et je ne veux pas me retrouver du mauvais côté de la barre.",
en: "You're absolutely right — actions are what matter! The world has changed since our golden age. The fear I inspired in humans was more than enough back then. But times have changed, and you have to make decisions to survive. I don't intend to end up on the wrong side of things."
}
},
{
type : 'dialogue',
character: 'artemis_human',
side : 'left',
text: {
fr: "Nous sommes en train de trouver une solution. Tu pourrais arrêter ça et nous rejoindre.",
en: "We're working on a solution. You could stop all this and join us."
}
},
{
type: 'image',
src : 'media/img/story/4harpy_wings.webp'
},
{
type : 'dialogue',
character: 'harpy',
side : 'left',
text: {
fr: "Malheureusement, il est trop tard. J'ai déjà reçu une offre plus alléchante. Tu sais de quoi je parle.",
en: "Unfortunately, it's too late. I've already received a more attractive offer. You know what I'm talking about."
}
},
{
type : 'dialogue',
character: 'artemis_human',
side : 'left',
text: {
fr: "…",
en: "…"
}
},
{
type : 'dialogue',
character: 'me',
side : 'right',
text: {
fr: "De quoi parle-t-elle ?",
en: "What is she talking about?"
}
},
{
type : 'dialogue',
character: 'artemis_human',
side : 'left',
text: {
fr: "Nous verrons cela plus tard. Nous devons d'abord la ramener à la villa.",
en: "We'll deal with that later. First we need to bring her back to the villa."
}
},
{
type : 'dialogue',
character: 'artemis_human',
side : 'left',
text: {
fr: "Si tu ne veux pas nous suivre de ton plein gré, alors nous allons passer au plan B, Harpie.",
en: "If you won't come willingly, then we're moving to plan B, Harpy."
}
},
{
type : 'dialogue',
character: 'harpy',
side : 'left',
text: {
fr: "Enfin ! Je commençais à m'impatienter…",
en: "Finally! I was starting to get impatient…"
}
}
]
};
}());
<</script>>
<<goto "Narration">>
/* ── Victoire — la Harpie emprisonnée ─────────────────────────── */<<script>>
(function () {
GameEvents.set('mistyHQVictory');
State.variables.narratCtx = {
onEnd: 'DungeonAdvance',
bg : 'city',
sequence: [
{
type : 'dialogue',
character: 'april_human',
side : 'left',
text: {
fr: "Elle est affaiblie, maintenant !",
en: "She's weakened now!"
}
},
{
type: 'image',
src : 'media/img/story/4harpy_catch.webp'
},
{
type: 'narration',
text: {
fr: "Artémis agite les doigts et des étincelles en jaillissent. Des lianes surgies du sol emprisonnent la Harpie.",
en: "Artemis wiggles her fingers and sparks fly from them. Vines burst up from the ground and ensnare the Harpy."
}
},
{
type : 'dialogue',
character: 'artemis_human',
side : 'left',
text: {
fr: "Ouvre le portail vers le sous-sol d'Héphaïstos, April !",
en: "Open the portal to Hephaestus's basement, April!"
}
},
{
type: 'narration',
text: {
fr: "April fait de larges mouvements gracieux avec les bras et un portail s'ouvre directement vers la salle des cellules. Artémis en profite pour propulser la Harpie dans l'une d'entre elles et la porte se referme aussitôt automatiquement. Nous sautons dans le portail à notre tour. La Harpie tape frénétiquement sur la vitre, mais elle ne réussit même pas à faire une rayure.",
en: "April sweeps her arms in wide, graceful arcs and a portal opens directly into the cell block. Artemis seizes the moment to hurl the Harpy into one of the cells — the door seals automatically the instant she crosses the threshold. We leap through the portal after them. The Harpy hammers frantically on the glass, but can't even leave a scratch."
}
},
{
type: 'image',
src : 'media/img/story/4harpy_prisonner_laugh.webp'
},
{
type : 'dialogue',
character: 'harpy',
side : 'left',
text: {
fr: "Ne pensez pas que ça va se terminer comme ça ! C'est bientôt la fin pour vous et vos belles valeurs. Vous savez très bien qu'ils vont arriver et vous ne pourrez rien faire contre eux ! Je vais juste faire un petit somme en attendant votre annihilation.",
en: "Don't think this is how it ends! Your time is almost up — yours and your precious little values. You know full well they're coming, and there's nothing you can do to stop them! I'll just take a little nap while I wait for your annihilation."
}
},
{
type : 'dialogue',
character: 'artemis_human',
side : 'left',
text: {
fr: "Allons-y maintenant, nous avons fait notre travail.",
en: "Let's go — we've done what we came to do."
}
},
{
type : 'dialogue',
character: 'me',
side : 'right',
text: {
fr: "Mais de qui parle-t-elle ?",
en: "But who is she talking about?"
}
},
{
type : 'dialogue',
character: 'artemis_human',
side : 'left',
text: {
fr: "Plus tard, nous devons avertir Héra et nous reposer pour l'instant.",
en: "Later — we need to warn Hera and get some rest for now."
}
}
]
};
}());
<</script>>
<<goto "Narration">><<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="sicon-tip"></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="turn-display">
<<= $lang === "fr" ? "Tour" : "Turn">> <span id="turn-num">1</span>
</span>
<button id="end-turn-btn">
<<= $lang === "fr" ? "Fin du Tour" : "End Turn">>
</button>
<span id="ap-display">⚡ <span id="ap-count">3</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">
<span id="target-overlay-prompt"></span>
<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) {
/* Allow entry when the combat def imposes its own team */
var _earlyDef = ctx.combatId && window.DB_Combats && window.DB_Combats[ctx.combatId];
if (!(_earlyDef && _earlyDef.playerTeam && _earlyDef.playerTeam.length)) {
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 _pendingCardDef = null; /* cached card object to survive hand mutations */
var _targetSide = null;
/* Dual-target state: null when inactive; during a 2-step selection:
{ step: 'enemy'|'ally', enemySide, enemyIdx } */
var _dualTarget = 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 _rs = CombatEngine.getState();
console.log('[DD] render: phase=' + _rs.phase + ' turn=' + _rs.turn);
/* Remove any orphaned card tooltips left behind when cards are re-rendered */
document.querySelectorAll('.card-tip').forEach(function (el) { if (el.parentNode) el.parentNode.removeChild(el); });
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;
/* Update End Turn button label with active character name */
var _activeIdx = CombatEngine.getActiveCharIdx();
var _endBtn = document.getElementById("end-turn-btn");
if (_activeIdx >= 0 && s.player.characters[_activeIdx]) {
var _activeName = s.player.characters[_activeIdx].nameKey[lang];
_endBtn.textContent = lang === "fr"
? "Fin du tour de " + _activeName
: "End " + _activeName + "'s Turn";
} else {
_endBtn.textContent = lang === "fr" ? "Fin du Tour" : "End Turn";
}
/* Show overflow cards briefly then animate to discard */
if (s.player.overflowCards && s.player.overflowCards.length > 0) {
CombatUI.showOverflowCards(s.player.overflowCards);
s.player.overflowCards = [];
}
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 */
var s = CombatEngine.getState();
var turnQueue = (s && s.turnQueue) || [];
var turnQueuePos= (s && s.turnQueuePos) || 0;
var activeSide = flip ? "enemy" : "player";
var activeIdx = (s && s.phase === "player" && !flip)
? CombatEngine.getActiveCharIdx() : -1;
/* Build a position-label map: (side+idx) → queue display number (1-based) */
var queueLabels = {};
for (var qi = 0; qi < turnQueue.length; qi++) {
var qe = turnQueue[qi];
queueLabels[qe.side + "_" + qe.idx] = qi + 1;
}
/* ── 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>';
var queueNum = queueLabels[activeSide + "_" + idx];
var isActive = (idx === activeIdx);
var turnBadge = queueNum !== undefined ? '<span class="turn-order-badge' + (isActive ? ' is-active' : '') + '">' + queueNum + '</span>' : '';
/* ── PATCH existing element (animates HP bar via CSS transition) ── */
if (!needsRebuild) {
var wrap = existingWraps[idx];
if (!wrap) return; /* guard: NodeList out of sync (shouldn't happen) */
/* Update alive/dead class and active-turn highlight */
wrap.className = "combatant"
+ (c.alive ? "" : " dead")
+ (window.DB_Divinities && DB_Divinities[c.id] ? " divine" : "")
+ (isActive ? " active-turn" : "");
/* Update turn-order badge (inside .hp-bar-wrap, after .hp-bar) */
var existingBadge = wrap.querySelector(".turn-order-badge");
if (queueNum !== undefined) {
if (existingBadge) {
existingBadge.textContent = queueNum;
existingBadge.classList.toggle('is-active', isActive);
} else {
var badgeEl = document.createElement("span");
badgeEl.className = "turn-order-badge" + (isActive ? " is-active" : "");
badgeEl.textContent = queueNum;
var hpBarWrap = wrap.querySelector(".hp-bar-wrap");
if (hpBarWrap) hpBarWrap.appendChild(badgeEl);
}
} else if (existingBadge) {
existingBadge.parentNode.removeChild(existingBadge);
}
/* 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 — now lives inside .hp-bar */
var shieldBadge = wrap.querySelector(".shield-badge");
if (c.shield) {
if (shieldBadge) {
shieldBadge.textContent = '🛡' + c.shield;
} else {
var hpBar = wrap.querySelector(".hp-bar");
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");
if (window.SoundManager) SoundManager.play("strike");
setTimeout(function () { sprite.classList.remove("shake-hit", "glow-hit"); }, 480);
}
if (!c.alive && prev.hp > 0 && window.SoundManager) SoundManager.play("death");
if (hpGain) { sprite.classList.add("glow-heal"); if (window.SoundManager) SoundManager.play("heal"); setTimeout(function () { sprite.classList.remove("glow-heal"); }, 750); }
if (shieldGain) { sprite.classList.add("glow-shield"); if (window.SoundManager) SoundManager.play("armor"); setTimeout(function () { sprite.classList.remove("glow-shield"); }, 750); }
}
return; /* skip full-build path */
}
/* ── FULL BUILD (first render or team size changed) ── */
console.log('[DD] renderSide full-build:', elId, 'idx=', idx, 'c=', c && c.id);
var wrap = document.createElement("div");
wrap.className = "combatant"
+ (c.alive ? "" : " dead")
+ (window.DB_Divinities && DB_Divinities[c.id] ? " divine" : "")
+ (isActive ? " active-turn" : "");
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>'
+ (c.shield ? '<span class="shield-badge">🛡' + c.shield + '</span>' : '')
+ '</div>'
+ turnBadge
+ '</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" },
counter : { img: "media/img/icons/protect.webp", label: "COUNTER" },
empower : { img: "media/img/icons/strength.webp", label: "EMPOWER" },
expose : { img: "media/img/icons/stun.webp", label: "EXPOSE" },
soul_mark : { img: "media/img/icons/soul_mark.webp", label: "SOUL MARK" },
precision : { img: "media/img/icons/precision.webp", label: "PRÉCISION" }
},
/* Build buff/debuff/tag icons string */
statusIcons: function (c) {
var map = CombatUI.statusIconMap;
var html = '<div class="status-icons">';
function _buffValStr(stat, value) {
if (stat === 'spd') return '+' + Math.round((value - 1) * 100) + '%';
if (stat === 'counter') return '(' + value + ')';
if (stat === 'empower') return '×' + value;
if (stat === 'precision') return '×' + value;
if (value !== 1) return '+' + Math.round((value - 1) * 100) + '%';
return '';
}
function _debuffValStr(stat, value) {
if (stat === 'spd') return '-' + Math.round((1 - value) * 100) + '%';
if (stat === 'expose') return '×' + value;
if (value !== 1) return '-' + Math.round((1 - value) * 100) + '%';
return '';
}
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 valStr = _buffValStr(b.stat, b.value);
var descStr = '';
if (b.stat === 'precision') {
var _critBonus = Math.min(b.value * 15, 60);
descStr = (lang === 'fr')
? b.value + ' stack' + (b.value > 1 ? 's' : '') + ' de Précision. +' + _critBonus + '% de chance de crit en bonus (15% par stack, max +60%).'
: b.value + ' Precision stack' + (b.value > 1 ? 's' : '') + '. +' + _critBonus + '% bonus crit chance (15% per stack, max +60%).';
}
html += '<span class="status-icon buff"'
+ ' data-sicon-name="' + (m ? m.label : b.stat) + '"'
+ ' data-sicon-val="' + valStr + '"'
+ ' data-sicon-dur="' + b.duration + '"'
+ (m ? ' data-sicon-icon="' + m.img + '"' : '')
+ (descStr ? ' data-sicon-desc="' + descStr.replace(/"/g, '"') + '"' : '')
+ ' data-sicon-type="buff">'
+ 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 valStr = _debuffValStr(d.stat, d.value);
html += '<span class="status-icon debuff"'
+ ' data-sicon-name="' + (m ? m.label : d.stat) + '"'
+ ' data-sicon-val="' + valStr + '"'
+ ' data-sicon-dur="' + d.duration + '"'
+ (m ? ' data-sicon-icon="' + m.img + '"' : '')
+ ' data-sicon-type="debuff">'
+ img + '<span class="sicon-dur">' + d.duration + '</span></span>';
});
/* Tags (authority, tempo, momentum, …) */
if (c.tags && c.tags.length) {
var _TAG_IMGS = { fury: 'media/img/icons/fury.webp', guard: 'media/img/icons/guard.webp', grace: 'media/img/icons/grace.webp', authority: 'media/img/icons/authority.webp', relay: 'media/img/icons/relay.webp', momentum: 'media/img/icons/momentum.webp', tempo: 'media/img/icons/tempo.webp' };
var _TAG_EMOJI = { threshold_reduction: '🔰' };
/* Tags that are beneficial to their holder → green; harmful → red; else purple */
var _TAG_NATURE = {
fury: 'buff', guard: 'buff', grace: 'buff', tempo: 'buff',
momentum: 'buff', threshold_reduction: 'buff',
authority: 'debuff' /* applied to enemies — malus for them */
};
c.tags.forEach(function (t) {
var tagDef = window.DB_Tags && DB_Tags[t.id];
var label = tagDef ? tagDef.nameKey[lang] : t.id;
var stackStr = t.stacks > 1 ? '×' + t.stacks : '';
var title = label + (stackStr ? ' ' + stackStr : '') + ' (' + t.duration + 't)';
var tagIcon = _TAG_IMGS[t.id]
? '<img src="' + _TAG_IMGS[t.id] + '" class="sicon-img" alt="' + label + '">'
: (_TAG_EMOJI[t.id] || '🔷');
var nature = _TAG_NATURE[t.id] || 'neutral';
var _badge = (tagDef && tagDef.hideCount) ? '' : '<span class="sicon-dur">' + (t.stacks > 1 ? t.stacks : t.duration) + '</span>';
var _tagDesc = (tagDef && tagDef.descKey) ? (tagDef.descKey[lang] || '').replace(/"/g, '"') : '';
html += '<span class="status-icon tag-icon tag-' + nature + '"'
+ ' data-sicon-name="' + label + '"'
+ ' data-sicon-val="' + (t.stacks > 1 ? '\u00d7' + t.stacks : '') + '"'
+ ' data-sicon-dur="' + t.duration + '"'
+ (_TAG_IMGS[t.id] ? ' data-sicon-icon="' + _TAG_IMGS[t.id] + '"' : '')
+ ' data-sicon-type="tag-' + nature + '"'
+ ' data-sicon-desc="' + _tagDesc + '">'
+ tagIcon + _badge + '</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 (window.SoundManager) SoundManager.play('strike_iron');
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 bPct = (entry.value && entry.value !== 1) ? ' +' + Math.round((entry.value - 1) * 100) + '%' : '';
var bText = '▲ ' + bLabel + bPct;
if (window.SoundManager) SoundManager.play('bonus');
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 dPct = (entry.value && entry.value !== 1) ? ' -' + Math.round((1 - entry.value) * 100) + '%' : '';
var dText = '▼ ' + dLabel + dPct;
if (window.SoundManager) SoundManager.play('malus');
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 (window.SoundManager) SoundManager.play('malus');
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 (window.SoundManager) SoundManager.play('strike_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');
} else if (entry.type === 'execute_trigger') {
var execText = lang === 'fr' ? '💀 EXÉCUTION ×' + entry.multiplier + '!' : '💀 EXECUTE ×' + entry.multiplier + '!';
if (eIdx >= 0) CombatUI.showFloatText('enemy', eIdx, execText, 'float-crit', '1.4rem');
if (pIdx >= 0) CombatUI.showFloatText('player', pIdx, execText, 'float-crit', '1.4rem');
} else if (entry.type === 'tag_applied') {
var tagDefA = window.DB_Tags && DB_Tags[entry.tag];
var tagLabel = tagDefA ? tagDefA.nameKey[lang] : entry.tag;
var tagText = '🔱 ' + tagLabel + (entry.stacks > 1 ? ' ×' + entry.stacks : '');
if (pIdx >= 0) CombatUI.showFloatText('player', pIdx, tagText, 'float-tag-apply', '1rem');
if (eIdx >= 0) CombatUI.showFloatText('enemy', eIdx, tagText, 'float-tag-apply', '1rem');
} else if (entry.type === 'tag_punish') {
var srcIdxP = s.player.characters.findIndex(function (c) { return c.id === entry.source; });
var srcIdxE = s.enemy.characters.findIndex(function (c) { return c.id === entry.source; });
var pText = (lang === 'fr' ? '🔱 Sanction −' : '🔱 Punished −') + entry.amount;
if (srcIdxP >= 0) CombatUI.showFloatText('player', srcIdxP, pText, 'float-tag-punish', '1.1rem');
if (srcIdxE >= 0) CombatUI.showFloatText('enemy', srcIdxE, pText, 'float-tag-punish', '1.1rem');
} else if (entry.type === 'tag_consume') {
var tagDefC = window.DB_Tags && DB_Tags[entry.tag];
var cLabel = tagDefC ? tagDefC.nameKey[lang] : entry.tag;
var cText = '🔱 ' + cLabel + (lang === 'fr' ? ' consommé ×' : ' consumed ×') + entry.stacks + ' ⇒ −' + entry.bonus;
if (pIdx >= 0) CombatUI.showFloatText('player', pIdx, cText, 'float-tag-consume', '1rem');
if (eIdx >= 0) CombatUI.showFloatText('enemy', eIdx, cText, 'float-tag-consume', '1rem');
} else if (entry.type === 'tag_effect') {
var isShield = entry.effect === 'shield';
var isDR = entry.effect === 'dr';
var isRelayGained = entry.effect === 'relay_gained';
var isRelayConsumed = entry.effect === 'relay_consumed';
if (isShield && entry.amount) {
var _tagShieldDef = entry.tag && window.DB_Tags && DB_Tags[entry.tag];
var _tagShieldPfx = _tagShieldDef ? ('🔱 ' + _tagShieldDef.nameKey[lang] + ' ') : '';
var _shieldTxt = _tagShieldPfx + '+' + entry.amount + '🛡';
if (pIdx >= 0) CombatUI.showFloatText('player', pIdx, _shieldTxt, 'float-shield-gain', '1rem');
if (eIdx >= 0) CombatUI.showFloatText('enemy', eIdx, _shieldTxt, 'float-shield-gain', '1rem');
} else if (isDR && entry.amount) {
if (pIdx >= 0) CombatUI.showFloatText('player', pIdx, '🔱 −' + entry.amount, 'float-tag-dr', '1rem');
if (eIdx >= 0) CombatUI.showFloatText('enemy', eIdx, '🔱 −' + entry.amount, 'float-tag-dr', '1rem');
} else if (isRelayGained) {
var _relayGainedIdx = s.player.characters.findIndex(function (c) { return c.id === entry.target; });
if (_relayGainedIdx >= 0) CombatUI.showFloatText('player', _relayGainedIdx, '🔗 +Relais', 'float-tag-apply', '0.9rem');
} else if (isRelayConsumed) {
var _relayConsumedIdx = s.player.characters.findIndex(function (c) { return c.id === entry.target; });
var _relayConsumedTxt = lang === 'fr'
? '🔗 Relais → +' + entry.apGain + ' PA'
: '🔗 Relay → +' + entry.apGain + ' AP';
if (_relayConsumedIdx >= 0) CombatUI.showFloatText('player', _relayConsumedIdx, _relayConsumedTxt, 'float-relay', '1.2rem');
}
} else if (entry.type === 'counter_hit') {
var cSrcPIdx = s.player.characters.findIndex(function (c) { return c.id === entry.source; });
var cSrcEIdx = s.enemy.characters.findIndex(function (c) { return c.id === entry.source; });
var cTgtPIdx = s.player.characters.findIndex(function (c) { return c.id === entry.target; });
var cTgtEIdx = s.enemy.characters.findIndex(function (c) { return c.id === entry.target; });
var ctrText = '\u21a9 ' + entry.amount;
if (cSrcPIdx >= 0) CombatUI.showFloatText('player', cSrcPIdx, ctrText, 'float-counter-src', '1rem');
if (cSrcEIdx >= 0) CombatUI.showFloatText('enemy', cSrcEIdx, ctrText, 'float-counter-src', '1rem');
if (cTgtPIdx >= 0) CombatUI.showFloatText('player', cTgtPIdx, '\u2212' + entry.amount, 'float-damage', '1rem');
if (cTgtEIdx >= 0) CombatUI.showFloatText('enemy', cTgtEIdx, '\u2212' + entry.amount, 'float-damage', '1rem');
}
}
},
/* Show overflow cards (deck full) — fly in from deck, hold, fly out to discard */
showOverflowCards: function (cards) {
if (!cards || !cards.length) return;
var deckEl = document.getElementById("deck-counter");
var handEl = document.getElementById("player-hand");
if (!handEl) return;
var deckRect = deckEl ? deckEl.getBoundingClientRect() : null;
var handRect = handEl.getBoundingClientRect();
/* Natural card size — read from a live hand card; fall back to CSS clamp midpoints */
var CARD_W = 180, CARD_H = 280;
var refCard = handEl.querySelector('.game-card');
if (refCard) {
CARD_W = refCard.offsetWidth || CARD_W;
CARD_H = refCard.offsetHeight || CARD_H;
}
var n = cards.length;
var gap = 12;
var totalW = CARD_W * n + gap * (n - 1);
var groupLeft = Math.round((window.innerWidth - totalW) / 2);
var groupTop = Math.max(8, Math.round(handRect.top - CARD_H - 20));
/* Deck centre — where each card starts its flight */
var deckCX = deckRect ? deckRect.left + deckRect.width / 2 : groupLeft + totalW / 2;
var deckCY = deckRect ? deckRect.top + deckRect.height / 2 : groupTop + CARD_H / 2;
cards.forEach(function (card, i) {
var finalLeft = groupLeft + i * (CARD_W + gap);
var finalTop = groupTop;
/* Initial transform puts the card's centre at the deck counter */
var startTX = Math.round(deckCX - (finalLeft + CARD_W / 2));
var startTY = Math.round(deckCY - (finalTop + CARD_H / 2));
var el = CardUI.buildCardElement(card, lang, false);
el.classList.add("overflow-card");
el.style.cssText = [
'position:fixed',
'left:' + finalLeft + 'px',
'top:' + finalTop + 'px',
'z-index:' + (9000 + i),
'opacity:0',
'transform:translate(' + startTX + 'px,' + startTY + 'px) scale(0.05)',
'transform-origin:center center',
'pointer-events:none',
'will-change:transform,opacity'
].join(';');
document.body.appendChild(el);
/* Stagger each card's fly-in by 120 ms */
setTimeout(function () {
requestAnimationFrame(function () {
requestAnimationFrame(function () {
/* Phase 1 – fly from deck to display position */
el.style.transition = 'transform 0.38s cubic-bezier(.2,.8,.4,1), opacity 0.28s ease';
el.style.transform = 'translate(0,0) scale(1)';
el.style.opacity = '1';
/* Phase 2 – after hold, fly to discard counter */
setTimeout(function () {
var dEl = document.getElementById("discard-counter");
var dRect = dEl ? dEl.getBoundingClientRect() : null;
var tx = dRect ? Math.round((dRect.left + dRect.width / 2) - (finalLeft + CARD_W / 2)) : 0;
var ty = dRect ? Math.round((dRect.top + dRect.height / 2) - (finalTop + CARD_H / 2)) : 0;
el.style.transition = 'transform 0.4s ease-in, opacity 0.35s ease-in';
el.style.transform = 'translate(' + tx + 'px,' + ty + 'px) scale(0.05)';
el.style.opacity = '0';
setTimeout(function () {
if (el.parentNode) el.parentNode.removeChild(el);
}, 420);
}, 900);
});
});
}, i * 120);
});
},
/* 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);
/* Le handler centralisé #player-hand gère le preview sur mobile —
désactive le trigger par carte pour éviter le biais vers la dernière */
cardEl._skipLongPress = true;
var _cs = CombatEngine.getState();
var _activeEntryR = _cs.turnQueue[_cs.turnQueuePos];
var _activeCharIdR = (_activeEntryR && _cs.player.characters[_activeEntryR.idx])
? _cs.player.characters[_activeEntryR.idx].id : null;
var _effCostR = CombatEngine.getEffectiveCardCost(card, _activeCharIdR);
if (_cs.phase === 'player' && _cs.actionPoints >= _effCostR) {
cardEl.classList.add('card-playable');
}
var _costElR = cardEl.querySelector('.card-cost');
if (_costElR && _effCostR !== card.cost) {
_costElR.textContent = _effCostR;
if (_effCostR > card.cost) _costElR.classList.add('card-cost-surcharge');
else _costElR.classList.add('card-cost-cheaper');
}
/* ── Drag support ── */
/* draggable natif uniquement sur desktop — sur mobile le touch D&D gère tout */
if (!('ontouchstart' in window) && navigator.maxTouchPoints < 1) {
cardEl.setAttribute('draggable', 'true');
cardEl.addEventListener('dragstart', function (e) { e.preventDefault(); });
}
/* ── Mouse drag-and-drop (desktop) ────────────────────────── */
(function (capturedIdx, capturedCard) {
var mClone = null, mDragging = false, mStartX = 0, mStartY = 0;
cardEl.addEventListener('mousedown', function (e) {
if (e.button !== 0) return;
var s = CombatEngine.getState();
var _mActiveEntry = s.turnQueue[s.turnQueuePos];
var _mActiveCharId = (_mActiveEntry && s.player.characters[_mActiveEntry.idx])
? s.player.characters[_mActiveEntry.idx].id : null;
if (s.phase !== 'player' || s.actionPoints < CombatEngine.getEffectiveCardCost(capturedCard, _mActiveCharId)) return;
mDragging = false; mClone = null;
mStartX = e.clientX; mStartY = e.clientY;
function onMouseMove(e2) {
var dx = e2.clientX - mStartX;
var dy = e2.clientY - mStartY;
if (!mDragging && (Math.abs(dx) > 8 || Math.abs(dy) > 8)) {
mDragging = true;
var rect = cardEl.getBoundingClientRect();
mClone = cardEl.cloneNode(true);
mClone.style.cssText = [
'position:fixed',
'left:' + rect.left + 'px', 'top:' + rect.top + 'px',
'width:' + rect.width + 'px', 'height:' + rect.height + 'px',
'margin:0', 'z-index:9999', 'pointer-events:none',
'opacity:0.88', 'transform-origin:center center',
'transition:none', 'will-change:transform'
].join(';');
document.body.appendChild(mClone);
cardEl.style.opacity = '0.35';
var needsTarget = capturedCard.effects.some(function (eff) {
return eff.target === 'enemy' || eff.target === 'ally';
});
if (needsTarget) {
var targSide = capturedCard.effects.some(function (eff) { return eff.target === 'enemy'; }) ? 'enemy' : 'player';
var candidates = Array.from(document.querySelectorAll(".combatant[data-side='" + targSide + "']" ))
.filter(function (el2) { return !el2.classList.contains('dead'); });
var tauntEl2 = targSide === 'enemy' ? candidates.find(function (el2) {
var c2 = CombatEngine.getState().enemy.characters[parseInt(el2.dataset.index, 10)];
return c2 && c2.alive && c2.buffs && c2.buffs.some(function (b) { return b.stat === 'taunt'; });
}) : null;
(tauntEl2 ? [tauntEl2] : candidates).forEach(function (el2) { el2.classList.add('drop-target'); });
} else {
var ca2 = document.getElementById('combat-area');
if (ca2) ca2.classList.add('drop-target-area');
}
}
if (mDragging && mClone) {
mClone.style.transform = 'translate(' + dx + 'px,' + dy + 'px) rotate(4deg) scale(1.07)';
mClone.style.display = 'none';
var under = document.elementFromPoint(e2.clientX, e2.clientY);
mClone.style.display = '';
document.querySelectorAll('.combatant.drop-hover').forEach(function (el2) { el2.classList.remove('drop-hover'); });
var caHl = document.getElementById('combat-area');
if (caHl) caHl.classList.remove('drop-hover-area');
if (under) {
var combHl = under.closest && under.closest('.combatant.drop-target');
if (combHl) combHl.classList.add('drop-hover');
if (caHl && caHl.classList.contains('drop-target-area') && caHl.contains(under)) caHl.classList.add('drop-hover-area');
}
}
}
function _mCleanup() {
if (mClone && mClone.parentNode) mClone.parentNode.removeChild(mClone);
mClone = null;
cardEl.style.opacity = '';
document.querySelectorAll('.drop-target,.drop-hover').forEach(function (el2) {
el2.classList.remove('drop-target', 'drop-hover');
});
var caC = document.getElementById('combat-area');
if (caC) caC.classList.remove('drop-target-area', 'drop-hover-area');
}
function onMouseUp(e2) {
document.removeEventListener('mousemove', onMouseMove);
if (!mDragging || !mClone) { mDragging = false; _mCleanup(); return; }
mClone.style.display = 'none';
var under = document.elementFromPoint(e2.clientX, e2.clientY);
cardEl._dragged = true; /* empêche le onclick de jouer la carte */
_mCleanup();
mDragging = false;
var s2 = CombatEngine.getState();
var _mu2ActiveEntry = s2.turnQueue[s2.turnQueuePos];
var _mu2ActiveCharId = (_mu2ActiveEntry && s2.player.characters[_mu2ActiveEntry.idx])
? s2.player.characters[_mu2ActiveEntry.idx].id : null;
if (!under || s2.phase !== 'player' || s2.actionPoints < CombatEngine.getEffectiveCardCost(capturedCard, _mu2ActiveCharId)) return;
var combatant = under.closest && under.closest('.combatant');
var needsTarget = capturedCard.effects.some(function (eff) {
return eff.target === 'enemy' || eff.target === 'ally';
});
if (combatant && needsTarget) {
var dropSide = combatant.dataset.side;
var dropIdx = parseInt(combatant.dataset.index, 10);
var expSide = capturedCard.effects.some(function (eff) { return eff.target === 'enemy'; }) ? 'enemy' : 'player';
if (dropSide !== expSide) return;
var sideState = s2[dropSide === 'enemy' ? 'enemy' : 'player'];
var dropChar = sideState.characters[dropIdx];
if (!dropChar || !dropChar.alive) return;
if (dropSide === 'enemy') {
var tauntIdx = s2.enemy.characters.findIndex(function (c2) {
return c2.alive && c2.buffs && c2.buffs.some(function (b) { return b.stat === 'taunt'; });
});
if (tauntIdx >= 0 && tauntIdx !== dropIdx) return;
}
_pendingCard = capturedIdx;
CombatUI.selectTarget(dropSide, dropIdx);
} else if (!needsTarget) {
var anyZone = under.closest && (under.closest('.combatant') || under.closest('#combat-area'));
if (anyZone) CombatUI.selectCard(capturedIdx);
}
}
document.addEventListener('mousemove', onMouseMove);
document.addEventListener('mouseup', onMouseUp, { once: true });
});
})(idx, card);
/* ── Touch drag-and-drop (mobile tactile uniquement) ──────────── */
(function (capturedIdx, capturedCard) {
if (!('ontouchstart' in window) && navigator.maxTouchPoints < 1) return;
var tClone = null, tDragging = false;
var tStartX = 0, tStartY = 0;
cardEl.addEventListener('touchstart', function (e) {
e.preventDefault(); /* coupe le scroll dès le départ → touchmove cancelable */
tDragging = false; tClone = null;
tStartX = e.touches[0].clientX;
tStartY = e.touches[0].clientY;
}, { passive: false });
cardEl.addEventListener('touchmove', function (e) {
var t = e.touches[0];
var dx = t.clientX - tStartX;
var dy = t.clientY - tStartY;
/* Seuil de déclenchement du drag : 8 px */
if (!tDragging && (Math.abs(dx) > 8 || Math.abs(dy) > 8)) {
var s = CombatEngine.getState();
var _tmActiveEntry = s.turnQueue[s.turnQueuePos];
var _tmActiveCharId = (_tmActiveEntry && s.player.characters[_tmActiveEntry.idx])
? s.player.characters[_tmActiveEntry.idx].id : null;
if (s.phase !== 'player' || s.actionPoints < CombatEngine.getEffectiveCardCost(capturedCard, _tmActiveCharId)) return;
tDragging = true;
/* Clone visuel de la carte */
var rect = cardEl.getBoundingClientRect();
tClone = cardEl.cloneNode(true);
tClone.style.cssText = [
'position:fixed',
'left:' + rect.left + 'px',
'top:' + rect.top + 'px',
'width:' + rect.width + 'px',
'height:' + rect.height + 'px',
'margin:0', 'z-index:9999', 'pointer-events:none',
'opacity:0.88', 'transform-origin:center center',
'transition:none', 'will-change:transform'
].join(';');
document.body.appendChild(tClone);
cardEl.style.opacity = '0.35';
/* Afficher les zones de drop valides */
var needsTarget = capturedCard.effects.some(function (eff) {
return eff.target === 'enemy' || eff.target === 'ally';
});
if (needsTarget) {
var targSide = capturedCard.effects.some(function (eff) { return eff.target === 'enemy'; }) ? 'enemy' : 'player';
var candidates = Array.from(document.querySelectorAll(".combatant[data-side='" + targSide + "']"))
.filter(function (el2) { return !el2.classList.contains('dead'); });
var tauntEl2 = targSide === 'enemy' ? candidates.find(function (el2) {
var c2 = CombatEngine.getState().enemy.characters[parseInt(el2.dataset.index, 10)];
return c2 && c2.alive && c2.buffs && c2.buffs.some(function (b) { return b.stat === 'taunt'; });
}) : null;
(tauntEl2 ? [tauntEl2] : candidates).forEach(function (el2) { el2.classList.add('drop-target'); });
} else {
var ca2 = document.getElementById('combat-area');
if (ca2) ca2.classList.add('drop-target-area');
}
}
if (tDragging && tClone) {
e.preventDefault();
tClone.style.transform = 'translate(' + dx + 'px,' + dy + 'px) rotate(4deg) scale(1.07)';
/* Highlight combattant sous le doigt */
tClone.style.display = 'none';
var under = document.elementFromPoint(t.clientX, t.clientY);
tClone.style.display = '';
document.querySelectorAll('.combatant.drop-hover').forEach(function (el2) { el2.classList.remove('drop-hover'); });
var caHl = document.getElementById('combat-area');
if (caHl) caHl.classList.remove('drop-hover-area');
if (under) {
var combHl = under.closest && under.closest('.combatant.drop-target');
if (combHl) combHl.classList.add('drop-hover');
if (caHl && caHl.classList.contains('drop-target-area') && caHl.contains(under)) caHl.classList.add('drop-hover-area');
}
}
}, { passive: false });
function _tCleanup() {
if (tClone && tClone.parentNode) tClone.parentNode.removeChild(tClone);
tClone = null; tDragging = false;
cardEl.style.opacity = '';
document.querySelectorAll('.drop-target,.drop-hover').forEach(function (el2) {
el2.classList.remove('drop-target', 'drop-hover');
});
var caC = document.getElementById('combat-area');
if (caC) caC.classList.remove('drop-target-area', 'drop-hover-area');
}
cardEl.addEventListener('touchend', function (e) {
if (!tDragging || !tClone) { _tCleanup(); return; }
var t2 = e.changedTouches[0];
tClone.style.display = 'none';
var under = document.elementFromPoint(t2.clientX, t2.clientY);
_tCleanup();
var s = CombatEngine.getState();
var _teActiveEntry = s.turnQueue[s.turnQueuePos];
var _teActiveCharId = (_teActiveEntry && s.player.characters[_teActiveEntry.idx])
? s.player.characters[_teActiveEntry.idx].id : null;
if (!under || s.phase !== 'player' || s.actionPoints < CombatEngine.getEffectiveCardCost(capturedCard, _teActiveCharId)) return;
var combatant = under.closest && under.closest('.combatant');
var needsTarget = capturedCard.effects.some(function (eff) {
return eff.target === 'enemy' || eff.target === 'ally';
});
if (combatant && needsTarget) {
var dropSide = combatant.dataset.side;
var dropIdx = parseInt(combatant.dataset.index, 10);
var expSide = capturedCard.effects.some(function (eff) { return eff.target === 'enemy'; }) ? 'enemy' : 'player';
if (dropSide !== expSide) return;
var sideState = s[dropSide === 'enemy' ? 'enemy' : 'player'];
var dropChar = sideState.characters[dropIdx];
if (!dropChar || !dropChar.alive) return;
/* Respect la contrainte taunt */
if (dropSide === 'enemy') {
var tauntIdx = s.enemy.characters.findIndex(function (c2) {
return c2.alive && c2.buffs && c2.buffs.some(function (b) { return b.stat === 'taunt'; });
});
if (tauntIdx >= 0 && tauntIdx !== dropIdx) return;
}
_pendingCard = capturedIdx;
CombatUI.selectTarget(dropSide, dropIdx);
} else if (!needsTarget) {
var anyZone = under.closest && (under.closest('.combatant') || under.closest('#combat-area'));
if (anyZone) CombatUI.selectCard(capturedIdx);
}
}, { passive: true });
cardEl.addEventListener('touchcancel', _tCleanup, { passive: true });
})(idx, card);
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) return;
/* Use effective cost (includes cross-character +1 surcharge) for the guard */
var _scActiveEntry = s.turnQueue[s.turnQueuePos];
var _scActiveCharId = (_scActiveEntry && s.player.characters[_scActiveEntry.idx])
? s.player.characters[_scActiveEntry.idx].id : null;
if (s.actionPoints < CombatEngine.getEffectiveCardCost(card, _scActiveCharId)) return;
/* If another card was already waiting for a target, cancel it first */
if (_pendingCard !== null) {
CombatUI.cancelTarget();
}
var hasEnemyTarget = card.effects.some(function (e) { return e.target === "enemy"; });
var hasAllyTarget = card.effects.some(function (e) { return e.target === "ally"; });
var needsTarget = hasEnemyTarget || hasAllyTarget;
console.log('[DD:selectCard]', card.id, '| hasEnemy:', hasEnemyTarget, '| hasAlly:', hasAllyTarget, '| needsTarget:', needsTarget);
if (needsTarget) {
_pendingCard = handIndex;
_pendingCardDef = card;
/* Dual-target card (e.g. damage enemy + heal ally): start with enemy step */
if (hasEnemyTarget && hasAllyTarget) {
console.log('[DD:selectCard] dual-target flow START');
_dualTarget = { step: "enemy" };
_targetSide = "enemy";
} else {
_dualTarget = null;
_targetSide = hasEnemyTarget ? "enemy" : "player";
}
var _tOvShow = document.getElementById("target-overlay");
if (_tOvShow) {
_tOvShow.classList.remove("hidden");
var _promptEl = document.getElementById("target-overlay-prompt");
if (_promptEl) {
if (_dualTarget) {
_promptEl.textContent = lang === "fr" ? "1/2 — Choisir la cible ennemie" : "1/2 — Choose enemy target";
} else {
_promptEl.textContent = "";
}
}
}
/* 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 (typeof result === "string") {
/* Engine rejected the card (e.g. not_enough_ap) — restore visual state */
CombatUI.render();
return;
}
if (card.hitEffect && aoeSide && aoeIndices && aoeIndices.length) {
CombatUI.playHitEffect(aoeSide, aoeIndices, card.hitEffect, card.hitEffectDuration);
}
CombatUI.animateCaster(card, function () { CombatUI.render(); });
});
}
},
/* Player clicks a combatant as target */
selectTarget: function (side, index) {
if (_pendingCard === null) return;
/* ── Dual-target step 1: enemy selected → switch to ally selection ── */
if (_dualTarget && _dualTarget.step === "enemy") {
if (side !== "enemy") return; /* only enemy clicks are valid here */
console.log('[DD:selectTarget] dual step1 — enemy selected, side:', side, 'idx:', index);
/* Store the enemy target and advance to step 2 */
_dualTarget = { step: "ally", enemySide: side, enemyIdx: index };
/* Clear current highlighting and show ally targets */
document.querySelectorAll(".combatant").forEach(function (el) {
el.classList.remove("targetable");
});
var allyEls = Array.from(document.querySelectorAll(".combatant[data-side='player']"))
.filter(function (el) { return !el.classList.contains("dead"); });
allyEls.forEach(function (el) { el.classList.add("targetable"); });
/* Update the overlay prompt for step 2 */
var _promptEl2 = document.getElementById("target-overlay-prompt");
if (_promptEl2) _promptEl2.textContent = lang === "fr" ? "2/2 — Choisir l'alliée à soigner" : "2/2 — Choose ally to heal";
return; /* don't play yet */
}
/* ── Dual-target step 2: ally selected → play card with both targets ── */
if (_dualTarget && _dualTarget.step === "ally") {
if (side !== "player") return; /* only ally clicks are valid here */
console.log('[DD:selectTarget] dual step2 — ally selected, side:', side, 'idx:', index, '| savedDual:', JSON.stringify(_dualTarget));
var savedDual = _dualTarget;
_dualTarget = null;
var _tOvD2 = document.getElementById("target-overlay");
if (_tOvD2) _tOvD2.classList.add("hidden");
var _promptD2 = document.getElementById("target-overlay-prompt");
if (_promptD2) _promptD2.textContent = "";
document.querySelectorAll(".combatant").forEach(function (el) {
el.classList.remove("targetable");
});
var pendingIdxD2 = _pendingCard;
_pendingCard = null;
var cardD2 = _pendingCardDef || CombatEngine.getState().player.hand[pendingIdxD2];
_pendingCardDef = null;
if (!cardD2) { console.error('[DD] selectTarget dual step2: card undefined'); CombatUI.render(); return; }
CombatUI.animateCardToDiscard(pendingIdxD2, function () {
_lastLogSerial = CombatEngine.getState().logTotal || 0;
var result = CombatEngine.playCard(pendingIdxD2, savedDual.enemySide, savedDual.enemyIdx, index);
if (cardD2.hitEffect) {
CombatUI.playHitEffect(savedDual.enemySide, [savedDual.enemyIdx], cardD2.hitEffect, cardD2.hitEffectDuration);
}
CombatUI.animateCaster(cardD2, function () {
if (typeof result !== "string") CombatUI.render();
});
});
return;
}
/* ── Normal single-target flow ── */
var _tOvSel = document.getElementById("target-overlay");
if (_tOvSel) _tOvSel.classList.add("hidden");
var _promptSel = document.getElementById("target-overlay-prompt");
if (_promptSel) _promptSel.textContent = "";
document.querySelectorAll(".combatant").forEach(function (el) {
el.classList.remove("targetable");
});
var pendingIdx = _pendingCard;
_pendingCard = null;
var card = _pendingCardDef || CombatEngine.getState().player.hand[pendingIdx];
_pendingCardDef = null;
if (!card) { console.error('[DD] selectTarget: card at hand[' + pendingIdx + '] is undefined/null — aborting'); CombatUI.render(); return; }
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);
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">' + (lang === 'fr' ? 'Esquive' : 'Dodge') + '</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 if (entry.type === 'tag_applied') {
var tagDefLA = window.DB_Tags && DB_Tags[entry.tag];
var tagNameLA = tagDefLA ? tagDefLA.nameKey[lang] : entry.tag;
row.className = 'clog-row clog-tag';
row.innerHTML =
'<div class="clog-main">'
+ '<span class="clog-side">\ud83d\udd31</span>'
+ '<span class="clog-char">' + resolveName(entry.target) + '</span>'
+ '<span class="clog-sep">\u2014</span>'
+ '<span class="clog-card">' + tagNameLA + (entry.stacks > 1 ? ' \u00d7' + entry.stacks : '') + '</span>'
+ '</div>';
} else if (entry.type === 'tag_punish') {
var tagDefLP = window.DB_Tags && DB_Tags[entry.tag];
var tagNameLP = tagDefLP ? tagDefLP.nameKey[lang] : entry.tag;
row.className = 'clog-row clog-tag-punish';
row.innerHTML =
'<div class="clog-main">'
+ '<span class="clog-side">\ud83d\udd31</span>'
+ '<span class="clog-char">' + resolveName(entry.source) + '</span>'
+ '<span class="clog-sep">\u2014</span>'
+ '<span class="clog-card">' + (lang === 'fr' ? 'Sanctionn\u00e9 (' + tagNameLP + ')' : 'Punished (' + tagNameLP + ')') + '</span>'
+ '<span class="clog-sep">\u2212</span>'
+ '<span class="clog-tag-amount">' + entry.amount + '</span>'
+ '</div>';
} else if (entry.type === 'tag_consume') {
var tagDefLC = window.DB_Tags && DB_Tags[entry.tag];
var tagNameLC = tagDefLC ? tagDefLC.nameKey[lang] : entry.tag;
row.className = 'clog-row clog-tag-punish';
row.innerHTML =
'<div class="clog-main">'
+ '<span class="clog-side">\ud83d\udd31</span>'
+ '<span class="clog-char">' + resolveName(entry.target) + '</span>'
+ '<span class="clog-sep">\u2014</span>'
+ '<span class="clog-card">' + tagNameLC + (lang === 'fr' ? ' consomm\u00e9 \u00d7' : ' consumed \u00d7') + entry.stacks + '</span>'
+ '<span class="clog-sep">\u21d2</span>'
+ '<span class="clog-tag-amount">\u2212' + entry.bonus + '</span>'
+ '</div>';
} else if (entry.type === 'tag_effect') {
var tagDefLE = window.DB_Tags && DB_Tags[entry.tag];
var tagNameLE = tagDefLE ? tagDefLE.nameKey[lang] : entry.tag;
var effectMapLE = {
shield: lang === 'fr' ? 'Bouclier accord\u00e9' : 'Shield granted',
weaken: lang === 'fr' ? 'Affaibli' : 'Weakened',
ap_bonus: lang === 'fr' ? '+1 PA' : '+1 AP',
dr: lang === 'fr' ? 'R\u00e9duction d\u00e9g\u00e2ts' : 'Damage reduced'
};
var effectLabelE = effectMapLE[entry.effect] || entry.effect;
row.className = 'clog-row clog-tag';
row.innerHTML =
'<div class="clog-main">'
+ '<span class="clog-side">\ud83d\udd31</span>'
+ '<span class="clog-char">' + resolveName(entry.target) + '</span>'
+ '<span class="clog-sep">\u2014</span>'
+ '<span class="clog-card">' + tagNameLE + ': ' + effectLabelE
+ (entry.amount ? ' (' + entry.amount + ')' : '') + '</span>'
+ '</div>';
} else if (entry.type === 'counter_hit') {
row.className = 'clog-row clog-counter';
row.innerHTML =
'<div class="clog-main">'
+ '<span class="clog-side">\u21a9</span>'
+ '<span class="clog-char">' + resolveName(entry.source) + '</span>'
+ '<span class="clog-sep">\u2192</span>'
+ '<span class="clog-char">' + resolveName(entry.target) + '</span>'
+ '<span class="clog-sep">\u2014</span>'
+ '<span class="clog-card">' + (lang === 'fr' ? 'Contre \u2212' : 'Counter \u2212') + entry.amount + '</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);
}
if (!modal) { console.error('[DD] showLog: combat-log-modal is null'); return; }
modal.classList.remove('hidden');
},
/* Cancel target selection */
cancelTarget: function () {
_pendingCard = null;
_pendingCardDef = null;
_dualTarget = null;
var _tOvCancel = document.getElementById("target-overlay");
if (_tOvCancel) _tOvCancel.classList.add("hidden");
var _promptCancel = document.getElementById("target-overlay-prompt");
if (_promptCancel) _promptCancel.textContent = "";
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 btn = document.getElementById("end-turn-btn");
btn.disabled = true;
var result = CombatEngine.endPlayerTurn();
if (typeof result === "string") { btn.disabled = false; return; }
var s = CombatEngine.getState();
if (s.phase === "end") {
CombatUI.render();
btn.disabled = false;
return;
}
if (s.phase === "player") {
/* Next actor is a player character — just re-render */
CombatUI.render();
btn.disabled = false;
return;
}
/* phase === "enemy" — run the consecutive enemy block */
CombatUI._runEnemyBlock(btn);
},
/* Run the current consecutive enemy block, then restore player control.
One animation plays per card played. Calls itself recursively if a new
enemy block opens right after (e.g. new round starts with enemies first). */
_runEnemyBlock: function (btn) {
var actions = CombatEngine.peekEnemyActions();
var ANIM_MS = 650;
var PAUSE_MS = 1800;
if (actions.length === 0) {
/* All enemies in this block are stunned — skip straight to finishEnemyTurn */
try {
var r = CombatEngine.finishEnemyTurn();
if (typeof r !== "string") CombatUI.render();
} catch (e) { console.error("[DD] finishEnemyTurn(stunned skip) error:", e); }
var sAfter = CombatEngine.getState();
if (sAfter.phase === "enemy") {
CombatUI._runEnemyBlock(btn);
} else {
btn.disabled = false;
}
return;
}
/* Outer loop: one entry per enemy.
Inner loop (runOneCard): one animation per card until AP spent. */
function runAction(actionIdx) {
if (actionIdx >= actions.length) {
/* All enemies done — wrap up turn */
try {
var result = CombatEngine.finishEnemyTurn();
if (typeof result !== "string") CombatUI.render();
} catch (e) {
console.error("[DD] finishEnemyTurn/render error:", e);
}
var sNext = CombatEngine.getState();
if (sNext.phase === "enemy") {
/* New round started with enemies going first */
CombatUI._runEnemyBlock(btn);
} else {
btn.disabled = false;
}
return;
}
var action = actions[actionIdx];
var enemyIdx = action.enemyIdx;
/* Always reset AP/done state at the start of each enemy sub-turn.
This clears any stale _turnDone from a previous round that ended
mid-block (e.g. player died during enemy phase → finishEnemyTurn
was not called → _turnDone persisted). */
CombatEngine.startEnemySubTurn(enemyIdx);
function runOneCard() {
var s = CombatEngine.getState();
if (s.phase === "end") { btn.disabled = false; return; }
/* Enemy spent all its AP for this sub-turn → advance to next action */
if (CombatEngine.isEnemyTurnDone(enemyIdx)) {
setTimeout(function () { runAction(actionIdx + 1); }, 0);
return;
}
var enemyEl = document.getElementById("enemy-side");
var wrap = enemyEl ? enemyEl.children[enemyIdx] : null;
/* Show the card this enemy is about to play */
var _previewId = CombatEngine.peekNextEnemyCard(enemyIdx);
var _previewDef = _previewId && window.DB_Cards && window.DB_Cards[_previewId];
CombatUI.showEnemyCard(_previewDef || null);
/* No affordable card: flush the sub-turn (fires tag ticks etc.) without animation */
if (!_previewId) {
CombatEngine.runSingleEnemyAction(enemyIdx);
CombatUI.render();
setTimeout(function () { runAction(actionIdx + 1); }, 0);
return;
}
var _animDone = false;
function afterAnim() {
if (_animDone) return;
_animDone = true;
try {
CombatEngine.runSingleEnemyAction(enemyIdx);
CombatUI.render();
/* Show card name as float, even when effect has no visible impact */
if (_previewDef) {
var _fl = (window.SugarCube && State.variables.lang) || 'en';
var _cardName = _previewDef.nameKey ? (_previewDef.nameKey[_fl] || _previewDef.nameKey.en) : _previewId;
CombatUI.showFloatText('enemy', enemyIdx, _cardName, 'float-card-played', '.7rem');
}
} catch (e) {
console.error("[DD] afterAnim error (enemy " + enemyIdx + "):", e);
}
setTimeout(function () { runOneCard(); }, PAUSE_MS);
}
if (wrap) {
wrap.classList.add("lunge-forward-enemy");
wrap.addEventListener("animationend", function () {
wrap.classList.remove("lunge-forward-enemy");
afterAnim();
}, { once: true });
setTimeout(function () {
if (wrap.classList.contains("lunge-forward-enemy")) {
wrap.classList.remove("lunge-forward-enemy");
}
afterAnim();
}, ANIM_MS + 100);
} else {
afterAnim();
}
}
runOneCard();
}
runAction(0);
},
/* Show victory / defeat overlay and grant rewards */
showResult: function (s) {
console.log('[DD] showResult called: result=' + (s && s.result));
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");
console.log('[DD] showResult elements: overlay=', overlay, 'title=', titleEl, 'continue=', continueBtn);
if (!overlay) { console.error('[DD] showResult: result-overlay is null'); return; }
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 nextPassage = ctx.dungeonId ? (ctx.onDefeat || "VillaHub")
: ctx.onDefeat ? ctx.onDefeat
: "VillaHub";
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) {
var html = "";
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, 3250, 3850, 4500, 5200, 5950, 6750, 7600, 8500, 9450, 10450];
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 < 20 ? XP_TABLE[nextLv] : capXP;
var xpBase = nextLv >= 2 ? XP_TABLE[nextLv - 1] : 0;
var xpPct = nextLv >= 20 ? 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: redirect to onDefeat ── */
if (ctx.dungeonId) nextPassage = ctx.onDefeat || "VillaHub";
/* ── Survival dungeon: reset to step 0 + full HP on defeat ── */
if (ctx.dungeonId && window.DB_Dungeons) {
var _survDef = DB_Dungeons[ctx.dungeonId];
if (_survDef && _survDef.survivalMode) {
var _sdProg = State.variables.dungeonProgress;
if (_sdProg && _sdProg[ctx.dungeonId]) {
_sdProg[ctx.dungeonId].step = 0;
_sdProg[ctx.dungeonId].survivalProcessed = false;
}
State.variables.wounds = {};
State.variables.survivalHp = {};
State.variables.survivalApBonus = 0;
State.variables.survivalTempCards = [];
State.variables.dungeonProgress = _sdProg;
}
}
/* ── 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 && (_savedCombat.combatId || null) === (ctx.combatId || null)) {
CombatEngine.restoreState(_savedCombat);
} else if (ctx.combatId) {
State.variables.combatState = null; /* discard stale save from a different combat */
CombatEngine.initFromCombatDef(ctx.combatId, team);
} else {
CombatEngine.init(team, enemyIds, ctx.backgroundId || "default");
}
/* Survival mode: inject persistent wounds & carry-over HP into combatants */
if (window.SurvivalEngine && ctx.dungeonId) {
var _dungDef = window.DB_Dungeons && DB_Dungeons[ctx.dungeonId];
if (_dungDef && _dungDef.survivalMode) {
SurvivalEngine.injectWoundsIntoCombat(CombatEngine.getState());
}
}
/* 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 () {
/* ── Fullscreen + landscape on mobile ───────────────────── */
if ('ontouchstart' in window || navigator.maxTouchPoints > 0) {
/* 0. Replier la sidebar (flèche reste visible) */
if (typeof UIBar !== 'undefined') UIBar.stow(true);
/* 1. Fullscreen */
var _fsEl = document.documentElement;
var _fsReq = _fsEl.requestFullscreen
|| _fsEl.webkitRequestFullscreen
|| _fsEl.mozRequestFullScreen
|| _fsEl.msRequestFullscreen;
if (_fsReq) {
try {
var _fsP = _fsReq.call(_fsEl);
/* 2. Orientation lock : attendre la promesse fullscreen si dispo */
function _lockLandscape() {
if (window.screen && window.screen.orientation
&& typeof window.screen.orientation.lock === 'function') {
try {
var _lP = screen.orientation.lock('landscape');
if (_lP && typeof _lP.catch === 'function') _lP.catch(function () {});
} catch (e) {}
}
}
if (_fsP && typeof _fsP.then === 'function') {
_fsP.then(_lockLandscape).catch(function () {});
} else {
_lockLandscape();
}
} catch (e) {}
} else {
/* Pas de fullscreen API — essaie quand même l'orientation */
if (window.screen && window.screen.orientation
&& typeof window.screen.orientation.lock === 'function') {
try {
var _lP2 = screen.orientation.lock('landscape');
if (_lP2 && typeof _lP2.catch === 'function') _lP2.catch(function () {});
} catch (e) {}
}
}
/* 3. Unlock + restore sidebar en quittant le combat */
$(document).one(':passagestart', function () {
try {
var _fsExit = document.exitFullscreen
|| document.webkitExitFullscreen
|| document.mozCancelFullScreen
|| document.msExitFullscreen;
if (_fsExit && document.fullscreenElement) _fsExit.call(document);
} catch (e) {}
try { screen.orientation.unlock(); } catch (e) {}
if (typeof UIBar !== 'undefined') UIBar.unstow(true);
});
}
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;
}
/* ── Survival dungeon: reset to step 0 + full HP on flee ── */
if (ctx.dungeonId && window.DB_Dungeons) {
var _fleeDef = DB_Dungeons[ctx.dungeonId];
if (_fleeDef && _fleeDef.survivalMode) {
var _fleeProg = State.variables.dungeonProgress;
if (_fleeProg && _fleeProg[ctx.dungeonId]) {
_fleeProg[ctx.dungeonId].step = 0;
_fleeProg[ctx.dungeonId].survivalProcessed = false;
}
State.variables.wounds = {};
State.variables.survivalHp = {};
State.variables.survivalApBonus = 0;
State.variables.survivalTempCards = [];
State.variables.dungeonProgress = _fleeProg;
}
}
Engine.play(ctx.onDefeat || "VillaHub");
});
document.getElementById("combat-log-btn").addEventListener("click", function () {
CombatUI.showLog();
});
document.getElementById("combat-log-close").addEventListener("click", function () {
var _logModal = document.getElementById("combat-log-modal");
if (!_logModal) { console.error('[DD] close btn: combat-log-modal is null'); } else { _logModal.classList.add("hidden"); }
});
document.getElementById("combat-log-modal").addEventListener("click", function (e) {
if (e.target === this) this.classList.add("hidden");
});
/* ── Tap routing centralisé sur la main (mobile) ─────────────────────── */
/* Sur mobile, les cartes se chevauchent et la dernière carte (z-index max
par ordre DOM) capte tous les events. Ce handler lit la position X du
tap et route le preview vers la bonne carte via ses bandes exposées. */
(function () {
if (!('ontouchstart' in window) && navigator.maxTouchPoints < 1) return;
var _hEl = document.getElementById('player-hand');
if (!_hEl) return;
var _hStartX = 0, _hStartY = 0, _hMoved = false;
var _hPreview = null;
function _hClose() {
if (_hPreview && _hPreview.parentNode) _hPreview.parentNode.removeChild(_hPreview);
_hPreview = null;
}
_hEl.addEventListener('touchstart', function (e) {
_hMoved = false;
_hStartX = e.touches[0].clientX;
_hStartY = e.touches[0].clientY;
}, { passive: true });
_hEl.addEventListener('touchmove', function (e) {
var dx = e.touches[0].clientX - _hStartX;
var dy = e.touches[0].clientY - _hStartY;
if (Math.abs(dx) > 10 || Math.abs(dy) > 10) _hMoved = true;
}, { passive: true });
_hEl.addEventListener('touchend', function (e) {
if (_hMoved) { _hMoved = false; return; }
_hMoved = false;
var cards = Array.from(_hEl.querySelectorAll('.game-card'));
if (!cards.length) return;
var tx = e.changedTouches[0].clientX;
var fRect = cards[0].getBoundingClientRect();
if (tx < fRect.left) return; /* tap hors de la main */
/* Trouver la carte dont la bande exposée (gauche jusqu'au prochain début) contient tx */
var target = cards[cards.length - 1]; /* défaut : dernière carte */
for (var ci = 0; ci < cards.length - 1; ci++) {
var r0 = cards[ci].getBoundingClientRect();
var r1 = cards[ci + 1].getBoundingClientRect();
if (tx >= r0.left && tx < r1.left) { target = cards[ci]; break; }
}
/* Toggle : 2e tap ferme le preview existant sans en ouvrir un nouveau */
if (_hPreview) { _hClose(); return; }
_hPreview = target.cloneNode(true);
_hPreview.classList.add('card-preview');
document.body.appendChild(_hPreview);
if (window.CardUI) window.CardUI.fitPreviewText(_hPreview);
document.addEventListener('touchstart', _hClose, { passive: true, once: true });
}, { passive: true });
})();
/* ── Drop zone on combat-area (cards with no target: AoE, self, etc.) ── */
var combatArea = document.getElementById('combat-area');
/* ── Status-icon tooltip ── */
var _siTip = document.getElementById('sicon-tip');
if (_siTip) {
combatArea.addEventListener('mouseover', function (ev) {
var ico = ev.target.closest && ev.target.closest('[data-sicon-name]');
if (!ico) return;
var name = ico.dataset.siconName || '';
var val = ico.dataset.siconVal || '';
var dur = ico.dataset.siconDur || '';
var icon = ico.dataset.siconIcon || '';
var type = ico.dataset.siconType || 'buff';
var desc = ico.dataset.siconDesc || '';
var nameColor = (type === 'debuff' || type === 'tag-debuff') ? '#ffb060'
: type === 'tag-neutral' ? '#c8b0ff'
: '#90d8ff';
var borderColor = (type === 'debuff' || type === 'tag-debuff') ? '#c05020'
: type === 'tag-neutral' ? '#9060c8'
: type.startsWith('tag') ? '#50b0e0'
: '#3080c0';
_siTip.innerHTML =
'<div class="sicon-tip-header" style="border-left-color:' + borderColor + '">'
+ (icon ? '<img class="sicon-tip-icon" src="' + icon + '" alt="">' : '')
+ '<span class="sicon-tip-name" style="color:' + nameColor + '">' + name + '</span>'
+ (val ? '<span class="sicon-tip-val">' + val + '</span>' : '')
+ (dur ? '<span class="sicon-tip-dur">' + dur + 't</span>' : '')
+ '</div>'
+ (desc ? '<div class="sicon-tip-desc">' + desc + '</div>' : '');
var r = ico.getBoundingClientRect();
var tx = Math.max(110, Math.min(r.left + r.width / 2, window.innerWidth - 110));
var ty = r.top - 6;
_siTip.style.left = tx + 'px';
_siTip.style.top = ty + 'px';
_siTip.classList.add('sicon-tip-visible');
});
combatArea.addEventListener('mouseout', function (ev) {
var rTarget = ev.relatedTarget;
if (rTarget && rTarget.closest && rTarget.closest('[data-sicon-name]')) return;
_siTip.classList.remove('sicon-tip-visible');
});
}
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();
/* If all enemies are faster than all players, first actor is an enemy —
auto-run their block before player can act. */
(function () {
var _is = CombatEngine.getState();
if (_is && _is.phase === "enemy") {
var _initBtn = document.getElementById("end-turn-btn");
_initBtn.disabled = true;
CombatUI._runEnemyBlock(_initBtn);
}
})();
/* 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 QTE unifié (ForgeEngine)
═══════════════════════════════════════════════════════ -->
<div id="forge-screen">
<!-- Écran de démarrage -->
<div id="forge-start-overlay">
<div id="forge-start-inner">
<img id="forge-start-icon" src="" alt="">
<h2 id="forge-start-title"></h2>
<p id="forge-start-subtitle"><<= $lang === "fr" ? "Accomplissez le rituel de dévotion" : "Complete the devotion ritual">></p>
<button id="forge-start-btn"><<= $lang === "fr" ? "Commencer le Rituel" : "Begin the Ritual">></button>
</div>
</div>
<!-- Jeu principal -->
<div id="forge-main" class="hidden">
<div id="forge-video-area">
<!-- Lecteur vidéo + QTE ring -->
<div id="forge-video-wrap">
<video id="forge-video" autoplay playsinline></video>
<div id="forge-qte-center">
<div id="forge-qte-feedback"></div>
<div id="forge-qte-target" class="hidden"></div>
<div id="forge-qte-ring" class="hidden"></div>
</div>
</div>
<!-- Jauge verticale de progression -->
<div id="forge-gauge-wrap">
<span id="forge-gauge-label">0%</span>
<div id="forge-gauge-track">
<div id="forge-gauge-fill"></div>
</div>
</div>
</div>
<!-- Panneau inférieur : stats rituel + bouton de fin -->
<div id="forge-action-wrap" style="flex-direction:column;gap:8px;">
<div id="ritual-bottom">
<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">—</span>
</div>
<div id="ritual-combo-display" class="hidden"></div>
<div id="ritual-link-reward" class="hidden"></div>
</div>
<button id="forge-end-btn" class="hidden"><<= $lang === "fr" ? "Continuer →" : "Continue →">></button>
</div>
</div>
</div>
<!-- ═══════════════════════════════════════════════════════
SCRIPT
═══════════════════════════════════════════════════════ -->
<<script>>
$(document).one(':passagedisplay', function () {
var cfg = window.DB_Rituals && window.DB_Rituals[State.variables.currentRitual];
if (!cfg) { console.error('DevotionRitual: no config for', State.variables.currentRitual); return; }
var lang = State.variables.lang || 'en';
var iconEl = document.getElementById('forge-start-icon');
var titleEl = document.getElementById('forge-start-title');
if (iconEl) iconEl.src = cfg.icon || '';
if (titleEl) titleEl.textContent = cfg.nameKey ? (cfg.nameKey[lang] || cfg.nameKey['en'] || '') : '';
var startBtn = document.getElementById('forge-start-btn');
if (startBtn) {
startBtn.onclick = function () {
var overlay = document.getElementById('forge-start-overlay');
var main = document.getElementById('forge-main');
if (overlay) overlay.style.display = 'none';
if (main) main.classList.remove('hidden');
window.ForgeEngine.start();
};
}
});
<</script>><<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;
/* ── Survival mode post-combat processing ──────────────────
Run once per combat victory when survivalMode is active.
Flag survivalProcessed prevents double-execution on reentry.
─────────────────────────────────────────────────────────── */
if (def.survivalMode && prog[id].survivalProcessed) {
prog[id].survivalProcessed = false; /* reset for next combat */
/* 1. Save wounds + HP from the just-completed combat */
if (window.SurvivalEngine && window.CombatEngine) {
var _lastState = CombatEngine.getState();
if (_lastState) SurvivalEngine.extractAndSaveWounds(_lastState);
}
/* 2. Low post-combat heal (10% max HP) */
if (window.SurvivalEngine) SurvivalEngine.applyPostCombatHeal(10);
/* 3. Trigger a random inter-combat event */
if (window.SurvivalEngine && !State.variables.survivalEventId) {
var _ev = SurvivalEngine.randomSurvivalEvent();
if (_ev) {
State.variables.survivalEventId = _ev.id;
State.variables.activeDungeonId = id;
State.variables.dungeonProgress = prog;
State.temporary.dungeonNext = 'SurvivalEvent';
return;
}
}
}
/* ── All steps completed → dungeon victory ── */
if (step >= def.steps.length) {
prog[id].completed = true;
State.variables.dungeonProgress = prog;
/* Track completion day for day-gated story events */
if (id === 'hermes_dungeon' && !State.variables.hermesDoneDay)
State.variables.hermesDoneDay = State.variables.day || 1;
/* Auto-recruit the divinity unlocked by this dungeon */
if (def.unlocksDivinity && !State.variables.roster.includes(def.unlocksDivinity))
State.variables.roster.push(def.unlocksDivinity);
/* Clear survival state on dungeon completion */
if (def.survivalMode) {
State.variables.wounds = {};
State.variables.survivalHp = {};
State.variables.survivalMaxHp = {};
State.variables.survivalApBonus = 0;
State.variables.survivalTempCards = [];
}
/* 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'
};
/* Mark that the next DungeonAdvance entry should run survival processing */
if (def.survivalMode) prog[id].survivalProcessed = true;
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;
}
State.variables.dungeonProgress = prog;
}());
<</script>>
<<goto _dungeonNext>><!-- ═══════════════════════════════════════════════════════════
FORGE MINI-GAME
Inspiré du minage de Wartales :
– Jauge qui monte en continu
– Cercle QTE (anneau qui rétrécit) → cliquer = ralentit la jauge
– Complétion → vidéo de fin + bouton continuer
═══════════════════════════════════════════════════════════ -->
<div id="forge-screen">
<!-- ─── Overlay de démarrage ─────────────────────────── -->
<div id="forge-start-overlay">
<div id="forge-start-inner">
<img id="forge-start-icon" src="" alt="">
<h2 id="forge-start-title"></h2>
<p id="forge-start-subtitle"></p>
<button id="forge-start-btn">
<<= $lang === "fr" ? "Commencer" : "Begin">>
</button>
</div>
</div>
<!-- ─── Jeu principal (caché jusqu'au démarrage) ──────── -->
<div id="forge-main" class="hidden">
<!-- Vidéo + jauge verticale -->
<div id="forge-video-area">
<!-- Lecteur vidéo -->
<div id="forge-video-wrap">
<video id="forge-video" autoplay playsinline></video>
<!-- Centre QTE : anneau cible (donut fixe) + anneau mobile qui rétrécit -->
<div id="forge-qte-center">
<div id="forge-qte-feedback"></div>
<div id="forge-qte-target" class="hidden"></div>
<div id="forge-qte-ring" class="hidden"></div>
</div>
<!-- Flammes divines (overlay bas de vidéo) -->
<div id="forge-flames-wrap">
<span id="forge-flames-label">
<img src="media/img/logos/pure_flame.webp" class="stat-label-icon" alt=""> <<= $lang === "fr" ? "Flammes divines" : "Divine Flames">>
</span>
<div id="forge-flames-bar-wrap">
<div id="forge-flames-bar-fill" style="width: <<= Math.min($divineFlames || 0, 100)>>%"></div>
<span id="forge-flames-pct"><<= $divineFlames || 0>>%</span>
</div>
<span id="forge-flames-gain" class="hidden"></span>
</div>
</div>
<!-- Jauge de progression (verticale, à droite) -->
<div id="forge-gauge-wrap">
<span id="forge-gauge-label">0%</span>
<div id="forge-gauge-track">
<div id="forge-gauge-fill"></div>
</div>
</div>
</div>
<!-- Bouton de fin -->
<div id="forge-action-wrap">
<button id="forge-end-btn" class="hidden">
<<= $lang === "fr" ? "Continuer →" : "Continue →">>
</button>
</div>
</div>
</div>
<!-- ═══════════════════════════════════════════════════════════
SCRIPT – initialise l'overlay puis démarre ForgeEngine
═══════════════════════════════════════════════════════════ -->
<<script>>
$(document).one(':passagedisplay', function () {
var lang = State.variables.lang || 'en';
var ctx = State.variables.forgeContext;
var cfg = window.DB_Forge && window.DB_Forge[ctx];
if (!cfg) { console.error('ForgeGame: no config for', ctx); return; }
/* Remplir l'overlay */
var icon = document.getElementById('forge-start-icon');
var title = document.getElementById('forge-start-title');
var sub = document.getElementById('forge-start-subtitle');
if (icon) icon.src = cfg.icon || '';
if (title) title.textContent = cfg.nameKey ? (cfg.nameKey[lang] || cfg.nameKey.en) : '';
if (sub) sub.textContent = cfg.subtitle ? (cfg.subtitle[lang] || cfg.subtitle.en) : '';
/* Bouton de démarrage */
var startBtn = document.getElementById('forge-start-btn');
if (startBtn) {
startBtn.onclick = function () {
var overlay = document.getElementById('forge-start-overlay');
var main = document.getElementById('forge-main');
if (overlay) overlay.style.display = 'none';
if (main) main.classList.remove('hidden');
window.ForgeEngine.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 || '';
var raw = txt(beat.name) || beat.character || '';
return raw.replace(/_[^_]+$/, '');
}
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.display = 'none';
if (skipBtn) skipBtn.style.display = 'none';
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.display = 'none';
if (skipBtn) skipBtn.style.display = 'none';
} else if (state === 'next') {
hint.style.display = '';
hint.style.visibility = 'visible';
hint.textContent = lang === 'fr' ? '\u25bc Cliquer pour continuer' : '\u25bc Click to continue';
if (skipBtn) { skipBtn.style.display = ''; skipBtn.style.visibility = 'visible'; }
} else {
hint.style.display = 'none';
if (skipBtn) { skipBtn.style.display = ''; 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">><div id="survival-event-screen">
<div id="survival-event-box">
<div id="survival-event-icon"></div>
<div id="survival-event-title"></div>
<div id="survival-event-text"></div>
<div id="survival-event-choices"></div>
<!-- Wound panel: persistent wounds on living team members -->
<div id="survival-wounds-panel">
<div id="survival-wounds-title"></div>
<div id="survival-wounds-list"></div>
</div>
</div>
</div>
<<script>>
(function () {
var lang = State.variables.lang;
var evId = State.variables.survivalEventId;
var ev = (window.DB_EVENTS_SURVIVAL || []).find(function (e) { return e.id === evId; });
/* Fallback: pick a random event if none set */
if (!ev && window.SurvivalEngine) {
ev = SurvivalEngine.randomSurvivalEvent();
if (ev) State.variables.survivalEventId = ev.id;
}
$(document).one(':passagedisplay', function () {
/* ── Event title (humanised from id) ── */
var titleDiv = document.getElementById('survival-event-title');
if (titleDiv && ev) {
titleDiv.textContent = ev.id.replace(/_/g, ' ');
}
/* ── Render event text ── */
var textEl = document.getElementById('survival-event-text');
if (textEl && ev) {
textEl.textContent = ev.textKey ? (ev.textKey[lang] || ev.textKey.en) : "???";
}
/* ── Render choices ── */
var choicesEl = document.getElementById('survival-event-choices');
if (choicesEl && ev) {
ev.choices.forEach(function (choice, idx) {
var btn = document.createElement('button');
btn.className = 'survival-choice-btn';
btn.textContent = choice.labelKey ? (choice.labelKey[lang] || choice.labelKey.en) : "???";
btn.onclick = function () {
if (window.SurvivalEngine) {
SurvivalEngine.applySurvivalEvent(ev.id, idx);
}
State.variables.survivalEventId = null;
Engine.play('DungeonAdvance');
};
choicesEl.appendChild(btn);
});
}
/* ── Wounds panel ── */
var titleEl = document.getElementById('survival-wounds-title');
var listEl = document.getElementById('survival-wounds-list');
if (titleEl) titleEl.textContent = lang === 'fr' ? 'Blessures persistantes' : 'Persistent Wounds';
if (listEl && window.SurvivalEngine && window.DB_Wounds) {
var team = (State.variables.team || []).filter(Boolean);
var anyWound = false;
team.forEach(function (charId) {
var wounds = SurvivalEngine.getPersistentWounds(charId);
if (!wounds.length) return;
anyWound = true;
var base = (window.DB_Characters && DB_Characters[charId])
|| (window.DB_Divinities && DB_Divinities[charId]);
var name = base ? (base.nameKey[lang] || charId) : charId;
var row = document.createElement('div');
row.className = 'survival-wound-row';
var badges = wounds.map(function (wId) {
var def = DB_Wounds[wId];
if (!def) return '';
var wName = def.nameKey ? (def.nameKey[lang] || def.nameKey.en) : wId;
var wDesc = def.descKey ? (def.descKey[lang] || def.descKey.en) : '';
return '<span class="wound-badge" title="' + wDesc + '">' + (def.icon || '⚠') + ' ' + wName + '</span>';
}).join('');
row.innerHTML = '<span class="survival-wound-char">' + name + '</span>' + badges;
listEl.appendChild(row);
});
if (!anyWound) {
listEl.innerHTML = '<em class="survival-no-wounds">'
+ (lang === 'fr' ? 'Aucune blessure persistante.' : 'No persistent wounds.') + '</em>';
}
}
});
}());
<</script>>