From 103be8e9c710ac396590299e881fd95f7ab7a8f0 Mon Sep 17 00:00:00 2001 From: perco Date: Thu, 11 Jun 2026 13:26:56 +0200 Subject: [PATCH] =?UTF-8?q?1.1.0=20:=20=C3=A9diteur=20de=20niveaux=20et=20?= =?UTF-8?q?partage=20communautaire?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit É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=. Le conteneur passe de nginx à node:22-alpine. Co-Authored-By: Claude Fable 5 --- Dockerfile | 11 +- README.md | 38 +++++- css/style.css | 92 ++++++++++++- index.html | 52 ++++++++ js/editor.js | 357 ++++++++++++++++++++++++++++++++++++++++++++++++++ js/game.js | 131 ++++++++++++++---- server.js | 153 ++++++++++++++++++++++ test/smoke.js | 34 +++++ 8 files changed, 831 insertions(+), 37 deletions(-) create mode 100644 js/editor.js create mode 100644 server.js diff --git a/Dockerfile b/Dockerfile index d3d6db4..40e7b95 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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"] diff --git a/README.md b/README.md index 937fd6f..143fab7 100644 --- a/README.md +++ b/README.md @@ -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=`. + +### API + +| Route | Description | +|---|---| +| `GET /api/levels` | Liste (id, nom, auteur, date, nb monstres, nb parties) | +| `GET /api/levels/` | 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=`, + 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. diff --git a/css/style.css b/css/style.css index 94e1df6..4aacd65 100644 --- a/css/style.css +++ b/css/style.css @@ -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 { diff --git a/index.html b/index.html index 7e42c80..df2e228 100644 --- a/index.html +++ b/index.html @@ -19,6 +19,57 @@
+ + + + + + + + + @@ -67,5 +118,6 @@ + diff --git a/js/editor.js b/js/editor.js new file mode 100644 index 0000000..6ff4f12 --- /dev/null +++ b/js/editor.js @@ -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 = '

Chargement…

'; + try { + const res = await fetch("api/levels"); + const levels = await res.json(); + list.innerHTML = ""; + if (levels.length === 0) { + list.innerHTML = '

Aucun niveau publié pour l\'instant. Sois le premier, l\'éditeur t\'attend !

'; + 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 = '

Impossible de joindre le serveur (le mode communautaire nécessite le site en ligne).

'; + } +} + +/* ---------- 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= : 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); }); + } + }); +} diff --git a/js/game.js b/js/game.js index 18559de..819a1be 100644 --- a/js/game.js +++ b/js/game.js @@ -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 !

- Monstres tués : ${t.kills} · Mountyzédons : ${t.gold} · DLA écoulées : ${t.dla}
Score : ${score}` - : `${t.name} le ${t.race} a été terrassé par ${killer ? killer.name : "les profondeurs"} à la profondeur −${G.depth}.
- À MountyHall on ne meurt jamais vraiment : les Dieux Trõlls te ramèneront à la Taverne.

- Monstres tués : ${t.kills} · Mountyzédons : ${t.gold} · DLA écoulées : ${t.dla}
Score : ${score}`; + const stats = `Monstres tués : ${t.kills} · Mountyzédons : ${t.gold} · DLA écoulées : ${t.dla}
Score : ${score}`; + 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}.
+ À MountyHall on ne meurt jamais vraiment : les Dieux Trõlls te ramèneront à la Taverne.`; + } + $("end-text").innerHTML = text + "

" + 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, }; } diff --git a/server.js b/server.js new file mode 100644 index 0000000..45032a7 --- /dev/null +++ b/server.js @@ -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 }; diff --git a/test/smoke.js b/test/smoke.js index d73d5a6..f1e9747 100644 --- a/test/smoke.js +++ b/test/smoke.js @@ -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.");