From dc3685de115a073f42364e22516d7ce4e798717f Mon Sep 17 00:00:00 2001 From: perco Date: Wed, 17 Jun 2026 11:21:45 +0200 Subject: [PATCH] v2.12.0 : editeur pose les monstres du bestiaire (mob+age) + retrait du Behemoth game.js : rollBestiaryMonster(b, age, ..) extrait (build d'un monstre precis) ; monsterFromSpec gere {mob:nom, age} (bestiaire) + retrocompat {type,tpl} et boss:true ; bestiaryBoss() = le plus fort du bestiaire ; buildLevel MAX_DEPTH -> bestiaryBoss (plus de Behemoth statique) ; messages 'gardien du Hall' ; applyLevel filtre les specs nulles. index.html : selects famille/monstre/age dans l'editeur. editor.js : edSetupMobPickers/edFillMobs/ edFillAges, pinceau 'Poser le monstre choisi', placement {mob,age}, rendu emoji de famille (retrocompat type/boss). server.js validateLevel accepte {mob,age}. Behemoth (BOSS) garde comme repli si bestiaire absent. Co-Authored-By: Claude Opus 4.8 --- README.md | 9 +++++++ index.html | 4 ++- js/editor.js | 57 +++++++++++++++++++++++++++++------------- js/game.js | 70 +++++++++++++++++++++++++++++++++++----------------- server.js | 4 ++- 5 files changed, 103 insertions(+), 41 deletions(-) diff --git a/README.md b/README.md index 961168d..3f48139 100644 --- a/README.md +++ b/README.md @@ -234,6 +234,15 @@ non affilié au jeu original de Mountyhall SARL. ## Versions +- **2.12.0** (2026-06-17) — **L'éditeur de niveaux pose les monstres du + bestiaire.** On choisit la **famille → le monstre → l'âge** (« Xorn [Mineur] », + « Nécrochore [Naissant] »…) et on place la créature voulue. L'**ancien Béhémoth + est retiré** : dans la partie aléatoire, le gardien du dernier niveau est + désormais le **monstre le plus puissant du bestiaire** ; et dans l'éditeur, plus + de pinceau « boss » — on place le monstre fort qu'on veut comme boss de fin. + Rétrocompatibilité conservée pour les niveaux déjà publiés (anciens types et + ancien boss → repris depuis le bestiaire). `monsterFromSpec` accepte le nouveau + format `{mob, age}` (validé côté serveur). - **2.11.2** (2026-06-17) — Ménage admin : suppression de l'ancienne section « 🐗 Tuning du bestiaire » (les 7 monstres d'avant), devenue inutile depuis que le jeu utilise le nouveau bestiaire. Seule reste l'édition du bestiaire complet. diff --git a/index.html b/index.html index af189a7..dc6b74f 100644 --- a/index.html +++ b/index.html @@ -50,7 +50,9 @@

Terrain

Monstres

- + + +

Objets

diff --git a/js/editor.js b/js/editor.js index 4d989ed..8693504 100644 --- a/js/editor.js +++ b/js/editor.js @@ -102,22 +102,12 @@ function edBuildPalette() { tilesDiv.innerHTML = ""; for (const t of tiles) edAddBrushBtn(tilesDiv, t.label, t); - const tplSel = document.getElementById("ed-tpl"); - tplSel.innerHTML = ""; - TEMPLATES.forEach((tpl, i) => { - const opt = document.createElement("option"); - opt.value = i; - opt.textContent = (tpl.prefix.trim() || "Normal") + ` (×${tpl.mult})`; - if (i === 1) opt.selected = true; - tplSel.appendChild(opt); - }); - + // Monstres : on choisit dans le bestiaire (famille → monstre → âge) puis on + // pose la créature choisie. Un « Xorn [Adulte] », un « Nécrochore [Naissant] »… + edSetupMobPickers(); const monstersDiv = document.getElementById("ed-monsters"); monstersDiv.innerHTML = ""; - MONSTER_TYPES.forEach((m, i) => { - edAddBrushBtn(monstersDiv, `${m.emoji} ${m.name} (niv. ${m.level})`, { mode: "monster", type: i }); - }); - edAddBrushBtn(monstersDiv, `${BOSS.emoji} ${BOSS.name} (boss)`, { mode: "monster", boss: true }); + edAddBrushBtn(monstersDiv, "🐲 Poser le monstre choisi", { mode: "monster" }); const itemsDiv = document.getElementById("ed-items"); itemsDiv.innerHTML = ""; @@ -164,6 +154,36 @@ function edBuildPalette() { toolsDiv.appendChild(caves); } +/* Sélecteurs bestiaire de l'éditeur : famille → monstre → âge. */ +function edBestiary() { return typeof BESTIARY !== "undefined" ? BESTIARY : []; } +function edSetupMobPickers() { + const famSel = document.getElementById("ed-mob-family"); + const mobSel = document.getElementById("ed-mob"); + if (!famSel.dataset.init) { + famSel.dataset.init = "1"; + famSel.onchange = edFillMobs; + mobSel.onchange = edFillAges; + } + const fams = [...new Set(edBestiary().map(m => m.family))].sort(); + famSel.innerHTML = '' + fams.map(f => ``).join(""); + edFillMobs(); +} +function edFillMobs() { + const fam = document.getElementById("ed-mob-family").value; + const list = edBestiary().filter(m => !fam || m.family === fam).sort((a, b) => a.name.localeCompare(b.name)); + document.getElementById("ed-mob").innerHTML = list.map(m => ``).join(""); + edFillAges(); +} +function edFillAges() { + const b = edBestiary().find(m => m.name === document.getElementById("ed-mob").value); + const ageSel = document.getElementById("ed-mob-age"); + if (!b) { ageSel.innerHTML = ""; return; } + const names = ((b.gender === "f" ? AGE_NAMES_F : AGE_NAMES) || {})[b.family] || []; + let html = ""; + for (let a = b.minAge; a <= b.maxAge; a++) html += ``; + ageSel.innerHTML = html; +} + function edAddBrushBtn(parent, label, brush) { const b = document.createElement("button"); b.textContent = label; @@ -233,9 +253,10 @@ function edApply(x, y) { ED.doors.push({ x, y, target }); } else if (b.mode === "monster") { if (ED.grid[y][x] === "#") return; + const mob = document.getElementById("ed-mob").value; + if (!mob) return; ED.monsters = ED.monsters.filter(m => m.x !== x || m.y !== y); - if (b.boss) ED.monsters.push({ x, y, boss: true }); - else ED.monsters.push({ x, y, type: b.type, tpl: Number(document.getElementById("ed-tpl").value) }); + ED.monsters.push({ x, y, mob, age: Number(document.getElementById("ed-mob-age").value) || 0 }); } else if (b.mode === "item") { if (ED.grid[y][x] === "#") return; ED.items = ED.items.filter(i => i.x !== x || i.y !== y); @@ -291,9 +312,11 @@ function edRender() { ctx.fillText("🚪", d.x * TILE + TILE / 2, d.y * TILE + TILE / 2 + 1); } for (const m of ED.monsters) { + const b = m.mob ? edBestiary().find(x => x.name === m.mob) : null; disc(m.x, m.y, m.boss ? "#7a2070" : "#8a3030"); ctx.fillStyle = "#1a140e"; - const emoji = m.boss ? BOSS.emoji : MONSTER_TYPES[m.type].emoji; + const emoji = m.mob ? (b ? (FAMILY_EMOJI_G[b.family] || "👹") : "👹") + : m.boss ? "👹" : (MONSTER_TYPES[m.type] ? MONSTER_TYPES[m.type].emoji : "👺"); ctx.fillText(emoji, m.x * TILE + TILE / 2, m.y * TILE + TILE / 2 + 1); } if (ED.start) { diff --git a/js/game.js b/js/game.js index 3b820b0..0669fa8 100644 --- a/js/game.js +++ b/js/game.js @@ -4,7 +4,7 @@ "use strict"; -const APP_VERSION = "2.11.2"; +const APP_VERSION = "2.12.0"; /* Alpha : maîtrise initiale haute pour les tests. Remettre 15 % / 15 % à la v1.0 officielle. */ const START_COMP_PCT = 90; @@ -218,17 +218,10 @@ const FAMILY_EMOJI_G = { Insecte: "🐛", Animal: "🐾", "Démon": "👿", Huma * on tire chaque stat dans sa plage. `list` = lignes du bestiaire, `ageMults` = * [m0..m7], `ageNames` = noms d'âge par famille. Partagé solo (données statiques * de bestiary.js) / multi (données de la base, tunées admin). */ -function buildBestiaryMonster(list, ageMults, ageNames, ageNamesF, depth, x, y) { - if (!list || !list.length) return null; - const band = 3; - let pool = list.filter(m => m.levelMin <= depth + band && m.levelMax >= depth - band); - if (!pool.length) { // hors tranche : on prend les plus proches en niveau - let best = Infinity; - for (const m of list) best = Math.min(best, Math.abs((m.levelMin + m.levelMax) / 2 - depth)); - pool = list.filter(m => Math.abs((m.levelMin + m.levelMax) / 2 - depth) <= best + 1); - } - const b = pool[Math.floor(Math.random() * pool.length)]; - const age = b.minAge + Math.floor(Math.random() * (b.maxAge - b.minAge + 1)); +/* Construit un monstre concret depuis une ligne de bestiaire `b` et un âge donné + * (le multiplicateur d'âge est appliqué, chaque stat tirée dans sa plage). */ +function rollBestiaryMonster(b, age, ageMults, ageNames, ageNamesF, x, y) { + age = Math.max(b.minAge, Math.min(b.maxAge, age | 0)); const f = (ageMults[age] || 1) / (ageMults[b.minAge] || 1); const roll = (mn, mx) => { const lo = Math.round(mn * f), hi = Math.round(mx * f); return lo + Math.floor(Math.random() * (Math.max(lo, hi) - lo + 1)); }; const att = Math.max(1, roll(b.attMin, b.attMax)), deg = Math.max(1, roll(b.degMin, b.degMax)), pv = Math.max(1, roll(b.pvMin, b.pvMax)); @@ -248,6 +241,20 @@ function buildBestiaryMonster(list, ageMults, ageNames, ageNamesF, depth, x, y) }; } +function buildBestiaryMonster(list, ageMults, ageNames, ageNamesF, depth, x, y) { + if (!list || !list.length) return null; + const band = 3; + let pool = list.filter(m => m.levelMin <= depth + band && m.levelMax >= depth - band); + if (!pool.length) { // hors tranche : on prend les plus proches en niveau + let best = Infinity; + for (const m of list) best = Math.min(best, Math.abs((m.levelMin + m.levelMax) / 2 - depth)); + pool = list.filter(m => Math.abs((m.levelMin + m.levelMax) / 2 - depth) <= best + 1); + } + const b = pool[Math.floor(Math.random() * pool.length)]; + const age = b.minAge + Math.floor(Math.random() * (b.maxAge - b.minAge + 1)); + return rollBestiaryMonster(b, age, ageMults, ageNames, ageNamesF, x, y); +} + function makeMonster(depth, x, y) { // Bestiaire (données statiques chargées dans le navigateur en solo) if (typeof BESTIARY !== "undefined" && BESTIARY.length) @@ -260,12 +267,31 @@ function makeMonster(depth, x, y) { return applyTemplate(type, tpl, x, y); } -/* Instancie un monstre depuis une spec d'éditeur : {x, y, type, tpl} ou {x, y, boss: true} */ +/* Le monstre le plus puissant du bestiaire (à son âge le plus vieux) — sert de + * « gardien » pour le dernier niveau de la partie aléatoire. */ +function bestiaryBoss(x, y) { + if (typeof BESTIARY === "undefined" || !BESTIARY.length) return { ...BOSS, pvMax: BOSS.pv, x, y, boss: true, static: false }; + const b = BESTIARY.reduce((a, c) => (c.levelMax > a.levelMax ? c : a)); + const m = rollBestiaryMonster(b, b.maxAge, AGE_MULT, AGE_NAMES, AGE_NAMES_F, x, y); + m.boss = true; + return m; +} + +/* Instancie un monstre depuis une spec d'éditeur. Nouveau format : + * {x, y, mob: "Nom", age: N} → tiré du bestiaire. Rétrocompat : {x, y, type, tpl} + * (anciens niveaux) et {x, y, boss: true} (ancien Béhémoth → plus fort du bestiaire). */ 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); + if (spec.mob && typeof BESTIARY !== "undefined") { + const b = BESTIARY.find(m => m.name === spec.mob); + if (b) return rollBestiaryMonster(b, spec.age != null ? spec.age : b.minAge, AGE_MULT, AGE_NAMES, AGE_NAMES_F, spec.x, spec.y); + } + if (spec.boss) return bestiaryBoss(spec.x, spec.y); + if (Number.isInteger(spec.type)) { + const type = MONSTER_TYPES[spec.type % MONSTER_TYPES.length]; + const tpl = TEMPLATES[(spec.tpl || 0) % TEMPLATES.length]; + return applyTemplate(type, tpl, spec.x, spec.y); + } + return null; } /* Instancie un objet depuis une spec d'éditeur : {x, y, kind, slot?, idx?, gold?}. @@ -425,7 +451,7 @@ function buildCustomLevel(level) { 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.monsters = level.monsters.map(monsterFromSpec).filter(Boolean); G.items = level.items.map(itemFromSpec); G.doors = (level.doors || []).map(d => ({ ...d })); G.stairs = null; @@ -470,7 +496,7 @@ function buildLevel() { 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 }); + G.monsters.push(bestiaryBoss(bp.x, bp.y)); // gardien : le plus fort du bestiaire } const count = 4 + G.depth * 2 - (G.depth === MAX_DEPTH ? 3 : 0); for (let i = 0; i < count; i++) { @@ -1192,7 +1218,7 @@ function descend() { } 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"); + if (G.depth === MAX_DEPTH) log("👹 Le sol tremble. Le gardien du Hall est proche.", "bad"); buildLevel(); afterAction(); } @@ -1309,7 +1335,7 @@ function die(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"); + else log("🏆 Le gardien du Hall s'effondre ! Le Trésor de MountyHall est à toi !", "good"); showEnd(true); } @@ -1744,7 +1770,7 @@ if (typeof document !== "undefined") { if (typeof module !== "undefined" && module.exports) { module.exports = { APP_VERSION, rollDice, resolveAttack, resolveSpell, masteryRoll, improveCost, levelFromTotalPI, killPX, - RACES, MONSTER_TYPES, BOSS, TEMPLATES, applyTemplate, makeMonster, buildBestiaryMonster, monsterFromSpec, itemFromSpec, equipGear, unequipToBag, + RACES, MONSTER_TYPES, BOSS, TEMPLATES, applyTemplate, makeMonster, buildBestiaryMonster, rollBestiaryMonster, bestiaryBoss, monsterFromSpec, itemFromSpec, equipGear, unequipToBag, generateCavern, largestRegion, MAP_W, MAP_H, T_WALL, T_FLOOR, T_STAIRS, COSTS, PA_PER_TURN, START_COMP_PCT, START_SORT_PCT, diff --git a/server.js b/server.js index 21ab5ce..ce93a13 100644 --- a/server.js +++ b/server.js @@ -72,7 +72,9 @@ function validateLevel(l) { if (!Array.isArray(l.monsters) || l.monsters.length > 60) return "monstres invalides (max 60)"; for (const m of l.monsters) { if (!inBounds(m) || tile(l, m) === "#") return "monstre hors-sol"; - if (m.boss !== true && !(Number.isInteger(m.type) && m.type >= 0 && Number.isInteger(m.tpl) && m.tpl >= 0)) return "type de monstre invalide"; + const okMob = typeof m.mob === "string" && m.mob.length > 0 && m.mob.length <= 60 && Number.isInteger(m.age) && m.age >= 0 && m.age <= 7; + const okLegacy = m.boss === true || (Number.isInteger(m.type) && m.type >= 0 && Number.isInteger(m.tpl) && m.tpl >= 0); + if (!okMob && !okLegacy) return "type de monstre invalide"; } if (!Array.isArray(l.items) || l.items.length > 60) return "objets invalides (max 60)"; for (const i of l.items) {