From bffe9f9f75216a063082f86cafa50768163a0056 Mon Sep 17 00:00:00 2001 From: perco Date: Thu, 11 Jun 2026 14:31:53 +0200 Subject: [PATCH] =?UTF-8?q?1.4.0=20:=20panneau=20=C2=AB=20D=C3=A9tail=20du?= =?UTF-8?q?=20combat=20=C2=BB=20au=20format=20MountyHall?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bloc détaillé à droite du journal pour chaque action : jets d'Attaque/ Esquive avec dés, coup critique, Seuil de Résistance et jet de Résistance des sortilèges, progression de Maîtrise, dégâts, mise à mort, butin et total de PX — y compris pour les attaques des monstres. Ajout des coups critiques (dégâts ×2 si attaque ≥ 2 × esquive), du butin de monstres (40 % : potion ou Mountyzédons) et du mode ?demo=combat pour les captures. Co-Authored-By: Claude Fable 5 --- README.md | 9 ++- css/style.css | 42 +++++++++- index.html | 8 +- js/game.js | 207 ++++++++++++++++++++++++++++++++++++++++++-------- test/smoke.js | 10 ++- 5 files changed, 238 insertions(+), 38 deletions(-) diff --git a/README.md b/README.md index 98f5388..0897e0d 100644 --- a/README.md +++ b/README.md @@ -36,7 +36,7 @@ lance directement une partie (utilisé pour les captures d'écran et les tests). |---|---|---| | Tour de jeu | DLA de 12 h, 6 PA | DLA instantanée quand les PA sont épuisés (ou bouton « Passer la DLA »), 6 PA | | Caractéristiques | ATT/ESQ en D6, DEG/REG en D3, PV, Vue | Identique, profils de base officiels des 5 races (`MH_Rules/Races_*.php`) | -| Combat | Somme ATT D6 vs somme ESQ D6, dégâts DEG D3 − armure | Identique, avec le détail des jets dans le journal | +| Combat | Somme ATT D6 vs somme ESQ D6, dégâts DEG D3 − armure, coup critique (attaque ≥ 2 × esquive) aux dégâts doublés | Identique, avec un panneau « Détail du combat » au format des rapports MH (jets, Seuil de Résistance, jet de Résistance, maîtrise, butin, PX) à droite du journal | | Talents | Compétence + sortilège réservés par race, 15 % de maîtrise initiale, jet D100 sous le %, progression à l'usage (+1D6 % < 50, +1D3 % < 75, +1 % ensuite ; sous 50 %, un échec donne quand même +1 %), plafonds 90 % compétences / 80 % sortilèges, échec = PA partiellement remboursés | Identique | | Magie | Seuil de Résistance basé sur MM vs RM, borné 10–90 % | Identique : jet de résistance des monstres → effets de moitié | | Progression | PX → PI, le N-ième achat coûte N × coût de base racial (table de Rules_3.php) | Identique : 16 PI de base (12 pour la caractéristique favorite), REG 30 (Darkling 22), Armure naturelle 30 pour tous, PV par tranche de 10 | @@ -142,6 +142,13 @@ non affilié au jeu original de Mountyhall SARL. ## Versions +- **1.4.0** (2026-06-11) — Panneau « Détail du combat » au format des rapports + MountyHall (jets d'Attaque/Esquive, coup critique, Seuil et jet de Résistance, + progression de Maîtrise, dégâts, mise à mort, butin, total de PX), pour chaque + action du troll et chaque attaque de monstre. Ajout des coups critiques + (dégâts ×2 quand l'attaque fait au moins le double de l'esquive) et du butin + lâché par les monstres (40 % de chance : potion ou Mountyzédons). + `?demo=combat` simule un combat pour les captures d'écran. - **1.3.2** (2026-06-11) — Sous 50 % de maîtrise, un jet de talent raté fait quand même progresser de 1 % (plafonds inchangés : 90 % compétences, 80 % sortilèges). - **1.3.1** (2026-06-11) — Coûts d'amélioration officiels de Rules_3.php (N-ième diff --git a/css/style.css b/css/style.css index 50e9b3d..cb6ae67 100644 --- a/css/style.css +++ b/css/style.css @@ -258,18 +258,56 @@ header h1 { font-size: 1.3em; color: #8fbf5a; } #equipment div, #inventory .empty { font-size: .88em; padding: 2px 0; color: #c9b685; } -#log { +#bottom-row { + display: flex; + gap: 10px; margin: 0 10px 10px; + align-items: stretch; +} + +#log { + flex: 1 1 55%; background: #14100a; border: 2px solid #4a3a20; border-radius: 8px; padding: 8px 12px; - height: 140px; + height: 190px; overflow-y: auto; font-size: .85em; font-family: monospace; line-height: 1.45; } + +#combat-detail { + flex: 1 1 45%; + background: #14100a; + border: 2px solid #4a3a20; + border-radius: 8px; + padding: 8px 12px; + height: 190px; + overflow-y: auto; + font-size: .85em; + line-height: 1.5; +} +#combat-detail .cd-header { + color: #d8a040; + font-weight: bold; + border-bottom: 1px solid #4a3a20; + margin-bottom: 6px; + position: sticky; + top: 0; + background: #14100a; +} +#combat-detail .cd-empty { color: #6a5c40; font-style: italic; } +.cd-block { + border-bottom: 1px dashed #3a2f1c; + padding: 6px 0; + color: #cbb98c; +} +.cd-block .cd-title { color: #8fbf5a; font-weight: bold; } +.cd-val { color: #e0b070; font-weight: bold; } +.cd-good { color: #8fbf5a; font-weight: bold; } +.cd-bad { color: #e06060; font-weight: bold; } #log .l-combat { color: #e0b070; } #log .l-bad { color: #e06060; } #log .l-good { color: #8fbf5a; } diff --git a/index.html b/index.html index 9ab616c..ed63e3b 100644 --- a/index.html +++ b/index.html @@ -111,7 +111,13 @@ -
+
+
+
+
⚔️ Détail du combat
+
— Aucun combat pour l'instant —
+
+
diff --git a/js/game.js b/js/game.js index 76e183b..4f3888c 100644 --- a/js/game.js +++ b/js/game.js @@ -22,10 +22,11 @@ function resolveAttack(attacker, defender, opts = {}) { attRoll: att.total, esqRoll: esq.total, attDice: attacker.att, esqDice: defender.esq, hit: opts.autoHit || att.total > esq.total, damage: 0, rawDamage: 0, + critical: !opts.autoHit && att.total >= esq.total * 2, // coup critique MH : dégâts doublés }; if (result.hit) { const deg = rollDice(attacker.deg, 3); - result.rawDamage = deg.total + (attacker.degBonus || 0); + result.rawDamage = (deg.total + (attacker.degBonus || 0)) * (result.critical ? 2 : 1); // armure : réduction fixe (équipement) + armure naturelle en D3 (achetée en PI) const armor = opts.ignoreArmor ? 0 : (defender.armor || 0) + (defender.armorDice ? rollDice(defender.armorDice, 3).total : 0); @@ -428,6 +429,71 @@ function inSight(e) { /* ================= Actions du troll ================= */ +/* ----- Détail du combat (panneau façon MountyHall, à droite du journal) ----- */ + +let CD = null; // bloc de détail en cours de construction + +function cdStart(title) { + CD = { title, lines: [] }; + G.actionPX = 0; +} + +function cdLine(html) { + if (CD) CD.lines.push(html); +} + +function cdPXLine() { + cdLine(`Pour cette action, vous avez gagné un total de ${G.actionPX} PX.`); +} + +function cdFlush() { + if (!CD) return; + const block = CD; + CD = null; + if (typeof document === "undefined") return; + const panel = $("combat-detail"); + if (!panel) return; + const empty = panel.querySelector(".cd-empty"); + if (empty) empty.remove(); + const div = document.createElement("div"); + div.className = "cd-block"; + div.innerHTML = `
${block.title}
` + block.lines.map(l => `
${l}
`).join(""); + panel.appendChild(div); + while (panel.querySelectorAll(".cd-block").length > 25) panel.querySelector(".cd-block").remove(); + panel.scrollTop = panel.scrollHeight; +} + +/* Gain de PX (convertis en PI) comptabilisé dans l'action en cours. */ +function gainPX(n) { + G.troll.pi += n; + G.troll.totalPI += n; + if (G.actionPX !== undefined) G.actionPX += n; +} + +/* Jets de toucher au format MH dans le détail. */ +function cdAttackRolls(r) { + cdLine(`Votre jet d'Attaque est de : ${r.attRoll} (${r.attDice}D6)`); + cdLine(`Le jet d'Esquive de votre adversaire est de : ${r.esqRoll} (${r.esqDice}D6)`); + if (r.hit) { + cdLine(r.critical + ? `Vous avez donc TOUCHÉ votre adversaire par un coup critique.` + : `Vous avez donc TOUCHÉ votre adversaire.`); + } else { + cdLine(`Vous avez donc RATÉ votre adversaire.`); + } +} + +/* Jet de résistance magique au format MH. */ +function resistInfo(m) { + const r = resolveSpell(trollMM(), monsterRM(m)); + cdLine(`Seuil de Résistance de la cible : ${r.sr} %`); + cdLine(`Jet de Résistance : ${r.roll}`); + cdLine(r.success + ? `La cible endure donc pleinement l'effet du sortilège.` + : `La cible résiste partiellement à l'effet du sortilège.`); + return !r.success; +} + function spendPA(cost) { if (G.troll.pa < cost) { log(`Pas assez de PA (${cost} requis).`, "info"); return false; } G.troll.pa -= cost; @@ -470,15 +536,21 @@ function tryMove(dx, dy) { function attackMonster(m, opts = {}) { const cost = opts.cost ?? COSTS.attack; if (!spendPA(cost)) return false; + cdStart(`⚔️ Attaque sur ${m.name}`); + cdLine(`Vous avez attaqué ${m.name} avec votre ${G.troll.weapon ? G.troll.weapon.name : "Grosse Patte de Trõll"}.`); const r = resolveAttack(G.troll, effMonster(m)); + cdAttackRolls(r); if (r.hit) { m.pv -= r.damage; - G.troll.pi += 1; G.troll.totalPI += 1; // +1 PX par attaque réussie (converti en PI) - log(`Tu attaques ${m.name} : ATT ${r.attDice}D6=${r.attRoll} vs ESQ ${r.esqDice}D6=${r.esqRoll} → touché ! ${r.rawDamage} dégâts (−${m.armor} armure) = ${r.damage} PV.`, "combat"); + gainPX(1); // +1 PX par attaque réussie (converti en PI) + cdLine(`Vous lui avez infligé ${r.damage} points de dégâts.`); + log(`Tu attaques ${m.name} : ATT ${r.attRoll} vs ESQ ${r.esqRoll} → ${r.critical ? "CRITIQUE ! " : "touché ! "}${r.damage} dégâts.`, "combat"); if (m.pv <= 0) killMonster(m); } else { - log(`Tu attaques ${m.name} : ATT ${r.attDice}D6=${r.attRoll} vs ESQ ${r.esqDice}D6=${r.esqRoll} → esquivé !`, "combat"); + log(`Tu attaques ${m.name} : ATT ${r.attRoll} vs ESQ ${r.esqRoll} → esquivé !`, "combat"); } + cdPXLine(); + cdFlush(); if (G.troll.camo) { G.troll.camo = false; log("Ton attaque brise le camouflage.", "info"); @@ -489,9 +561,19 @@ function attackMonster(m, opts = {}) { function killMonster(m) { const px = m.level * 2; - G.troll.pi += px; G.troll.totalPI += px; + gainPX(px); G.troll.kills++; + cdLine(`Vous l'avez TUÉ et vous avez débarrassé le Monde Souterrain de sa présence maléfique.`); log(`💀 ${m.name} est terrassé ! +${px} PX (convertis en PI à l'entraînement).`, "good"); + // butin : les monstres lâchent parfois un trésor en mourant + if (Math.random() < 0.4 && !G.items.some(i => i.x === m.x && i.y === m.y)) { + const drop = Math.random() < 0.5 + ? { kind: "potion", name: "Potion de Vie", emoji: "🧪", x: m.x, y: m.y } + : { kind: "gold", gold: m.level * 10, name: `${m.level * 10} Mountyzédons`, emoji: "💰", x: m.x, y: m.y }; + G.items.push(drop); + cdLine(`Il a de plus laissé tomber ${drop.emoji} ${drop.name}.`); + log(`${m.name} laisse tomber ${drop.emoji} ${drop.name}.`, "info"); + } G.monsters = G.monsters.filter(x => x !== m); if (G.custom) { if (G.monsters.length === 0) { @@ -508,24 +590,33 @@ function killMonster(m) { const trollMM = () => levelFromTotalPI(G.troll.totalPI) + 1; const monsterRM = m => Math.ceil(m.level / 2); -/* Jet de résistance magique du monstre : s'il résiste, l'effet est de moitié. */ -function resisted(m) { - return !resolveSpell(trollMM(), monsterRM(m)).success; +/* En fin d'action de talent : clôt le bloc de détail s'il est ouvert. */ +function cdClose() { + if (CD) { cdPXLine(); cdFlush(); } } /* Tente le talent : dépense les PA (moitié remboursée en cas d'échec, comme à MH), - * jette la maîtrise et journalise. Retourne true si l'effet s'applique. */ + * jette la maîtrise, journalise et ouvre le bloc de détail du combat. + * Retourne true si l'effet s'applique ; le bloc reste à flusher par l'appelant. */ function tryTalent(talent, cap, cost, label) { if (!spendPA(cost)) return false; + cdStart(label); + const before = talent.pct; // le jet se compare à la maîtrise d'avant progression const r = masteryRoll(talent, cap); + cdLine(`Jet de maîtrise : ${r.roll} (il fallait ${before} % ou moins)`); if (!r.success) { G.troll.pa += Math.floor(cost / 2); - const learn = r.gain ? ` Tu apprends de ton raté : maîtrise +1 % → ${talent.pct} %.` : ""; + if (r.gain) cdLine(`Vous avez augmenté votre Maîtrise de 1 point en apprenant de votre raté (→ ${talent.pct} %).`); + cdLine(`L'action échoue ; la moitié des PA est remboursée.`); + const learn = r.gain ? ` Maîtrise +1 % → ${talent.pct} %.` : ""; log(`${label} : échec (jet ${r.roll}). La moitié des PA est remboursée.${learn}`, "combat"); return false; } - G.troll.pi += 1; G.troll.totalPI += 1; - if (talent.pct < cap && r.gain) log(`${label} : réussite (jet ${r.roll}) — maîtrise +${r.gain} % → ${talent.pct} %.`, "good"); + gainPX(1); + if (r.gain && talent.pct > before) { + cdLine(`Vous avez augmenté votre Maîtrise de ${talent.pct - before} point(s) (→ ${talent.pct} %).`); + log(`${label} : réussite (jet ${r.roll}) — maîtrise +${talent.pct - before} % → ${talent.pct} %.`, "good"); + } return true; } @@ -538,51 +629,63 @@ function useComp() { if (t.compUsed) { log("Botte Secrète déjà utilisée cette DLA.", "info"); return; } const m = adjacentMonster(); if (!m) { log("Aucun monstre adjacent.", "info"); return; } - if (!tryTalent(t.comp, 90, comp.cost, "🥋 Botte Secrète")) { afterAction(); return; } + if (!tryTalent(t.comp, 90, comp.cost, "🥋 Botte Secrète")) { cdFlush(); afterAction(); return; } t.compUsed = true; + cdLine(`Vous portez une Botte Secrète à ${m.name}.`); const pseudo = { att: Math.max(1, Math.floor(t.att * 2 / 3)), deg: Math.max(1, Math.floor(t.att / 2)), degBonus: 0 }; const r = resolveAttack(pseudo, effMonster(m)); + cdAttackRolls(r); if (r.hit) { m.pv -= r.damage; - log(`Botte Secrète : ${r.attDice}D6=${r.attRoll} vs ${r.esqDice}D6=${r.esqRoll} → ${r.damage} dégâts !`, "combat"); + cdLine(`Vous lui avez infligé ${r.damage} points de dégâts.`); + log(`Botte Secrète : ${r.attRoll} vs ${r.esqRoll} → ${r.damage} dégâts !`, "combat"); if (m.pv <= 0) killMonster(m); } else { log(`Botte Secrète esquivée (${r.attRoll} vs ${r.esqRoll}).`, "combat"); } } else if (t.race === "Durakuir") { // Régénération Accrue : 1D3 par tranche de 15 PV max - if (!tryTalent(t.comp, 90, comp.cost, "🥋 Régénération Accrue")) { afterAction(); return; } + if (!tryTalent(t.comp, 90, comp.cost, "🥋 Régénération Accrue")) { cdFlush(); afterAction(); return; } const dice = Math.max(1, Math.floor(t.pvMax / 15)); const heal = rollDice(dice, 3).total; t.pv = Math.min(t.pvMax, t.pv + heal); + cdLine(`Votre chair se referme : vous récupérez ${heal} points de Vie (${dice}D3).`); log(`Régénération Accrue : ${dice}D3 → +${heal} PV.`, "good"); } else if (t.race === "Kastar") { // Accélération du Métabolisme : PV → PA, fatigue croissante - if (!tryTalent(t.comp, 90, comp.cost, "🥋 Accélération du Métabolisme")) { afterAction(); return; } + if (!tryTalent(t.comp, 90, comp.cost, "🥋 Accélération du Métabolisme")) { cdFlush(); afterAction(); return; } const cost = rollDice(1, 3).total + t.fatigue; t.fatigue += 1; t.pv -= cost; t.pa = Math.min(PA_PER_TURN, t.pa + 4); + cdLine(`Vous sacrifiez ${cost} points de Vie pour regagner 4 PA (fatigue : ${t.fatigue}).`); log(`Accélération du Métabolisme : −${cost} PV, +4 PA (fatigue ${t.fatigue}).`, "good"); - if (t.pv <= 0) { die({ name: "son propre métabolisme" }); return; } + if (t.pv <= 0) { cdClose(); die({ name: "son propre métabolisme" }); return; } } else if (t.race === "Tomawak") { // Camouflage : invisible tant qu'on n'attaque pas if (t.camo) { log("Tu es déjà camouflé.", "info"); return; } - if (!tryTalent(t.comp, 90, comp.cost, "🥋 Camouflage")) { afterAction(); return; } + if (!tryTalent(t.comp, 90, comp.cost, "🥋 Camouflage")) { cdFlush(); afterAction(); return; } t.camo = true; + cdLine(`Vous vous fondez dans les ombres : invisible tant que vous n'attaquez pas.`); log("🌫️ Camouflage : les monstres ne te voient plus. Chaque pas risque de te dévoiler (75 % de la maîtrise).", "good"); } else if (t.race === "Darkling") { // Balayage : déstabilise, la cible perd son tour if (t.compUsed) { log("Balayage déjà utilisé cette DLA.", "info"); return; } const m = adjacentMonster(); if (!m) { log("Aucun monstre adjacent.", "info"); return; } - if (!tryTalent(t.comp, 90, comp.cost, "🥋 Balayage")) { afterAction(); return; } + if (!tryTalent(t.comp, 90, comp.cost, "🥋 Balayage")) { cdFlush(); afterAction(); return; } t.compUsed = true; const destab = rollDice(t.att, 6).total; const stab = rollDice(Math.max(1, Math.floor(m.esq * 2 / 3)), 6).total; + cdLine(`Vous balayez ${m.name}.`); + cdLine(`Votre jet de Déstabilisation est de : ${destab}`); + cdLine(`Le jet de Stabilité de votre adversaire est de : ${stab}`); if (destab > stab) { m.skip = (m.skip || 0) + 1; + cdLine(`Votre adversaire est À TERRE et perdra son prochain tour.`); log(`Balayage : ${destab} vs ${stab} → ${m.name} est à terre et perdra son prochain tour !`, "good"); } else { + cdLine(`Votre adversaire reste stable.`); log(`Balayage : ${destab} vs ${stab} → ${m.name} reste stable.`, "combat"); } } + cdClose(); afterAction(); } @@ -594,36 +697,47 @@ function useSort() { if (t.race === "Skrim") { // Hypnotisme : esquive /2 + perd son tour const m = adjacentMonster(); if (!m) { log("Aucun monstre adjacent.", "info"); return; } - if (!tryTalent(t.sort, 80, sort.cost, "🔮 Hypnotisme")) { afterAction(); return; } - const res = resisted(m); + if (!tryTalent(t.sort, 80, sort.cost, "🔮 Hypnotisme")) { cdFlush(); afterAction(); return; } + cdLine(`Vous avez attaqué ${m.name} grâce à un sortilège.`); + const res = resistInfo(m); m.esqHalfTurns = (m.esqHalfTurns || 0) + (res ? 1 : 2); if (!res) m.skip = (m.skip || 0) + 1; + cdLine(res + ? `Son esquive est divisée par 2 pendant 1 tour.` + : `Son esquive est divisée par 2 pendant 2 tours et il perd son prochain tour.`); log(res ? `Hypnotisme : ${m.name} résiste — esquive réduite 1 tour seulement.` : `Hypnotisme : ${m.name} est hébété ! Esquive divisée par 2 et tour perdu.`, "good"); } else if (t.race === "Durakuir") { // Rafale Psychique : imparable, ignore l'armure const m = adjacentMonster(); if (!m) { log("Aucun monstre adjacent.", "info"); return; } - if (!tryTalent(t.sort, 80, sort.cost, "🔮 Rafale Psychique")) { afterAction(); return; } + if (!tryTalent(t.sort, 80, sort.cost, "🔮 Rafale Psychique")) { cdFlush(); afterAction(); return; } + cdLine(`Vous avez attaqué ${m.name} grâce à un sortilège.`); + cdLine(`Votre jet d'Attaque est : automatiquement réussi (imparable).`); let dmg = rollDice(t.deg, 3).total; - const res = resisted(m); + const res = resistInfo(m); if (res) dmg = Math.max(1, Math.ceil(dmg / 2)); m.pv -= dmg; + cdLine(`Vous lui avez infligé ${dmg} points de dégâts (armure ignorée).`); log(`Rafale Psychique : touche automatique, ${dmg} dégâts${res ? " (résisté : moitié)" : ""}, armure ignorée.`, "combat"); if (m.pv <= 0) killMonster(m); } else if (t.race === "Kastar") { // Vampirisme : dégâts + vol de vie const m = adjacentMonster(); if (!m) { log("Aucun monstre adjacent.", "info"); return; } - if (!tryTalent(t.sort, 80, sort.cost, "🔮 Vampirisme")) { afterAction(); return; } + if (!tryTalent(t.sort, 80, sort.cost, "🔮 Vampirisme")) { cdFlush(); afterAction(); return; } + cdLine(`Vous avez attaqué ${m.name} grâce à un sortilège.`); const pseudo = { att: Math.max(1, Math.floor(t.deg * 2 / 3)), deg: t.deg, degBonus: 0 }; const r = resolveAttack(pseudo, effMonster(m), { ignoreArmor: true }); + cdAttackRolls(r); if (r.hit) { let dmg = r.damage; - const res = resisted(m); + const res = resistInfo(m); if (res) dmg = Math.max(1, Math.ceil(dmg / 2)); m.pv -= dmg; const heal = Math.ceil(dmg / 2); t.pv = Math.min(t.pvMax, t.pv + heal); + cdLine(`Vous lui avez infligé ${dmg} points de dégâts (armure ignorée).`); + cdLine(`Vous avez également gagné ${heal} points de Vie grâce au Vampirisme.`); log(`Vampirisme : ${dmg} dégâts${res ? " (résisté)" : ""}, tu draines ${heal} PV.`, "good"); if (m.pv <= 0) killMonster(m); } else { @@ -632,16 +746,19 @@ function useSort() { } else if (t.race === "Tomawak") { // Projectile Magique : à distance, portée = Vue const m = nearestVisibleMonster(); if (!m) { log("Aucun monstre en vue.", "info"); return; } - if (!tryTalent(t.sort, 80, sort.cost, "🔮 Projectile Magique")) { afterAction(); return; } + if (!tryTalent(t.sort, 80, sort.cost, "🔮 Projectile Magique")) { cdFlush(); afterAction(); return; } const dist = Math.max(Math.abs(m.x - t.x), Math.abs(m.y - t.y)); const proxBonus = Math.max(0, t.vue - dist); + cdLine(`Vous avez attaqué ${m.name} grâce à un sortilège (distance ${dist}, bonus de proximité +${proxBonus}D6).`); const pseudo = { att: t.vue + proxBonus, deg: Math.max(1, Math.floor(t.vue / 2)), degBonus: 0 }; const r = resolveAttack(pseudo, effMonster(m), { ignoreArmor: true }); + cdAttackRolls(r); if (r.hit) { let dmg = r.damage; - const res = resisted(m); + const res = resistInfo(m); if (res) dmg = Math.max(1, Math.ceil(dmg / 2)); m.pv -= dmg; + cdLine(`Vous lui avez infligé ${dmg} points de dégâts (armure ignorée).`); log(`Projectile Magique sur ${m.name} (distance ${dist}) : ${dmg} dégâts${res ? " (résisté)" : ""}.`, "combat"); if (m.pv <= 0) killMonster(m); } else { @@ -650,28 +767,34 @@ function useSort() { // 25 % de la maîtrise pour conserver le camouflage if (t.camo && 1 + Math.floor(Math.random() * 100) > Math.floor(t.comp.pct * 0.25)) { t.camo = false; + cdLine(`Votre camouflage se dissipe dans l'éclat du projectile.`); log("Ton camouflage se dissipe dans l'éclat du projectile.", "info"); } } else if (t.race === "Darkling") { // Siphon des Âmes : dégâts + nécrose (−ATT) const m = adjacentMonster(); if (!m) { log("Aucun monstre adjacent.", "info"); return; } - if (!tryTalent(t.sort, 80, sort.cost, "🔮 Siphon des Âmes")) { afterAction(); return; } + if (!tryTalent(t.sort, 80, sort.cost, "🔮 Siphon des Âmes")) { cdFlush(); afterAction(); return; } + cdLine(`Vous avez attaqué ${m.name} grâce à un sortilège.`); const pseudo = { att: t.att, deg: t.reg, degBonus: 0 }; const r = resolveAttack(pseudo, effMonster(m), { ignoreArmor: true }); + cdAttackRolls(r); if (r.hit) { let dmg = r.damage; - const res = resisted(m); + const res = resistInfo(m); if (res) dmg = Math.max(1, Math.ceil(dmg / 2)); m.pv -= dmg; const necrose = res ? Math.max(1, Math.floor(t.reg / 2)) : t.reg; m.attDownDice = (m.attDownDice || 0) + necrose; m.attDownTurns = 2; + cdLine(`Vous lui avez infligé ${dmg} points de dégâts (toute armure ignorée).`); + cdLine(`La nécrose lui fait perdre ${necrose} dé(s) d'Attaque pendant 2 tours.`); log(`Siphon des Âmes : ${dmg} dégâts${res ? " (résisté)" : ""}, nécrose −${necrose} dé(s) d'ATT pendant 2 tours.`, "combat"); if (m.pv <= 0) killMonster(m); } else { log(`Siphon des Âmes esquivé (${r.attRoll} vs ${r.esqRoll}).`, "combat"); } } + cdClose(); if (!G.over) afterAction(); } @@ -788,13 +911,19 @@ function passDLA() { if (dist <= 1 && !t.camo) { const eff = effMonster(m); const r = resolveAttack(eff, t); + cdStart(`${m.emoji} ${m.name} vous attaque`); + cdLine(`Son jet d'Attaque est de : ${r.attRoll} (${r.attDice}D6)`); + cdLine(`Votre jet d'Esquive est de : ${r.esqRoll} (${r.esqDice}D6)`); if (r.hit) { t.pv -= r.damage; - log(`${m.emoji} ${m.name} t'attaque : ${r.attDice}D6=${r.attRoll} vs ${r.esqDice}D6=${r.esqRoll} → ${r.damage} dégâts !`, "bad"); - if (t.pv <= 0) { die(m); return; } + cdLine(`Il vous a TOUCHÉ${r.critical ? ` d'un coup critique` : ""} et vous a infligé ${r.damage} points de dégâts.`); + log(`${m.emoji} ${m.name} t'attaque : ${r.attRoll} vs ${r.esqRoll} → ${r.damage} dégâts !`, "bad"); + if (t.pv <= 0) { cdLine(`Il vous a TERRASSÉ…`); cdFlush(); die(m); return; } } else { + cdLine(`Vous avez ESQUIVÉ son coup.`); log(`${m.emoji} ${m.name} t'attaque… et tu esquives !`, "combat"); } + cdFlush(); } else if (seesTroll && !m.static) { stepToward(m, t); } else if (!m.static && Math.random() < 0.4) { @@ -1075,6 +1204,7 @@ function startGame(customLevel = null) { $("screen-end").classList.add("hidden"); $("screen-game").classList.remove("hidden"); $("log").innerHTML = ""; + $("combat-detail").innerHTML = '
⚔️ Détail du combat
— Aucun combat pour l\'instant —
'; newGame(name, selectedRace, customLevel); render(); renderPanels(); @@ -1107,6 +1237,21 @@ if (typeof document !== "undefined") { if (RACES[params.get("race")]) selectedRace = params.get("race"); if (params.get("name")) $("troll-name").value = params.get("name"); startGame(); + // ?demo=combat : matérialise un gobelin adjacent et enchaîne attaque + sortilège + // (sert aux captures d'écran et au test visuel du détail du combat) + if (params.get("demo") === "combat") { + const t = G.troll; + const spot = [[1, 0], [-1, 0], [0, 1], [0, -1]] + .map(([dx, dy]) => ({ x: t.x + dx, y: t.y + dy })) + .find(p => G.grid[p.y] && G.grid[p.y][p.x] === T_FLOOR); + if (spot) { + const m = makeMonster(1, spot.x, spot.y); + G.monsters.push(m); + render(); + attackMonster(m); + if (G.monsters.includes(m)) useSort(); + } + } } $("btn-restart").onclick = () => { $("screen-end").classList.add("hidden"); diff --git a/test/smoke.js b/test/smoke.js index 225962f..b79a90f 100644 --- a/test/smoke.js +++ b/test/smoke.js @@ -11,17 +11,21 @@ for (let i = 0; i < 200; i++) { assert.strictEqual(r.rolls.length, 3); } -// Combat : un attaquant écrasant doit toucher la plupart du temps. Dégâts en D3. -let hits = 0; +// Combat : un attaquant écrasant doit toucher la plupart du temps. Dégâts en D3, +// doublés sur coup critique (jet d'attaque ≥ 2 × jet d'esquive). +let hits = 0, crits = 0; for (let i = 0; i < 500; i++) { const r = g.resolveAttack({ att: 10, deg: 3, degBonus: 2 }, { esq: 1, armor: 2 }); if (r.hit) { hits++; + if (r.critical) crits++; assert(r.damage >= 1, "dégâts minimum 1"); - assert(r.damage <= 3 * 3 + 2 - 2, "dégâts trop élevés (D3 attendu) : " + r.damage); + const max = (3 * 3 + 2) * (r.critical ? 2 : 1) - 2; + assert(r.damage <= max, "dégâts trop élevés : " + r.damage + (r.critical ? " (critique)" : "")); } } assert(hits > 450, "10D6 vs 1D6 devrait presque toujours toucher (" + hits + "/500)"); +assert(crits > 0, "10D6 vs 1D6 devrait produire des coups critiques"); // autoHit : ne rate jamais (Rafale Psychique) for (let i = 0; i < 50; i++) {