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:
perco
2026-06-11 13:26:56 +02:00
co-authored by Claude Fable 5
parent a106b0d021
commit 103be8e9c7
8 changed files with 831 additions and 37 deletions
+7 -4
View File
@@ -1,4 +1,7 @@
FROM nginx:alpine FROM node:22-alpine
COPY index.html /usr/share/nginx/html/ WORKDIR /app
COPY css /usr/share/nginx/html/css COPY index.html server.js ./
COPY js /usr/share/nginx/html/js COPY css ./css
COPY js ./js
EXPOSE 80
CMD ["node", "server.js"]
+31 -7
View File
@@ -9,19 +9,18 @@ de trésors et de PX — jusqu'au Béhémoth qui rôde à la profondeur 5.
## Jouer ## Jouer
🎮 **https://mountycrawl.nas.percolouco.com** (déployé via Traefik, compose dans 🎮 **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 ```bash
# Directement PORT=8080 LEVELS_FILE=/tmp/levels.json node server.js
xdg-open index.html
# Ou via un petit serveur
python3 -m http.server 8080
# → http://localhost:8080 # → 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 : Après une modification, redéployer avec :
```bash ```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, Pierre — déclinés en gabarits *Jeune / Vieux / Ancien / Mythique* selon la profondeur,
et le **Béhémoth** comme boss final à la profondeur 5. 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, 160 monstres…) |
Stockage : `/data/levels.json` dans le volume `/opt/container/mountycrawl/data`.
## Tests ## Tests
```bash ```bash
@@ -89,5 +110,8 @@ non affilié au jeu original de Mountyhall SARL.
## Versions ## 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 - **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. fidèle aux règles MH, progression PI, brouillard de guerre, boss Béhémoth.
+91 -1
View File
@@ -11,7 +11,7 @@ body {
.hidden { display: none !important; } .hidden { display: none !important; }
/* ---------- Écrans création / fin ---------- */ /* ---------- Écrans création / fin ---------- */
#screen-create, #screen-end { #screen-create, #screen-end, #screen-community {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: 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; } .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; } #equipment div, #inventory .empty { font-size: .88em; padding: 2px 0; color: #c9b685; }
#log { #log {
+52
View File
@@ -19,6 +19,57 @@
<input type="text" id="troll-name" placeholder="Nom de ton Trõll" maxlength="20" value="Trõllinet"> <input type="text" id="troll-name" placeholder="Nom de ton Trõll" maxlength="20" value="Trõllinet">
<div id="race-list"></div> <div id="race-list"></div>
<button id="btn-start" class="big-btn">⚔️ Descendre dans le Monde Souterrain</button> <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>
</div> </div>
@@ -67,5 +118,6 @@
</div> </div>
<script src="js/game.js"></script> <script src="js/game.js"></script>
<script src="js/editor.js"></script>
</body> </body>
</html> </html>
+357
View File
@@ -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
View File
@@ -113,11 +113,7 @@ const TEMPLATES = [
{ prefix: "Mythique ", mult: 2.0 }, { prefix: "Mythique ", mult: 2.0 },
]; ];
function makeMonster(depth, x, y) { function applyTemplate(type, tpl, 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 { return {
name: tpl.prefix + type.name, emoji: type.emoji, name: tpl.prefix + type.name, emoji: type.emoji,
level: Math.max(1, Math.round(type.level * tpl.mult)), 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 ================= */ /* ================= Génération du Monde Souterrain ================= */
const MAP_W = 28, MAP_H = 20; 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 let G = null; // état global de la partie
function newGame(name, race) { function newGame(name, race, customLevel = null) {
const s = RACES[race].stats; const s = RACES[race].stats;
G = { G = {
custom: customLevel,
troll: { troll: {
name, race, name, race,
att: s.att, esq: s.esq, deg: s.deg, reg: s.reg, 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, depth: 1, grid: null, monsters: [], items: [], stairs: null,
seen: new Set(), over: false, seen: new Set(), over: false,
}; };
buildLevel(); if (customLevel) buildCustomLevel(customLevel);
log(`${name} le ${race} pénètre dans le Monde Souterrain. Que les Dieux Trõlls te gardent !`, "good"); 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() { function buildLevel() {
@@ -333,7 +383,8 @@ function tryMove(dx, dy) {
updateFov(); updateFov();
if (G.grid[ny][nx] === T_STAIRS) { 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); 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");
@@ -371,7 +422,11 @@ function killMonster(m) {
G.troll.kills++; G.troll.kills++;
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 (m.boss) win(); if (G.custom) {
if (G.monsters.length === 0) win();
} else if (m.boss) {
win();
}
} }
function useAbility() { function useAbility() {
@@ -463,6 +518,12 @@ function useBagItem(idx) {
function descend() { function descend() {
if (G.grid[G.troll.y][G.troll.x] !== T_STAIRS) return; 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++; G.depth++;
log(`⬇️ Tu descends. Profondeur ${G.depth}. L'air devient lourd…`, "info"); 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"); 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() { function win() {
G.over = true; 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); showEnd(true);
} }
@@ -644,7 +706,9 @@ function render() {
function renderPanels() { function renderPanels() {
const t = G.troll; 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)}`; $("troll-title").textContent = `${RACES[t.race].emoji} ${t.name}, ${t.race} niv. ${levelFromTotalPI(t.totalPI)}`;
const pct = Math.max(0, t.pv / t.pvMax); 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); 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("⬇️ Descendre", descend, onStairs); addBtn(G.custom ? "🚪 Sortir" : "⬇️ Descendre", descend, onStairs);
addBtn("⏳ Passer la DLA", passDLA, true); addBtn("⏳ Passer la DLA", passDLA, true);
$("equipment").innerHTML = $("equipment").innerHTML =
@@ -714,18 +778,29 @@ function renderPanels() {
}); });
} }
function esc(s) {
return String(s).replace(/[&<>"']/g, c => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c]));
}
function showEnd(victory, killer) { function showEnd(victory, killer) {
const t = G.troll; const t = G.troll;
$("screen-game").classList.add("hidden"); $("screen-game").classList.add("hidden");
$("screen-end").classList.remove("hidden"); $("screen-end").classList.remove("hidden");
$("end-title").textContent = victory ? "🏆 GLOIRE AU TRÕLL !" : "☠️ MORT DANS LES PROFONDEURS"; $("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); const score = t.gold + t.totalPI * 10 + t.kills * 25 + (victory ? 1000 : 0);
$("end-text").innerHTML = victory const stats = `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 terrassé le Béhémoth et rapporte le Trésor de MountyHall à la Taverne !<br><br> let text;
Monstres tués : ${t.kills} · Mountyzédons : ${t.gold} · DLA écoulées : ${t.dla}<br><b>Score : ${score}</b>` if (G.custom) {
: `${t.name} le ${t.race} a été terrassé par ${killer ? killer.name : "les profondeurs"} à la profondeur ${G.depth}.<br> text = victory
À MountyHall on ne meurt jamais vraiment : les Dieux Trõlls te ramèneront à la Taverne.<br><br> ? `${esc(t.name)} le ${t.race} a vaincu « ${esc(G.custom.name)} », le niveau de ${esc(G.custom.author)} !`
Monstres tués : ${t.kills} · Mountyzédons : ${t.gold} · DLA écoulées : ${t.dla}<br><b>Score : ${score}</b>`; : `${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 ================= */ /* ================= Création du personnage ================= */
@@ -750,13 +825,13 @@ function initCreateScreen() {
} }
} }
function startGame() { function startGame(customLevel = null) {
const name = $("troll-name").value.trim() || "Trõllinet"; const name = $("troll-name").value.trim() || "Trõllinet";
$("screen-create").classList.add("hidden"); $("screen-create").classList.add("hidden");
$("screen-end").classList.add("hidden"); $("screen-end").classList.add("hidden");
$("screen-game").classList.remove("hidden"); $("screen-game").classList.remove("hidden");
$("log").innerHTML = ""; $("log").innerHTML = "";
newGame(name, selectedRace); newGame(name, selectedRace, customLevel);
render(); render();
renderPanels(); renderPanels();
} }
@@ -781,7 +856,7 @@ if (typeof document !== "undefined") {
document.addEventListener("DOMContentLoaded", () => { document.addEventListener("DOMContentLoaded", () => {
initCreateScreen(); initCreateScreen();
bindKeys(); bindKeys();
$("btn-start").onclick = startGame; $("btn-start").onclick = () => startGame();
// ?autostart=1&race=Durakuir&name=Grosbill : lance directement une partie // ?autostart=1&race=Durakuir&name=Grosbill : lance directement une partie
const params = new URLSearchParams(location.search); const params = new URLSearchParams(location.search);
if (params.get("autostart")) { if (params.get("autostart")) {
@@ -791,8 +866,13 @@ if (typeof document !== "undefined") {
} }
$("btn-restart").onclick = () => { $("btn-restart").onclick = () => {
$("screen-end").classList.add("hidden"); $("screen-end").classList.add("hidden");
$("screen-create").classList.remove("hidden"); if (window.MC_afterEnd === "editor") {
initCreateScreen(); 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) { if (typeof module !== "undefined" && module.exports) {
module.exports = { module.exports = {
rollDice, resolveAttack, resolveSpell, improveCost, levelFromTotalPI, 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, MAP_W, MAP_H, T_WALL, T_FLOOR, T_STAIRS,
}; };
} }
+153
View File
@@ -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 (140 caractères)";
if (typeof l.author !== "string" || !l.author.trim() || l.author.length > 30) return "auteur invalide (130 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 };
+34
View File
@@ -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."); console.log("✅ Tous les tests de fumée passent.");