Portes 🚪 (max 10/niveau) téléportant vers un autre niveau publié avec troll conservé (PV, PI, équipement) ; clé d'auteur renvoyée au POST et stockée en localStorage, PUT /api/levels/<id> protégé par X-Level-Secret, section « Mes niveaux publiés » dans l'éditeur, objectifs de victoire affinés (sortie > nettoyage, portes = continuation). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
483 lines
18 KiB
JavaScript
483 lines
18 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);
|
||
|
||
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);
|
||
});
|
||
|
||
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 });
|
||
|
||
const itemsDiv = document.getElementById("ed-items");
|
||
itemsDiv.innerHTML = "";
|
||
edAddBrushBtn(itemsDiv, "🧪 Potion de Vie", { mode: "item", kind: "potion" });
|
||
edAddBrushBtn(itemsDiv, "💰 Mountyzédons", { mode: "item", kind: "gold" });
|
||
WEAPONS.forEach((w, i) => edAddBrushBtn(itemsDiv, `${w.emoji} ${w.name} (+${w.bonus})`, { mode: "item", kind: "weapon", idx: i }));
|
||
ARMORS.forEach((a, i) => edAddBrushBtn(itemsDiv, `${a.emoji} ${a.name} (+${a.bonus})`, { mode: "item", kind: "armor", idx: i }));
|
||
|
||
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);
|
||
}
|
||
|
||
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;
|
||
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) });
|
||
} 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.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" : "#7a8db0");
|
||
ctx.fillStyle = "#1a140e";
|
||
const emoji = i.kind === "potion" ? "🧪" : i.kind === "gold" ? "💰"
|
||
: i.kind === "weapon" ? WEAPONS[i.idx || 0].emoji : ARMORS[i.idx || 0].emoji;
|
||
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) {
|
||
disc(m.x, m.y, m.boss ? "#7a2070" : "#8a3030");
|
||
ctx.fillStyle = "#1a140e";
|
||
const emoji = m.boss ? BOSS.emoji : 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); });
|
||
}
|
||
});
|
||
}
|