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) {