commit 293066df5cfd0e352c5e71281def019dc1480baa Author: perco Date: Thu Jun 11 11:11:46 2026 +0200 Version initiale de MountyCrawl 1.0.0 Roguelike navigateur hommage à MountyHall : 5 races, combat aux dés (ATT/ESQ/DEG D6), DLA à 6 PA, progression PX/PI, brouillard de guerre basé sur la Vue, 5 profondeurs et boss Béhémoth. Vanilla JS sans dépendance, tests de fumée node. Co-Authored-By: Claude Fable 5 diff --git a/README.md b/README.md new file mode 100644 index 0000000..edac26e --- /dev/null +++ b/README.md @@ -0,0 +1,82 @@ +# 🧌 MountyCrawl — Le Monde Souterrain + +Un roguelike navigateur en hommage à [MountyHall](https://www.mountyhall.com), la Terre des Trõlls. +Tu incarnes un Trõll qui descend dans le Monde Souterrain à la recherche de monstres, +de trésors et de PX — jusqu'au Béhémoth qui rôde à la profondeur −5. + +100 % vanilla HTML/CSS/JS, aucune dépendance, aucun build. Ouvrir `index.html` et jouer. + +## Lancer le jeu + +```bash +# Directement +xdg-open index.html + +# Ou via un petit serveur +python3 -m http.server 8080 +# → http://localhost:8080 +``` + +Paramètres d'URL pratiques : `index.html?autostart=1&race=Durakuir&name=Grosbill` +lance directement une partie (utilisé pour les captures d'écran et les tests). + +## Mécaniques reprises de MountyHall + +| Mécanique | Dans MountyHall | Dans MountyCrawl | +|---|---|---| +| 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, DEG, REG, PV, Vue en dés | Identique : ATT/ESQ/DEG en D6, REG en D3, Vue = rayon de vision (brouillard de guerre) | +| Combat | Somme ATT D6 vs somme ESQ D6, dégâts DEG D6 − armure | Identique, avec le détail des jets dans le journal | +| Magie | Seuil de Résistance basé sur MM vs RM, borné 10–90 % | Identique (Siphon d'Âme du Darkling) | +| Progression | PX → PI à l'entraînement, coût (dés + 1) × coût racial | Identique, avec réduction sur la caractéristique favorite de la race | +| Niveaux | 10 × N PI pour atteindre le niveau N | Identique | +| Races | 5 races, chacune avec sort/compétence propre | Les 5 races avec leur capacité signature (voir ci-dessous) | +| Coûts en PA | Déplacement 1–3, attaque, ramasser, équiper, potion… | Déplacement 1, attaque 3, ramasser 1, équiper 2, potion 1 | + +Simplifications assumées : les PX sont convertis en PI immédiatement (pas de phase +d'entraînement), la DLA ne dure pas 12 h réelles (sinon la partie durerait trois mois), +et le Monde Souterrain est généré procéduralement à chaque descente. + +## Les 5 races + +| Race | Favori (PI réduits) | Capacité signature | +|---|---|---| +| 🟢 Skrim | Attaque | Frappe Double (4 PA) : deux attaques dans le même assaut | +| 🔴 Kastar | Dégâts | Morsure Vampirique (4 PA) : soigne 50 % des dégâts infligés | +| 🟤 Durakuir | PV | Peau de Pierre (2 PA) : +3 d'armure jusqu'à la prochaine DLA | +| 🟡 Tomawak | Vue | Camouflage (3 PA) : invisible jusqu'à la prochaine DLA | +| 🟣 Darkling | Régénération | Siphon d'Âme (3 PA) : 2D6 dégâts magiques MM vs RM, ignore l'armure, soigne la moitié | + +## Contrôles + +- **ZQSD / flèches** : se déplacer (1 PA) — marcher sur un monstre l'attaque (3 PA) +- **Espace** : passer la DLA (les monstres agissent, tu régénères REG D3) +- **E** : ramasser l'objet sous tes pieds (1 PA) +- Boutons à l'écran pour la capacité raciale, l'équipement, les potions et l'entraînement (PI) + +## Bestiaire + +Gobelin, Champignon Vénéneux, Araignée Géante, Gargouille, Momie, Sorcière, Golem de +Pierre — déclinés en gabarits *Jeune / Vieux / Ancien / Mythique* selon la profondeur, +et le **Béhémoth** comme boss final à la profondeur −5. + +## Tests + +```bash +node test/smoke.js +``` + +Vérifie les dés, la résolution de combat, le Seuil de Résistance, les coûts +d'amélioration, les niveaux, la connexité des cavernes générées et le bestiaire. + +## Sources + +Mécaniques reconstituées depuis le [site officiel](https://www.mountyhall.com), +les [règles](https://www.mountyhall.com/MH_Rules/Rules_4.php) et la +[Mountypedia](https://mountypedia.mountyhall.com). MountyCrawl est un hommage +non affilié au jeu original de Mountyhall SARL. + +## Versions + +- **1.0.0** (2026-06-11) — Version initiale : 5 races, 5 profondeurs, combat aux dés + fidèle aux règles MH, progression PI, brouillard de guerre, boss Béhémoth. diff --git a/css/style.css b/css/style.css new file mode 100644 index 0000000..94e1df6 --- /dev/null +++ b/css/style.css @@ -0,0 +1,185 @@ +* { box-sizing: border-box; margin: 0; padding: 0; } + +body { + background: #1a140e; + color: #e8d9b0; + font-family: Georgia, "Times New Roman", serif; + min-height: 100vh; +} + +.screen { min-height: 100vh; } +.hidden { display: none !important; } + +/* ---------- Écrans création / fin ---------- */ +#screen-create, #screen-end { + display: flex; + align-items: center; + justify-content: center; + padding: 20px; +} + +.create-box { + max-width: 760px; + background: #2a2014; + border: 3px solid #6b5430; + border-radius: 10px; + padding: 30px; + text-align: center; +} + +.create-box h1 { color: #8fbf5a; font-size: 2.2em; } +.subtitle { color: #b89c6a; font-style: italic; margin: 6px 0 14px; } +.lore { margin: 10px 0 20px; line-height: 1.5; color: #d8c79a; } + +#troll-name { + width: 60%; + padding: 10px; + font-size: 1.1em; + background: #1a140e; + border: 2px solid #6b5430; + border-radius: 6px; + color: #e8d9b0; + text-align: center; + margin-bottom: 18px; +} + +#race-list { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + gap: 10px; + margin-bottom: 20px; + text-align: left; +} + +.race-card { + background: #1f1810; + border: 2px solid #4a3a20; + border-radius: 8px; + padding: 12px; + cursor: pointer; + transition: border-color .15s, background .15s; +} +.race-card:hover { border-color: #8fbf5a; } +.race-card.selected { border-color: #8fbf5a; background: #28341c; } +.race-card h4 { color: #8fbf5a; margin-bottom: 4px; } +.race-card .race-desc { font-size: .85em; color: #c9b685; line-height: 1.35; } +.race-card .race-stats { font-size: .78em; color: #8a7a55; margin-top: 6px; font-family: monospace; } + +.big-btn { + background: #4a6b2a; + color: #f0e6c0; + border: 2px solid #8fbf5a; + border-radius: 8px; + padding: 12px 26px; + font-size: 1.15em; + font-family: inherit; + cursor: pointer; +} +.big-btn:hover { background: #5d8535; } + +/* ---------- Écran de jeu ---------- */ +header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 8px 16px; + background: #2a2014; + border-bottom: 2px solid #6b5430; +} +header h1 { font-size: 1.3em; color: #8fbf5a; } +#depth-label { font-size: 1.1em; color: #d8a040; font-weight: bold; } + +#layout { + display: flex; + gap: 10px; + padding: 10px; + align-items: flex-start; +} + +#panel-left, #panel-right { + width: 230px; + flex-shrink: 0; + background: #2a2014; + border: 2px solid #6b5430; + border-radius: 8px; + padding: 10px; + font-size: .9em; +} + +#panel-left h2 { color: #8fbf5a; font-size: 1.05em; margin-bottom: 8px; } +#panel-left h3, #panel-right h3 { + color: #d8a040; + font-size: .95em; + margin: 12px 0 6px; + border-bottom: 1px solid #4a3a20; +} + +#stats div { display: flex; justify-content: space-between; padding: 2px 0; } +#stats .stat-val { font-family: monospace; color: #f0e6c0; } + +.hp-bar-wrap { background: #3a2a1a; border-radius: 4px; height: 14px; margin: 6px 0; overflow: hidden; } +.hp-bar { background: #b03030; height: 100%; transition: width .2s; } +.hp-bar.high { background: #5d8535; } +.hp-bar.mid { background: #d8a040; } + +#improve button, #inventory button, #actions button { + display: block; + width: 100%; + margin: 4px 0; + padding: 6px 8px; + background: #1f1810; + border: 1px solid #6b5430; + border-radius: 5px; + color: #e8d9b0; + font-family: inherit; + font-size: .92em; + cursor: pointer; + text-align: left; +} +#improve button:hover:not(:disabled), +#inventory button:hover:not(:disabled), +#actions button:hover:not(:disabled) { border-color: #8fbf5a; background: #28341c; } +#improve button:disabled, #inventory button:disabled, #actions button:disabled { + opacity: .4; cursor: default; +} + +#panel-center { flex-grow: 1; text-align: center; } + +#map { + background: #0d0a06; + border: 2px solid #6b5430; + border-radius: 6px; + image-rendering: pixelated; + max-width: 100%; +} + +#pa-bar { margin: 8px 0; font-size: 1.3em; letter-spacing: 4px; } + +#actions { + display: flex; + flex-wrap: wrap; + gap: 6px; + justify-content: center; +} +#actions button { width: auto; display: inline-block; } + +.help p { font-size: .8em; color: #a89568; margin: 3px 0; } + +#equipment div, #inventory .empty { font-size: .88em; padding: 2px 0; color: #c9b685; } + +#log { + margin: 0 10px 10px; + background: #14100a; + border: 2px solid #4a3a20; + border-radius: 8px; + padding: 8px 12px; + height: 140px; + overflow-y: auto; + font-size: .85em; + font-family: monospace; + line-height: 1.45; +} +#log .l-combat { color: #e0b070; } +#log .l-bad { color: #e06060; } +#log .l-good { color: #8fbf5a; } +#log .l-info { color: #9a8a65; } diff --git a/index.html b/index.html new file mode 100644 index 0000000..7e42c80 --- /dev/null +++ b/index.html @@ -0,0 +1,71 @@ + + + + + +MountyCrawl — Le Monde Souterrain + + + + + +
+
+

🧌 MountyCrawl

+

Le Monde Souterrain — hommage à MountyHall, la Terre des Trõlls

+

Au 24e cycle après Ragnarok, les Trõlls descendent dans le Monde Souterrain + en quête de monstres, de trésors et de gloire. Choisis ta race, jeune Trõllinet, + et pars affronter le Béhémoth qui rôde dans les profondeurs…

+ +
+ +
+
+ + + + + + + + + + diff --git a/js/game.js b/js/game.js new file mode 100644 index 0000000..f379f32 --- /dev/null +++ b/js/game.js @@ -0,0 +1,794 @@ +/* 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 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 { + 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, + }; +} + +/* ================= 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) { + const s = RACES[race].stats; + G = { + 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, + }; + buildLevel(); + log(`${name} le ${race} pénètre dans le Monde Souterrain. Que les Dieux Trõlls te gardent !`, "good"); +} + +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) { + 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 (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; + 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; + 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); + } + } + } + + for (const i of G.items) { + if (!G.seen.has(i.y * MAP_W + i.x)) continue; + 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; + 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); + } + 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 = `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("⬇️ 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 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); + $("end-text").innerHTML = victory + ? `${t.name} le ${t.race} a terrassé le Béhémoth et rapporte le Trésor de MountyHall à la Taverne !

+ Monstres tués : ${t.kills} · Mountyzédons : ${t.gold} · DLA écoulées : ${t.dla}
Score : ${score}` + : `${t.name} le ${t.race} a été terrassé par ${killer ? killer.name : "les profondeurs"} à la profondeur −${G.depth}.
+ À MountyHall on ne meurt jamais vraiment : les Dieux Trõlls te ramèneront à la Taverne.

+ Monstres tués : ${t.kills} · Mountyzédons : ${t.gold} · DLA écoulées : ${t.dla}
Score : ${score}`; +} + +/* ================= 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() { + 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); + 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"); + $("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, makeMonster, generateCavern, largestRegion, + MAP_W, MAP_H, T_WALL, T_FLOOR, T_STAIRS, + }; +} diff --git a/test/smoke.js b/test/smoke.js new file mode 100644 index 0000000..d73d5a6 --- /dev/null +++ b/test/smoke.js @@ -0,0 +1,73 @@ +/* Tests de fumée du cœur des règles — exécuter avec : node test/smoke.js */ +"use strict"; + +const assert = require("assert"); +const g = require("../js/game.js"); + +// Dés +for (let i = 0; i < 200; i++) { + const r = g.rollDice(3, 6); + assert(r.total >= 3 && r.total <= 18, "3D6 hors bornes : " + r.total); + assert.strictEqual(r.rolls.length, 3); +} + +// Combat : un attaquant écrasant doit toucher la plupart du temps, et jamais l'inverse d'un mur +let hits = 0; +for (let i = 0; i < 500; i++) { + const r = g.resolveAttack({ att: 10, deg: 3, degBonus: 2 }, { esq: 1, armor: 2 }); + if (r.hit) { + hits++; + assert(r.damage >= 1, "dégâts minimum 1"); + assert(r.damage <= 3 * 6 + 2 - 2, "dégâts trop élevés : " + r.damage); + } +} +assert(hits > 450, "10D6 vs 1D6 devrait presque toujours toucher (" + hits + "/500)"); + +// Sortilège : SR borné [10, 90] +assert.strictEqual(g.resolveSpell(100, 0).sr, 90); +assert.strictEqual(g.resolveSpell(0, 100).sr, 10); + +// Coûts d'amélioration : la race favorisée paie moins cher +const skrimAtt = g.improveCost("att", 3, "Skrim"); +const kastarAtt = g.improveCost("att", 3, "Kastar"); +assert(skrimAtt < kastarAtt, `Skrim devrait payer son ATT moins cher (${skrimAtt} vs ${kastarAtt})`); + +// Niveaux : 0 PI → niv 1 ; 20 PI → niv 2 ; 50 PI → niv 3 (20 + 30) +assert.strictEqual(g.levelFromTotalPI(0), 1); +assert.strictEqual(g.levelFromTotalPI(20), 2); +assert.strictEqual(g.levelFromTotalPI(49), 2); +assert.strictEqual(g.levelFromTotalPI(50), 3); + +// Les 5 races existent avec toutes leurs stats +assert.strictEqual(Object.keys(g.RACES).length, 5); +for (const r of Object.values(g.RACES)) { + for (const k of ["att", "esq", "deg", "reg", "pvMax", "vue"]) { + assert(typeof r.stats[k] === "number", "stat manquante : " + k); + } +} + +// Génération de caverne : connexe, bordures murées, assez de sol +for (let i = 0; i < 20; i++) { + const grid = g.generateCavern(g.MAP_W, g.MAP_H); + let floor = 0; + for (let y = 0; y < g.MAP_H; y++) { + for (let x = 0; x < g.MAP_W; x++) { + if (grid[y][x] === g.T_FLOOR) floor++; + const border = x === 0 || y === 0 || x === g.MAP_W - 1 || y === g.MAP_H - 1; + if (border) assert.strictEqual(grid[y][x], g.T_WALL, "bordure non murée"); + } + } + assert(floor > 50, "caverne trop petite : " + floor + " cases de sol"); + const region = g.largestRegion(grid, g.MAP_W, g.MAP_H); + assert.strictEqual(region.size, floor, "caverne non connexe"); +} + +// Monstres : niveaux et stats positifs à toutes les profondeurs +for (let depth = 1; depth <= 5; depth++) { + for (let i = 0; i < 50; i++) { + const m = g.makeMonster(depth, 1, 1); + assert(m.level >= 1 && m.pv >= 1 && m.att >= 1 && m.deg >= 1, "monstre invalide : " + JSON.stringify(m)); + } +} + +console.log("✅ Tous les tests de fumée passent.");