/* 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"; /* ================= 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 D6 (+ bonus d'arme) − armure. */ function resolveAttack(attacker, defender, opts = {}) { const att = rollDice(attacker.att, 6); const esq = rollDice(defender.esq, 6); const result = { attRoll: att.total, esqRoll: esq.total, attDice: attacker.att, esqDice: defender.esq, hit: att.total > esq.total, damage: 0, rawDamage: 0, }; if (result.hit) { const deg = rollDice(attacker.deg, 6); result.rawDamage = deg.total + (attacker.degBonus || 0); const armor = opts.ignoreArmor ? 0 : (defender.armor || 0); result.damage = Math.max(1, result.rawDamage - armor); } return result; } /* 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ût en PI du prochain dé d'une caractéristique : (dés actuels + 1) × coût de base, * réduit pour la caractéristique favorite de la race (cf. règles MH). */ function improveCost(stat, currentValue, race) { const base = { att: 18, esq: 18, deg: 18, reg: 15, pv: 10, vue: 15 }; const favored = RACES[race].favored === stat; const mult = favored ? Math.ceil(base[stat] * 2 / 3) : base[stat]; if (stat === "pv") return mult; // +5 PV, coût fixe return (currentValue + 1) * mult; } /* 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; } /* ================= Les 5 races ================= */ const RACES = { Skrim: { emoji: "🟢", favored: "att", desc: "Rapides et précis, ils frappent deux fois là où d'autres hésitent.", ability: "Frappe Double (4 PA) : deux attaques dans le même assaut.", stats: { att: 4, esq: 4, deg: 1, reg: 2, pvMax: 35, vue: 3 }, }, Kastar: { emoji: "🔴", favored: "deg", desc: "Les vampires du Hall : leurs coups les nourrissent.", ability: "Morsure Vampirique (4 PA) : attaque qui soigne 50 % des dégâts infligés.", stats: { att: 3, esq: 3, deg: 3, reg: 1, pvMax: 40, vue: 2 }, }, Durakuir: { emoji: "🟤", favored: "pv", desc: "Les tanks du Hall, durs au mal et infatigables.", ability: "Peau de Pierre (2 PA) : +3 d'armure jusqu'à la prochaine DLA.", stats: { att: 3, esq: 2, deg: 2, reg: 3, pvMax: 55, vue: 2 }, }, Tomawak: { emoji: "🟡", favored: "vue", desc: "Trõlls furtifs, chasseurs embusqués aux yeux perçants.", ability: "Camouflage (3 PA) : invisible aux monstres jusqu'à la prochaine DLA.", stats: { att: 3, esq: 3, deg: 2, reg: 2, pvMax: 40, vue: 4 }, }, Darkling: { emoji: "🟣", favored: "reg", desc: "Mystiques des profondeurs, ils siphonnent l'âme de leurs proies.", ability: "Siphon d'Âme (3 PA) : 2D6 dégâts magiques (MM vs RM, ignore l'armure), soigne la moitié.", stats: { att: 2, esq: 3, deg: 2, reg: 3, pvMax: 38, vue: 3 }, }, }; /* ================= Bestiaire ================= */ const MONSTER_TYPES = [ { name: "Gobelin", emoji: "👺", level: 1, att: 2, esq: 2, deg: 1, pv: 12, armor: 0, vue: 4 }, { name: "Champignon Vénéneux", emoji: "🍄", level: 1, att: 3, esq: 1, deg: 2, pv: 10, armor: 0, vue: 1, static: true }, { name: "Araignée Géante", emoji: "🕷️", level: 2, att: 3, esq: 3, deg: 2, pv: 16, armor: 0, vue: 5 }, { name: "Gargouille", emoji: "🦇", level: 3, att: 3, esq: 3, deg: 2, pv: 22, armor: 2, vue: 4 }, { name: "Momie", emoji: "🧟", level: 3, att: 4, esq: 2, deg: 3, pv: 26, armor: 1, vue: 3 }, { name: "Sorcière", emoji: "🧙", level: 4, att: 4, esq: 3, deg: 3, pv: 24, armor: 0, vue: 6 }, { name: "Golem de Pierre", emoji: "🗿", level: 5, att: 4, esq: 1, deg: 4, pv: 40, armor: 4, vue: 3 }, ]; const BOSS = { name: "Béhémoth", emoji: "👹", level: 9, att: 6, esq: 4, deg: 5, 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, kind: "potion", name: "Potion de Vie", emoji: "🧪" }; 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 { kind: "potion", name: "Potion de Vie", emoji: "🧪" }; 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, mm: race === "Darkling" ? 4 : 2, weapon: null, armorItem: null, pa: PA_PER_TURN, pi: 0, totalPI: 0, gold: 0, bag: [], camo: false, stoneSkin: 0, kills: 0, dla: 1, }, depth: 1, grid: null, monsters: [], items: [], 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"); log("Objectif : terrasser tous les monstres, ou atteindre la sortie ▼ s'il y en a une.", "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.stairs = null; updateFov(); } 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) ================= */ function updateFov() { const { x, y } = G.troll; const r = G.troll.vue; for (let dy = -r; dy <= r; dy++) { for (let dx = -r; dx <= r; dx++) { const tx = x + dx, ty = y + dy; if (tx < 0 || ty < 0 || tx >= MAP_W || ty >= MAP_H) continue; if (dx * dx + dy * dy <= r * r) G.seen.add(ty * MAP_W + tx); } } } function inSight(e) { const dx = e.x - G.troll.x, dy = e.y - G.troll.y; return dx * dx + dy * dy <= G.troll.vue * G.troll.vue; } /* ================= Actions du troll ================= */ 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(); 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"); } 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; const swings = opts.swings || 1; for (let i = 0; i < swings && m.pv > 0; i++) { const r = resolveAttack(G.troll, m); 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"); if (opts.vampiric) { const heal = Math.ceil(r.damage / 2); G.troll.pv = Math.min(G.troll.pvMax, G.troll.pv + heal); log(`🩸 Morsure Vampirique : tu récupères ${heal} PV.`, "good"); } 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"); } } G.troll.camo = false; afterAction(); return true; } function killMonster(m) { const px = m.level * 2; G.troll.pi += px; G.troll.totalPI += px; G.troll.kills++; log(`💀 ${m.name} est terrassé ! +${px} PX (convertis en PI à l'entraînement).`, "good"); G.monsters = G.monsters.filter(x => x !== m); if (G.custom) { if (G.monsters.length === 0) win(); } else if (m.boss) { win(); } } function useAbility() { if (G.over) return; const t = G.troll; const race = t.race; if (race === "Skrim") { const m = adjacentMonster(); if (!m) { log("Aucun monstre adjacent pour la Frappe Double.", "info"); return; } log("⚡ Frappe Double !", "good"); attackMonster(m, { cost: 4, swings: 2 }); } else if (race === "Kastar") { const m = adjacentMonster(); if (!m) { log("Aucun monstre adjacent pour la Morsure Vampirique.", "info"); return; } attackMonster(m, { cost: 4, vampiric: true }); } else if (race === "Durakuir") { if (!spendPA(2)) return; t.stoneSkin = 3; log("🪨 Peau de Pierre : +3 d'armure jusqu'à la prochaine DLA.", "good"); afterAction(); } else if (race === "Tomawak") { if (!spendPA(3)) return; t.camo = true; log("🌫️ Camouflage : les monstres ne te voient plus jusqu'à la prochaine DLA.", "good"); afterAction(); } else if (race === "Darkling") { const m = adjacentMonster(); if (!m) { log("Aucun monstre adjacent pour le Siphon d'Âme.", "info"); return; } if (!spendPA(3)) return; const spell = resolveSpell(t.mm, Math.ceil(m.level / 2)); if (spell.success) { const dmg = rollDice(2, 6).total; m.pv -= dmg; const heal = Math.ceil(dmg / 2); t.pv = Math.min(t.pvMax, t.pv + heal); G.troll.pi += 1; G.troll.totalPI += 1; log(`🔮 Siphon d'Âme (SR ${spell.sr}%, jet ${spell.roll}) : ${dmg} dégâts, tu récupères ${heal} PV.`, "good"); if (m.pv <= 0) killMonster(m); } else { log(`🔮 Siphon d'Âme raté (SR ${spell.sr}%, jet ${spell.roll}).`, "combat"); } afterAction(); } } 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; const heal = rollDice(2, 6).total + 3; t.pv = Math.min(t.pvMax, t.pv + heal); t.bag.splice(idx, 1); log(`🧪 Glou glou : +${heal} PV.`, "good"); } 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 current = stat === "pv" ? t.pvMax : t[stat]; const cost = improveCost(stat, stat === "pv" ? 0 : current, t.race); if (t.pi < cost) return; t.pi -= cost; if (stat === "pv") { t.pvMax += 5; t.pv += 5; } else t[stat] += 1; const label = { att: "Attaque", esq: "Esquive", deg: "Dégâts", reg: "Régénération", pv: "PV max", vue: "Vue" }[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++; // Tour des monstres for (const m of G.monsters) { if (m.pv <= 0) 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 armorBonus = t.stoneSkin > 0 ? 3 : 0; const r = resolveAttack(m, { ...t, armor: t.armor + armorBonus }); 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; } } else { log(`${m.emoji} ${m.name} t'attaque… et tu esquives !`, "combat"); } } 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); } } // Régénération (REG D3, comme à MountyHall) if (t.pv < t.pvMax && t.pv > 0) { const r = rollDice(t.reg, 3); t.pv = Math.min(t.pvMax, t.pv + r.total); log(`💤 Nouvelle DLA n°${t.dla} : tu régénères ${r.total} PV (${t.reg}D3).`, "info"); } else { log(`💤 Nouvelle DLA n°${t.dla}.`, "info"); } t.pa = PA_PER_TURN; t.camo = false; if (t.stoneSkin > 0) t.stoneSkin--; 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 = (x - G.troll.x) ** 2 + (y - G.troll.y) ** 2 <= G.troll.vue ** 2; 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 (!G.seen.has(i.y * MAP_W + i.x)) continue; disc(i.x, i.y, i.kind === "gold" ? "#caa53d" : i.kind === "potion" ? "#5d8535" : "#7a8db0"); ctx.fillStyle = "#1a140e"; ctx.fillText(i.emoji, i.x * TILE + TILE / 2, i.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 pct = Math.max(0, t.pv / t.pvMax); const hpClass = pct > 0.6 ? "high" : pct > 0.3 ? "mid" : ""; $("stats").innerHTML = `
PV${t.pv} / ${t.pvMax}
Attaque${t.att}D6
Esquive${t.esq}D6
Dégâts${t.deg}D6${t.degBonus ? "+" + t.degBonus : ""}
Régénération${t.reg}D3
Armure${t.armor + (t.stoneSkin > 0 ? 3 : 0)}
Vue${t.vue}
PI${t.pi}
Mountyzédons${t.gold}
Monstres tués${t.kills}
`; const improve = $("improve"); improve.innerHTML = ""; const labels = { att: "Attaque +1D6", esq: "Esquive +1D6", deg: "Dégâts +1D6", reg: "Régén. +1D3", pv: "PV max +5", vue: "Vue +1" }; for (const stat of Object.keys(labels)) { const current = stat === "pv" ? 0 : t[stat]; const cost = improveCost(stat, current, 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 abilityCost = { Skrim: 4, Kastar: 4, Durakuir: 2, Tomawak: 3, Darkling: 3 }[t.race]; addBtn(`✨ ${RACES[t.race].ability.split(" (")[0]} (${abilityCost} PA)`, useAbility, t.pa >= abilityCost); 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); 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"; 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.ability}
ATT ${s.att}D6 · ESQ ${s.esq}D6 · DEG ${s.deg}D6 · 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 = ""; 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", () => { initCreateScreen(); bindKeys(); $("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(); } $("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 = { rollDice, resolveAttack, resolveSpell, improveCost, levelFromTotalPI, RACES, MONSTER_TYPES, BOSS, TEMPLATES, makeMonster, monsterFromSpec, itemFromSpec, generateCavern, largestRegion, MAP_W, MAP_H, T_WALL, T_FLOOR, T_STAIRS, }; }