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:
@@ -76,19 +76,30 @@ Depuis l'écran d'accueil : **🛠️ Éditeur de niveaux**.
|
|||||||
|
|
||||||
- Peins le terrain à la souris (mur, sol, sortie ▼), place le départ 🧌, les monstres
|
- Peins le terrain à la souris (mur, sol, sortie ▼), place le départ 🧌, les monstres
|
||||||
(avec gabarit Jeune → Mythique) et les objets. Outils : tout sol / tout mur /
|
(avec gabarit Jeune → Mythique) et les objets. Outils : tout sol / tout mur /
|
||||||
caverne aléatoire.
|
caverne aléatoire / nouveau niveau.
|
||||||
|
- **🚪 Portes** : une porte téléporte le troll vers un autre niveau publié, en
|
||||||
|
conservant PV, PI et équipement — de quoi chaîner des niveaux en campagne.
|
||||||
- **▶️ Tester** joue ton niveau immédiatement (retour à l'éditeur après la partie).
|
- **▶️ Tester** joue ton niveau immédiatement (retour à l'éditeur après la partie).
|
||||||
- **🌍 Publier** l'envoie au serveur ; il apparaît dans « Niveaux de la communauté ».
|
- **🌍 Publier** l'envoie au serveur ; il apparaît dans « Niveaux de la communauté ».
|
||||||
- Victoire d'un niveau custom : terrasser tous les monstres, ou atteindre la sortie ▼.
|
La publication renvoie une clé d'auteur gardée en localStorage : tes niveaux
|
||||||
|
restent modifiables via la section « Mes niveaux publiés » (bouton devient
|
||||||
|
« Mettre à jour »).
|
||||||
|
- Victoire d'un niveau custom : atteindre la sortie ▼ si elle existe, sinon
|
||||||
|
terrasser tous les monstres. Un niveau avec porte(s) sans sortie ne se termine
|
||||||
|
pas sur place : la suite est derrière la porte.
|
||||||
- Lien partageable : `https://mountycrawl.nas.percolouco.com/?level=<id>`.
|
- Lien partageable : `https://mountycrawl.nas.percolouco.com/?level=<id>`.
|
||||||
|
|
||||||
### API
|
### API
|
||||||
|
|
||||||
| Route | Description |
|
| Route | Description |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `GET /api/levels` | Liste (id, nom, auteur, date, nb monstres, nb parties) |
|
| `GET /api/levels` | Liste (id, nom, auteur, date, nb monstres, nb portes, nb parties) |
|
||||||
| `GET /api/levels/<id>` | Niveau complet (incrémente le compteur de parties) |
|
| `GET /api/levels/<id>` | Niveau complet (incrémente le compteur de parties ; la clé d'auteur n'est jamais renvoyée) |
|
||||||
| `POST /api/levels` | Publie un niveau (validation côté serveur : grille 28×20, départ hors mur, 1–60 monstres…) |
|
| `POST /api/levels` | Publie un niveau ; renvoie `{id, secret}` — le `secret` est la clé d'auteur |
|
||||||
|
| `PUT /api/levels/<id>` | Met à jour un niveau ; exige l'en-tête `X-Level-Secret` (403 sinon) |
|
||||||
|
|
||||||
|
Validation serveur : grille 28×20, départ hors mur, max 60 monstres/objets et
|
||||||
|
10 portes (cible = id de niveau), au moins un objectif (monstre, porte ou sortie).
|
||||||
|
|
||||||
Stockage : `/data/levels.json` dans le volume `/opt/container/mountycrawl/data`.
|
Stockage : `/data/levels.json` dans le volume `/opt/container/mountycrawl/data`.
|
||||||
|
|
||||||
@@ -110,6 +121,9 @@ non affilié au jeu original de Mountyhall SARL.
|
|||||||
|
|
||||||
## Versions
|
## Versions
|
||||||
|
|
||||||
|
- **1.2.0** (2026-06-11) — Portes 🚪 entre niveaux (campagnes multi-niveaux, troll
|
||||||
|
conservé), édition post-publication avec clé d'auteur (`PUT` + localStorage),
|
||||||
|
section « Mes niveaux publiés », objectifs de victoire affinés.
|
||||||
- **1.1.0** (2026-06-11) — Mode création : éditeur de niveaux visuel, publication en
|
- **1.1.0** (2026-06-11) — Mode création : éditeur de niveaux visuel, publication en
|
||||||
ligne, écran « Niveaux de la communauté », liens partageables `?level=<id>`,
|
ligne, écran « Niveaux de la communauté », liens partageables `?level=<id>`,
|
||||||
backend node sans dépendance (`server.js`) avec validation et stockage JSON.
|
backend node sans dépendance (`server.js`) avec validation et stockage JSON.
|
||||||
|
|||||||
@@ -253,6 +253,7 @@ header h1 { font-size: 1.3em; color: #8fbf5a; }
|
|||||||
#ed-status { margin: 10px 0; min-height: 1.2em; font-size: .95em; }
|
#ed-status { margin: 10px 0; min-height: 1.2em; font-size: .95em; }
|
||||||
.ed-warn { color: #e06060; }
|
.ed-warn { color: #e06060; }
|
||||||
.ed-ok { color: #8fbf5a; }
|
.ed-ok { color: #8fbf5a; }
|
||||||
|
.ed-note { font-size: .8em; color: #a89568; }
|
||||||
#ed-help { max-width: 600px; margin: 0 auto; }
|
#ed-help { max-width: 600px; margin: 0 auto; }
|
||||||
|
|
||||||
#equipment div, #inventory .empty { font-size: .88em; padding: 2px 0; color: #c9b685; }
|
#equipment div, #inventory .empty { font-size: .88em; padding: 2px 0; color: #c9b685; }
|
||||||
|
|||||||
+8
-2
@@ -51,8 +51,13 @@
|
|||||||
<div id="ed-monsters"></div>
|
<div id="ed-monsters"></div>
|
||||||
<h3>Objets</h3>
|
<h3>Objets</h3>
|
||||||
<div id="ed-items"></div>
|
<div id="ed-items"></div>
|
||||||
|
<h3>Portes (vers un niveau publié)</h3>
|
||||||
|
<select id="ed-door-target"></select>
|
||||||
|
<div id="ed-doors"></div>
|
||||||
<h3>Outils</h3>
|
<h3>Outils</h3>
|
||||||
<div id="ed-tools"></div>
|
<div id="ed-tools"></div>
|
||||||
|
<h3>Mes niveaux publiés</h3>
|
||||||
|
<div id="ed-mine"></div>
|
||||||
</aside>
|
</aside>
|
||||||
<main id="ed-main">
|
<main id="ed-main">
|
||||||
<canvas id="ed-map" width="672" height="480"></canvas>
|
<canvas id="ed-map" width="672" height="480"></canvas>
|
||||||
@@ -66,8 +71,9 @@
|
|||||||
</div>
|
</div>
|
||||||
<div id="ed-status"></div>
|
<div id="ed-status"></div>
|
||||||
<div class="help" id="ed-help">
|
<div class="help" id="ed-help">
|
||||||
<p>Clic / glisser : peindre la sélection · Un niveau publiable a un point de départ 🧌 et au moins un monstre.</p>
|
<p>Clic / glisser : peindre la sélection · Un niveau publiable a un point de départ 🧌 et au moins un monstre, une porte ou une sortie.</p>
|
||||||
<p>La sortie ▼ est optionnelle : sans elle, il faut terrasser tous les monstres pour gagner.</p>
|
<p>Victoire : atteindre la sortie ▼ si elle existe, sinon terrasser tous les monstres. Les portes 🚪 téléportent vers un autre niveau publié (PV, PI et équipement conservés) — idéal pour chaîner une campagne.</p>
|
||||||
|
<p>Tes niveaux publiés restent modifiables depuis « Mes niveaux publiés » (la clé d'auteur est gardée par ton navigateur).</p>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+133
-8
@@ -7,6 +7,8 @@ const ED = {
|
|||||||
start: null, // {x, y}
|
start: null, // {x, y}
|
||||||
monsters: [], // specs {x, y, type, tpl} ou {x, y, boss: true}
|
monsters: [], // specs {x, y, type, tpl} ou {x, y, boss: true}
|
||||||
items: [], // specs {x, y, kind, idx?, gold?}
|
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: "#" },
|
brush: { mode: "tile", char: "#" },
|
||||||
painting: false,
|
painting: false,
|
||||||
};
|
};
|
||||||
@@ -23,6 +25,67 @@ function edReset() {
|
|||||||
ED.start = null;
|
ED.start = null;
|
||||||
ED.monsters = [];
|
ED.monsters = [];
|
||||||
ED.items = [];
|
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 ---------- */
|
/* ---------- 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 }));
|
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 }));
|
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");
|
const toolsDiv = document.getElementById("ed-tools");
|
||||||
toolsDiv.innerHTML = "";
|
toolsDiv.innerHTML = "";
|
||||||
|
const newBtn = document.createElement("button");
|
||||||
|
newBtn.textContent = "📄 Nouveau niveau (vider)";
|
||||||
|
newBtn.onclick = () => { edReset(); edRender(); };
|
||||||
|
toolsDiv.appendChild(newBtn);
|
||||||
const fill = (char, label) => {
|
const fill = (char, label) => {
|
||||||
const b = document.createElement("button");
|
const b = document.createElement("button");
|
||||||
b.textContent = label;
|
b.textContent = label;
|
||||||
@@ -108,9 +179,26 @@ function edPruneEntities() {
|
|||||||
const onFloor = e => ED.grid[e.y][e.x] !== "#";
|
const onFloor = e => ED.grid[e.y][e.x] !== "#";
|
||||||
ED.monsters = ED.monsters.filter(onFloor);
|
ED.monsters = ED.monsters.filter(onFloor);
|
||||||
ED.items = ED.items.filter(onFloor);
|
ED.items = ED.items.filter(onFloor);
|
||||||
|
ED.doors = ED.doors.filter(onFloor);
|
||||||
if (ED.start && !onFloor(ED.start)) ED.start = null;
|
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 ---------- */
|
/* ---------- Application du pinceau ---------- */
|
||||||
|
|
||||||
function edApply(x, y) {
|
function edApply(x, y) {
|
||||||
@@ -124,6 +212,7 @@ function edApply(x, y) {
|
|||||||
if (b.char === "#") {
|
if (b.char === "#") {
|
||||||
ED.monsters = ED.monsters.filter(m => m.x !== x || m.y !== y);
|
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.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;
|
if (ED.start && ED.start.x === x && ED.start.y === y) ED.start = null;
|
||||||
}
|
}
|
||||||
} else if (b.mode === "start") {
|
} else if (b.mode === "start") {
|
||||||
@@ -132,7 +221,14 @@ function edApply(x, y) {
|
|||||||
} else if (b.mode === "erase") {
|
} else if (b.mode === "erase") {
|
||||||
ED.monsters = ED.monsters.filter(m => m.x !== x || m.y !== y);
|
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.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;
|
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") {
|
} else if (b.mode === "monster") {
|
||||||
if (ED.grid[y][x] === "#") return;
|
if (ED.grid[y][x] === "#") return;
|
||||||
ED.monsters = ED.monsters.filter(m => m.x !== x || m.y !== y);
|
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;
|
: 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);
|
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) {
|
for (const m of ED.monsters) {
|
||||||
disc(m.x, m.y, m.boss ? "#7a2070" : "#8a3030");
|
disc(m.x, m.y, m.boss ? "#7a2070" : "#8a3030");
|
||||||
ctx.fillStyle = "#1a140e";
|
ctx.fillStyle = "#1a140e";
|
||||||
@@ -199,10 +300,15 @@ function edRender() {
|
|||||||
const status = document.getElementById("ed-status");
|
const status = document.getElementById("ed-status");
|
||||||
const missing = [];
|
const missing = [];
|
||||||
if (!ED.start) missing.push("un point de départ 🧌");
|
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
|
status.textContent = missing.length
|
||||||
? "Il manque : " + missing.join(" et ") + "."
|
? "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";
|
status.className = missing.length ? "ed-warn" : "ed-ok";
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -216,12 +322,14 @@ function edToLevel() {
|
|||||||
start: ED.start,
|
start: ED.start,
|
||||||
monsters: ED.monsters,
|
monsters: ED.monsters,
|
||||||
items: ED.items,
|
items: ED.items,
|
||||||
|
doors: ED.doors,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function edValidate(level) {
|
function edValidate(level) {
|
||||||
if (!level.start) return "place un point de départ 🧌";
|
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;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -243,14 +351,28 @@ async function edPublish() {
|
|||||||
(!level.author ? "indique ton pseudo d'auteur" : null);
|
(!level.author ? "indique ton pseudo d'auteur" : null);
|
||||||
if (err) { edFlash(err, true); return; }
|
if (err) { edFlash(err, true); return; }
|
||||||
try {
|
try {
|
||||||
const res = await fetch("api/levels", {
|
const updating = !!ED.editing;
|
||||||
method: "POST",
|
const res = await fetch(updating ? "api/levels/" + ED.editing.id : "api/levels", {
|
||||||
headers: { "Content-Type": "application/json" },
|
method: updating ? "PUT" : "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
...(updating ? { "X-Level-Secret": ED.editing.secret } : {}),
|
||||||
|
},
|
||||||
body: JSON.stringify(level),
|
body: JSON.stringify(level),
|
||||||
});
|
});
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
if (!res.ok) { edFlash("Refusé par le serveur : " + data.error, true); return; }
|
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 {
|
} catch {
|
||||||
edFlash("Impossible de joindre le serveur.", true);
|
edFlash("Impossible de joindre le serveur.", true);
|
||||||
}
|
}
|
||||||
@@ -280,7 +402,8 @@ async function communityShow() {
|
|||||||
for (const l of levels.slice().reverse()) {
|
for (const l of levels.slice().reverse()) {
|
||||||
const row = document.createElement("button");
|
const row = document.createElement("button");
|
||||||
row.className = "level-row";
|
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 () => {
|
row.onclick = async () => {
|
||||||
const r = await fetch("api/levels/" + l.id);
|
const r = await fetch("api/levels/" + l.id);
|
||||||
if (!r.ok) return;
|
if (!r.ok) return;
|
||||||
@@ -306,6 +429,8 @@ if (typeof document !== "undefined") {
|
|||||||
document.getElementById("btn-editor").onclick = () => {
|
document.getElementById("btn-editor").onclick = () => {
|
||||||
document.getElementById("screen-create").classList.add("hidden");
|
document.getElementById("screen-create").classList.add("hidden");
|
||||||
document.getElementById("screen-editor").classList.remove("hidden");
|
document.getElementById("screen-editor").classList.remove("hidden");
|
||||||
|
edRefreshDoorTargets();
|
||||||
|
edRenderMine();
|
||||||
edRender();
|
edRender();
|
||||||
};
|
};
|
||||||
document.getElementById("ed-back").onclick = () => {
|
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,
|
pa: PA_PER_TURN, pi: 0, totalPI: 0, gold: 0,
|
||||||
bag: [], camo: false, stoneSkin: 0, kills: 0, dla: 1,
|
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,
|
seen: new Set(), over: false,
|
||||||
};
|
};
|
||||||
if (customLevel) buildCustomLevel(customLevel);
|
if (customLevel) buildCustomLevel(customLevel);
|
||||||
else buildLevel();
|
else buildLevel();
|
||||||
if (customLevel) {
|
if (customLevel) {
|
||||||
log(`${name} le ${race} entre dans « ${customLevel.name} », un niveau de ${customLevel.author}.`, "good");
|
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 {
|
} else {
|
||||||
log(`${name} le ${race} pénètre dans le Monde Souterrain. Que les Dieux Trõlls te gardent !`, "good");
|
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.seen = new Set();
|
||||||
G.monsters = level.monsters.map(monsterFromSpec);
|
G.monsters = level.monsters.map(monsterFromSpec);
|
||||||
G.items = level.items.map(itemFromSpec);
|
G.items = level.items.map(itemFromSpec);
|
||||||
|
G.doors = (level.doors || []).map(d => ({ ...d }));
|
||||||
G.stairs = null;
|
G.stairs = null;
|
||||||
updateFov();
|
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() {
|
function buildLevel() {
|
||||||
const grid = generateCavern(MAP_W, MAP_H);
|
const grid = generateCavern(MAP_W, MAP_H);
|
||||||
const taken = new Set();
|
const taken = new Set();
|
||||||
@@ -386,6 +418,7 @@ function tryMove(dx, dy) {
|
|||||||
if (G.custom) log("La sortie ! (bouton « Sortir »)", "info");
|
if (G.custom) log("La sortie ! (bouton « Sortir »)", "info");
|
||||||
else log("Un passage s'enfonce vers les profondeurs… (bouton « Descendre »)", "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);
|
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");
|
if (item) log(`Tu vois : ${item.emoji} ${item.name}. Ramasse-le pour ${COSTS.pickup} PA.`, "info");
|
||||||
afterAction();
|
afterAction();
|
||||||
@@ -423,7 +456,10 @@ function killMonster(m) {
|
|||||||
log(`💀 ${m.name} est terrassé ! +${px} PX (convertis en PI à l'entraînement).`, "good");
|
log(`💀 ${m.name} est terrassé ! +${px} PX (convertis en PI à l'entraînement).`, "good");
|
||||||
G.monsters = G.monsters.filter(x => x !== m);
|
G.monsters = G.monsters.filter(x => x !== m);
|
||||||
if (G.custom) {
|
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) {
|
} else if (m.boss) {
|
||||||
win();
|
win();
|
||||||
}
|
}
|
||||||
@@ -685,6 +721,12 @@ function render() {
|
|||||||
ctx.fillStyle = "#1a140e";
|
ctx.fillStyle = "#1a140e";
|
||||||
ctx.fillText(i.emoji, i.x * TILE + TILE / 2, i.y * TILE + TILE / 2 + 1);
|
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) {
|
for (const m of G.monsters) {
|
||||||
if (!inSight(m)) continue;
|
if (!inSight(m)) continue;
|
||||||
disc(m.x, m.y, m.boss ? "#7a2070" : "#8a3030");
|
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);
|
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);
|
addBtn(`🖐️ Ramasser (${COSTS.pickup} PA)`, pickup, onItem && t.pa >= COSTS.pickup);
|
||||||
const onStairs = G.grid[t.y][t.x] === T_STAIRS;
|
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);
|
addBtn("⏳ Passer la DLA", passDLA, true);
|
||||||
|
|
||||||
$("equipment").innerHTML =
|
$("equipment").innerHTML =
|
||||||
|
|||||||
@@ -59,7 +59,16 @@ function validateLevel(l) {
|
|||||||
if (!inBounds(i) || tile(l, i) === "#") return "objet hors-sol";
|
if (!inBounds(i) || tile(l, i) === "#") return "objet hors-sol";
|
||||||
if (!["potion", "gold", "weapon", "armor"].includes(i.kind)) return "type d'objet invalide";
|
if (!["potion", "gold", "weapon", "armor"].includes(i.kind)) return "type d'objet invalide";
|
||||||
}
|
}
|
||||||
if (l.monsters.length === 0) return "place au moins un monstre, sinon pas de défi !";
|
if (l.doors !== undefined && !Array.isArray(l.doors)) return "portes invalides";
|
||||||
|
const doors = l.doors || [];
|
||||||
|
if (doors.length > 10) return "portes invalides (max 10)";
|
||||||
|
for (const d of doors) {
|
||||||
|
if (!inBounds(d) || tile(l, d) === "#") return "porte hors-sol";
|
||||||
|
if (typeof d.target !== "string" || !/^[a-f0-9]{12}$/.test(d.target)) return "cible de porte invalide";
|
||||||
|
}
|
||||||
|
const hasExit = l.grid.some(row => row.includes(">"));
|
||||||
|
if (l.monsters.length === 0 && doors.length === 0 && !hasExit)
|
||||||
|
return "place au moins un monstre, une porte ou une sortie, sinon pas de défi !";
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -73,13 +82,40 @@ function sendJSON(res, code, data) {
|
|||||||
res.end(JSON.stringify(data));
|
res.end(JSON.stringify(data));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Le secret d'auteur (renvoyé à la publication, gardé côté client) ne sort jamais en GET. */
|
||||||
|
function publicLevel(l) {
|
||||||
|
const { secret, ...pub } = l;
|
||||||
|
return pub;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cleanLevel(level) {
|
||||||
|
return {
|
||||||
|
name: level.name.trim(), author: level.author.trim(),
|
||||||
|
grid: level.grid, start: { x: level.start.x, y: level.start.y },
|
||||||
|
monsters: level.monsters, items: level.items, doors: level.doors || [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function readBody(req, res, cb) {
|
||||||
|
let body = "";
|
||||||
|
req.on("data", chunk => {
|
||||||
|
body += chunk;
|
||||||
|
if (body.length > MAX_BODY) { sendJSON(res, 413, { error: "niveau trop gros" }); req.destroy(); }
|
||||||
|
});
|
||||||
|
req.on("end", () => {
|
||||||
|
let data;
|
||||||
|
try { data = JSON.parse(body); } catch { return sendJSON(res, 400, { error: "JSON invalide" }); }
|
||||||
|
cb(data);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function handleAPI(req, res, url) {
|
function handleAPI(req, res, url) {
|
||||||
const idMatch = url.pathname.match(/^\/api\/levels\/([a-f0-9]{12})$/);
|
const idMatch = url.pathname.match(/^\/api\/levels\/([a-f0-9]{12})$/);
|
||||||
|
|
||||||
if (req.method === "GET" && url.pathname === "/api/levels") {
|
if (req.method === "GET" && url.pathname === "/api/levels") {
|
||||||
const list = loadLevels().map(l => ({
|
const list = loadLevels().map(l => ({
|
||||||
id: l.id, name: l.name, author: l.author, date: l.date,
|
id: l.id, name: l.name, author: l.author, date: l.date,
|
||||||
monsters: l.monsters.length, plays: l.plays || 0,
|
monsters: l.monsters.length, doors: (l.doors || []).length, plays: l.plays || 0,
|
||||||
}));
|
}));
|
||||||
return sendJSON(res, 200, list);
|
return sendJSON(res, 200, list);
|
||||||
}
|
}
|
||||||
@@ -90,34 +126,42 @@ function handleAPI(req, res, url) {
|
|||||||
if (!level) return sendJSON(res, 404, { error: "niveau introuvable" });
|
if (!level) return sendJSON(res, 404, { error: "niveau introuvable" });
|
||||||
level.plays = (level.plays || 0) + 1;
|
level.plays = (level.plays || 0) + 1;
|
||||||
saveLevels(levels);
|
saveLevels(levels);
|
||||||
return sendJSON(res, 200, level);
|
return sendJSON(res, 200, publicLevel(level));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (req.method === "POST" && url.pathname === "/api/levels") {
|
if (req.method === "POST" && url.pathname === "/api/levels") {
|
||||||
let body = "";
|
return readBody(req, res, level => {
|
||||||
req.on("data", chunk => {
|
|
||||||
body += chunk;
|
|
||||||
if (body.length > MAX_BODY) { sendJSON(res, 413, { error: "niveau trop gros" }); req.destroy(); }
|
|
||||||
});
|
|
||||||
req.on("end", () => {
|
|
||||||
let level;
|
|
||||||
try { level = JSON.parse(body); } catch { return sendJSON(res, 400, { error: "JSON invalide" }); }
|
|
||||||
const err = validateLevel(level);
|
const err = validateLevel(level);
|
||||||
if (err) return sendJSON(res, 400, { error: err });
|
if (err) return sendJSON(res, 400, { error: err });
|
||||||
const levels = loadLevels();
|
const levels = loadLevels();
|
||||||
if (levels.length >= MAX_LEVELS) return sendJSON(res, 507, { error: "trop de niveaux stockés" });
|
if (levels.length >= MAX_LEVELS) return sendJSON(res, 507, { error: "trop de niveaux stockés" });
|
||||||
const clean = {
|
const record = {
|
||||||
id: crypto.randomBytes(6).toString("hex"),
|
id: crypto.randomBytes(6).toString("hex"),
|
||||||
name: level.name.trim(), author: level.author.trim(),
|
secret: crypto.randomBytes(16).toString("hex"),
|
||||||
date: new Date().toISOString().slice(0, 10),
|
date: new Date().toISOString().slice(0, 10),
|
||||||
grid: level.grid, start: { x: level.start.x, y: level.start.y },
|
plays: 0,
|
||||||
monsters: level.monsters, items: level.items, plays: 0,
|
...cleanLevel(level),
|
||||||
};
|
};
|
||||||
levels.push(clean);
|
levels.push(record);
|
||||||
saveLevels(levels);
|
saveLevels(levels);
|
||||||
return sendJSON(res, 201, { id: clean.id });
|
return sendJSON(res, 201, { id: record.id, secret: record.secret });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.method === "PUT" && idMatch) {
|
||||||
|
return readBody(req, res, level => {
|
||||||
|
const levels = loadLevels();
|
||||||
|
const existing = levels.find(l => l.id === idMatch[1]);
|
||||||
|
if (!existing) return sendJSON(res, 404, { error: "niveau introuvable" });
|
||||||
|
const provided = req.headers["x-level-secret"];
|
||||||
|
if (!existing.secret || !provided || provided !== existing.secret)
|
||||||
|
return sendJSON(res, 403, { error: "clé d'auteur invalide : tu ne peux modifier que tes propres niveaux" });
|
||||||
|
const err = validateLevel(level);
|
||||||
|
if (err) return sendJSON(res, 400, { error: err });
|
||||||
|
Object.assign(existing, cleanLevel(level), { updated: new Date().toISOString().slice(0, 10) });
|
||||||
|
saveLevels(levels);
|
||||||
|
return sendJSON(res, 200, { id: existing.id });
|
||||||
});
|
});
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
sendJSON(res, 404, { error: "route inconnue" });
|
sendJSON(res, 404, { error: "route inconnue" });
|
||||||
|
|||||||
+10
-1
@@ -98,7 +98,16 @@ const goodLevel = {
|
|||||||
};
|
};
|
||||||
assert.strictEqual(srv.validateLevel(goodLevel), null);
|
assert.strictEqual(srv.validateLevel(goodLevel), null);
|
||||||
assert(srv.validateLevel({ ...goodLevel, name: "" }), "nom vide refusé");
|
assert(srv.validateLevel({ ...goodLevel, name: "" }), "nom vide refusé");
|
||||||
assert(srv.validateLevel({ ...goodLevel, monsters: [] }), "niveau sans monstre refusé");
|
assert(srv.validateLevel({ ...goodLevel, monsters: [] }), "niveau sans monstre ni porte ni sortie refusé");
|
||||||
|
|
||||||
|
// Portes : valides, et suffisantes comme objectif sans monstre
|
||||||
|
const door = { x: 4, y: 4, target: "abcdef012345" };
|
||||||
|
assert.strictEqual(srv.validateLevel({ ...goodLevel, doors: [door] }), null);
|
||||||
|
assert.strictEqual(srv.validateLevel({ ...goodLevel, monsters: [], doors: [door] }), null, "porte = objectif valable");
|
||||||
|
assert(srv.validateLevel({ ...goodLevel, doors: [{ x: 4, y: 4, target: "pas-un-id" }] }), "cible de porte invalide refusée");
|
||||||
|
assert(srv.validateLevel({ ...goodLevel, doors: [{ x: 0, y: 0, target: "abcdef012345" }] }), "porte dans un mur refusée");
|
||||||
|
const gridWithExit = goodGrid.map((r, y) => y === 5 ? r.slice(0, 5) + ">" + r.slice(6) : r);
|
||||||
|
assert.strictEqual(srv.validateLevel({ ...goodLevel, monsters: [], grid: gridWithExit }), null, "sortie = objectif valable");
|
||||||
assert(srv.validateLevel({ ...goodLevel, start: { x: 0, y: 0 } }), "départ dans un mur refusé");
|
assert(srv.validateLevel({ ...goodLevel, start: { x: 0, y: 0 } }), "départ dans un mur refusé");
|
||||||
assert(srv.validateLevel({ ...goodLevel, grid: goodGrid.slice(1) }), "grille tronquée refusée");
|
assert(srv.validateLevel({ ...goodLevel, grid: goodGrid.slice(1) }), "grille tronquée refusée");
|
||||||
assert(srv.validateLevel({ ...goodLevel, monsters: [{ x: 0, y: 0, type: 0, tpl: 0 }] }), "monstre dans un mur refusé");
|
assert(srv.validateLevel({ ...goodLevel, monsters: [{ x: 0, y: 0, type: 0, tpl: 0 }] }), "monstre dans un mur refusé");
|
||||||
|
|||||||
Reference in New Issue
Block a user