v2.9.1 : portage SOLO de l'empilement/coins/cases x2/portee + camera + deroulant toujours visible

Regle Perco : toute modif touche les 2 modes. game.js : TILE 24->48 ;
render() par coins (troll/monstre/tresor/lieu = portes+sortie) ; tryMove
ne fait plus attaquer ; adjacentMonster -> meme case (cheby 0) ;
sameCellMonsters + bouton attaque -> deroulant toujours affiche ;
dropBagItem/loot empilables ; moveMonster autorise l'empilement ;
demo=combat sur la case du troll. mp-client.js : deroulant toujours
affiche (>=1). Camera keepInView (game.js, partagee) appelee dans les 2
rendus ; #panel-center overflow auto + max-height.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
perco
2026-06-15 15:58:25 +02:00
co-authored by Claude Opus 4.8
parent a0547bf9a3
commit 28863303c6
4 changed files with 127 additions and 69 deletions
+7
View File
@@ -234,6 +234,13 @@ non affilié au jeu original de Mountyhall SARL.
## Versions ## Versions
- **2.9.1** (2026-06-15) — Le **solo reçoit tout ce que le monde partagé a eu**
(empilement des cases, rendu par coins avec le coin **lieu** ↘ qui affiche
enfin les portes 🚪 / la sortie ▼, cases ×2, attaque limitée à sa propre case,
drop empilable). Le **menu déroulant de cible s'affiche toujours**, même quand
il n'y a qu'un seul monstre/troll. Et comme les cases agrandies débordent du
cadre, une **caméra suit le troll** (le panneau défile pour le garder en vue),
dans les deux modes.
- **2.9.0** (2026-06-15) — Monde partagé : **cases deux fois plus grandes** - **2.9.0** (2026-06-15) — Monde partagé : **cases deux fois plus grandes**
(48 px) pour la lisibilité de l'empilement par coins. **Portée d'attaque = (48 px) pour la lisibilité de l'empilement par coins. **Portée d'attaque =
même case** : on ne peut frapper (attaque de base et compétences/sortilèges même case** : on ne peut frapper (attaque de base et compétences/sortilèges
+3 -1
View File
@@ -287,7 +287,9 @@ header h1 { font-size: 1.3em; color: #FFFFCC; text-shadow: 1px 1px 2px #000; }
opacity: .45; cursor: default; opacity: .45; cursor: default;
} }
#panel-center { flex-grow: 1; text-align: center; } /* Cases agrandies : le canvas peut dépasser le panneau → on le rend défilable
* (et une caméra recentre sur le troll côté JS). */
#panel-center { flex-grow: 1; min-width: 0; text-align: center; overflow: auto; max-height: 86vh; }
#map { #map {
background: #0d0a06; background: #0d0a06;
+108 -60
View File
@@ -4,7 +4,7 @@
"use strict"; "use strict";
const APP_VERSION = "2.9.0"; const APP_VERSION = "2.9.1";
/* Alpha : maîtrise initiale haute pour les tests. Remettre 15 % / 15 % à la v1.0 officielle. */ /* Alpha : maîtrise initiale haute pour les tests. Remettre 15 % / 15 % à la v1.0 officielle. */
const START_COMP_PCT = 90; const START_COMP_PCT = 90;
@@ -594,9 +594,8 @@ function tryMove(dx, dy) {
if (nx < 0 || ny < 0 || nx >= MAP_W || ny >= MAP_H) return; if (nx < 0 || ny < 0 || nx >= MAP_W || ny >= MAP_H) return;
if (G.grid[ny][nx] === T_WALL) return; if (G.grid[ny][nx] === T_WALL) return;
const target = G.monsters.find(m => m.x === nx && m.y === ny); // Empilement : se déplacer ne fait plus attaquer — on se pose sur la case
if (target) { attackMonster(target); return; } // (cohabitation), l'attaque passe par le bouton ⚔️ (cible choisie au besoin).
if (!spendPA(COSTS.move)) return; if (!spendPA(COSTS.move)) return;
t.x = nx; t.y = ny; t.x = nx; t.y = ny;
updateFov(); updateFov();
@@ -668,8 +667,8 @@ function killMonster(m, source = "attack") {
} else { } else {
log(`💀 ${m.name} est terrassé !`, "good"); log(`💀 ${m.name} est terrassé !`, "good");
} }
// butin : les monstres lâchent parfois un trésor en mourant // butin : les monstres lâchent parfois un trésor en mourant (empilable)
if (Math.random() < 0.4 && !G.items.some(i => i.x === m.x && i.y === m.y)) { if (Math.random() < 0.4) {
const lr = Math.random(); const lr = Math.random();
const drop = lr < 0.4 const drop = lr < 0.4
? { ...makeRandomPotion(), x: m.x, y: m.y } ? { ...makeRandomPotion(), x: m.x, y: m.y }
@@ -744,7 +743,7 @@ function useComp() {
if (t.race === "Skrim") { // Botte Secrète : attaque bonus, 1 fois par DLA if (t.race === "Skrim") { // Botte Secrète : attaque bonus, 1 fois par DLA
if (t.compUsed) { log("Botte Secrète déjà utilisée cette DLA.", "info"); return; } if (t.compUsed) { log("Botte Secrète déjà utilisée cette DLA.", "info"); return; }
const m = adjacentMonster(); const m = adjacentMonster();
if (!m) { log("Aucun monstre adjacent.", "info"); return; } if (!m) { log("Aucun monstre sur ta case.", "info"); return; }
if (!tryTalent(t.comp, 90, comp.cost, "🥋 Botte Secrète", "comp")) { cdFlush(); afterAction(); return; } if (!tryTalent(t.comp, 90, comp.cost, "🥋 Botte Secrète", "comp")) { cdFlush(); afterAction(); return; }
t.compUsed = true; t.compUsed = true;
cdLine(`Vous portez une <b>Botte Secrète</b> à <b>${m.name}</b>.`); cdLine(`Vous portez une <b>Botte Secrète</b> à <b>${m.name}</b>.`);
@@ -785,7 +784,7 @@ function useComp() {
} else if (t.race === "Darkling") { // Balayage : déstabilise, la cible perd son tour } else if (t.race === "Darkling") { // Balayage : déstabilise, la cible perd son tour
if (t.compUsed) { log("Balayage déjà utilisé cette DLA.", "info"); return; } if (t.compUsed) { log("Balayage déjà utilisé cette DLA.", "info"); return; }
const m = adjacentMonster(); const m = adjacentMonster();
if (!m) { log("Aucun monstre adjacent.", "info"); return; } if (!m) { log("Aucun monstre sur ta case.", "info"); return; }
if (!tryTalent(t.comp, 90, comp.cost, "🥋 Balayage", "comp")) { cdFlush(); afterAction(); return; } if (!tryTalent(t.comp, 90, comp.cost, "🥋 Balayage", "comp")) { cdFlush(); afterAction(); return; }
t.compUsed = true; t.compUsed = true;
const destab = rollDice(te.att, 6).total; const destab = rollDice(te.att, 6).total;
@@ -814,7 +813,7 @@ function useSort() {
if (t.race === "Skrim") { // Hypnotisme : esquive /2 + perd son tour if (t.race === "Skrim") { // Hypnotisme : esquive /2 + perd son tour
const m = adjacentMonster(); const m = adjacentMonster();
if (!m) { log("Aucun monstre adjacent.", "info"); return; } if (!m) { log("Aucun monstre sur ta case.", "info"); return; }
if (!tryTalent(t.sort, 80, sort.cost, "🔮 Hypnotisme", "sort")) { cdFlush(); afterAction(); return; } if (!tryTalent(t.sort, 80, sort.cost, "🔮 Hypnotisme", "sort")) { cdFlush(); afterAction(); return; }
cdLine(`Vous avez attaqué <b>${m.name}</b> grâce à un sortilège.`); cdLine(`Vous avez attaqué <b>${m.name}</b> grâce à un sortilège.`);
const res = resistInfo(m); const res = resistInfo(m);
@@ -828,7 +827,7 @@ function useSort() {
: `Hypnotisme : ${m.name} est hébété ! Esquive divisée par 2 et tour perdu.`, "good"); : `Hypnotisme : ${m.name} est hébété ! Esquive divisée par 2 et tour perdu.`, "good");
} else if (t.race === "Durakuir") { // Rafale Psychique : imparable, ignore l'armure } else if (t.race === "Durakuir") { // Rafale Psychique : imparable, ignore l'armure
const m = adjacentMonster(); const m = adjacentMonster();
if (!m) { log("Aucun monstre adjacent.", "info"); return; } if (!m) { log("Aucun monstre sur ta case.", "info"); return; }
if (!tryTalent(t.sort, 80, sort.cost, "🔮 Rafale Psychique", "sort")) { cdFlush(); afterAction(); return; } if (!tryTalent(t.sort, 80, sort.cost, "🔮 Rafale Psychique", "sort")) { cdFlush(); afterAction(); return; }
cdLine(`Vous avez attaqué <b>${m.name}</b> grâce à un sortilège.`); cdLine(`Vous avez attaqué <b>${m.name}</b> grâce à un sortilège.`);
cdLine(`Votre jet d'Attaque est : <span class="cd-good">automatiquement réussi</span> (imparable).`); cdLine(`Votre jet d'Attaque est : <span class="cd-good">automatiquement réussi</span> (imparable).`);
@@ -842,7 +841,7 @@ function useSort() {
if (m.pv <= 0) killMonster(m, "sort"); if (m.pv <= 0) killMonster(m, "sort");
} else if (t.race === "Kastar") { // Vampirisme : dégâts + vol de vie } else if (t.race === "Kastar") { // Vampirisme : dégâts + vol de vie
const m = adjacentMonster(); const m = adjacentMonster();
if (!m) { log("Aucun monstre adjacent.", "info"); return; } if (!m) { log("Aucun monstre sur ta case.", "info"); return; }
if (!tryTalent(t.sort, 80, sort.cost, "🔮 Vampirisme", "sort")) { cdFlush(); afterAction(); return; } if (!tryTalent(t.sort, 80, sort.cost, "🔮 Vampirisme", "sort")) { cdFlush(); afterAction(); return; }
cdLine(`Vous avez attaqué <b>${m.name}</b> grâce à un sortilège.`); cdLine(`Vous avez attaqué <b>${m.name}</b> grâce à un sortilège.`);
const pseudo = { ...te, att: Math.max(1, Math.floor(te.deg * 2 / 3)) }; const pseudo = { ...te, att: Math.max(1, Math.floor(te.deg * 2 / 3)) };
@@ -891,7 +890,7 @@ function useSort() {
} }
} else if (t.race === "Darkling") { // Siphon des Âmes : dégâts + nécrose (ATT) } else if (t.race === "Darkling") { // Siphon des Âmes : dégâts + nécrose (ATT)
const m = adjacentMonster(); const m = adjacentMonster();
if (!m) { log("Aucun monstre adjacent.", "info"); return; } if (!m) { log("Aucun monstre sur ta case.", "info"); return; }
if (!tryTalent(t.sort, 80, sort.cost, "🔮 Siphon des Âmes", "sort")) { cdFlush(); afterAction(); return; } if (!tryTalent(t.sort, 80, sort.cost, "🔮 Siphon des Âmes", "sort")) { cdFlush(); afterAction(); return; }
cdLine(`Vous avez attaqué <b>${m.name}</b> grâce à un sortilège.`); cdLine(`Vous avez attaqué <b>${m.name}</b> grâce à un sortilège.`);
const pseudo = { ...te, deg: te.reg, degBonus: 0 }; const pseudo = { ...te, deg: te.reg, degBonus: 0 };
@@ -939,8 +938,14 @@ function nearestVisibleMonster() {
return best; return best;
} }
// On ne peut frapper (attaque de base, compétences/sortilèges d'attaque) qu'un
// monstre sur SA PROPRE case. Exception : le Projectile Magique (sortilège du
// Tomawak) a sa propre portée (jusqu'à la Vue) et n'utilise pas cette fonction.
function adjacentMonster() { function adjacentMonster() {
return G.monsters.find(m => Math.abs(m.x - G.troll.x) <= 1 && Math.abs(m.y - G.troll.y) <= 1); return G.monsters.find(m => m.x === G.troll.x && m.y === G.troll.y);
}
function sameCellMonsters() {
return G.monsters.filter(m => m.x === G.troll.x && m.y === G.troll.y);
} }
function pickup() { function pickup() {
@@ -1017,10 +1022,7 @@ function dropBagItem(idx) {
const t = G.troll; const t = G.troll;
const item = t.bag[idx]; const item = t.bag[idx];
if (!item) return; if (!item) return;
if (G.items.some(i => i.x === t.x && i.y === t.y)) { // Empilement : plusieurs trésors peuvent cohabiter sur une case.
log("Il y a déjà quelque chose à terre ici.", "info");
return;
}
if (!spendPA(COSTS.drop)) return; if (!spendPA(COSTS.drop)) return;
t.bag.splice(idx, 1); t.bag.splice(idx, 1);
G.items.push({ ...item, x: t.x, y: t.y }); G.items.push({ ...item, x: t.x, y: t.y });
@@ -1249,8 +1251,9 @@ function stepToward(m, t) {
function moveMonster(m, nx, ny) { function moveMonster(m, nx, ny) {
if (nx < 0 || ny < 0 || nx >= MAP_W || ny >= MAP_H) return false; if (nx < 0 || ny < 0 || nx >= MAP_W || ny >= MAP_H) return false;
if (G.grid[ny][nx] === T_WALL) return false; if (G.grid[ny][nx] === T_WALL) return false;
// Empilement : les monstres peuvent partager une case (mais ne marchent pas
// sur le troll — ils l'attaquent quand il est sur leur case).
if (nx === G.troll.x && ny === G.troll.y) return false; if (nx === G.troll.x && ny === G.troll.y) return false;
if (G.monsters.some(o => o !== m && o.x === nx && o.y === ny)) return false;
m.x = nx; m.y = ny; m.x = nx; m.y = ny;
return true; return true;
} }
@@ -1271,7 +1274,7 @@ function win() {
/* ================= Interface ================= */ /* ================= Interface ================= */
const $ = id => document.getElementById(id); const $ = id => document.getElementById(id);
const TILE = 24; const TILE = 48;
function log(msg, cls = "info") { function log(msg, cls = "info") {
if (typeof document === "undefined") return; if (typeof document === "undefined") return;
@@ -1295,6 +1298,18 @@ function afterAction() {
} }
} }
/* Caméra : recentre le panneau défilable sur le troll dès qu'il approche d'un
* bord (cases agrandies → le canvas dépasse le panneau). Partagé solo/multi. */
function keepInView(canvas, tx, ty, tile) {
const box = canvas && canvas.parentElement;
if (!box || !box.clientWidth) return;
const cx = tx * tile + tile / 2, cy = ty * tile + tile / 2, m = tile * 2;
if (cx < box.scrollLeft + m) box.scrollLeft = cx - m;
else if (cx > box.scrollLeft + box.clientWidth - m) box.scrollLeft = cx - box.clientWidth + m;
if (cy < box.scrollTop + m) box.scrollTop = cy - m;
else if (cy > box.scrollTop + box.clientHeight - m) box.scrollTop = cy - box.clientHeight + m;
}
function render() { function render() {
const canvas = $("map"); const canvas = $("map");
const ctx = canvas.getContext("2d"); const ctx = canvas.getContext("2d");
@@ -1313,50 +1328,67 @@ function render() {
if (t === T_WALL) ctx.fillStyle = visible ? "#4a3a22" : "#2c2315"; if (t === T_WALL) ctx.fillStyle = visible ? "#4a3a22" : "#2c2315";
else ctx.fillStyle = visible ? "#7a6a45" : "#3d3522"; else ctx.fillStyle = visible ? "#7a6a45" : "#3d3522";
ctx.fillRect(px, py, TILE - 1, TILE - 1); ctx.fillRect(px, py, TILE - 1, TILE - 1);
if (t === T_STAIRS) {
ctx.fillStyle = "#111";
ctx.fillText("▼", px + TILE / 2, py + TILE / 2 + 1);
}
} }
} }
// pastille de couleur sous chaque entité : lisible même sans police emoji // Empilement : une case peut contenir plusieurs monstres/trésors et un lieu
const disc = (x, y, color) => { // (porte 🚪 ou sortie ▼). On regroupe par case et on dessine chaque TYPE dans
// un coin — troll ↖, monstre ↗, trésor ↙, lieu ↘ — avec un « ×N » si plusieurs.
const cells = {};
const cell = (x, y) => (cells[x + "," + y] = cells[x + "," + y] || { x, y, monsters: [], items: [], place: null, youHere: false });
for (const i of G.items) if (inSightAt(i.x, i.y)) cell(i.x, i.y).items.push(i);
for (const m of G.monsters) if (inSight(m)) cell(m.x, m.y).monsters.push(m);
for (const d of (G.doors || [])) if (inSightAt(d.x, d.y)) cell(d.x, d.y).place = "🚪";
for (let y = 0; y < MAP_H; y++) for (let x = 0; x < MAP_W; x++) {
if (G.grid[y][x] === T_STAIRS && G.seen.has(y * MAP_W + x)) cell(x, y).place = "▼";
}
cell(G.troll.x, G.troll.y).youHere = true;
const Q = TILE / 4;
const EMOJI_FONT = Math.round(TILE / 3) + "px serif";
const COUNT_FONT = "bold " + Math.round(TILE / 4.5) + "px sans-serif";
const corner = (c, qx, qy, color, emoji, count, mine) => {
const cx = c.x * TILE + qx, cy = c.y * TILE + qy;
ctx.fillStyle = color; ctx.fillStyle = color;
ctx.beginPath(); ctx.beginPath();
ctx.arc(x * TILE + TILE / 2, y * TILE + TILE / 2, TILE / 2 - 3, 0, Math.PI * 2); ctx.arc(cx, cy, Q, 0, Math.PI * 2);
ctx.fill(); ctx.fill();
if (mine) { ctx.strokeStyle = "#eaffdc"; ctx.lineWidth = 2; ctx.stroke(); }
ctx.fillStyle = "#1a140e";
ctx.font = EMOJI_FONT;
ctx.fillText(emoji, cx, cy + 1);
if (count > 1) {
ctx.font = COUNT_FONT;
ctx.lineWidth = 3;
ctx.strokeStyle = "#1a140e";
ctx.strokeText("×" + count, cx + Q * 0.9, cy + Q * 0.9);
ctx.fillStyle = "#ffe";
ctx.fillText("×" + count, cx + Q * 0.9, cy + Q * 0.9);
}
}; };
for (const i of G.items) {
if (!inSightAt(i.x, i.y)) continue; for (const c of Object.values(cells)) {
disc(i.x, i.y, i.kind === "gold" ? "#caa53d" if (c.youHere) corner(c, Q, Q, "#8fbf5a", "🧌", 1, true); // ↖
: i.kind === "potion" || i.kind === "scroll" ? (i.color || "#5d8535") : "#7a8db0"); if (c.monsters.length) {
ctx.fillStyle = "#1a140e"; corner(c, TILE - Q, Q, c.monsters[0].boss ? "#7a2070" : "#8a3030", c.monsters[0].emoji, c.monsters.length); // ↗
ctx.fillText(i.emoji, i.x * TILE + TILE / 2, i.y * TILE + TILE / 2 + 1); const low = c.monsters.reduce((a, b) => (b.pv / b.pvMax < a.pv / a.pvMax ? b : a));
} const bw = Math.max(3, Math.round((TILE - 6) * low.pv / low.pvMax));
for (const d of G.doors || []) {
if (!inSightAt(d.x, d.y)) continue;
disc(d.x, d.y, "#a06a28");
ctx.fillStyle = "#1a140e";
ctx.fillText("🚪", d.x * TILE + TILE / 2, d.y * TILE + TILE / 2 + 1);
}
for (const m of G.monsters) {
if (!inSight(m)) continue;
disc(m.x, m.y, m.boss ? "#7a2070" : "#8a3030");
ctx.fillStyle = "#1a140e";
ctx.fillText(m.emoji, m.x * TILE + TILE / 2, m.y * TILE + TILE / 2 + 1);
// barre de vie du monstre
const w = Math.max(2, Math.round((TILE - 6) * m.pv / m.pvMax));
ctx.fillStyle = "#b03030"; ctx.fillStyle = "#b03030";
ctx.fillRect(m.x * TILE + 3, m.y * TILE + 1, w, 3); ctx.fillRect(c.x * TILE + 3, c.y * TILE + 2, bw, 3);
}
if (c.items.length) {
const it = c.items[0];
const col = it.kind === "gold" ? "#caa53d" : (it.kind === "potion" || it.kind === "scroll") ? (it.color || "#5d8535") : "#7a8db0";
corner(c, Q, TILE - Q, col, it.emoji, c.items.length); // ↙
}
if (c.place) corner(c, TILE - Q, TILE - Q, "#6a5a8a", c.place, 1); // ↘ lieu (porte/sortie)
} }
disc(G.troll.x, G.troll.y, "#8fbf5a");
ctx.fillStyle = "#1a140e";
ctx.fillText("🧌", G.troll.x * TILE + TILE / 2, G.troll.y * TILE + TILE / 2 + 1);
if (G.troll.camo) { if (G.troll.camo) {
ctx.strokeStyle = "#8fbf5a"; ctx.strokeStyle = "#8fbf5a";
ctx.strokeRect(G.troll.x * TILE + 1, G.troll.y * TILE + 1, TILE - 2, TILE - 2); ctx.strokeRect(G.troll.x * TILE + 1, G.troll.y * TILE + 1, TILE - 2, TILE - 2);
} }
ctx.font = "18px serif";
keepInView(canvas, G.troll.x, G.troll.y, TILE);
} }
function renderPanels() { function renderPanels() {
@@ -1424,8 +1456,29 @@ function renderPanels() {
b.onclick = fn; b.onclick = fn;
actions.appendChild(b); actions.appendChild(b);
}; };
const adj = adjacentMonster(); // Cibles : monstres sur TA case. Toujours un menu déroulant dès qu'il y en a
addBtn(`⚔️ Attaquer (${COSTS.attack} PA)`, () => adj && attackMonster(adj), adj && t.pa >= COSTS.attack); // une (même une seule), pour choisir laquelle frapper en cas d'empilement.
const targets = sameCellMonsters();
const canAttack = targets.length > 0 && t.pa >= COSTS.attack && !G.over;
if (targets.length >= 1) {
const wrap = document.createElement("div");
wrap.className = "mp-attack-sel";
const sel = document.createElement("select");
for (const m of targets) {
const opt = document.createElement("option");
opt.textContent = `${m.emoji} ${m.name} (${m.pv}/${m.pvMax} PV)`;
sel.appendChild(opt);
}
const b = document.createElement("button");
b.textContent = `⚔️ Attaquer (${COSTS.attack} PA)`;
b.disabled = !canAttack;
b.onclick = () => { const m = targets[sel.selectedIndex]; if (m) attackMonster(m); };
wrap.appendChild(sel);
wrap.appendChild(b);
actions.appendChild(wrap);
} else {
addBtn(`⚔️ Attaquer (${COSTS.attack} PA)`, () => {}, false);
}
const comp = RACES[t.race].comp, sort = RACES[t.race].sort; const comp = RACES[t.race].comp, sort = RACES[t.race].sort;
addBtn(`🥋 ${comp.name} (${comp.cost} PA · ${t.comp.pct} %)`, useComp, t.pa >= comp.cost); addBtn(`🥋 ${comp.name} (${comp.cost} PA · ${t.comp.pct} %)`, useComp, t.pa >= comp.cost);
addBtn(`🔮 ${sort.name} (${sort.cost} PA · ${t.sort.pct} %)`, useSort, t.pa >= sort.cost); addBtn(`🔮 ${sort.name} (${sort.cost} PA · ${t.sort.pct} %)`, useSort, t.pa >= sort.cost);
@@ -1607,21 +1660,16 @@ if (typeof document !== "undefined") {
if (RACES[params.get("race")]) selectedRace = params.get("race"); if (RACES[params.get("race")]) selectedRace = params.get("race");
if (params.get("name")) $("troll-name").value = params.get("name"); if (params.get("name")) $("troll-name").value = params.get("name");
startGame(); startGame();
// ?demo=combat : matérialise un gobelin adjacent et enchaîne attaque + sortilège // ?demo=combat : matérialise un gobelin sur la case du troll (on n'attaque
// (sert aux captures d'écran et au test visuel du détail du combat) // que sa propre case) et enchaîne attaque + sortilège (captures / test visuel)
if (params.get("demo") === "combat") { if (params.get("demo") === "combat") {
const t = G.troll; const t = G.troll;
const spot = [[1, 0], [-1, 0], [0, 1], [0, -1]] const m = makeMonster(1, t.x, t.y);
.map(([dx, dy]) => ({ x: t.x + dx, y: t.y + dy }))
.find(p => G.grid[p.y] && G.grid[p.y][p.x] === T_FLOOR);
if (spot) {
const m = makeMonster(1, spot.x, spot.y);
G.monsters.push(m); G.monsters.push(m);
render(); render();
attackMonster(m); attackMonster(m);
if (G.monsters.includes(m)) useSort(); if (G.monsters.includes(m)) useSort();
} }
}
// ?demo=sac : remplit le sac et équipe deux pièces (captures du sac trié) // ?demo=sac : remplit le sac et équipe deux pièces (captures du sac trié)
if (params.get("demo") === "sac") { if (params.get("demo") === "sac") {
const t = G.troll; const t = G.troll;
+4 -3
View File
@@ -275,6 +275,7 @@ function mpRenderMap(st) {
ctx.strokeStyle = "#8fbf5a"; ctx.strokeStyle = "#8fbf5a";
ctx.strokeRect(you.x * MP_TILE + 1, you.y * MP_TILE + 1, MP_TILE - 2, MP_TILE - 2); ctx.strokeRect(you.x * MP_TILE + 1, you.y * MP_TILE + 1, MP_TILE - 2, MP_TILE - 2);
} }
keepInView(canvas, you.x, you.y, MP_TILE);
} }
function mpRenderPanels(st) { function mpRenderPanels(st) {
@@ -345,7 +346,8 @@ function mpRenderPanels(st) {
...st.trolls.filter(onCell).map(o => ({ id: o.id, label: `🧌 ${o.name} (troll niv. ${o.level} · ${Math.round(o.pvPct * 100)} % PV)` })), ...st.trolls.filter(onCell).map(o => ({ id: o.id, label: `🧌 ${o.name} (troll niv. ${o.level} · ${Math.round(o.pvPct * 100)} % PV)` })),
]; ];
const canAttack = attackable.length > 0 && you.pa >= 3 && !you.dead; const canAttack = attackable.length > 0 && you.pa >= 3 && !you.dead;
if (attackable.length > 1) { if (attackable.length >= 1) {
// toujours un menu déroulant dès qu'il y a une cible (même une seule)
const wrap = document.createElement("div"); const wrap = document.createElement("div");
wrap.className = "mp-attack-sel"; wrap.className = "mp-attack-sel";
const sel = document.createElement("select"); const sel = document.createElement("select");
@@ -362,8 +364,7 @@ function mpRenderPanels(st) {
wrap.appendChild(b); wrap.appendChild(b);
actions.appendChild(wrap); actions.appendChild(wrap);
} else { } else {
const target = attackable[0]; addBtn("⚔️ Attaquer (3 PA)", () => {}, false); // aucune cible : bouton désactivé
addBtn("⚔️ Attaquer (3 PA)", () => target && mpAction({ type: "attack", target: target.id }), canAttack);
} }
const comp = RACES[you.race].comp, sort = RACES[you.race].sort; const comp = RACES[you.race].comp, sort = RACES[you.race].sort;
addBtn(`🥋 ${comp.name} (${comp.cost} PA · ${you.comp.pct} %)`, () => mpAction({ type: "comp" }), you.pa >= comp.cost); addBtn(`🥋 ${comp.name} (${comp.cost} PA · ${you.comp.pct} %)`, () => mpAction({ type: "comp" }), you.pa >= comp.cost);