Files
percoandClaude Opus 4.8 dc3685de11 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>
2026-06-17 11:21:45 +02:00

511 lines
19 KiB
JavaScript

/* Éditeur de niveaux MountyCrawl — partage les globales de game.js (chargé avant). */
"use strict";
const ED = {
grid: null, // tableau 2D de caractères : '#' mur, '.' sol, '>' sortie
start: null, // {x, y}
monsters: [], // specs {x, y, type, tpl} ou {x, y, boss: true}
items: [], // specs {x, y, kind, idx?, gold?}
doors: [], // {x, y, target: id de niveau publié}
editing: null, // {id, secret} quand on modifie un niveau déjà publié
brush: { mode: "tile", char: "#" },
painting: false,
};
function edReset() {
ED.grid = [];
for (let y = 0; y < MAP_H; y++) {
ED.grid.push([]);
for (let x = 0; x < MAP_W; x++) {
const border = x === 0 || y === 0 || x === MAP_W - 1 || y === MAP_H - 1;
ED.grid[y].push(border ? "#" : ".");
}
}
ED.start = null;
ED.monsters = [];
ED.items = [];
ED.doors = [];
ED.editing = null;
const name = document.getElementById("ed-name");
if (name) { name.value = ""; document.getElementById("ed-author").value = ""; }
edSyncPublishLabel();
}
/* ---------- Mes niveaux (clés d'auteur en localStorage) ---------- */
function myLevels() {
try { return JSON.parse(localStorage.getItem("mc_myLevels")) || []; }
catch { return []; }
}
function saveMyLevel(entry) {
const mine = myLevels().filter(l => l.id !== entry.id);
mine.push(entry);
localStorage.setItem("mc_myLevels", JSON.stringify(mine));
}
function edRenderMine() {
const div = document.getElementById("ed-mine");
div.innerHTML = "";
const mine = myLevels();
if (mine.length === 0) {
div.innerHTML = '<p class="ed-note">Aucun pour l\'instant — publie un niveau et il apparaîtra ici, modifiable à volonté.</p>';
return;
}
for (const entry of mine) {
const b = document.createElement("button");
b.textContent = `✏️ ${entry.name}`;
b.title = "Recharger ce niveau dans l'éditeur pour le modifier";
b.onclick = () => edLoadMine(entry);
div.appendChild(b);
}
}
async function edLoadMine(entry) {
try {
const res = await fetch("api/levels/" + entry.id);
if (!res.ok) { edFlash("Niveau introuvable sur le serveur.", true); return; }
const level = await res.json();
ED.grid = level.grid.map(row => row.split(""));
ED.start = level.start;
ED.monsters = level.monsters;
ED.items = level.items;
ED.doors = level.doors || [];
ED.editing = { id: entry.id, secret: entry.secret };
document.getElementById("ed-name").value = level.name;
document.getElementById("ed-author").value = level.author;
edSyncPublishLabel();
edRender();
edFlash(${level.name} » chargé — modifie puis « Mettre à jour ».`, false);
} catch {
edFlash("Impossible de joindre le serveur.", true);
}
}
function edSyncPublishLabel() {
const btn = document.getElementById("ed-publish");
if (btn) btn.textContent = ED.editing ? "💾 Mettre à jour le niveau" : "🌍 Publier en ligne";
}
/* ---------- Palette ---------- */
function edBuildPalette() {
const tiles = [
{ label: "🟫 Mur", mode: "tile", char: "#" },
{ label: "⬜ Sol", mode: "tile", char: "." },
{ label: "▼ Sortie", mode: "tile", char: ">" },
{ label: "🧌 Départ du Trõll", mode: "start" },
{ label: "🧽 Gomme (entités)", mode: "erase" },
];
const tilesDiv = document.getElementById("ed-tiles");
tilesDiv.innerHTML = "";
for (const t of tiles) edAddBrushBtn(tilesDiv, t.label, t);
// 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 = "";
edAddBrushBtn(monstersDiv, "🐲 Poser le monstre choisi", { mode: "monster" });
const itemsDiv = document.getElementById("ed-items");
itemsDiv.innerHTML = "";
edAddBrushBtn(itemsDiv, "🧪 Potion (aléatoire)", { mode: "item", kind: "potion" });
edAddBrushBtn(itemsDiv, "📜 Parchemin (aléatoire)", { mode: "item", kind: "scroll" });
edAddBrushBtn(itemsDiv, "💰 Mountyzédons", { mode: "item", kind: "gold" });
for (const [slot, info] of Object.entries(GEAR_SLOTS)) {
edAddBrushBtn(itemsDiv, `${info.emoji} ${info.label} (aléatoire)`, { mode: "item", kind: "gear", slot });
}
const doorsDiv = document.getElementById("ed-doors");
doorsDiv.innerHTML = "";
edAddBrushBtn(doorsDiv, "🚪 Placer une porte", { mode: "door" });
const toolsDiv = document.getElementById("ed-tools");
toolsDiv.innerHTML = "";
const newBtn = document.createElement("button");
newBtn.textContent = "📄 Nouveau niveau (vider)";
newBtn.onclick = () => { edReset(); edRender(); };
toolsDiv.appendChild(newBtn);
const fill = (char, label) => {
const b = document.createElement("button");
b.textContent = label;
b.onclick = () => {
for (let y = 1; y < MAP_H - 1; y++)
for (let x = 1; x < MAP_W - 1; x++) ED.grid[y][x] = char;
if (char === "#") { ED.start = null; ED.monsters = []; ED.items = []; }
edRender();
};
toolsDiv.appendChild(b);
};
fill(".", "⬜ Tout remplir de sol");
fill("#", "🟫 Tout remplir de mur (vide les entités)");
const caves = document.createElement("button");
caves.textContent = "🎲 Caverne aléatoire";
caves.onclick = () => {
const cavern = generateCavern(MAP_W, MAP_H);
for (let y = 0; y < MAP_H; y++)
for (let x = 0; x < MAP_W; x++)
ED.grid[y][x] = cavern[y][x] === T_WALL ? "#" : ".";
edPruneEntities();
edRender();
};
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) {
const b = document.createElement("button");
b.textContent = label;
b.className = "ed-brush";
b.onclick = () => {
ED.brush = brush;
parent.parentElement.querySelectorAll(".ed-brush").forEach(x => x.classList.remove("selected"));
b.classList.add("selected");
};
parent.appendChild(b);
}
/* Supprime les entités qui se retrouvent dans un mur après modification du terrain. */
function edPruneEntities() {
const onFloor = e => ED.grid[e.y][e.x] !== "#";
ED.monsters = ED.monsters.filter(onFloor);
ED.items = ED.items.filter(onFloor);
ED.doors = ED.doors.filter(onFloor);
if (ED.start && !onFloor(ED.start)) ED.start = null;
}
/* Remplit le sélecteur de cible des portes avec les niveaux publiés. */
async function edRefreshDoorTargets() {
const sel = document.getElementById("ed-door-target");
sel.innerHTML = "<option value=''>— choisis le niveau cible —</option>";
try {
const levels = await (await fetch("api/levels")).json();
for (const l of levels) {
if (ED.editing && l.id === ED.editing.id) continue; // pas de porte vers soi-même
const opt = document.createElement("option");
opt.value = l.id;
opt.textContent = `${l.name} (par ${l.author})`;
sel.appendChild(opt);
}
} catch { /* hors-ligne : le sélecteur reste vide */ }
}
/* ---------- Application du pinceau ---------- */
function edApply(x, y) {
if (x < 0 || y < 0 || x >= MAP_W || y >= MAP_H) return;
const b = ED.brush;
if (b.mode === "tile") {
// bordure inviolable pour garder le troll dans la carte
const border = x === 0 || y === 0 || x === MAP_W - 1 || y === MAP_H - 1;
if (border && b.char !== "#") return;
ED.grid[y][x] = b.char;
if (b.char === "#") {
ED.monsters = ED.monsters.filter(m => m.x !== x || m.y !== y);
ED.items = ED.items.filter(i => i.x !== x || i.y !== y);
ED.doors = ED.doors.filter(d => d.x !== x || d.y !== y);
if (ED.start && ED.start.x === x && ED.start.y === y) ED.start = null;
}
} else if (b.mode === "start") {
if (ED.grid[y][x] === "#") return;
ED.start = { x, y };
} else if (b.mode === "erase") {
ED.monsters = ED.monsters.filter(m => m.x !== x || m.y !== y);
ED.items = ED.items.filter(i => i.x !== x || i.y !== y);
ED.doors = ED.doors.filter(d => d.x !== x || d.y !== y);
if (ED.start && ED.start.x === x && ED.start.y === y) ED.start = null;
} else if (b.mode === "door") {
if (ED.grid[y][x] === "#") return;
const target = document.getElementById("ed-door-target").value;
if (!target) { edFlash("Choisis d'abord le niveau cible de la porte dans la liste.", true); return; }
ED.doors = ED.doors.filter(d => d.x !== x || d.y !== 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);
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);
const spec = { x, y, kind: b.kind };
if (b.idx !== undefined) spec.idx = b.idx;
if (b.slot !== undefined) spec.slot = b.slot;
if (b.kind === "gold") spec.gold = 60;
ED.items.push(spec);
}
edRender();
}
/* ---------- Rendu ---------- */
function edRender() {
const canvas = document.getElementById("ed-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 c = ED.grid[y][x];
ctx.fillStyle = c === "#" ? "#4a3a22" : "#7a6a45";
ctx.fillRect(x * TILE, y * TILE, TILE - 1, TILE - 1);
if (c === ">") {
ctx.fillStyle = "#111";
ctx.fillText("▼", x * TILE + TILE / 2, y * TILE + TILE / 2 + 1);
}
}
}
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 ED.items) {
disc(i.x, i.y, i.kind === "gold" ? "#caa53d" : i.kind === "potion" ? "#5d8535"
: i.kind === "scroll" ? "#d8c890" : "#7a8db0");
ctx.fillStyle = "#1a140e";
const emoji = i.kind === "potion" ? "🧪" : i.kind === "scroll" ? "📜" : i.kind === "gold" ? "💰"
: i.kind === "gear" ? (GEAR_SLOTS[i.slot] || GEAR_SLOTS.arme).emoji
: i.kind === "weapon" ? "🗡️" : "🦺";
ctx.fillText(emoji, i.x * TILE + TILE / 2, i.y * TILE + TILE / 2 + 1);
}
for (const d of ED.doors) {
disc(d.x, d.y, "#a06a28");
ctx.fillStyle = "#1a140e";
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.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) {
disc(ED.start.x, ED.start.y, "#8fbf5a");
ctx.fillStyle = "#1a140e";
ctx.fillText("🧌", ED.start.x * TILE + TILE / 2, ED.start.y * TILE + TILE / 2 + 1);
}
const status = document.getElementById("ed-status");
const missing = [];
if (!ED.start) missing.push("un point de départ 🧌");
if (ED.monsters.length === 0 && ED.doors.length === 0 && !ED.grid.some(r => r.includes(">")))
missing.push("au moins un monstre, une porte ou une sortie");
const extras = [
ED.doors.length ? `${ED.doors.length} porte(s)` : "",
ED.grid.some(r => r.includes(">")) ? "une sortie" : "",
].filter(Boolean).join(", ");
status.textContent = missing.length
? "Il manque : " + missing.join(" et ") + "."
: `Prêt : ${ED.monsters.length} monstre(s), ${ED.items.length} objet(s)${extras ? ", " + extras : ""}.`;
status.className = missing.length ? "ed-warn" : "ed-ok";
}
/* ---------- Export / test / publication ---------- */
function edToLevel() {
return {
name: document.getElementById("ed-name").value.trim(),
author: document.getElementById("ed-author").value.trim(),
grid: ED.grid.map(row => row.join("")),
start: ED.start,
monsters: ED.monsters,
items: ED.items,
doors: ED.doors,
};
}
function edValidate(level) {
if (!level.start) return "place un point de départ 🧌";
if (level.monsters.length === 0 && level.doors.length === 0 && !level.grid.some(r => r.includes(">")))
return "place au moins un monstre, une porte ou une sortie";
return null;
}
function edTest() {
const level = edToLevel();
const err = edValidate(level);
if (err) { edFlash(err, true); return; }
if (!level.name) level.name = "Test sans nom";
if (!level.author) level.author = "moi";
window.MC_afterEnd = "editor";
document.getElementById("screen-editor").classList.add("hidden");
startGame(level);
}
async function edPublish() {
const level = edToLevel();
const err = edValidate(level) ||
(!level.name ? "donne un nom à ton niveau" : null) ||
(!level.author ? "indique ton pseudo d'auteur" : null);
if (err) { edFlash(err, true); return; }
try {
const updating = !!ED.editing;
const res = await fetch(updating ? "api/levels/" + ED.editing.id : "api/levels", {
method: updating ? "PUT" : "POST",
headers: {
"Content-Type": "application/json",
...(updating ? { "X-Level-Secret": ED.editing.secret } : {}),
},
body: JSON.stringify(level),
});
const data = await res.json();
if (!res.ok) { edFlash("Refusé par le serveur : " + data.error, true); return; }
if (updating) {
saveMyLevel({ id: ED.editing.id, secret: ED.editing.secret, name: level.name });
edFlash(`💾 « ${level.name} » mis à jour pour tous les joueurs.`, false);
} else {
ED.editing = { id: data.id, secret: data.secret };
saveMyLevel({ id: data.id, secret: data.secret, name: level.name });
edSyncPublishLabel();
edFlash(`🌍 Publié ! « ${level.name} » est jouable par tous (id ${data.id}).`, false);
}
edRenderMine();
edRefreshDoorTargets();
} catch {
edFlash("Impossible de joindre le serveur.", true);
}
}
function edFlash(msg, isError) {
const status = document.getElementById("ed-status");
status.textContent = msg;
status.className = isError ? "ed-warn" : "ed-ok";
}
/* ---------- Niveaux communautaires ---------- */
async function communityShow() {
document.getElementById("screen-create").classList.add("hidden");
document.getElementById("screen-community").classList.remove("hidden");
const list = document.getElementById("level-list");
list.innerHTML = '<p class="lore">Chargement…</p>';
try {
const res = await fetch("api/levels");
const levels = await res.json();
list.innerHTML = "";
if (levels.length === 0) {
list.innerHTML = '<p class="lore">Aucun niveau publié pour l\'instant. Sois le premier, l\'éditeur t\'attend !</p>';
return;
}
for (const l of levels.slice().reverse()) {
const row = document.createElement("button");
row.className = "level-row";
row.textContent = `⚔️ ${l.name} — par ${l.author} · ${l.monsters} monstre(s)`
+ (l.doors ? ` · ${l.doors} porte(s)` : "") + ` · joué ${l.plays} fois · ${l.date}`;
row.onclick = async () => {
const r = await fetch("api/levels/" + l.id);
if (!r.ok) return;
const level = await r.json();
document.getElementById("screen-community").classList.add("hidden");
window.MC_afterEnd = null;
startGame(level);
};
list.appendChild(row);
}
} catch {
list.innerHTML = '<p class="lore">Impossible de joindre le serveur (le mode communautaire nécessite le site en ligne).</p>';
}
}
/* ---------- Branchement ---------- */
if (typeof document !== "undefined") {
document.addEventListener("DOMContentLoaded", () => {
edReset();
edBuildPalette();
document.getElementById("btn-editor").onclick = () => {
document.getElementById("screen-create").classList.add("hidden");
document.getElementById("screen-editor").classList.remove("hidden");
edRefreshDoorTargets();
edRenderMine();
edRender();
};
document.getElementById("ed-back").onclick = () => {
document.getElementById("screen-editor").classList.add("hidden");
document.getElementById("screen-create").classList.remove("hidden");
};
document.getElementById("btn-community").onclick = communityShow;
document.getElementById("btn-comm-back").onclick = () => {
document.getElementById("screen-community").classList.add("hidden");
document.getElementById("screen-create").classList.remove("hidden");
};
document.getElementById("ed-test").onclick = edTest;
document.getElementById("ed-publish").onclick = edPublish;
const canvas = document.getElementById("ed-map");
const cellOf = e => {
const rect = canvas.getBoundingClientRect();
return {
x: Math.floor((e.clientX - rect.left) * (canvas.width / rect.width) / TILE),
y: Math.floor((e.clientY - rect.top) * (canvas.height / rect.height) / TILE),
};
};
canvas.addEventListener("mousedown", e => {
ED.painting = true;
const { x, y } = cellOf(e);
edApply(x, y);
});
canvas.addEventListener("mousemove", e => {
if (!ED.painting || ED.brush.mode !== "tile") return;
const { x, y } = cellOf(e);
edApply(x, y);
});
document.addEventListener("mouseup", () => { ED.painting = false; });
// ?screen=editor | community : accès direct (captures d'écran, raccourcis)
const params = new URLSearchParams(location.search);
const screen = params.get("screen");
if (screen === "editor") document.getElementById("btn-editor").onclick();
else if (screen === "community") communityShow();
// ?level=<id> : lien partageable vers un niveau communautaire
const levelId = params.get("level");
if (levelId && /^[a-f0-9]{12}$/.test(levelId)) {
fetch("api/levels/" + levelId)
.then(r => r.ok ? r.json() : null)
.then(level => { if (level) startGame(level); });
}
});
}