/* É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 = '

Aucun pour l\'instant — publie un niveau et il apparaîtra ici, modifiable à volonté.

'; 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 = '' + 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; 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 = ""; 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 = '

Chargement…

'; try { const res = await fetch("api/levels"); const levels = await res.json(); list.innerHTML = ""; if (levels.length === 0) { list.innerHTML = '

Aucun niveau publié pour l\'instant. Sois le premier, l\'éditeur t\'attend !

'; 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 = '

Impossible de joindre le serveur (le mode communautaire nécessite le site en ligne).

'; } } /* ---------- 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= : 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); }); } }); }