1.4.0 : panneau « Détail du combat » au format MountyHall

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 <noreply@anthropic.com>
This commit is contained in:
perco
2026-06-11 14:31:53 +02:00
co-authored by Claude Fable 5
parent 026e604b0b
commit bffe9f9f75
5 changed files with 238 additions and 38 deletions
+8 -1
View File
@@ -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 | | 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`) | | 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 | | 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é 1090 % | Identique : jet de résistance des monstres → effets de moitié | | Magie | Seuil de Résistance basé sur MM vs RM, borné 1090 % | 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 | | 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 ## 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 - **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). 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 - **1.3.1** (2026-06-11) — Coûts d'amélioration officiels de Rules_3.php (N-ième
+40 -2
View File
@@ -258,18 +258,56 @@ header h1 { font-size: 1.3em; color: #8fbf5a; }
#equipment div, #inventory .empty { font-size: .88em; padding: 2px 0; color: #c9b685; } #equipment div, #inventory .empty { font-size: .88em; padding: 2px 0; color: #c9b685; }
#log { #bottom-row {
display: flex;
gap: 10px;
margin: 0 10px 10px; margin: 0 10px 10px;
align-items: stretch;
}
#log {
flex: 1 1 55%;
background: #14100a; background: #14100a;
border: 2px solid #4a3a20; border: 2px solid #4a3a20;
border-radius: 8px; border-radius: 8px;
padding: 8px 12px; padding: 8px 12px;
height: 140px; height: 190px;
overflow-y: auto; overflow-y: auto;
font-size: .85em; font-size: .85em;
font-family: monospace; font-family: monospace;
line-height: 1.45; 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-combat { color: #e0b070; }
#log .l-bad { color: #e06060; } #log .l-bad { color: #e06060; }
#log .l-good { color: #8fbf5a; } #log .l-good { color: #8fbf5a; }
+7 -1
View File
@@ -111,7 +111,13 @@
</div> </div>
</aside> </aside>
</div> </div>
<div id="log"></div> <div id="bottom-row">
<div id="log"></div>
<div id="combat-detail">
<div class="cd-header">⚔️ Détail du combat</div>
<div class="cd-empty">— Aucun combat pour l'instant —</div>
</div>
</div>
</div> </div>
<!-- Écran de fin --> <!-- Écran de fin -->
+176 -31
View File
@@ -22,10 +22,11 @@ function resolveAttack(attacker, defender, opts = {}) {
attRoll: att.total, esqRoll: esq.total, attRoll: att.total, esqRoll: esq.total,
attDice: attacker.att, esqDice: defender.esq, attDice: attacker.att, esqDice: defender.esq,
hit: opts.autoHit || att.total > esq.total, damage: 0, rawDamage: 0, 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) { if (result.hit) {
const deg = rollDice(attacker.deg, 3); 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) // armure : réduction fixe (équipement) + armure naturelle en D3 (achetée en PI)
const armor = opts.ignoreArmor ? 0 const armor = opts.ignoreArmor ? 0
: (defender.armor || 0) + (defender.armorDice ? rollDice(defender.armorDice, 3).total : 0); : (defender.armor || 0) + (defender.armorDice ? rollDice(defender.armorDice, 3).total : 0);
@@ -428,6 +429,71 @@ function inSight(e) {
/* ================= Actions du troll ================= */ /* ================= 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 <span class="cd-val">${G.actionPX} PX</span>.`);
}
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 = `<div class="cd-title">${block.title}</div>` + block.lines.map(l => `<div>${l}</div>`).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 : <span class="cd-val">${r.attRoll}</span> (${r.attDice}D6)`);
cdLine(`Le jet d'Esquive de votre adversaire est de : <span class="cd-val">${r.esqRoll}</span> (${r.esqDice}D6)`);
if (r.hit) {
cdLine(r.critical
? `Vous avez donc <span class="cd-good">TOUCHÉ</span> votre adversaire par un <span class="cd-good">coup critique</span>.`
: `Vous avez donc <span class="cd-good">TOUCHÉ</span> votre adversaire.`);
} else {
cdLine(`Vous avez donc <span class="cd-bad">RATÉ</span> 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 : <span class="cd-val">${r.sr} %</span>`);
cdLine(`Jet de Résistance : <span class="cd-val">${r.roll}</span>`);
cdLine(r.success
? `La cible endure donc <span class="cd-good">pleinement l'effet</span> du sortilège.`
: `La cible <span class="cd-bad">résiste partiellement</span> à l'effet du sortilège.`);
return !r.success;
}
function spendPA(cost) { function spendPA(cost) {
if (G.troll.pa < cost) { log(`Pas assez de PA (${cost} requis).`, "info"); return false; } if (G.troll.pa < cost) { log(`Pas assez de PA (${cost} requis).`, "info"); return false; }
G.troll.pa -= cost; G.troll.pa -= cost;
@@ -470,15 +536,21 @@ function tryMove(dx, dy) {
function attackMonster(m, opts = {}) { function attackMonster(m, opts = {}) {
const cost = opts.cost ?? COSTS.attack; const cost = opts.cost ?? COSTS.attack;
if (!spendPA(cost)) return false; if (!spendPA(cost)) return false;
cdStart(`⚔️ Attaque sur ${m.name}`);
cdLine(`Vous avez attaqué <b>${m.name}</b> avec votre ${G.troll.weapon ? G.troll.weapon.name : "Grosse Patte de Trõll"}.`);
const r = resolveAttack(G.troll, effMonster(m)); const r = resolveAttack(G.troll, effMonster(m));
cdAttackRolls(r);
if (r.hit) { if (r.hit) {
m.pv -= r.damage; m.pv -= r.damage;
G.troll.pi += 1; G.troll.totalPI += 1; // +1 PX par attaque réussie (converti en PI) gainPX(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"); cdLine(`Vous lui avez infligé <span class="cd-val">${r.damage} points de dégâts</span>.`);
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); if (m.pv <= 0) killMonster(m);
} else { } 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) { if (G.troll.camo) {
G.troll.camo = false; G.troll.camo = false;
log("Ton attaque brise le camouflage.", "info"); log("Ton attaque brise le camouflage.", "info");
@@ -489,9 +561,19 @@ function attackMonster(m, opts = {}) {
function killMonster(m) { function killMonster(m) {
const px = m.level * 2; const px = m.level * 2;
G.troll.pi += px; G.troll.totalPI += px; gainPX(px);
G.troll.kills++; G.troll.kills++;
cdLine(`Vous l'avez <span class="cd-good">TUÉ</span> 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"); 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 <span class="cd-val">${drop.emoji} ${drop.name}</span>.`);
log(`${m.name} laisse tomber ${drop.emoji} ${drop.name}.`, "info");
}
G.monsters = G.monsters.filter(x => x !== m); G.monsters = G.monsters.filter(x => x !== m);
if (G.custom) { if (G.custom) {
if (G.monsters.length === 0) { if (G.monsters.length === 0) {
@@ -508,24 +590,33 @@ function killMonster(m) {
const trollMM = () => levelFromTotalPI(G.troll.totalPI) + 1; const trollMM = () => levelFromTotalPI(G.troll.totalPI) + 1;
const monsterRM = m => Math.ceil(m.level / 2); const monsterRM = m => Math.ceil(m.level / 2);
/* Jet de résistance magique du monstre : s'il résiste, l'effet est de moitié. */ /* En fin d'action de talent : clôt le bloc de détail s'il est ouvert. */
function resisted(m) { function cdClose() {
return !resolveSpell(trollMM(), monsterRM(m)).success; if (CD) { cdPXLine(); cdFlush(); }
} }
/* Tente le talent : dépense les PA (moitié remboursée en cas d'échec, comme à MH), /* 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) { function tryTalent(talent, cap, cost, label) {
if (!spendPA(cost)) return false; 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); const r = masteryRoll(talent, cap);
cdLine(`Jet de maîtrise : <span class="cd-val">${r.roll}</span> (il fallait ${before} % ou moins)`);
if (!r.success) { if (!r.success) {
G.troll.pa += Math.floor(cost / 2); 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 <span class="cd-good">augmenté votre Maîtrise</span> de <span class="cd-val">1 point</span> en apprenant de votre raté (${talent.pct} %).`);
cdLine(`L'action <span class="cd-bad">échoue</span> ; 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"); log(`${label} : échec (jet ${r.roll}). La moitié des PA est remboursée.${learn}`, "combat");
return false; return false;
} }
G.troll.pi += 1; G.troll.totalPI += 1; gainPX(1);
if (talent.pct < cap && r.gain) log(`${label} : réussite (jet ${r.roll}) — maîtrise +${r.gain} % → ${talent.pct} %.`, "good"); if (r.gain && talent.pct > before) {
cdLine(`Vous avez <span class="cd-good">augmenté votre Maîtrise</span> de <span class="cd-val">${talent.pct - before} point(s)</span> (→ ${talent.pct} %).`);
log(`${label} : réussite (jet ${r.roll}) — maîtrise +${talent.pct - before} % → ${talent.pct} %.`, "good");
}
return true; return true;
} }
@@ -538,51 +629,63 @@ function useComp() {
if (t.compUsed) { log("Botte Secrète déjà utilisée cette DLA.", "info"); return; } if (t.compUsed) { log("Botte Secrète déjà utilisée cette DLA.", "info"); return; }
const m = adjacentMonster(); const m = adjacentMonster();
if (!m) { log("Aucun monstre adjacent.", "info"); return; } 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; t.compUsed = true;
cdLine(`Vous portez une <b>Botte Secrète</b> à <b>${m.name}</b>.`);
const pseudo = { att: Math.max(1, Math.floor(t.att * 2 / 3)), deg: Math.max(1, Math.floor(t.att / 2)), degBonus: 0 }; 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)); const r = resolveAttack(pseudo, effMonster(m));
cdAttackRolls(r);
if (r.hit) { if (r.hit) {
m.pv -= r.damage; 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é <span class="cd-val">${r.damage} points de dégâts</span>.`);
log(`Botte Secrète : ${r.attRoll} vs ${r.esqRoll}${r.damage} dégâts !`, "combat");
if (m.pv <= 0) killMonster(m); if (m.pv <= 0) killMonster(m);
} else { } else {
log(`Botte Secrète esquivée (${r.attRoll} vs ${r.esqRoll}).`, "combat"); 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 } 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 dice = Math.max(1, Math.floor(t.pvMax / 15));
const heal = rollDice(dice, 3).total; const heal = rollDice(dice, 3).total;
t.pv = Math.min(t.pvMax, t.pv + heal); t.pv = Math.min(t.pvMax, t.pv + heal);
cdLine(`Votre chair se referme : vous récupérez <span class="cd-val">${heal} points de Vie</span> (${dice}D3).`);
log(`Régénération Accrue : ${dice}D3 → +${heal} PV.`, "good"); 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 } 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; const cost = rollDice(1, 3).total + t.fatigue;
t.fatigue += 1; t.fatigue += 1;
t.pv -= cost; t.pv -= cost;
t.pa = Math.min(PA_PER_TURN, t.pa + 4); t.pa = Math.min(PA_PER_TURN, t.pa + 4);
cdLine(`Vous sacrifiez <span class="cd-bad">${cost} points de Vie</span> pour regagner <span class="cd-val">4 PA</span> (fatigue : ${t.fatigue}).`);
log(`Accélération du Métabolisme : ${cost} PV, +4 PA (fatigue ${t.fatigue}).`, "good"); 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 } 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 (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; t.camo = true;
cdLine(`Vous vous fondez dans les ombres : <span class="cd-good">invisible</span> 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"); 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 } 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; } if (t.compUsed) { log("Balayage déjà utilisé cette DLA.", "info"); return; }
const m = adjacentMonster(); const m = adjacentMonster();
if (!m) { log("Aucun monstre adjacent.", "info"); return; } 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; t.compUsed = true;
const destab = rollDice(t.att, 6).total; const destab = rollDice(t.att, 6).total;
const stab = rollDice(Math.max(1, Math.floor(m.esq * 2 / 3)), 6).total; const stab = rollDice(Math.max(1, Math.floor(m.esq * 2 / 3)), 6).total;
cdLine(`Vous balayez <b>${m.name}</b>.`);
cdLine(`Votre jet de Déstabilisation est de : <span class="cd-val">${destab}</span>`);
cdLine(`Le jet de Stabilité de votre adversaire est de : <span class="cd-val">${stab}</span>`);
if (destab > stab) { if (destab > stab) {
m.skip = (m.skip || 0) + 1; m.skip = (m.skip || 0) + 1;
cdLine(`Votre adversaire est <span class="cd-good">À TERRE</span> et perdra son prochain tour.`);
log(`Balayage : ${destab} vs ${stab}${m.name} est à terre et perdra son prochain tour !`, "good"); log(`Balayage : ${destab} vs ${stab}${m.name} est à terre et perdra son prochain tour !`, "good");
} else { } else {
cdLine(`Votre adversaire <span class="cd-bad">reste stable</span>.`);
log(`Balayage : ${destab} vs ${stab}${m.name} reste stable.`, "combat"); log(`Balayage : ${destab} vs ${stab}${m.name} reste stable.`, "combat");
} }
} }
cdClose();
afterAction(); afterAction();
} }
@@ -594,36 +697,47 @@ function useSort() {
if (t.race === "Skrim") { // Hypnotisme : esquive /2 + perd son tour if (t.race === "Skrim") { // Hypnotisme : esquive /2 + perd son tour
const m = adjacentMonster(); const m = adjacentMonster();
if (!m) { log("Aucun monstre adjacent.", "info"); return; } if (!m) { log("Aucun monstre adjacent.", "info"); return; }
if (!tryTalent(t.sort, 80, sort.cost, "🔮 Hypnotisme")) { afterAction(); return; } if (!tryTalent(t.sort, 80, sort.cost, "🔮 Hypnotisme")) { cdFlush(); afterAction(); return; }
const res = resisted(m); cdLine(`Vous avez attaqué <b>${m.name}</b> grâce à un sortilège.`);
const res = resistInfo(m);
m.esqHalfTurns = (m.esqHalfTurns || 0) + (res ? 1 : 2); m.esqHalfTurns = (m.esqHalfTurns || 0) + (res ? 1 : 2);
if (!res) m.skip = (m.skip || 0) + 1; if (!res) m.skip = (m.skip || 0) + 1;
cdLine(res
? `Son esquive est <span class="cd-val">divisée par 2</span> pendant 1 tour.`
: `Son esquive est <span class="cd-val">divisée par 2</span> pendant 2 tours et il <span class="cd-good">perd son prochain tour</span>.`);
log(res log(res
? `Hypnotisme : ${m.name} résiste — esquive réduite 1 tour seulement.` ? `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"); : `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 } else if (t.race === "Durakuir") { // Rafale Psychique : imparable, ignore l'armure
const m = adjacentMonster(); const m = adjacentMonster();
if (!m) { log("Aucun monstre adjacent.", "info"); return; } 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é <b>${m.name}</b> grâce à un sortilège.`);
cdLine(`Votre jet d'Attaque est : <span class="cd-good">automatiquement réussi</span> (imparable).`);
let dmg = rollDice(t.deg, 3).total; 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)); if (res) dmg = Math.max(1, Math.ceil(dmg / 2));
m.pv -= dmg; m.pv -= dmg;
cdLine(`Vous lui avez infligé <span class="cd-val">${dmg} points de dégâts</span> (armure ignorée).`);
log(`Rafale Psychique : touche automatique, ${dmg} dégâts${res ? " (résisté : moitié)" : ""}, armure ignorée.`, "combat"); log(`Rafale Psychique : touche automatique, ${dmg} dégâts${res ? " (résisté : moitié)" : ""}, armure ignorée.`, "combat");
if (m.pv <= 0) killMonster(m); if (m.pv <= 0) killMonster(m);
} else if (t.race === "Kastar") { // Vampirisme : dégâts + vol de vie } else if (t.race === "Kastar") { // Vampirisme : dégâts + vol de vie
const m = adjacentMonster(); const m = adjacentMonster();
if (!m) { log("Aucun monstre adjacent.", "info"); return; } 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é <b>${m.name}</b> grâce à un sortilège.`);
const pseudo = { att: Math.max(1, Math.floor(t.deg * 2 / 3)), deg: t.deg, degBonus: 0 }; 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 }); const r = resolveAttack(pseudo, effMonster(m), { ignoreArmor: true });
cdAttackRolls(r);
if (r.hit) { if (r.hit) {
let dmg = r.damage; let dmg = r.damage;
const res = resisted(m); const res = resistInfo(m);
if (res) dmg = Math.max(1, Math.ceil(dmg / 2)); if (res) dmg = Math.max(1, Math.ceil(dmg / 2));
m.pv -= dmg; m.pv -= dmg;
const heal = Math.ceil(dmg / 2); const heal = Math.ceil(dmg / 2);
t.pv = Math.min(t.pvMax, t.pv + heal); t.pv = Math.min(t.pvMax, t.pv + heal);
cdLine(`Vous lui avez infligé <span class="cd-val">${dmg} points de dégâts</span> (armure ignorée).`);
cdLine(`Vous avez également gagné <span class="cd-good">${heal} points de Vie</span> grâce au Vampirisme.`);
log(`Vampirisme : ${dmg} dégâts${res ? " (résisté)" : ""}, tu draines ${heal} PV.`, "good"); log(`Vampirisme : ${dmg} dégâts${res ? " (résisté)" : ""}, tu draines ${heal} PV.`, "good");
if (m.pv <= 0) killMonster(m); if (m.pv <= 0) killMonster(m);
} else { } else {
@@ -632,16 +746,19 @@ function useSort() {
} else if (t.race === "Tomawak") { // Projectile Magique : à distance, portée = Vue } else if (t.race === "Tomawak") { // Projectile Magique : à distance, portée = Vue
const m = nearestVisibleMonster(); const m = nearestVisibleMonster();
if (!m) { log("Aucun monstre en vue.", "info"); return; } 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 dist = Math.max(Math.abs(m.x - t.x), Math.abs(m.y - t.y));
const proxBonus = Math.max(0, t.vue - dist); const proxBonus = Math.max(0, t.vue - dist);
cdLine(`Vous avez attaqué <b>${m.name}</b> 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 pseudo = { att: t.vue + proxBonus, deg: Math.max(1, Math.floor(t.vue / 2)), degBonus: 0 };
const r = resolveAttack(pseudo, effMonster(m), { ignoreArmor: true }); const r = resolveAttack(pseudo, effMonster(m), { ignoreArmor: true });
cdAttackRolls(r);
if (r.hit) { if (r.hit) {
let dmg = r.damage; let dmg = r.damage;
const res = resisted(m); const res = resistInfo(m);
if (res) dmg = Math.max(1, Math.ceil(dmg / 2)); if (res) dmg = Math.max(1, Math.ceil(dmg / 2));
m.pv -= dmg; m.pv -= dmg;
cdLine(`Vous lui avez infligé <span class="cd-val">${dmg} points de dégâts</span> (armure ignorée).`);
log(`Projectile Magique sur ${m.name} (distance ${dist}) : ${dmg} dégâts${res ? " (résisté)" : ""}.`, "combat"); log(`Projectile Magique sur ${m.name} (distance ${dist}) : ${dmg} dégâts${res ? " (résisté)" : ""}.`, "combat");
if (m.pv <= 0) killMonster(m); if (m.pv <= 0) killMonster(m);
} else { } else {
@@ -650,28 +767,34 @@ function useSort() {
// 25 % de la maîtrise pour conserver le camouflage // 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)) { if (t.camo && 1 + Math.floor(Math.random() * 100) > Math.floor(t.comp.pct * 0.25)) {
t.camo = false; t.camo = false;
cdLine(`Votre <span class="cd-bad">camouflage se dissipe</span> dans l'éclat du projectile.`);
log("Ton camouflage se dissipe dans l'éclat du projectile.", "info"); 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) } else if (t.race === "Darkling") { // Siphon des Âmes : dégâts + nécrose (ATT)
const m = adjacentMonster(); const m = adjacentMonster();
if (!m) { log("Aucun monstre adjacent.", "info"); return; } 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é <b>${m.name}</b> grâce à un sortilège.`);
const pseudo = { att: t.att, deg: t.reg, degBonus: 0 }; const pseudo = { att: t.att, deg: t.reg, degBonus: 0 };
const r = resolveAttack(pseudo, effMonster(m), { ignoreArmor: true }); const r = resolveAttack(pseudo, effMonster(m), { ignoreArmor: true });
cdAttackRolls(r);
if (r.hit) { if (r.hit) {
let dmg = r.damage; let dmg = r.damage;
const res = resisted(m); const res = resistInfo(m);
if (res) dmg = Math.max(1, Math.ceil(dmg / 2)); if (res) dmg = Math.max(1, Math.ceil(dmg / 2));
m.pv -= dmg; m.pv -= dmg;
const necrose = res ? Math.max(1, Math.floor(t.reg / 2)) : t.reg; const necrose = res ? Math.max(1, Math.floor(t.reg / 2)) : t.reg;
m.attDownDice = (m.attDownDice || 0) + necrose; m.attDownDice = (m.attDownDice || 0) + necrose;
m.attDownTurns = 2; m.attDownTurns = 2;
cdLine(`Vous lui avez infligé <span class="cd-val">${dmg} points de dégâts</span> (toute armure ignorée).`);
cdLine(`La nécrose lui fait perdre <span class="cd-val">${necrose} dé(s) d'Attaque</span> 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"); 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); if (m.pv <= 0) killMonster(m);
} else { } else {
log(`Siphon des Âmes esquivé (${r.attRoll} vs ${r.esqRoll}).`, "combat"); log(`Siphon des Âmes esquivé (${r.attRoll} vs ${r.esqRoll}).`, "combat");
} }
} }
cdClose();
if (!G.over) afterAction(); if (!G.over) afterAction();
} }
@@ -788,13 +911,19 @@ function passDLA() {
if (dist <= 1 && !t.camo) { if (dist <= 1 && !t.camo) {
const eff = effMonster(m); const eff = effMonster(m);
const r = resolveAttack(eff, t); const r = resolveAttack(eff, t);
cdStart(`${m.emoji} ${m.name} vous attaque`);
cdLine(`Son jet d'Attaque est de : <span class="cd-val">${r.attRoll}</span> (${r.attDice}D6)`);
cdLine(`Votre jet d'Esquive est de : <span class="cd-val">${r.esqRoll}</span> (${r.esqDice}D6)`);
if (r.hit) { if (r.hit) {
t.pv -= r.damage; 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"); cdLine(`Il vous a <span class="cd-bad">TOUCHÉ</span>${r.critical ? ` d'un <span class="cd-bad">coup critique</span>` : ""} et vous a infligé <span class="cd-bad">${r.damage} points de dégâts</span>.`);
if (t.pv <= 0) { die(m); return; } 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 <span class="cd-bad">TERRASSÉ</span>…`); cdFlush(); die(m); return; }
} else { } else {
cdLine(`Vous avez <span class="cd-good">ESQUIVÉ</span> son coup.`);
log(`${m.emoji} ${m.name} t'attaque… et tu esquives !`, "combat"); log(`${m.emoji} ${m.name} t'attaque… et tu esquives !`, "combat");
} }
cdFlush();
} else if (seesTroll && !m.static) { } else if (seesTroll && !m.static) {
stepToward(m, t); stepToward(m, t);
} else if (!m.static && Math.random() < 0.4) { } else if (!m.static && Math.random() < 0.4) {
@@ -1075,6 +1204,7 @@ function startGame(customLevel = null) {
$("screen-end").classList.add("hidden"); $("screen-end").classList.add("hidden");
$("screen-game").classList.remove("hidden"); $("screen-game").classList.remove("hidden");
$("log").innerHTML = ""; $("log").innerHTML = "";
$("combat-detail").innerHTML = '<div class="cd-header">⚔️ Détail du combat</div><div class="cd-empty">— Aucun combat pour l\'instant —</div>';
newGame(name, selectedRace, customLevel); newGame(name, selectedRace, customLevel);
render(); render();
renderPanels(); renderPanels();
@@ -1107,6 +1237,21 @@ if (typeof document !== "undefined") {
if (RACES[params.get("race")]) selectedRace = params.get("race"); if (RACES[params.get("race")]) selectedRace = params.get("race");
if (params.get("name")) $("troll-name").value = params.get("name"); if (params.get("name")) $("troll-name").value = params.get("name");
startGame(); 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 = () => { $("btn-restart").onclick = () => {
$("screen-end").classList.add("hidden"); $("screen-end").classList.add("hidden");
+7 -3
View File
@@ -11,17 +11,21 @@ for (let i = 0; i < 200; i++) {
assert.strictEqual(r.rolls.length, 3); assert.strictEqual(r.rolls.length, 3);
} }
// Combat : un attaquant écrasant doit toucher la plupart du temps. Dégâts en D3. // Combat : un attaquant écrasant doit toucher la plupart du temps. Dégâts en D3,
let hits = 0; // doublés sur coup critique (jet d'attaque ≥ 2 × jet d'esquive).
let hits = 0, crits = 0;
for (let i = 0; i < 500; i++) { for (let i = 0; i < 500; i++) {
const r = g.resolveAttack({ att: 10, deg: 3, degBonus: 2 }, { esq: 1, armor: 2 }); const r = g.resolveAttack({ att: 10, deg: 3, degBonus: 2 }, { esq: 1, armor: 2 });
if (r.hit) { if (r.hit) {
hits++; hits++;
if (r.critical) crits++;
assert(r.damage >= 1, "dégâts minimum 1"); 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(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) // autoHit : ne rate jamais (Rafale Psychique)
for (let i = 0; i < 50; i++) { for (let i = 0; i < 50; i++) {