1.1.0 : éditeur de niveaux et partage communautaire
Éditeur visuel (terrain, monstres avec gabarits, objets, départ, sortie), test en un clic, publication via une API node sans dépendance (server.js, validation serveur, stockage JSON sur volume), écran « Niveaux de la communauté » et liens partageables ?level=<id>. Le conteneur passe de nginx à node:22-alpine. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
+7
-4
@@ -1,4 +1,7 @@
|
||||
FROM nginx:alpine
|
||||
COPY index.html /usr/share/nginx/html/
|
||||
COPY css /usr/share/nginx/html/css
|
||||
COPY js /usr/share/nginx/html/js
|
||||
FROM node:22-alpine
|
||||
WORKDIR /app
|
||||
COPY index.html server.js ./
|
||||
COPY css ./css
|
||||
COPY js ./js
|
||||
EXPOSE 80
|
||||
CMD ["node", "server.js"]
|
||||
|
||||
@@ -9,19 +9,18 @@ de trésors et de PX — jusqu'au Béhémoth qui rôde à la profondeur −5.
|
||||
## Jouer
|
||||
|
||||
🎮 **https://mountycrawl.nas.percolouco.com** (déployé via Traefik, compose dans
|
||||
`/opt/container/mountycrawl`, image nginx:alpine construite depuis ce repo).
|
||||
`/opt/container/mountycrawl`, image node:22-alpine construite depuis ce repo).
|
||||
|
||||
En local sans docker :
|
||||
En local :
|
||||
|
||||
```bash
|
||||
# Directement
|
||||
xdg-open index.html
|
||||
|
||||
# Ou via un petit serveur
|
||||
python3 -m http.server 8080
|
||||
PORT=8080 LEVELS_FILE=/tmp/levels.json node server.js
|
||||
# → http://localhost:8080
|
||||
```
|
||||
|
||||
(Le jeu solo fonctionne aussi en ouvrant `index.html` directement ; seuls l'éditeur
|
||||
« Publier » et les niveaux communautaires ont besoin du serveur.)
|
||||
|
||||
Après une modification, redéployer avec :
|
||||
|
||||
```bash
|
||||
@@ -71,6 +70,28 @@ Gobelin, Champignon Vénéneux, Araignée Géante, Gargouille, Momie, Sorcière,
|
||||
Pierre — déclinés en gabarits *Jeune / Vieux / Ancien / Mythique* selon la profondeur,
|
||||
et le **Béhémoth** comme boss final à la profondeur −5.
|
||||
|
||||
## Éditeur de niveaux & partage
|
||||
|
||||
Depuis l'écran d'accueil : **🛠️ Éditeur de niveaux**.
|
||||
|
||||
- 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 /
|
||||
caverne aléatoire.
|
||||
- **▶️ 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é ».
|
||||
- Victoire d'un niveau custom : terrasser tous les monstres, ou atteindre la sortie ▼.
|
||||
- Lien partageable : `https://mountycrawl.nas.percolouco.com/?level=<id>`.
|
||||
|
||||
### API
|
||||
|
||||
| Route | Description |
|
||||
|---|---|
|
||||
| `GET /api/levels` | Liste (id, nom, auteur, date, nb monstres, nb parties) |
|
||||
| `GET /api/levels/<id>` | Niveau complet (incrémente le compteur de parties) |
|
||||
| `POST /api/levels` | Publie un niveau (validation côté serveur : grille 28×20, départ hors mur, 1–60 monstres…) |
|
||||
|
||||
Stockage : `/data/levels.json` dans le volume `/opt/container/mountycrawl/data`.
|
||||
|
||||
## Tests
|
||||
|
||||
```bash
|
||||
@@ -89,5 +110,8 @@ non affilié au jeu original de Mountyhall SARL.
|
||||
|
||||
## Versions
|
||||
|
||||
- **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>`,
|
||||
backend node sans dépendance (`server.js`) avec validation et stockage JSON.
|
||||
- **1.0.0** (2026-06-11) — Version initiale : 5 races, 5 profondeurs, combat aux dés
|
||||
fidèle aux règles MH, progression PI, brouillard de guerre, boss Béhémoth.
|
||||
|
||||
+91
-1
@@ -11,7 +11,7 @@ body {
|
||||
.hidden { display: none !important; }
|
||||
|
||||
/* ---------- Écrans création / fin ---------- */
|
||||
#screen-create, #screen-end {
|
||||
#screen-create, #screen-end, #screen-community {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
@@ -165,6 +165,96 @@ header h1 { font-size: 1.3em; color: #8fbf5a; }
|
||||
|
||||
.help p { font-size: .8em; color: #a89568; margin: 3px 0; }
|
||||
|
||||
/* ---------- Menu & communauté ---------- */
|
||||
.menu-row { margin-top: 14px; display: flex; gap: 10px; justify-content: center; }
|
||||
.menu-btn {
|
||||
background: #1f1810;
|
||||
color: #d8c79a;
|
||||
border: 2px solid #6b5430;
|
||||
border-radius: 8px;
|
||||
padding: 8px 16px;
|
||||
font-family: inherit;
|
||||
font-size: .95em;
|
||||
cursor: pointer;
|
||||
}
|
||||
.menu-btn:hover { border-color: #8fbf5a; }
|
||||
|
||||
#level-list { margin: 16px 0; text-align: left; max-height: 50vh; overflow-y: auto; }
|
||||
.level-row {
|
||||
display: block;
|
||||
width: 100%;
|
||||
margin: 6px 0;
|
||||
padding: 10px 12px;
|
||||
background: #1f1810;
|
||||
border: 2px solid #4a3a20;
|
||||
border-radius: 8px;
|
||||
color: #e8d9b0;
|
||||
font-family: inherit;
|
||||
font-size: .95em;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
.level-row:hover { border-color: #8fbf5a; background: #28341c; }
|
||||
|
||||
/* ---------- Éditeur ---------- */
|
||||
#ed-layout {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
padding: 10px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
#ed-palette {
|
||||
width: 250px;
|
||||
flex-shrink: 0;
|
||||
background: #2a2014;
|
||||
border: 2px solid #6b5430;
|
||||
border-radius: 8px;
|
||||
padding: 10px;
|
||||
font-size: .9em;
|
||||
max-height: 80vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
#ed-palette h3 {
|
||||
color: #d8a040;
|
||||
font-size: .95em;
|
||||
margin: 10px 0 6px;
|
||||
border-bottom: 1px solid #4a3a20;
|
||||
}
|
||||
#ed-palette button, #ed-palette select {
|
||||
display: block;
|
||||
width: 100%;
|
||||
margin: 3px 0;
|
||||
padding: 6px 8px;
|
||||
background: #1f1810;
|
||||
border: 1px solid #6b5430;
|
||||
border-radius: 5px;
|
||||
color: #e8d9b0;
|
||||
font-family: inherit;
|
||||
font-size: .92em;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
}
|
||||
#ed-palette button:hover { border-color: #8fbf5a; }
|
||||
#ed-palette button.selected { border-color: #8fbf5a; background: #28341c; }
|
||||
|
||||
#ed-main { flex-grow: 1; text-align: center; }
|
||||
#ed-map { cursor: crosshair; }
|
||||
#ed-meta { margin: 10px 0; display: flex; gap: 10px; justify-content: center; }
|
||||
#ed-meta input {
|
||||
padding: 8px 10px;
|
||||
font-size: 1em;
|
||||
background: #1a140e;
|
||||
border: 2px solid #6b5430;
|
||||
border-radius: 6px;
|
||||
color: #e8d9b0;
|
||||
width: 40%;
|
||||
}
|
||||
#ed-actions { display: flex; gap: 10px; justify-content: center; }
|
||||
#ed-status { margin: 10px 0; min-height: 1.2em; font-size: .95em; }
|
||||
.ed-warn { color: #e06060; }
|
||||
.ed-ok { color: #8fbf5a; }
|
||||
#ed-help { max-width: 600px; margin: 0 auto; }
|
||||
|
||||
#equipment div, #inventory .empty { font-size: .88em; padding: 2px 0; color: #c9b685; }
|
||||
|
||||
#log {
|
||||
|
||||
+52
@@ -19,6 +19,57 @@
|
||||
<input type="text" id="troll-name" placeholder="Nom de ton Trõll" maxlength="20" value="Trõllinet">
|
||||
<div id="race-list"></div>
|
||||
<button id="btn-start" class="big-btn">⚔️ Descendre dans le Monde Souterrain</button>
|
||||
<div class="menu-row">
|
||||
<button id="btn-community" class="menu-btn">🌍 Niveaux de la communauté</button>
|
||||
<button id="btn-editor" class="menu-btn">🛠️ Éditeur de niveaux</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Écran des niveaux communautaires -->
|
||||
<div id="screen-community" class="screen hidden">
|
||||
<div class="create-box">
|
||||
<h1>🌍 Niveaux de la communauté</h1>
|
||||
<p class="subtitle">Tu y joueras avec le Trõll configuré sur l'écran d'accueil</p>
|
||||
<div id="level-list"><p class="lore">Chargement…</p></div>
|
||||
<button id="btn-comm-back" class="big-btn">← Retour à la Taverne</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Écran éditeur de niveaux -->
|
||||
<div id="screen-editor" class="screen hidden">
|
||||
<header>
|
||||
<h1>🛠️ Éditeur de niveaux</h1>
|
||||
<button id="ed-back" class="menu-btn">← Retour à la Taverne</button>
|
||||
</header>
|
||||
<div id="ed-layout">
|
||||
<aside id="ed-palette">
|
||||
<h3>Terrain</h3>
|
||||
<div id="ed-tiles"></div>
|
||||
<h3>Monstres</h3>
|
||||
<select id="ed-tpl"></select>
|
||||
<div id="ed-monsters"></div>
|
||||
<h3>Objets</h3>
|
||||
<div id="ed-items"></div>
|
||||
<h3>Outils</h3>
|
||||
<div id="ed-tools"></div>
|
||||
</aside>
|
||||
<main id="ed-main">
|
||||
<canvas id="ed-map" width="672" height="480"></canvas>
|
||||
<div id="ed-meta">
|
||||
<input id="ed-name" placeholder="Nom du niveau" maxlength="40">
|
||||
<input id="ed-author" placeholder="Ton pseudo d'auteur" maxlength="30">
|
||||
</div>
|
||||
<div id="ed-actions">
|
||||
<button id="ed-test" class="big-btn">▶️ Tester le niveau</button>
|
||||
<button id="ed-publish" class="big-btn">🌍 Publier en ligne</button>
|
||||
</div>
|
||||
<div id="ed-status"></div>
|
||||
<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>La sortie ▼ est optionnelle : sans elle, il faut terrasser tous les monstres pour gagner.</p>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -67,5 +118,6 @@
|
||||
</div>
|
||||
|
||||
<script src="js/game.js"></script>
|
||||
<script src="js/editor.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+357
@@ -0,0 +1,357 @@
|
||||
/* É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?}
|
||||
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 = [];
|
||||
}
|
||||
|
||||
/* ---------- 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 toolsDiv = document.getElementById("ed-tools");
|
||||
toolsDiv.innerHTML = "";
|
||||
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);
|
||||
if (ED.start && !onFloor(ED.start)) ED.start = null;
|
||||
}
|
||||
|
||||
/* ---------- 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);
|
||||
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);
|
||||
if (ED.start && ED.start.x === x && ED.start.y === y) ED.start = null;
|
||||
} 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 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) missing.push("au moins un monstre");
|
||||
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" : ""}.`;
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
function edValidate(level) {
|
||||
if (!level.start) return "place un point de départ 🧌";
|
||||
if (level.monsters.length === 0) return "place au moins un monstre";
|
||||
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 res = await fetch("api/levels", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
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);
|
||||
} 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) · 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");
|
||||
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); });
|
||||
}
|
||||
});
|
||||
}
|
||||
+106
-25
@@ -113,11 +113,7 @@ const TEMPLATES = [
|
||||
{ prefix: "Mythique ", mult: 2.0 },
|
||||
];
|
||||
|
||||
function makeMonster(depth, x, y) {
|
||||
const pool = MONSTER_TYPES.filter(m => m.level <= depth + 1 && m.level >= Math.max(1, depth - 2));
|
||||
const type = pool[Math.floor(Math.random() * pool.length)];
|
||||
const tplMax = Math.min(TEMPLATES.length - 1, depth - 1);
|
||||
const tpl = TEMPLATES[Math.floor(Math.random() * (tplMax + 1))];
|
||||
function applyTemplate(type, tpl, x, y) {
|
||||
return {
|
||||
name: tpl.prefix + type.name, emoji: type.emoji,
|
||||
level: Math.max(1, Math.round(type.level * tpl.mult)),
|
||||
@@ -130,6 +126,34 @@ function makeMonster(depth, x, y) {
|
||||
};
|
||||
}
|
||||
|
||||
function makeMonster(depth, x, y) {
|
||||
const pool = MONSTER_TYPES.filter(m => m.level <= depth + 1 && m.level >= Math.max(1, depth - 2));
|
||||
const type = pool[Math.floor(Math.random() * pool.length)];
|
||||
const tplMax = Math.min(TEMPLATES.length - 1, depth - 1);
|
||||
const tpl = TEMPLATES[Math.floor(Math.random() * (tplMax + 1))];
|
||||
return applyTemplate(type, tpl, x, y);
|
||||
}
|
||||
|
||||
/* Instancie un monstre depuis une spec d'éditeur : {x, y, type, tpl} ou {x, y, boss: true} */
|
||||
function monsterFromSpec(spec) {
|
||||
if (spec.boss) return { ...BOSS, pvMax: BOSS.pv, x: spec.x, y: spec.y, boss: true, static: false };
|
||||
const type = MONSTER_TYPES[spec.type % MONSTER_TYPES.length];
|
||||
const tpl = TEMPLATES[spec.tpl % TEMPLATES.length];
|
||||
return applyTemplate(type, tpl, spec.x, spec.y);
|
||||
}
|
||||
|
||||
/* Instancie un objet depuis une spec d'éditeur : {x, y, kind, idx?, gold?} */
|
||||
function itemFromSpec(spec) {
|
||||
const base = { x: spec.x, y: spec.y };
|
||||
if (spec.kind === "potion") return { ...base, kind: "potion", name: "Potion de Vie", emoji: "🧪" };
|
||||
if (spec.kind === "gold") {
|
||||
const gold = spec.gold || 60;
|
||||
return { ...base, kind: "gold", name: `${gold} Mountyzédons`, emoji: "💰", gold };
|
||||
}
|
||||
const list = spec.kind === "weapon" ? WEAPONS : ARMORS;
|
||||
return { ...base, kind: "gear", ...list[(spec.idx || 0) % list.length] };
|
||||
}
|
||||
|
||||
/* ================= Génération du Monde Souterrain ================= */
|
||||
|
||||
const MAP_W = 28, MAP_H = 20;
|
||||
@@ -237,9 +261,10 @@ const COSTS = { move: 1, attack: 3, pickup: 1, equip: 2, potion: 1 };
|
||||
|
||||
let G = null; // état global de la partie
|
||||
|
||||
function newGame(name, race) {
|
||||
function newGame(name, race, customLevel = null) {
|
||||
const s = RACES[race].stats;
|
||||
G = {
|
||||
custom: customLevel,
|
||||
troll: {
|
||||
name, race,
|
||||
att: s.att, esq: s.esq, deg: s.deg, reg: s.reg,
|
||||
@@ -252,8 +277,33 @@ function newGame(name, race) {
|
||||
depth: 1, grid: null, monsters: [], items: [], stairs: null,
|
||||
seen: new Set(), over: false,
|
||||
};
|
||||
buildLevel();
|
||||
log(`${name} le ${race} pénètre dans le Monde Souterrain. Que les Dieux Trõlls te gardent !`, "good");
|
||||
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");
|
||||
} else {
|
||||
log(`${name} le ${race} pénètre dans le Monde Souterrain. Que les Dieux Trõlls te gardent !`, "good");
|
||||
}
|
||||
}
|
||||
|
||||
/* Construit un niveau venu de l'éditeur : grid = tableau de chaînes '#' mur, '.' sol, '>' sortie. */
|
||||
function buildCustomLevel(level) {
|
||||
const grid = [];
|
||||
for (let y = 0; y < MAP_H; y++) {
|
||||
grid.push([]);
|
||||
for (let x = 0; x < MAP_W; x++) {
|
||||
const c = level.grid[y][x];
|
||||
grid[y].push(c === "#" ? T_WALL : c === ">" ? T_STAIRS : T_FLOOR);
|
||||
}
|
||||
}
|
||||
G.grid = grid;
|
||||
G.troll.x = level.start.x; G.troll.y = level.start.y;
|
||||
G.seen = new Set();
|
||||
G.monsters = level.monsters.map(monsterFromSpec);
|
||||
G.items = level.items.map(itemFromSpec);
|
||||
G.stairs = null;
|
||||
updateFov();
|
||||
}
|
||||
|
||||
function buildLevel() {
|
||||
@@ -333,7 +383,8 @@ function tryMove(dx, dy) {
|
||||
updateFov();
|
||||
|
||||
if (G.grid[ny][nx] === T_STAIRS) {
|
||||
log("Un passage s'enfonce vers les profondeurs… (bouton « Descendre »)", "info");
|
||||
if (G.custom) log("La sortie ! (bouton « Sortir »)", "info");
|
||||
else log("Un passage s'enfonce vers les profondeurs… (bouton « Descendre »)", "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");
|
||||
@@ -371,7 +422,11 @@ function killMonster(m) {
|
||||
G.troll.kills++;
|
||||
log(`💀 ${m.name} est terrassé ! +${px} PX (convertis en PI à l'entraînement).`, "good");
|
||||
G.monsters = G.monsters.filter(x => x !== m);
|
||||
if (m.boss) win();
|
||||
if (G.custom) {
|
||||
if (G.monsters.length === 0) win();
|
||||
} else if (m.boss) {
|
||||
win();
|
||||
}
|
||||
}
|
||||
|
||||
function useAbility() {
|
||||
@@ -463,6 +518,12 @@ function useBagItem(idx) {
|
||||
|
||||
function descend() {
|
||||
if (G.grid[G.troll.y][G.troll.x] !== T_STAIRS) return;
|
||||
if (G.custom) {
|
||||
G.over = true;
|
||||
log("🚪 Tu atteins la sortie, sain et sauf !", "good");
|
||||
showEnd(true);
|
||||
return;
|
||||
}
|
||||
G.depth++;
|
||||
log(`⬇️ Tu descends. Profondeur −${G.depth}. L'air devient lourd…`, "info");
|
||||
if (G.depth === MAX_DEPTH) log("👹 Le sol tremble. Le Béhémoth est proche.", "bad");
|
||||
@@ -554,7 +615,8 @@ function die(killer) {
|
||||
|
||||
function win() {
|
||||
G.over = true;
|
||||
log("🏆 Le Béhémoth s'effondre ! Le Trésor de MountyHall est à toi !", "good");
|
||||
if (G.custom) log("🏆 Tous les monstres sont terrassés ! Niveau vaincu !", "good");
|
||||
else log("🏆 Le Béhémoth s'effondre ! Le Trésor de MountyHall est à toi !", "good");
|
||||
showEnd(true);
|
||||
}
|
||||
|
||||
@@ -644,7 +706,9 @@ function render() {
|
||||
|
||||
function renderPanels() {
|
||||
const t = G.troll;
|
||||
$("depth-label").textContent = `Profondeur −${G.depth} · DLA n°${t.dla}`;
|
||||
$("depth-label").textContent = G.custom
|
||||
? `« ${G.custom.name} » par ${G.custom.author} · DLA n°${t.dla} · ${G.monsters.length} monstre(s) restant(s)`
|
||||
: `Profondeur −${G.depth} · DLA n°${t.dla}`;
|
||||
$("troll-title").textContent = `${RACES[t.race].emoji} ${t.name}, ${t.race} niv. ${levelFromTotalPI(t.totalPI)}`;
|
||||
|
||||
const pct = Math.max(0, t.pv / t.pvMax);
|
||||
@@ -694,7 +758,7 @@ 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("⬇️ Descendre", descend, onStairs);
|
||||
addBtn(G.custom ? "🚪 Sortir" : "⬇️ Descendre", descend, onStairs);
|
||||
addBtn("⏳ Passer la DLA", passDLA, true);
|
||||
|
||||
$("equipment").innerHTML =
|
||||
@@ -714,18 +778,29 @@ function renderPanels() {
|
||||
});
|
||||
}
|
||||
|
||||
function esc(s) {
|
||||
return String(s).replace(/[&<>"']/g, c => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c]));
|
||||
}
|
||||
|
||||
function showEnd(victory, killer) {
|
||||
const t = G.troll;
|
||||
$("screen-game").classList.add("hidden");
|
||||
$("screen-end").classList.remove("hidden");
|
||||
$("end-title").textContent = victory ? "🏆 GLOIRE AU TRÕLL !" : "☠️ MORT DANS LES PROFONDEURS";
|
||||
const score = t.gold + t.totalPI * 10 + t.kills * 25 + (victory ? 1000 : 0);
|
||||
$("end-text").innerHTML = victory
|
||||
? `${t.name} le ${t.race} a terrassé le Béhémoth et rapporte le Trésor de MountyHall à la Taverne !<br><br>
|
||||
Monstres tués : ${t.kills} · Mountyzédons : ${t.gold} · DLA écoulées : ${t.dla}<br><b>Score : ${score}</b>`
|
||||
: `${t.name} le ${t.race} a été terrassé par ${killer ? killer.name : "les profondeurs"} à la profondeur −${G.depth}.<br>
|
||||
À MountyHall on ne meurt jamais vraiment : les Dieux Trõlls te ramèneront à la Taverne.<br><br>
|
||||
Monstres tués : ${t.kills} · Mountyzédons : ${t.gold} · DLA écoulées : ${t.dla}<br><b>Score : ${score}</b>`;
|
||||
const stats = `Monstres tués : ${t.kills} · Mountyzédons : ${t.gold} · DLA écoulées : ${t.dla}<br><b>Score : ${score}</b>`;
|
||||
let text;
|
||||
if (G.custom) {
|
||||
text = victory
|
||||
? `${esc(t.name)} le ${t.race} a vaincu « ${esc(G.custom.name)} », le niveau de ${esc(G.custom.author)} !`
|
||||
: `${esc(t.name)} le ${t.race} a été terrassé par ${esc(killer ? killer.name : "les profondeurs")} dans « ${esc(G.custom.name)} » (niveau de ${esc(G.custom.author)}).`;
|
||||
} else {
|
||||
text = victory
|
||||
? `${esc(t.name)} le ${t.race} a terrassé le Béhémoth et rapporte le Trésor de MountyHall à la Taverne !`
|
||||
: `${esc(t.name)} le ${t.race} a été terrassé par ${esc(killer ? killer.name : "les profondeurs")} à la profondeur −${G.depth}.<br>
|
||||
À MountyHall on ne meurt jamais vraiment : les Dieux Trõlls te ramèneront à la Taverne.`;
|
||||
}
|
||||
$("end-text").innerHTML = text + "<br><br>" + stats;
|
||||
}
|
||||
|
||||
/* ================= Création du personnage ================= */
|
||||
@@ -750,13 +825,13 @@ function initCreateScreen() {
|
||||
}
|
||||
}
|
||||
|
||||
function startGame() {
|
||||
function startGame(customLevel = null) {
|
||||
const name = $("troll-name").value.trim() || "Trõllinet";
|
||||
$("screen-create").classList.add("hidden");
|
||||
$("screen-end").classList.add("hidden");
|
||||
$("screen-game").classList.remove("hidden");
|
||||
$("log").innerHTML = "";
|
||||
newGame(name, selectedRace);
|
||||
newGame(name, selectedRace, customLevel);
|
||||
render();
|
||||
renderPanels();
|
||||
}
|
||||
@@ -781,7 +856,7 @@ if (typeof document !== "undefined") {
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
initCreateScreen();
|
||||
bindKeys();
|
||||
$("btn-start").onclick = startGame;
|
||||
$("btn-start").onclick = () => startGame();
|
||||
// ?autostart=1&race=Durakuir&name=Grosbill : lance directement une partie
|
||||
const params = new URLSearchParams(location.search);
|
||||
if (params.get("autostart")) {
|
||||
@@ -791,8 +866,13 @@ if (typeof document !== "undefined") {
|
||||
}
|
||||
$("btn-restart").onclick = () => {
|
||||
$("screen-end").classList.add("hidden");
|
||||
$("screen-create").classList.remove("hidden");
|
||||
initCreateScreen();
|
||||
if (window.MC_afterEnd === "editor") {
|
||||
window.MC_afterEnd = null;
|
||||
$("screen-editor").classList.remove("hidden");
|
||||
} else {
|
||||
$("screen-create").classList.remove("hidden");
|
||||
initCreateScreen();
|
||||
}
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -801,7 +881,8 @@ if (typeof document !== "undefined") {
|
||||
if (typeof module !== "undefined" && module.exports) {
|
||||
module.exports = {
|
||||
rollDice, resolveAttack, resolveSpell, improveCost, levelFromTotalPI,
|
||||
RACES, MONSTER_TYPES, BOSS, makeMonster, generateCavern, largestRegion,
|
||||
RACES, MONSTER_TYPES, BOSS, TEMPLATES, makeMonster, monsterFromSpec, itemFromSpec,
|
||||
generateCavern, largestRegion,
|
||||
MAP_W, MAP_H, T_WALL, T_FLOOR, T_STAIRS,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
/* Serveur MountyCrawl — fichiers statiques + API de niveaux communautaires.
|
||||
* Node pur, aucune dépendance. Stockage : un fichier JSON (LEVELS_FILE). */
|
||||
|
||||
"use strict";
|
||||
|
||||
const http = require("http");
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const crypto = require("crypto");
|
||||
|
||||
const PORT = process.env.PORT || 80;
|
||||
const ROOT = __dirname;
|
||||
const LEVELS_FILE = process.env.LEVELS_FILE || "/data/levels.json";
|
||||
const MAX_BODY = 100 * 1024; // 100 Ko par niveau, large
|
||||
const MAX_LEVELS = 500;
|
||||
|
||||
const MIME = {
|
||||
".html": "text/html; charset=utf-8",
|
||||
".css": "text/css; charset=utf-8",
|
||||
".js": "text/javascript; charset=utf-8",
|
||||
".json": "application/json; charset=utf-8",
|
||||
".png": "image/png",
|
||||
};
|
||||
|
||||
/* ---------- Stockage ---------- */
|
||||
|
||||
function loadLevels() {
|
||||
try { return JSON.parse(fs.readFileSync(LEVELS_FILE, "utf8")); }
|
||||
catch { return []; }
|
||||
}
|
||||
|
||||
function saveLevels(levels) {
|
||||
fs.mkdirSync(path.dirname(LEVELS_FILE), { recursive: true });
|
||||
const tmp = LEVELS_FILE + ".tmp";
|
||||
fs.writeFileSync(tmp, JSON.stringify(levels));
|
||||
fs.renameSync(tmp, LEVELS_FILE);
|
||||
}
|
||||
|
||||
/* ---------- Validation d'un niveau ---------- */
|
||||
|
||||
const MAP_W = 28, MAP_H = 20;
|
||||
|
||||
function validateLevel(l) {
|
||||
if (!l || typeof l !== "object") return "niveau invalide";
|
||||
if (typeof l.name !== "string" || !l.name.trim() || l.name.length > 40) return "nom invalide (1–40 caractères)";
|
||||
if (typeof l.author !== "string" || !l.author.trim() || l.author.length > 30) return "auteur invalide (1–30 caractères)";
|
||||
if (!Array.isArray(l.grid) || l.grid.length !== MAP_H) return "grille invalide";
|
||||
for (const row of l.grid) {
|
||||
if (typeof row !== "string" || row.length !== MAP_W || /[^#.>]/.test(row)) return "ligne de grille invalide";
|
||||
}
|
||||
if (!l.start || !inBounds(l.start) || tile(l, l.start) === "#") return "point de départ manquant ou dans un mur";
|
||||
if (!Array.isArray(l.monsters) || l.monsters.length > 60) return "monstres invalides (max 60)";
|
||||
for (const m of l.monsters) {
|
||||
if (!inBounds(m) || tile(l, m) === "#") return "monstre hors-sol";
|
||||
if (m.boss !== true && !(Number.isInteger(m.type) && m.type >= 0 && Number.isInteger(m.tpl) && m.tpl >= 0)) return "type de monstre invalide";
|
||||
}
|
||||
if (!Array.isArray(l.items) || l.items.length > 60) return "objets invalides (max 60)";
|
||||
for (const i of l.items) {
|
||||
if (!inBounds(i) || tile(l, i) === "#") return "objet hors-sol";
|
||||
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 !";
|
||||
return null;
|
||||
}
|
||||
|
||||
const inBounds = p => p && Number.isInteger(p.x) && Number.isInteger(p.y) && p.x >= 0 && p.x < MAP_W && p.y >= 0 && p.y < MAP_H;
|
||||
const tile = (l, p) => l.grid[p.y][p.x];
|
||||
|
||||
/* ---------- API ---------- */
|
||||
|
||||
function sendJSON(res, code, data) {
|
||||
res.writeHead(code, { "Content-Type": "application/json; charset=utf-8" });
|
||||
res.end(JSON.stringify(data));
|
||||
}
|
||||
|
||||
function handleAPI(req, res, url) {
|
||||
const idMatch = url.pathname.match(/^\/api\/levels\/([a-f0-9]{12})$/);
|
||||
|
||||
if (req.method === "GET" && url.pathname === "/api/levels") {
|
||||
const list = loadLevels().map(l => ({
|
||||
id: l.id, name: l.name, author: l.author, date: l.date,
|
||||
monsters: l.monsters.length, plays: l.plays || 0,
|
||||
}));
|
||||
return sendJSON(res, 200, list);
|
||||
}
|
||||
|
||||
if (req.method === "GET" && idMatch) {
|
||||
const levels = loadLevels();
|
||||
const level = levels.find(l => l.id === idMatch[1]);
|
||||
if (!level) return sendJSON(res, 404, { error: "niveau introuvable" });
|
||||
level.plays = (level.plays || 0) + 1;
|
||||
saveLevels(levels);
|
||||
return sendJSON(res, 200, level);
|
||||
}
|
||||
|
||||
if (req.method === "POST" && url.pathname === "/api/levels") {
|
||||
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 level;
|
||||
try { level = JSON.parse(body); } catch { return sendJSON(res, 400, { error: "JSON invalide" }); }
|
||||
const err = validateLevel(level);
|
||||
if (err) return sendJSON(res, 400, { error: err });
|
||||
const levels = loadLevels();
|
||||
if (levels.length >= MAX_LEVELS) return sendJSON(res, 507, { error: "trop de niveaux stockés" });
|
||||
const clean = {
|
||||
id: crypto.randomBytes(6).toString("hex"),
|
||||
name: level.name.trim(), author: level.author.trim(),
|
||||
date: new Date().toISOString().slice(0, 10),
|
||||
grid: level.grid, start: { x: level.start.x, y: level.start.y },
|
||||
monsters: level.monsters, items: level.items, plays: 0,
|
||||
};
|
||||
levels.push(clean);
|
||||
saveLevels(levels);
|
||||
return sendJSON(res, 201, { id: clean.id });
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
sendJSON(res, 404, { error: "route inconnue" });
|
||||
}
|
||||
|
||||
/* ---------- Statique ---------- */
|
||||
|
||||
function handleStatic(req, res, url) {
|
||||
let p = decodeURIComponent(url.pathname);
|
||||
if (p === "/") p = "/index.html";
|
||||
const file = path.normalize(path.join(ROOT, p));
|
||||
if (!file.startsWith(ROOT) || file === path.join(ROOT, "server.js")) {
|
||||
res.writeHead(403); return res.end("interdit");
|
||||
}
|
||||
fs.readFile(file, (err, data) => {
|
||||
if (err) { res.writeHead(404); return res.end("introuvable"); }
|
||||
res.writeHead(200, { "Content-Type": MIME[path.extname(file)] || "application/octet-stream" });
|
||||
res.end(data);
|
||||
});
|
||||
}
|
||||
|
||||
const server = http.createServer((req, res) => {
|
||||
const url = new URL(req.url, "http://localhost");
|
||||
if (url.pathname.startsWith("/api/")) return handleAPI(req, res, url);
|
||||
if (req.method !== "GET") { res.writeHead(405); return res.end(); }
|
||||
handleStatic(req, res, url);
|
||||
});
|
||||
|
||||
if (require.main === module) {
|
||||
server.listen(PORT, () => console.log(`MountyCrawl sur le port ${PORT}, niveaux dans ${LEVELS_FILE}`));
|
||||
}
|
||||
|
||||
module.exports = { validateLevel, MAP_W, MAP_H };
|
||||
@@ -70,4 +70,38 @@ for (let depth = 1; depth <= 5; depth++) {
|
||||
}
|
||||
}
|
||||
|
||||
// Specs d'éditeur → entités de jeu
|
||||
const gob = g.monsterFromSpec({ x: 2, y: 3, type: 0, tpl: 2 });
|
||||
assert.strictEqual(gob.name, "Vieux Gobelin");
|
||||
assert.strictEqual(gob.x, 2);
|
||||
const boss = g.monsterFromSpec({ x: 1, y: 1, boss: true });
|
||||
assert.strictEqual(boss.name, "Béhémoth");
|
||||
assert(boss.pv === boss.pvMax && boss.pv > 0);
|
||||
const pot = g.itemFromSpec({ x: 1, y: 1, kind: "potion" });
|
||||
assert.strictEqual(pot.kind, "potion");
|
||||
const sword = g.itemFromSpec({ x: 1, y: 1, kind: "weapon", idx: 2 });
|
||||
assert.strictEqual(sword.kind, "gear");
|
||||
assert(sword.bonus > 0);
|
||||
|
||||
// Validation serveur des niveaux
|
||||
const srv = require("../server.js");
|
||||
const goodGrid = [];
|
||||
for (let y = 0; y < srv.MAP_H; y++) {
|
||||
const border = y === 0 || y === srv.MAP_H - 1;
|
||||
goodGrid.push(border ? "#".repeat(srv.MAP_W) : "#" + ".".repeat(srv.MAP_W - 2) + "#");
|
||||
}
|
||||
const goodLevel = {
|
||||
name: "Test", author: "perco", grid: goodGrid,
|
||||
start: { x: 1, y: 1 },
|
||||
monsters: [{ x: 2, y: 2, type: 0, tpl: 1 }],
|
||||
items: [{ x: 3, y: 3, kind: "potion" }],
|
||||
};
|
||||
assert.strictEqual(srv.validateLevel(goodLevel), null);
|
||||
assert(srv.validateLevel({ ...goodLevel, name: "" }), "nom vide refusé");
|
||||
assert(srv.validateLevel({ ...goodLevel, monsters: [] }), "niveau sans monstre 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, monsters: [{ x: 0, y: 0, type: 0, tpl: 0 }] }), "monstre dans un mur refusé");
|
||||
assert(srv.validateLevel({ ...goodLevel, items: [{ x: 3, y: 3, kind: "nawak" }] }), "objet inconnu refusé");
|
||||
|
||||
console.log("✅ Tous les tests de fumée passent.");
|
||||
|
||||
Reference in New Issue
Block a user