/* MountyCrawl — roguelike hommage à MountyHall (https://www.mountyhall.com) * Vanilla JS, aucune dépendance. Le cœur des règles (dés, combat, progression) * est exporté pour les tests node (voir test/smoke.js). */ "use strict"; const APP_VERSION = "0.1.5"; /* Alpha : maîtrise initiale haute pour les tests. Remettre 15 % / 15 % à la v1.0 officielle. */ const START_COMP_PCT = 90; const START_SORT_PCT = 80; /* potions.js est chargé avant ce fichier dans le navigateur ; en node, require explicite. */ if (typeof module !== "undefined" && module.exports && typeof makeRandomPotion === "undefined") { Object.assign(globalThis, require("./potions.js")); } /* ================= Dés & règles de base ================= */ function rollDice(n, faces) { const rolls = []; for (let i = 0; i < n; i++) rolls.push(1 + Math.floor(Math.random() * faces)); return { total: rolls.reduce((a, b) => a + b, 0), rolls }; } /* Jet d'attaque MountyHall : somme de ATT D6 contre somme de ESQ D6. * Si l'attaque dépasse l'esquive, dégâts = DEG D3 (+ bonus d'arme) − armure. * (Les dégâts sont en D3 comme dans les profils officiels des races.) */ function resolveAttack(attacker, defender, opts = {}) { const att = rollDice(attacker.att, 6); const esq = rollDice(defender.esq, 6); const attFlat = attacker.attFlat || 0; const esqFlat = defender.esqFlat || 0; const attTotal = att.total + attFlat; const esqTotal = esq.total + esqFlat; const result = { attRoll: attTotal, esqRoll: esqTotal, attDice: attacker.att, esqDice: defender.esq, attFlat, esqFlat, hit: opts.autoHit || attTotal > esqTotal, damage: 0, rawDamage: 0, critical: !opts.autoHit && attTotal >= esqTotal * 2, // coup critique MH : dégâts doublés }; if (result.hit) { const deg = rollDice(attacker.deg, 3); const degFlat = attacker.degFlat || 0; result.rawDamage = (deg.total + (attacker.degBonus || 0) + degFlat) * (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); result.damage = Math.max(1, result.rawDamage - armor); } return result; } /* Maîtrise d'un talent (compétence ou sortilège) : jet D100 sous le pourcentage. * En cas de réussite, la maîtrise progresse comme à MountyHall : * +1D6 % jusqu'à 50 %, +1D3 % jusqu'à 75 %, +1 % ensuite. * Sous 50 %, un échec fait quand même progresser de 1 % (on apprend de ses ratés). * Plafond `cap` : 90 % pour les compétences, 80 % pour les sortilèges. */ function masteryRoll(talent, cap, threshold) { const pct = threshold != null ? threshold : talent.pct; const roll = 1 + Math.floor(Math.random() * 100); const success = roll <= pct; let gain = 0; if (success) { gain = talent.pct < 50 ? rollDice(1, 6).total : talent.pct < 75 ? rollDice(1, 3).total : 1; } else if (talent.pct < 50) { gain = 1; } talent.pct = Math.min(cap, talent.pct + gain); return { roll, success, gain }; } /* Sortilège façon MM vs RM : Seuil de Résistance borné à [10, 90] %. */ function resolveSpell(mm, rm) { const sr = Math.min(90, Math.max(10, Math.round(50 + (mm - rm) * 5))); const roll = 1 + Math.floor(Math.random() * 100); return { sr, roll, success: roll <= sr }; } /* Coûts d'amélioration officiels (MH_Rules/Rules_3.php) : le N-ième achat d'une * caractéristique coûte N × coût de base de la race. La caractéristique favorite * est à 12 PI (REG du Darkling : 22), le reste à 16, REG à 30, Armure à 30. * `bought` = nombre d'améliorations déjà achetées dans cette caractéristique. */ const IMPROVE_BASE = { att: 16, esq: 16, deg: 16, reg: 30, pv: 16, vue: 16, armor: 30 }; const IMPROVE_FAVORED = { att: 12, deg: 12, pv: 12, vue: 12, reg: 22 }; function improveCost(stat, bought, race) { const favored = RACES[race].favored === stat; const base = favored ? IMPROVE_FAVORED[stat] : IMPROVE_BASE[stat]; return (bought + 1) * base; } /* Niveau d'ancienneté : passer au niveau N coûte 10×N PI gagnés (cumulés). */ function levelFromTotalPI(totalPI) { let level = 1, spent = 0; while (spent + 10 * (level + 1) <= totalPI) { spent += 10 * (level + 1); level++; } return level; } /* PX de mise à mort : 10 + 2 × (niveau cible − niveau troll) + niveau cible. */ function killPX(trollLevel, targetLevel) { return 10 + 2 * (targetLevel - trollLevel) + targetLevel; } /* ================= Les 5 races ================= */ /* Profils de base officiels (mountyhall.com/MH_Rules/Races_*.php) : * chaque race a sa compétence et son sortilège réservés (START_COMP_PCT / START_SORT_PCT). */ const RACES = { Skrim: { emoji: "🟢", favored: "att", desc: "Simplicité et vitesse : ils frappent plus souvent que leur ombre.", stats: { att: 4, esq: 3, deg: 3, reg: 1, pvMax: 30, vue: 3 }, comp: { name: "Botte Secrète", cost: 2, desc: "Une attaque supplémentaire (1/DLA) : 2D6 par tranche de 3 dés d'ATT, dégâts 1D3 par tranche de 2 dés d'ATT." }, sort: { name: "Hypnotisme", cost: 4, desc: "Cible adjacente : esquive divisée par 2 et perd son prochain tour (résistance : effets réduits)." }, }, Durakuir: { emoji: "🟤", favored: "pv", desc: "Les tanks du Hall, durs au mal et infatigables.", stats: { att: 3, esq: 3, deg: 3, reg: 1, pvMax: 40, vue: 3 }, comp: { name: "Régénération Accrue", cost: 2, desc: "Soigne immédiatement 1D3 par tranche de 15 PV max." }, sort: { name: "Rafale Psychique", cost: 4, desc: "Touche automatiquement (imparable) : 1D3 par dé de DEG, ignore l'armure (résistance : moitié)." }, }, Kastar: { emoji: "🔴", favored: "deg", desc: "Les vampires du Hall : leurs coups les nourrissent.", stats: { att: 3, esq: 3, deg: 4, reg: 1, pvMax: 30, vue: 3 }, comp: { name: "Accélération du Métabolisme", cost: 2, desc: "Sacrifie des PV (1D3 + fatigue) pour regagner 4 PA. La fatigue monte à chaque usage." }, sort: { name: "Vampirisme", cost: 4, desc: "Cible adjacente : 2D6 par tranche de 3 dés de DEG vs ESQ, dégâts 1D3 par dé de DEG, ignore l'armure, soigne 50 % (résistance : moitié)." }, }, Tomawak: { emoji: "🟡", favored: "vue", desc: "Trõlls furtifs, chasseurs embusqués aux yeux perçants.", stats: { att: 3, esq: 3, deg: 3, reg: 1, pvMax: 30, vue: 4 }, comp: { name: "Camouflage", cost: 2, desc: "Invisible aux monstres tant que tu n'attaques pas ; à chaque pas, jet sous 75 % de la maîtrise pour rester caché." }, sort: { name: "Projectile Magique", cost: 4, desc: "À distance (portée = Vue) : 1D6 par case de Vue + bonus de proximité, dégâts 1D3 par tranche de 2 cases de Vue, ignore l'armure (résistance : moitié)." }, }, Darkling: { emoji: "🟣", favored: "reg", desc: "Mystiques des profondeurs, ils siphonnent l'âme de leurs proies.", stats: { att: 3, esq: 3, deg: 3, reg: 2, pvMax: 30, vue: 3 }, comp: { name: "Balayage", cost: 2, desc: "Déstabilise (1/DLA) : 1D6 par dé d'ATT vs 2D6 par tranche de 3 dés d'ESQ de la cible ; à terre, elle perd son prochain tour." }, sort: { name: "Siphon des Âmes", cost: 4, desc: "1D6 par dé d'ATT vs ESQ, dégâts 1D3 par dé de REG, ignore toute armure, nécrose : la cible perd des dés d'ATT pendant 2 tours (résistance : moitié)." }, }, }; /* ================= Bestiaire ================= */ /* Dégâts des monstres en D3, comme ceux des trolls. */ const MONSTER_TYPES = [ { name: "Gobelin", emoji: "👺", level: 1, att: 2, esq: 2, deg: 2, pv: 12, armor: 0, vue: 4 }, { name: "Champignon Vénéneux", emoji: "🍄", level: 1, att: 3, esq: 1, deg: 3, pv: 10, armor: 0, vue: 1, static: true }, { name: "Araignée Géante", emoji: "🕷️", level: 2, att: 3, esq: 3, deg: 3, pv: 16, armor: 0, vue: 5 }, { name: "Gargouille", emoji: "🦇", level: 3, att: 3, esq: 3, deg: 3, pv: 22, armor: 2, vue: 4 }, { name: "Momie", emoji: "🧟", level: 3, att: 4, esq: 2, deg: 4, pv: 26, armor: 1, vue: 3 }, { name: "Sorcière", emoji: "🧙", level: 4, att: 4, esq: 3, deg: 5, pv: 24, armor: 0, vue: 6 }, { name: "Golem de Pierre", emoji: "🗿", level: 5, att: 4, esq: 1, deg: 6, pv: 40, armor: 4, vue: 3 }, ]; const BOSS = { name: "Béhémoth", emoji: "👹", level: 9, att: 6, esq: 4, deg: 8, pv: 90, armor: 3, vue: 8, boss: true }; /* Gabarits d'âge façon MountyHall : plus on descend, plus les bêtes sont vieilles. */ const TEMPLATES = [ { prefix: "Jeune ", mult: 0.7 }, { prefix: "", mult: 1.0 }, { prefix: "Vieux ", mult: 1.3 }, { prefix: "Ancien ", mult: 1.6 }, { prefix: "Mythique ", mult: 2.0 }, ]; function applyTemplate(type, tpl, x, y) { return { name: tpl.prefix + type.name, emoji: type.emoji, level: Math.max(1, Math.round(type.level * tpl.mult)), att: Math.max(1, Math.round(type.att * tpl.mult)), esq: Math.max(1, Math.round(type.esq * tpl.mult)), deg: Math.max(1, Math.round(type.deg * tpl.mult)), pv: Math.round(type.pv * tpl.mult), pvMax: Math.round(type.pv * tpl.mult), armor: type.armor, vue: type.vue, static: !!type.static, x, y, boss: false, }; } function makeMonster(depth, x, y) { const pool = MONSTER_TYPES.filter(m => m.level <= depth + 1 && m.level >= Math.max(1, depth - 2)); const type = pool[Math.floor(Math.random() * pool.length)]; const tplMax = Math.min(TEMPLATES.length - 1, depth - 1); const tpl = TEMPLATES[Math.floor(Math.random() * (tplMax + 1))]; return applyTemplate(type, tpl, x, y); } /* Instancie un monstre depuis une spec d'éditeur : {x, y, type, tpl} ou {x, y, boss: true} */ function monsterFromSpec(spec) { if (spec.boss) return { ...BOSS, pvMax: BOSS.pv, x: spec.x, y: spec.y, boss: true, static: false }; const type = MONSTER_TYPES[spec.type % MONSTER_TYPES.length]; const tpl = TEMPLATES[spec.tpl % TEMPLATES.length]; return applyTemplate(type, tpl, spec.x, spec.y); } /* Instancie un objet depuis une spec d'éditeur : {x, y, kind, idx?, gold?} */ function itemFromSpec(spec) { const base = { x: spec.x, y: spec.y }; if (spec.kind === "potion") return { ...base, ...makePotionItem(spec.potionId, spec.power) }; if (spec.kind === "gold") { const gold = spec.gold || 60; return { ...base, kind: "gold", name: `${gold} Mountyzédons`, emoji: "💰", gold }; } const list = spec.kind === "weapon" ? WEAPONS : ARMORS; return { ...base, kind: "gear", ...list[(spec.idx || 0) % list.length] }; } /* ================= Génération du Monde Souterrain ================= */ const MAP_W = 28, MAP_H = 20; const T_WALL = 0, T_FLOOR = 1, T_STAIRS = 2; function generateCavern(w, h) { let grid = []; for (let y = 0; y < h; y++) { grid.push([]); for (let x = 0; x < w; x++) { const border = x === 0 || y === 0 || x === w - 1 || y === h - 1; grid[y].push(border || Math.random() < 0.42 ? T_WALL : T_FLOOR); } } // Automate cellulaire : 4 passes de lissage for (let pass = 0; pass < 4; pass++) { const next = grid.map(row => row.slice()); for (let y = 1; y < h - 1; y++) { for (let x = 1; x < w - 1; x++) { let walls = 0; for (let dy = -1; dy <= 1; dy++) for (let dx = -1; dx <= 1; dx++) if (grid[y + dy][x + dx] === T_WALL) walls++; next[y][x] = walls >= 5 ? T_WALL : T_FLOOR; } } grid = next; } // Ne garder que la plus grande zone connexe const region = largestRegion(grid, w, h); for (let y = 0; y < h; y++) for (let x = 0; x < w; x++) if (grid[y][x] === T_FLOOR && !region.has(y * w + x)) grid[y][x] = T_WALL; return grid; } function largestRegion(grid, w, h) { const seen = new Set(); let best = new Set(); for (let y = 1; y < h - 1; y++) { for (let x = 1; x < w - 1; x++) { const key = y * w + x; if (grid[y][x] !== T_FLOOR || seen.has(key)) continue; const region = new Set(); const stack = [[x, y]]; while (stack.length) { const [cx, cy] = stack.pop(); const ck = cy * w + cx; if (seen.has(ck) || grid[cy][cx] !== T_FLOOR) continue; seen.add(ck); region.add(ck); stack.push([cx + 1, cy], [cx - 1, cy], [cx, cy + 1], [cx, cy - 1]); } if (region.size > best.size) best = region; } } return best; } function randomFloor(grid, taken) { for (let tries = 0; tries < 500; tries++) { const x = 1 + Math.floor(Math.random() * (MAP_W - 2)); const y = 1 + Math.floor(Math.random() * (MAP_H - 2)); if (grid[y][x] === T_FLOOR && !taken.has(y * MAP_W + x)) { taken.add(y * MAP_W + x); return { x, y }; } } return null; } /* ================= Objets ================= */ const WEAPONS = [ { name: "Dague Rouillée", emoji: "🗡️", slot: "weapon", bonus: 1 }, { name: "Hache de Jet", emoji: "🪓", slot: "weapon", bonus: 2 }, { name: "Épée Longue", emoji: "⚔️", slot: "weapon", bonus: 3 }, { name: "Masse d'Armes", emoji: "🔨", slot: "weapon", bonus: 4 }, ]; const ARMORS = [ { name: "Armure de Cuir", emoji: "🦺", slot: "armor", bonus: 1 }, { name: "Cotte de Mailles", emoji: "🥋", slot: "armor", bonus: 2 }, { name: "Armure de Plates", emoji: "🛡️", slot: "armor", bonus: 3 }, ]; function makeItem(depth) { const r = Math.random(); if (r < 0.35) return makeRandomPotion(); if (r < 0.55) { const w = WEAPONS[Math.min(WEAPONS.length - 1, Math.floor(Math.random() * (depth + 1)))]; return { kind: "gear", ...w }; } if (r < 0.7) { const a = ARMORS[Math.min(ARMORS.length - 1, Math.floor(Math.random() * depth))]; return { kind: "gear", ...a }; } const gold = rollDice(depth, 6).total * 10; return { kind: "gold", name: `${gold} Mountyzédons`, emoji: "💰", gold }; } /* ================= État du jeu ================= */ const MAX_DEPTH = 5; const PA_PER_TURN = 6; const COSTS = { move: 1, attack: 3, pickup: 1, equip: 2, potion: 1 }; let G = null; // état global de la partie function newGame(name, race, customLevel = null) { const s = RACES[race].stats; G = { custom: customLevel, troll: { name, race, att: s.att, esq: s.esq, deg: s.deg, reg: s.reg, pv: s.pvMax, pvMax: s.pvMax, vue: s.vue, degBonus: 0, armor: 0, armorDice: 0, bought: { att: 0, esq: 0, deg: 0, reg: 0, pv: 0, vue: 0, armor: 0 }, comp: { pct: START_COMP_PCT }, sort: { pct: START_SORT_PCT }, fatigue: 0, compUsed: false, compPXTurn: false, sortPXTurn: false, weapon: null, armorItem: null, pa: PA_PER_TURN, pi: 0, totalPI: 0, gold: 0, bag: [], camo: false, kills: 0, dla: 1, tour: 1, potionEffects: [], blockCamoTurns: 0, }, depth: 1, grid: null, monsters: [], items: [], doors: [], stairs: null, seen: new Set(), over: false, }; if (customLevel) buildCustomLevel(customLevel); else buildLevel(); if (customLevel) { log(`${name} le ${race} entre dans « ${customLevel.name} », un niveau de ${customLevel.author}.`, "good"); const hasExitTile = G.grid.some(row => row.includes(T_STAIRS)); const hasDoors = G.doors.length > 0; if (hasExitTile) log("Objectif : atteindre la sortie ▼.", "info"); else if (hasDoors) log("Objectif : des portes 🚪 mènent vers d'autres niveaux — explore !", "info"); else log("Objectif : terrasser tous les monstres.", "info"); } else { log(`${name} le ${race} pénètre dans le Monde Souterrain. Que les Dieux Trõlls te gardent !`, "good"); } } /* Construit un niveau venu de l'éditeur : grid = tableau de chaînes '#' mur, '.' sol, '>' sortie. */ function buildCustomLevel(level) { const grid = []; for (let y = 0; y < MAP_H; y++) { grid.push([]); for (let x = 0; x < MAP_W; x++) { const c = level.grid[y][x]; grid[y].push(c === "#" ? T_WALL : c === ">" ? T_STAIRS : T_FLOOR); } } G.grid = grid; G.troll.x = level.start.x; G.troll.y = level.start.y; G.seen = new Set(); G.monsters = level.monsters.map(monsterFromSpec); G.items = level.items.map(itemFromSpec); G.doors = (level.doors || []).map(d => ({ ...d })); G.stairs = null; updateFov(); } function doorAt(x, y) { return (G.doors || []).find(d => d.x === x && d.y === y); } /* Le niveau custom courant a-t-il encore un objectif au-delà du nettoyage ? */ function customHasExit() { return G.grid.some(row => row.includes(T_STAIRS)) || (G.doors || []).length > 0; } /* Franchir une porte : charge le niveau cible en conservant le troll (PV, PI, sac…). */ async function enterDoor() { const door = doorAt(G.troll.x, G.troll.y); if (!door || G.over) return; log("🚪 Tu pousses la lourde porte…", "info"); try { const res = await fetch("api/levels/" + door.target); if (!res.ok) throw new Error(); const level = await res.json(); G.custom = level; buildCustomLevel(level); log(`Tu débouches dans « ${level.name} », un niveau de ${level.author}.`, "good"); afterAction(); } catch { log("La porte est condamnée : le niveau cible n'existe plus.", "bad"); } } function buildLevel() { const grid = generateCavern(MAP_W, MAP_H); const taken = new Set(); const start = randomFloor(grid, taken); G.grid = grid; G.troll.x = start.x; G.troll.y = start.y; G.seen = new Set(); G.monsters = []; if (G.depth === MAX_DEPTH) { const bp = randomFloor(grid, taken); G.monsters.push({ ...BOSS, pvMax: BOSS.pv, x: bp.x, y: bp.y, static: false }); } const count = 4 + G.depth * 2 - (G.depth === MAX_DEPTH ? 3 : 0); for (let i = 0; i < count; i++) { const p = randomFloor(grid, taken); if (p) G.monsters.push(makeMonster(G.depth, p.x, p.y)); } G.items = []; for (let i = 0; i < 3 + G.depth; i++) { const p = randomFloor(grid, taken); if (p) G.items.push({ ...makeItem(G.depth), x: p.x, y: p.y }); } if (G.depth < MAX_DEPTH) { const p = randomFloor(grid, taken); grid[p.y][p.x] = T_STAIRS; G.stairs = p; } else { G.stairs = null; } updateFov(); } /* ================= Champ de vision (la Vue du troll) ================= */ /* Portée en cases (Chebyshev), comme pour la Vue des monstres à MH. */ function tileDist(x, y) { const t = G.troll; return Math.max(Math.abs(x - t.x), Math.abs(y - t.y)); } function currentVue() { return effTroll(G.troll).vue; } function inSightAt(x, y) { return tileDist(x, y) <= currentVue(); } function inSight(e) { return inSightAt(e.x, e.y); } /* Révèle les cases à portée de la Vue actuelle (ajout uniquement). * La mémoire (zones déjà explorées, affichées plus sombres) est conservée * même si la Vue diminue — seuls monstres et trésors hors portée sont masqués. */ function refreshFov() { const r = currentVue(); const { x, y } = G.troll; for (let dy = -r; dy <= r; dy++) { for (let dx = -r; dx <= r; dx++) { if (Math.max(Math.abs(dx), Math.abs(dy)) > r) continue; const tx = x + dx, ty = y + dy; if (tx < 0 || ty < 0 || tx >= MAP_W || ty >= MAP_H) continue; G.seen.add(ty * MAP_W + tx); } } } const updateFov = refreshFov; function spellMM() { const eff = effTroll(G.troll); return trollMM() + Math.round(eff.mmPct / 10); } function spellRM(m) { const eff = effTroll(G.troll); return monsterRM(m) + Math.round(eff.rmPct / 10); } /* ================= 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) { if (!n) return; G.troll.pi += n; G.troll.totalPI += n; if (G.actionPX !== undefined) G.actionPX += n; } function adjustPX(n) { if (!n) return; 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) { const attLbl = r.attFlat ? `${r.attDice}D6 ${r.attFlat > 0 ? "+" : ""}${r.attFlat}` : `${r.attDice}D6`; const esqLbl = r.esqFlat ? `${r.esqDice}D6 ${r.esqFlat > 0 ? "+" : ""}${r.esqFlat}` : `${r.esqDice}D6`; cdLine(`Votre jet d'Attaque est de : ${r.attRoll} (${attLbl})`); cdLine(`Le jet d'Esquive de votre adversaire est de : ${r.esqRoll} (${esqLbl})`); 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(spellMM(), spellRM(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; return true; } function tryMove(dx, dy) { if (G.over) return; const t = G.troll; const nx = t.x + dx, ny = t.y + dy; if (nx < 0 || ny < 0 || nx >= MAP_W || ny >= MAP_H) return; if (G.grid[ny][nx] === T_WALL) return; const target = G.monsters.find(m => m.x === nx && m.y === ny); if (target) { attackMonster(target); return; } if (!spendPA(COSTS.move)) return; t.x = nx; t.y = ny; updateFov(); // camouflé, chaque pas exige un jet sous 75 % de la maîtrise if (t.camo) { const threshold = Math.floor(t.comp.pct * 0.75); if (1 + Math.floor(Math.random() * 100) > threshold) { t.camo = false; log("Un caillou roule sous ton pied : te voilà repéré !", "bad"); } } if (G.grid[ny][nx] === T_STAIRS) { if (G.custom) log("La sortie ! (bouton « Sortir »)", "info"); else log("Un passage s'enfonce vers les profondeurs… (bouton « Descendre »)", "info"); } if (doorAt(nx, ny)) log("Une porte massive se dresse là. (bouton « Franchir la porte »)", "info"); const item = G.items.find(i => i.x === nx && i.y === ny); if (item) log(`Tu vois : ${item.emoji} ${item.name}. Ramasse-le pour ${COSTS.pickup} PA.`, "info"); afterAction(); } 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(effTroll(G.troll), effMonster(m)); cdAttackRolls(r); if (r.hit) { m.pv -= r.damage; 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, "attack"); } else { 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"); } afterAction(); return true; } function killMonster(m, source = "attack") { const trollLvl = levelFromTotalPI(G.troll.totalPI); const rawKillPx = killPX(trollLvl, m.level); let killGain = 0; if (rawKillPx < 0) { if (source === "attack") adjustPX(-1); // annule le +1 PX d'attaque réussie // comp/sort : on conserve uniquement le PX de talent déjà gagné } else { killGain = rawKillPx; gainPX(killGain); } G.troll.kills++; cdLine(`Vous l'avez TUÉ et vous avez débarrassé le Monde Souterrain de sa présence maléfique.`); if (killGain > 0) { log(`💀 ${m.name} est terrassé ! +${killGain} PX de mise à mort (convertis en PI à l'entraînement).`, "good"); } else if (rawKillPx < 0 && source !== "attack") { log(`💀 ${m.name} est terrassé ! Pas de bonus de mise à mort (adversaire trop faible).`, "good"); } else if (rawKillPx < 0) { log(`💀 ${m.name} est terrassé ! Pas de PX (adversaire trop faible).`, "good"); } else { log(`💀 ${m.name} est terrassé !`, "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 ? { ...makeRandomPotion(), 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) { if (customHasExit()) log("Le niveau est nettoyé… mais l'aventure continue derrière une porte ou une sortie.", "info"); else win(); } } else if (m.boss) { win(); } } /* ----- Compétences et sortilèges réservés (effets d'après la Mountypedia) ----- */ const trollMM = () => levelFromTotalPI(G.troll.totalPI) + 1; const monsterRM = m => Math.ceil(m.level / 2); /* 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, 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, kind) { if (!spendPA(cost)) return false; cdStart(label); const before = talent.pct; // le jet se compare à la maîtrise d'avant progression (+ potions) const effPct = talentPctWithPotions(G.troll, talent, cap); const r = masteryRoll(talent, cap, effPct); talent.tries = (talent.tries || 0) + 1; if (r.success) talent.successes = (talent.successes || 0) + 1; talent.lastUse = Date.now(); const pctLabel = effPct !== before ? `${effPct} % (base ${before} %)` : `${before} %`; cdLine(`Jet de maîtrise : ${r.roll} (il fallait ${pctLabel} ou moins)`); if (!r.success) { G.troll.pa += Math.floor(cost / 2); 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; } const pxFlag = kind === "comp" ? "compPXTurn" : "sortPXTurn"; if (!G.troll[pxFlag]) { gainPX(1); G.troll[pxFlag] = true; } 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; } function useComp() { if (G.over) return; const t = G.troll; const te = effTroll(t); const comp = RACES[t.race].comp; if (t.race === "Skrim") { // Botte Secrète : attaque bonus, 1 fois par DLA 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", "comp")) { cdFlush(); afterAction(); return; } t.compUsed = true; cdLine(`Vous portez une Botte Secrète à ${m.name}.`); const pseudo = { att: Math.max(1, Math.floor(te.att * 2 / 3)), deg: Math.max(1, Math.floor(te.att / 2)), degBonus: te.degBonus, attFlat: te.attFlat, degFlat: te.degFlat, }; const r = resolveAttack(pseudo, effMonster(m)); cdAttackRolls(r); if (r.hit) { m.pv -= r.damage; 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, "comp"); } 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", "comp")) { 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", "comp")) { 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) { cdClose(); die({ name: "son propre métabolisme" }); return; } } else if (t.race === "Tomawak") { // Camouflage : invisible tant qu'on n'attaque pas if (t.blockCamoTurns > 0) { log("La Pàïntûré t'empêche de te camoufler.", "info"); return; } if (t.camo) { log("Tu es déjà camouflé.", "info"); return; } if (!tryTalent(t.comp, 90, comp.cost, "🥋 Camouflage", "comp")) { 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", "comp")) { cdFlush(); afterAction(); return; } t.compUsed = true; const destab = rollDice(te.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(); } function useSort() { if (G.over) return; const t = G.troll; const te = effTroll(t); const sort = RACES[t.race].sort; 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", "sort")) { 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", "sort")) { 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(te.deg, 3).total; 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, "sort"); } 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", "sort")) { cdFlush(); afterAction(); return; } cdLine(`Vous avez attaqué ${m.name} grâce à un sortilège.`); const pseudo = { att: Math.max(1, Math.floor(te.deg * 2 / 3)), deg: te.deg, degBonus: te.degBonus, attFlat: te.attFlat, degFlat: te.degFlat, }; const r = resolveAttack(pseudo, effMonster(m), { ignoreArmor: true }); cdAttackRolls(r); if (r.hit) { let dmg = r.damage; 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, "sort"); } else { log(`Vampirisme esquivé (${r.attRoll} vs ${r.esqRoll}).`, "combat"); } } 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", "sort")) { cdFlush(); afterAction(); return; } const dist = Math.max(Math.abs(m.x - t.x), Math.abs(m.y - t.y)); const proxBonus = Math.max(0, te.vue - dist); cdLine(`Vous avez attaqué ${m.name} grâce à un sortilège (distance ${dist}, bonus de proximité +${proxBonus}D6).`); const pseudo = { att: te.vue + proxBonus, deg: Math.max(1, Math.floor(te.vue / 2)), degBonus: 0 }; const r = resolveAttack(pseudo, effMonster(m), { ignoreArmor: true }); cdAttackRolls(r); if (r.hit) { let dmg = r.damage; 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, "sort"); } else { log(`Projectile Magique esquivé (${r.attRoll} vs ${r.esqRoll}).`, "combat"); } // 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", "sort")) { cdFlush(); afterAction(); return; } cdLine(`Vous avez attaqué ${m.name} grâce à un sortilège.`); const pseudo = { att: te.att, deg: te.reg, degBonus: 0, attFlat: te.attFlat, degFlat: te.degFlat }; const r = resolveAttack(pseudo, effMonster(m), { ignoreArmor: true }); cdAttackRolls(r); if (r.hit) { let dmg = r.damage; 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(te.reg / 2)) : te.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, "sort"); } else { log(`Siphon des Âmes esquivé (${r.attRoll} vs ${r.esqRoll}).`, "combat"); } } cdClose(); if (!G.over) afterAction(); } /* Caractéristiques effectives d'un monstre, statuts compris. */ function effMonster(m) { return { ...m, att: Math.max(1, m.att - (m.attDownTurns > 0 ? m.attDownDice || 0 : 0)), esq: m.esqHalfTurns > 0 ? Math.max(1, Math.floor(m.esq / 2)) : m.esq, }; } function nearestVisibleMonster() { let best = null, bestDist = Infinity; for (const m of G.monsters) { if (!inSight(m)) continue; const d = Math.max(Math.abs(m.x - G.troll.x), Math.abs(m.y - G.troll.y)); if (d < bestDist) { best = m; bestDist = d; } } return best; } function adjacentMonster() { return G.monsters.find(m => Math.abs(m.x - G.troll.x) <= 1 && Math.abs(m.y - G.troll.y) <= 1); } function pickup() { const i = G.items.find(it => it.x === G.troll.x && it.y === G.troll.y); if (!i) return; if (!spendPA(COSTS.pickup)) return; G.items = G.items.filter(x => x !== i); if (i.kind === "gold") { G.troll.gold += i.gold; log(`💰 Tu ramasses ${i.gold} Mountyzédons.`, "good"); } else { G.troll.bag.push(i); log(`Tu ramasses ${i.emoji} ${i.name}.`, "good"); } afterAction(); } function useBagItem(idx) { const t = G.troll; const item = t.bag[idx]; if (!item) return; if (item.kind === "potion") { if (!spendPA(COSTS.potion)) return; if (!drinkPotion(t, item, rollDice, log)) return; t.bag.splice(idx, 1); if (countActiveEffects(t) > 0) switchLeftTab("effects"); updateFov(); } else if (item.kind === "gear") { if (!spendPA(COSTS.equip)) return; if (item.slot === "weapon") { if (t.weapon) t.bag.push(t.weapon); t.weapon = item; t.degBonus = item.bonus; } else { if (t.armorItem) t.bag.push(t.armorItem); t.armorItem = item; t.armor = item.bonus; } t.bag.splice(t.bag.indexOf(item), 1); log(`Tu t'équipes : ${item.emoji} ${item.name} (+${item.bonus}).`, "good"); } afterAction(); } function descend() { if (G.grid[G.troll.y][G.troll.x] !== T_STAIRS) return; if (G.custom) { G.over = true; log("🚪 Tu atteins la sortie, sain et sauf !", "good"); showEnd(true); return; } G.depth++; log(`⬇️ Tu descends. Profondeur −${G.depth}. L'air devient lourd…`, "info"); if (G.depth === MAX_DEPTH) log("👹 Le sol tremble. Le Béhémoth est proche.", "bad"); buildLevel(); afterAction(); } function improveStat(stat) { const t = G.troll; const cost = improveCost(stat, t.bought[stat], t.race); if (t.pi < cost) return; t.pi -= cost; t.bought[stat] += 1; if (stat === "pv") { t.pvMax += 10; t.pv += 10; } else if (stat === "armor") t.armorDice += 1; else t[stat] += 1; const label = { att: "Attaque", esq: "Esquive", deg: "Dégâts", reg: "Régénération", pv: "PV max", vue: "Vue", armor: "Armure naturelle" }[stat]; log(`📈 Entraînement : ${label} améliorée pour ${cost} PI.`, "good"); if (stat === "vue") updateFov(); afterAction(); } /* ================= La DLA : tour des monstres + régénération ================= */ function passDLA() { if (G.over) return; const t = G.troll; t.dla++; const dlaBonusPA = tickPotionTurns(t, log); // Tour des monstres for (const m of G.monsters) { if (m.pv <= 0) continue; if (m.skip > 0) { m.skip--; log(`${m.emoji} ${m.name} est au sol et perd son tour.`, "good"); continue; } const dist = Math.max(Math.abs(m.x - t.x), Math.abs(m.y - t.y)); const seesTroll = !t.camo && dist <= m.vue; if (dist <= 1 && !t.camo) { const eff = effMonster(m); const r = resolveAttack(eff, effTroll(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; 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) { const dirs = [[1, 0], [-1, 0], [0, 1], [0, -1]]; const [dx, dy] = dirs[Math.floor(Math.random() * 4)]; moveMonster(m, m.x + dx, m.y + dy); } // les statuts s'estompent à la fin de l'activation du monstre if (m.esqHalfTurns > 0) m.esqHalfTurns--; if (m.attDownTurns > 0 && --m.attDownTurns === 0) m.attDownDice = 0; } // Régénération (REG D3, comme à MountyHall) — bonus potions pris en compte const te = effTroll(t); if (t.pv < t.pvMax && t.pv > 0) { const r = rollDice(te.reg, 3); const heal = r.total + (te.regFlat || 0); t.pv = Math.min(t.pvMax, t.pv + heal); const regLbl = te.regFlat ? `${te.reg}D3 +${te.regFlat}` : `${te.reg}D3`; log(`💤 Tour n°${t.tour} (DLA n°${t.dla}) : tu régénères ${heal} PV (${regLbl}).`, "info"); } else { log(`💤 Tour n°${t.tour} (DLA n°${t.dla}).`, "info"); } t.pa = Math.min(PA_PER_TURN + 3, PA_PER_TURN + dlaBonusPA); t.compUsed = false; t.compPXTurn = false; t.sortPXTurn = false; t.fatigue = Math.floor(t.fatigue / 1.25); // la fatigue du Kastar retombe à chaque DLA refreshFov(); afterAction(); } function stepToward(m, t) { const dx = Math.sign(t.x - m.x), dy = Math.sign(t.y - m.y); if (dx !== 0 && moveMonster(m, m.x + dx, m.y)) return; if (dy !== 0 && moveMonster(m, m.x, m.y + dy)) return; if (dx !== 0 && dy !== 0) moveMonster(m, m.x + dx, m.y + dy); } function moveMonster(m, nx, ny) { if (nx < 0 || ny < 0 || nx >= MAP_W || ny >= MAP_H) return false; if (G.grid[ny][nx] === T_WALL) return false; if (nx === G.troll.x && ny === G.troll.y) return false; if (G.monsters.some(o => o !== m && o.x === nx && o.y === ny)) return false; m.x = nx; m.y = ny; return true; } function die(killer) { G.over = true; log(`☠️ ${killer.name} t'a terrassé…`, "bad"); showEnd(false, killer); } function win() { G.over = true; if (G.custom) log("🏆 Tous les monstres sont terrassés ! Niveau vaincu !", "good"); else log("🏆 Le Béhémoth s'effondre ! Le Trésor de MountyHall est à toi !", "good"); showEnd(true); } /* ================= Interface ================= */ const $ = id => document.getElementById(id); const TILE = 24; function log(msg, cls = "info") { if (typeof document === "undefined") return; const el = $("log"); if (!el) return; const line = document.createElement("div"); line.className = "l-" + cls; line.textContent = msg; el.appendChild(line); el.scrollTop = el.scrollHeight; while (el.children.length > 200) el.removeChild(el.firstChild); } function afterAction() { if (typeof document === "undefined") return; render(); renderPanels(); if (!G.over && G.troll.pa === 0) { log("Plus de PA — la DLA expire…", "info"); setTimeout(passDLA, 350); } } function render() { const canvas = $("map"); const ctx = canvas.getContext("2d"); ctx.clearRect(0, 0, canvas.width, canvas.height); ctx.font = "18px serif"; ctx.textAlign = "center"; ctx.textBaseline = "middle"; for (let y = 0; y < MAP_H; y++) { for (let x = 0; x < MAP_W; x++) { const key = y * MAP_W + x; const px = x * TILE, py = y * TILE; if (!G.seen.has(key)) { ctx.fillStyle = "#0d0a06"; ctx.fillRect(px, py, TILE, TILE); continue; } const visible = inSightAt(x, y); const t = G.grid[y][x]; if (t === T_WALL) ctx.fillStyle = visible ? "#4a3a22" : "#2c2315"; else ctx.fillStyle = visible ? "#7a6a45" : "#3d3522"; ctx.fillRect(px, py, TILE - 1, TILE - 1); if (t === T_STAIRS) { ctx.fillStyle = "#111"; ctx.fillText("▼", px + TILE / 2, py + TILE / 2 + 1); } } } // pastille de couleur sous chaque entité : lisible même sans police emoji const disc = (x, y, color) => { ctx.fillStyle = color; ctx.beginPath(); ctx.arc(x * TILE + TILE / 2, y * TILE + TILE / 2, TILE / 2 - 3, 0, Math.PI * 2); ctx.fill(); }; for (const i of G.items) { if (!inSightAt(i.x, i.y)) continue; disc(i.x, i.y, i.kind === "gold" ? "#caa53d" : i.kind === "potion" ? (i.color || "#5d8535") : "#7a8db0"); ctx.fillStyle = "#1a140e"; ctx.fillText(i.emoji, i.x * TILE + TILE / 2, i.y * TILE + TILE / 2 + 1); } for (const d of G.doors || []) { if (!inSightAt(d.x, d.y)) continue; disc(d.x, d.y, "#a06a28"); ctx.fillStyle = "#1a140e"; ctx.fillText("🚪", d.x * TILE + TILE / 2, d.y * TILE + TILE / 2 + 1); } for (const m of G.monsters) { if (!inSight(m)) continue; disc(m.x, m.y, m.boss ? "#7a2070" : "#8a3030"); ctx.fillStyle = "#1a140e"; ctx.fillText(m.emoji, m.x * TILE + TILE / 2, m.y * TILE + TILE / 2 + 1); // barre de vie du monstre const w = Math.max(2, Math.round((TILE - 6) * m.pv / m.pvMax)); ctx.fillStyle = "#b03030"; ctx.fillRect(m.x * TILE + 3, m.y * TILE + 1, w, 3); } disc(G.troll.x, G.troll.y, "#8fbf5a"); ctx.fillStyle = "#1a140e"; ctx.fillText("🧌", G.troll.x * TILE + TILE / 2, G.troll.y * TILE + TILE / 2 + 1); if (G.troll.camo) { ctx.strokeStyle = "#8fbf5a"; ctx.strokeRect(G.troll.x * TILE + 1, G.troll.y * TILE + 1, TILE - 2, TILE - 2); } } function renderPanels() { const t = G.troll; $("depth-label").textContent = G.custom ? `« ${G.custom.name} » par ${G.custom.author} · DLA n°${t.dla} · ${G.monsters.length} monstre(s) restant(s)` : `Profondeur −${G.depth} · DLA n°${t.dla}`; $("troll-title").textContent = `${RACES[t.race].emoji} ${t.name}, ${t.race} niv. ${levelFromTotalPI(t.totalPI)}`; const te = effTroll(t); const fmtDice = (n, faces, flat, extra = 0) => { let s = `${n}D${faces}`; const bonus = (flat || 0) + (extra || 0); if (bonus) s += bonus > 0 ? ` +${bonus}` : ` ${bonus}`; return s; }; const pct = Math.max(0, t.pv / t.pvMax); const hpClass = pct > 0.6 ? "high" : pct > 0.3 ? "mid" : ""; $("stats").innerHTML = `
Tour${t.tour}
PV${t.pv} / ${t.pvMax}
Attaque${fmtDice(te.att, 6, te.attFlat)}
Esquive${fmtDice(te.esq, 6, te.esqFlat)}
Dégâts${fmtDice(te.deg, 3, te.degFlat, te.degBonus)}
Régénération${fmtDice(te.reg, 3, te.regFlat)}
Armure${te.armor}${t.armorDice ? "+" + t.armorDice + "D3" : ""}
Vue${te.vue}
${RACES[t.race].comp.name}${t.comp.pct} %
${RACES[t.race].sort.name}${t.sort.pct} %
${t.race === "Kastar" ? `
Fatigue${t.fatigue}
` : ""} ${t.camo ? '
🌫️ Camoufléoui
' : ""}
PI${t.pi}
Mountyzédons${t.gold}
Monstres tués${t.kills}
`; const fxCount = countActiveEffects(t); const badge = $("fx-badge"); if (badge) { if (fxCount > 0) { badge.textContent = fxCount; badge.classList.remove("hidden"); } else { badge.classList.add("hidden"); } } const fxPanel = $("effects-panel"); if (fxPanel) fxPanel.innerHTML = renderEffectsPanel(t); const improve = $("improve"); improve.innerHTML = ""; const labels = { att: "Attaque +1D6", esq: "Esquive +1D6", deg: "Dégâts +1D3", reg: "Régén. +1D3", pv: "PV max +10", vue: "Vue +1", armor: "Armure +1D3" }; for (const stat of Object.keys(labels)) { const cost = improveCost(stat, t.bought[stat], t.race); const btn = document.createElement("button"); const fav = RACES[t.race].favored === stat ? " ★" : ""; btn.textContent = `${labels[stat]} — ${cost} PI${fav}`; btn.disabled = t.pi < cost || G.over; btn.onclick = () => improveStat(stat); improve.appendChild(btn); } $("pa-bar").textContent = "⚡".repeat(t.pa) + "▫️".repeat(PA_PER_TURN - t.pa); const actions = $("actions"); actions.innerHTML = ""; const addBtn = (label, fn, enabled) => { const b = document.createElement("button"); b.textContent = label; b.disabled = !enabled || G.over; b.onclick = fn; actions.appendChild(b); }; const adj = adjacentMonster(); addBtn(`⚔️ Attaquer (${COSTS.attack} PA)`, () => adj && attackMonster(adj), adj && t.pa >= COSTS.attack); const comp = RACES[t.race].comp, sort = RACES[t.race].sort; addBtn(`🥋 ${comp.name} (${comp.cost} PA · ${t.comp.pct} %)`, useComp, t.pa >= comp.cost); addBtn(`🔮 ${sort.name} (${sort.cost} PA · ${t.sort.pct} %)`, useSort, t.pa >= sort.cost); const onItem = G.items.some(i => i.x === t.x && i.y === t.y); addBtn(`🖐️ Ramasser (${COSTS.pickup} PA)`, pickup, onItem && t.pa >= COSTS.pickup); const onStairs = G.grid[t.y][t.x] === T_STAIRS; addBtn(G.custom ? "🏁 Sortir" : "⬇️ Descendre", descend, onStairs); if (doorAt(t.x, t.y)) addBtn("🚪 Franchir la porte", enterDoor, true); addBtn("⏳ Passer la DLA", passDLA, true); $("equipment").innerHTML = `
Arme : ${t.weapon ? t.weapon.emoji + " " + t.weapon.name + " (+" + t.weapon.bonus + ")" : "—"}
` + `
Armure : ${t.armorItem ? t.armorItem.emoji + " " + t.armorItem.name + " (+" + t.armorItem.bonus + ")" : "—"}
`; const inv = $("inventory"); inv.innerHTML = ""; if (t.bag.length === 0) inv.innerHTML = '
— vide —
'; t.bag.forEach((item, idx) => { const b = document.createElement("button"); const action = item.kind === "potion" ? `boire (${COSTS.potion} PA)` : `équiper (${COSTS.equip} PA)`; b.textContent = `${item.emoji} ${item.name} — ${action}`; b.disabled = G.over; b.onclick = () => useBagItem(idx); inv.appendChild(b); }); } function esc(s) { return String(s).replace(/[&<>"']/g, c => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c])); } function showEnd(victory, killer) { const t = G.troll; $("screen-game").classList.add("hidden"); $("screen-end").classList.remove("hidden"); $("end-title").textContent = victory ? "🏆 GLOIRE AU TRÕLL !" : "☠️ MORT DANS LES PROFONDEURS"; const score = t.gold + t.totalPI * 10 + t.kills * 25 + (victory ? 1000 : 0); const stats = `Monstres tués : ${t.kills} · Mountyzédons : ${t.gold} · DLA écoulées : ${t.dla}
Score : ${score}`; let text; if (G.custom) { text = victory ? `${esc(t.name)} le ${t.race} a vaincu « ${esc(G.custom.name)} », le niveau de ${esc(G.custom.author)} !` : `${esc(t.name)} le ${t.race} a été terrassé par ${esc(killer ? killer.name : "les profondeurs")} dans « ${esc(G.custom.name)} » (niveau de ${esc(G.custom.author)}).`; } else { text = victory ? `${esc(t.name)} le ${t.race} a terrassé le Béhémoth et rapporte le Trésor de MountyHall à la Taverne !` : `${esc(t.name)} le ${t.race} a été terrassé par ${esc(killer ? killer.name : "les profondeurs")} à la profondeur −${G.depth}.
À MountyHall on ne meurt jamais vraiment : les Dieux Trõlls te ramèneront à la Taverne.`; } $("end-text").innerHTML = text + "

" + stats; } /* ================= Création du personnage ================= */ let selectedRace = "Skrim"; let activeLeftTab = "stats"; function switchLeftTab(tab) { activeLeftTab = tab; const statsBtn = $("tab-stats"), fxBtn = $("tab-effects"); const statsPanel = $("panel-tab-stats"), fxPanel = $("panel-tab-effects"); if (!statsBtn || !fxBtn) return; const isStats = tab === "stats"; statsBtn.classList.toggle("active", isStats); fxBtn.classList.toggle("active", !isStats); statsBtn.setAttribute("aria-selected", isStats); fxBtn.setAttribute("aria-selected", !isStats); statsPanel.classList.toggle("hidden", !isStats); fxPanel.classList.toggle("hidden", isStats); } function initCreateScreen() { const list = $("race-list"); list.innerHTML = ""; for (const [name, r] of Object.entries(RACES)) { const card = document.createElement("div"); card.className = "race-card" + (name === selectedRace ? " selected" : ""); const s = r.stats; card.innerHTML = `

${r.emoji} ${name}

${r.desc}
🥋 ${r.comp.name} (${r.comp.cost} PA) : ${r.comp.desc}
🔮 ${r.sort.name} (${r.sort.cost} PA) : ${r.sort.desc}
ATT ${s.att}D6 · ESQ ${s.esq}D6 · DEG ${s.deg}D3 · REG ${s.reg}D3 · PV ${s.pvMax} · VUE ${s.vue}
`; card.onclick = () => { selectedRace = name; initCreateScreen(); }; list.appendChild(card); } } function startGame(customLevel = null) { const name = $("troll-name").value.trim() || "Trõllinet"; $("screen-create").classList.add("hidden"); $("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(); } function bindKeys() { document.addEventListener("keydown", e => { if (!G || G.over || $("screen-game").classList.contains("hidden")) return; const k = e.key.toLowerCase(); const moves = { arrowup: [0, -1], z: [0, -1], arrowdown: [0, 1], s: [0, 1], arrowleft: [-1, 0], q: [-1, 0], arrowright: [1, 0], d: [1, 0], }; if (moves[k]) { e.preventDefault(); tryMove(...moves[k]); } else if (k === " ") { e.preventDefault(); passDLA(); } else if (k === "e") pickup(); }); } if (typeof document !== "undefined") { document.addEventListener("DOMContentLoaded", () => { const footer = $("app-footer"); if (footer) footer.textContent = `MountyCrawl v${APP_VERSION}`; initCreateScreen(); bindKeys(); $("tab-stats")?.addEventListener("click", () => switchLeftTab("stats")); $("tab-effects")?.addEventListener("click", () => switchLeftTab("effects")); $("btn-start").onclick = () => startGame(); // ?autostart=1&race=Durakuir&name=Grosbill : lance directement une partie const params = new URLSearchParams(location.search); if (params.get("autostart")) { 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"); if (window.MC_afterEnd === "editor") { window.MC_afterEnd = null; $("screen-editor").classList.remove("hidden"); } else { $("screen-create").classList.remove("hidden"); initCreateScreen(); } }; }); } /* Export pour les tests node */ if (typeof module !== "undefined" && module.exports) { module.exports = { APP_VERSION, rollDice, resolveAttack, resolveSpell, masteryRoll, improveCost, levelFromTotalPI, killPX, RACES, MONSTER_TYPES, BOSS, TEMPLATES, makeMonster, monsterFromSpec, itemFromSpec, generateCavern, largestRegion, MAP_W, MAP_H, T_WALL, T_FLOOR, T_STAIRS, newGame, refreshFov, inSightAt, get state() { return G; }, }; }