1.2.0 : portes entre niveaux et édition post-publication
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>
This commit is contained in:
+133
-8
@@ -7,6 +7,8 @@ const ED = {
|
||||
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,
|
||||
};
|
||||
@@ -23,6 +25,67 @@ function edReset() {
|
||||
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 ---------- */
|
||||
@@ -63,8 +126,16 @@ function edBuildPalette() {
|
||||
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;
|
||||
@@ -108,9 +179,26 @@ 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) {
|
||||
@@ -124,6 +212,7 @@ function edApply(x, y) {
|
||||
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") {
|
||||
@@ -132,7 +221,14 @@ function edApply(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);
|
||||
@@ -184,6 +280,11 @@ function edRender() {
|
||||
: 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";
|
||||
@@ -199,10 +300,15 @@ function edRender() {
|
||||
const status = document.getElementById("ed-status");
|
||||
const missing = [];
|
||||
if (!ED.start) missing.push("un point de départ 🧌");
|
||||
if (ED.monsters.length === 0) missing.push("au moins un monstre");
|
||||
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)${ED.grid.some(r => r.includes(">")) ? ", une sortie" : ""}.`;
|
||||
: `Prêt : ${ED.monsters.length} monstre(s), ${ED.items.length} objet(s)${extras ? ", " + extras : ""}.`;
|
||||
status.className = missing.length ? "ed-warn" : "ed-ok";
|
||||
}
|
||||
|
||||
@@ -216,12 +322,14 @@ function edToLevel() {
|
||||
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) return "place au moins un monstre";
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -243,14 +351,28 @@ async function edPublish() {
|
||||
(!level.author ? "indique ton pseudo d'auteur" : null);
|
||||
if (err) { edFlash(err, true); return; }
|
||||
try {
|
||||
const res = await fetch("api/levels", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
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; }
|
||||
edFlash(`🌍 Publié ! « ${level.name} » est maintenant jouable par tous.`, false);
|
||||
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);
|
||||
}
|
||||
@@ -280,7 +402,8 @@ async function communityShow() {
|
||||
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) · joué ${l.plays} fois · ${l.date}`;
|
||||
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;
|
||||
@@ -306,6 +429,8 @@ if (typeof document !== "undefined") {
|
||||
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 = () => {
|
||||
|
||||
+47
-4
@@ -274,14 +274,18 @@ function newGame(name, race, customLevel = null) {
|
||||
pa: PA_PER_TURN, pi: 0, totalPI: 0, gold: 0,
|
||||
bag: [], camo: false, stoneSkin: 0, kills: 0, dla: 1,
|
||||
},
|
||||
depth: 1, grid: null, monsters: [], items: [], stairs: null,
|
||||
depth: 1, grid: null, monsters: [], items: [], doors: [], stairs: null,
|
||||
seen: new Set(), over: false,
|
||||
};
|
||||
if (customLevel) buildCustomLevel(customLevel);
|
||||
else buildLevel();
|
||||
if (customLevel) {
|
||||
log(`${name} le ${race} entre dans « ${customLevel.name} », un niveau de ${customLevel.author}.`, "good");
|
||||
log("Objectif : terrasser tous les monstres, ou atteindre la sortie ▼ s'il y en a une.", "info");
|
||||
const hasExitTile = G.grid.some(row => row.includes(T_STAIRS));
|
||||
const hasDoors = G.doors.length > 0;
|
||||
if (hasExitTile) log("Objectif : atteindre la sortie ▼.", "info");
|
||||
else if (hasDoors) log("Objectif : des portes 🚪 mènent vers d'autres niveaux — explore !", "info");
|
||||
else log("Objectif : terrasser tous les monstres.", "info");
|
||||
} else {
|
||||
log(`${name} le ${race} pénètre dans le Monde Souterrain. Que les Dieux Trõlls te gardent !`, "good");
|
||||
}
|
||||
@@ -302,10 +306,38 @@ function buildCustomLevel(level) {
|
||||
G.seen = new Set();
|
||||
G.monsters = level.monsters.map(monsterFromSpec);
|
||||
G.items = level.items.map(itemFromSpec);
|
||||
G.doors = (level.doors || []).map(d => ({ ...d }));
|
||||
G.stairs = null;
|
||||
updateFov();
|
||||
}
|
||||
|
||||
function doorAt(x, y) {
|
||||
return (G.doors || []).find(d => d.x === x && d.y === y);
|
||||
}
|
||||
|
||||
/* Le niveau custom courant a-t-il encore un objectif au-delà du nettoyage ? */
|
||||
function customHasExit() {
|
||||
return G.grid.some(row => row.includes(T_STAIRS)) || (G.doors || []).length > 0;
|
||||
}
|
||||
|
||||
/* Franchir une porte : charge le niveau cible en conservant le troll (PV, PI, sac…). */
|
||||
async function enterDoor() {
|
||||
const door = doorAt(G.troll.x, G.troll.y);
|
||||
if (!door || G.over) return;
|
||||
log("🚪 Tu pousses la lourde porte…", "info");
|
||||
try {
|
||||
const res = await fetch("api/levels/" + door.target);
|
||||
if (!res.ok) throw new Error();
|
||||
const level = await res.json();
|
||||
G.custom = level;
|
||||
buildCustomLevel(level);
|
||||
log(`Tu débouches dans « ${level.name} », un niveau de ${level.author}.`, "good");
|
||||
afterAction();
|
||||
} catch {
|
||||
log("La porte est condamnée : le niveau cible n'existe plus.", "bad");
|
||||
}
|
||||
}
|
||||
|
||||
function buildLevel() {
|
||||
const grid = generateCavern(MAP_W, MAP_H);
|
||||
const taken = new Set();
|
||||
@@ -386,6 +418,7 @@ function tryMove(dx, dy) {
|
||||
if (G.custom) log("La sortie ! (bouton « Sortir »)", "info");
|
||||
else log("Un passage s'enfonce vers les profondeurs… (bouton « Descendre »)", "info");
|
||||
}
|
||||
if (doorAt(nx, ny)) log("Une porte massive se dresse là. (bouton « Franchir la porte »)", "info");
|
||||
const item = G.items.find(i => i.x === nx && i.y === ny);
|
||||
if (item) log(`Tu vois : ${item.emoji} ${item.name}. Ramasse-le pour ${COSTS.pickup} PA.`, "info");
|
||||
afterAction();
|
||||
@@ -423,7 +456,10 @@ function killMonster(m) {
|
||||
log(`💀 ${m.name} est terrassé ! +${px} PX (convertis en PI à l'entraînement).`, "good");
|
||||
G.monsters = G.monsters.filter(x => x !== m);
|
||||
if (G.custom) {
|
||||
if (G.monsters.length === 0) win();
|
||||
if (G.monsters.length === 0) {
|
||||
if (customHasExit()) log("Le niveau est nettoyé… mais l'aventure continue derrière une porte ou une sortie.", "info");
|
||||
else win();
|
||||
}
|
||||
} else if (m.boss) {
|
||||
win();
|
||||
}
|
||||
@@ -685,6 +721,12 @@ function render() {
|
||||
ctx.fillStyle = "#1a140e";
|
||||
ctx.fillText(i.emoji, i.x * TILE + TILE / 2, i.y * TILE + TILE / 2 + 1);
|
||||
}
|
||||
for (const d of G.doors || []) {
|
||||
if (!G.seen.has(d.y * MAP_W + d.x)) continue;
|
||||
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 G.monsters) {
|
||||
if (!inSight(m)) continue;
|
||||
disc(m.x, m.y, m.boss ? "#7a2070" : "#8a3030");
|
||||
@@ -758,7 +800,8 @@ function renderPanels() {
|
||||
const onItem = G.items.some(i => i.x === t.x && i.y === t.y);
|
||||
addBtn(`🖐️ Ramasser (${COSTS.pickup} PA)`, pickup, onItem && t.pa >= COSTS.pickup);
|
||||
const onStairs = G.grid[t.y][t.x] === T_STAIRS;
|
||||
addBtn(G.custom ? "🚪 Sortir" : "⬇️ Descendre", descend, onStairs);
|
||||
addBtn(G.custom ? "🏁 Sortir" : "⬇️ Descendre", descend, onStairs);
|
||||
if (doorAt(t.x, t.y)) addBtn("🚪 Franchir la porte", enterDoor, true);
|
||||
addBtn("⏳ Passer la DLA", passDLA, true);
|
||||
|
||||
$("equipment").innerHTML =
|
||||
|
||||
Reference in New Issue
Block a user