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 <noreply@anthropic.com>
This commit is contained in:
perco
2026-06-17 11:21:45 +02:00
co-authored by Claude Opus 4.8
parent e2e749fa41
commit dc3685de11
5 changed files with 103 additions and 41 deletions
+9
View File
@@ -234,6 +234,15 @@ non affilié au jeu original de Mountyhall SARL.
## Versions ## 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 - **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 « 🐗 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. le jeu utilise le nouveau bestiaire. Seule reste l'édition du bestiaire complet.
+3 -1
View File
@@ -50,7 +50,9 @@
<h3>Terrain</h3> <h3>Terrain</h3>
<div id="ed-tiles"></div> <div id="ed-tiles"></div>
<h3>Monstres</h3> <h3>Monstres</h3>
<select id="ed-tpl"></select> <select id="ed-mob-family" title="Famille"></select>
<select id="ed-mob" title="Monstre"></select>
<select id="ed-mob-age" title="Âge"></select>
<div id="ed-monsters"></div> <div id="ed-monsters"></div>
<h3>Objets</h3> <h3>Objets</h3>
<div id="ed-items"></div> <div id="ed-items"></div>
+40 -17
View File
@@ -102,22 +102,12 @@ function edBuildPalette() {
tilesDiv.innerHTML = ""; tilesDiv.innerHTML = "";
for (const t of tiles) edAddBrushBtn(tilesDiv, t.label, t); for (const t of tiles) edAddBrushBtn(tilesDiv, t.label, t);
const tplSel = document.getElementById("ed-tpl"); // Monstres : on choisit dans le bestiaire (famille → monstre → âge) puis on
tplSel.innerHTML = ""; // pose la créature choisie. Un « Xorn [Adulte] », un « Nécrochore [Naissant] »…
TEMPLATES.forEach((tpl, i) => { edSetupMobPickers();
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);
});
const monstersDiv = document.getElementById("ed-monsters"); const monstersDiv = document.getElementById("ed-monsters");
monstersDiv.innerHTML = ""; monstersDiv.innerHTML = "";
MONSTER_TYPES.forEach((m, i) => { edAddBrushBtn(monstersDiv, "🐲 Poser le monstre choisi", { mode: "monster" });
edAddBrushBtn(monstersDiv, `${m.emoji} ${m.name} (niv. ${m.level})`, { mode: "monster", type: i });
});
edAddBrushBtn(monstersDiv, `${BOSS.emoji} ${BOSS.name} (boss)`, { mode: "monster", boss: true });
const itemsDiv = document.getElementById("ed-items"); const itemsDiv = document.getElementById("ed-items");
itemsDiv.innerHTML = ""; itemsDiv.innerHTML = "";
@@ -164,6 +154,36 @@ function edBuildPalette() {
toolsDiv.appendChild(caves); 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 = '<option value="">Toutes</option>' + fams.map(f => `<option>${f}</option>`).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 => `<option>${m.name}</option>`).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 += `<option value="${a}">${names[a] || "âge " + a}</option>`;
ageSel.innerHTML = html;
}
function edAddBrushBtn(parent, label, brush) { function edAddBrushBtn(parent, label, brush) {
const b = document.createElement("button"); const b = document.createElement("button");
b.textContent = label; b.textContent = label;
@@ -233,9 +253,10 @@ function edApply(x, y) {
ED.doors.push({ x, y, target }); ED.doors.push({ x, y, target });
} else if (b.mode === "monster") { } else if (b.mode === "monster") {
if (ED.grid[y][x] === "#") return; 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); ED.monsters = ED.monsters.filter(m => m.x !== x || m.y !== y);
if (b.boss) ED.monsters.push({ x, y, boss: true }); ED.monsters.push({ x, y, mob, age: Number(document.getElementById("ed-mob-age").value) || 0 });
else ED.monsters.push({ x, y, type: b.type, tpl: Number(document.getElementById("ed-tpl").value) });
} else if (b.mode === "item") { } else if (b.mode === "item") {
if (ED.grid[y][x] === "#") return; if (ED.grid[y][x] === "#") return;
ED.items = ED.items.filter(i => i.x !== x || i.y !== y); 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); ctx.fillText("🚪", d.x * TILE + TILE / 2, d.y * TILE + TILE / 2 + 1);
} }
for (const m of ED.monsters) { 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"); disc(m.x, m.y, m.boss ? "#7a2070" : "#8a3030");
ctx.fillStyle = "#1a140e"; 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); ctx.fillText(emoji, m.x * TILE + TILE / 2, m.y * TILE + TILE / 2 + 1);
} }
if (ED.start) { if (ED.start) {
+48 -22
View File
@@ -4,7 +4,7 @@
"use strict"; "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. */ /* Alpha : maîtrise initiale haute pour les tests. Remettre 15 % / 15 % à la v1.0 officielle. */
const START_COMP_PCT = 90; 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` = * 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 * [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). */ * de bestiary.js) / multi (données de la base, tunées admin). */
function buildBestiaryMonster(list, ageMults, ageNames, ageNamesF, depth, x, y) { /* Construit un monstre concret depuis une ligne de bestiaire `b` et un âge donné
if (!list || !list.length) return null; * (le multiplicateur d'âge est appliqué, chaque stat tirée dans sa plage). */
const band = 3; function rollBestiaryMonster(b, age, ageMults, ageNames, ageNamesF, x, y) {
let pool = list.filter(m => m.levelMin <= depth + band && m.levelMax >= depth - band); age = Math.max(b.minAge, Math.min(b.maxAge, age | 0));
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));
const f = (ageMults[age] || 1) / (ageMults[b.minAge] || 1); 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 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)); 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) { function makeMonster(depth, x, y) {
// Bestiaire (données statiques chargées dans le navigateur en solo) // Bestiaire (données statiques chargées dans le navigateur en solo)
if (typeof BESTIARY !== "undefined" && BESTIARY.length) if (typeof BESTIARY !== "undefined" && BESTIARY.length)
@@ -260,12 +267,31 @@ function makeMonster(depth, x, y) {
return applyTemplate(type, tpl, 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) { function monsterFromSpec(spec) {
if (spec.boss) return { ...BOSS, pvMax: BOSS.pv, x: spec.x, y: spec.y, boss: true, static: false }; if (spec.mob && typeof BESTIARY !== "undefined") {
const type = MONSTER_TYPES[spec.type % MONSTER_TYPES.length]; const b = BESTIARY.find(m => m.name === spec.mob);
const tpl = TEMPLATES[spec.tpl % TEMPLATES.length]; if (b) return rollBestiaryMonster(b, spec.age != null ? spec.age : b.minAge, AGE_MULT, AGE_NAMES, AGE_NAMES_F, spec.x, spec.y);
return applyTemplate(type, tpl, 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?}. /* 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.grid = grid;
G.troll.x = level.start.x; G.troll.y = level.start.y; G.troll.x = level.start.x; G.troll.y = level.start.y;
G.seen = new Set(); 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.items = level.items.map(itemFromSpec);
G.doors = (level.doors || []).map(d => ({ ...d })); G.doors = (level.doors || []).map(d => ({ ...d }));
G.stairs = null; G.stairs = null;
@@ -470,7 +496,7 @@ function buildLevel() {
G.monsters = []; G.monsters = [];
if (G.depth === MAX_DEPTH) { if (G.depth === MAX_DEPTH) {
const bp = randomFloor(grid, taken); 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); const count = 4 + G.depth * 2 - (G.depth === MAX_DEPTH ? 3 : 0);
for (let i = 0; i < count; i++) { for (let i = 0; i < count; i++) {
@@ -1192,7 +1218,7 @@ function descend() {
} }
G.depth++; G.depth++;
log(`⬇️ Tu descends. Profondeur ${G.depth}. L'air devient lourd…`, "info"); 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(); buildLevel();
afterAction(); afterAction();
} }
@@ -1309,7 +1335,7 @@ function die(killer) {
function win() { function win() {
G.over = true; G.over = true;
if (G.custom) log("🏆 Tous les monstres sont terrassés ! Niveau vaincu !", "good"); 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); showEnd(true);
} }
@@ -1744,7 +1770,7 @@ if (typeof document !== "undefined") {
if (typeof module !== "undefined" && module.exports) { if (typeof module !== "undefined" && module.exports) {
module.exports = { module.exports = {
APP_VERSION, rollDice, resolveAttack, resolveSpell, masteryRoll, improveCost, levelFromTotalPI, killPX, 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, generateCavern, largestRegion,
MAP_W, MAP_H, T_WALL, T_FLOOR, T_STAIRS, MAP_W, MAP_H, T_WALL, T_FLOOR, T_STAIRS,
COSTS, PA_PER_TURN, START_COMP_PCT, START_SORT_PCT, COSTS, PA_PER_TURN, START_COMP_PCT, START_SORT_PCT,
+3 -1
View File
@@ -72,7 +72,9 @@ function validateLevel(l) {
if (!Array.isArray(l.monsters) || l.monsters.length > 60) return "monstres invalides (max 60)"; if (!Array.isArray(l.monsters) || l.monsters.length > 60) return "monstres invalides (max 60)";
for (const m of l.monsters) { for (const m of l.monsters) {
if (!inBounds(m) || tile(l, m) === "#") return "monstre hors-sol"; 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)"; if (!Array.isArray(l.items) || l.items.length > 60) return "objets invalides (max 60)";
for (const i of l.items) { for (const i of l.items) {