diff --git a/README.md b/README.md
index bd66116..a488ce6 100644
--- a/README.md
+++ b/README.md
@@ -234,6 +234,13 @@ non affilié au jeu original de Mountyhall SARL.
## 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**
(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
diff --git a/css/style.css b/css/style.css
index 8824345..3b2eaac 100644
--- a/css/style.css
+++ b/css/style.css
@@ -287,7 +287,9 @@ header h1 { font-size: 1.3em; color: #FFFFCC; text-shadow: 1px 1px 2px #000; }
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 {
background: #0d0a06;
diff --git a/js/game.js b/js/game.js
index d6fd723..0dc7649 100644
--- a/js/game.js
+++ b/js/game.js
@@ -4,7 +4,7 @@
"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. */
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 (G.grid[ny][nx] === T_WALL) return;
- const target = G.monsters.find(m => m.x === nx && m.y === ny);
- if (target) { attackMonster(target); return; }
-
+ // Empilement : se déplacer ne fait plus attaquer — on se pose sur la case
+ // (cohabitation), l'attaque passe par le bouton ⚔️ (cible choisie au besoin).
if (!spendPA(COSTS.move)) return;
t.x = nx; t.y = ny;
updateFov();
@@ -668,8 +667,8 @@ function killMonster(m, source = "attack") {
} else {
log(`💀 ${m.name} est terrassé !`, "good");
}
- // butin : les monstres lâchent parfois un trésor en mourant
- if (Math.random() < 0.4 && !G.items.some(i => i.x === m.x && i.y === m.y)) {
+ // butin : les monstres lâchent parfois un trésor en mourant (empilable)
+ if (Math.random() < 0.4) {
const lr = Math.random();
const drop = lr < 0.4
? { ...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.compUsed) { log("Botte Secrète déjà utilisée cette DLA.", "info"); return; }
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; }
t.compUsed = true;
cdLine(`Vous portez une Botte Secrète à ${m.name}.`);
@@ -785,7 +784,7 @@ function useComp() {
} 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; }
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; }
t.compUsed = true;
const destab = rollDice(te.att, 6).total;
@@ -814,7 +813,7 @@ function useSort() {
if (t.race === "Skrim") { // Hypnotisme : esquive /2 + perd son tour
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; }
cdLine(`Vous avez attaqué ${m.name} grâce à un sortilège.`);
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");
} else if (t.race === "Durakuir") { // Rafale Psychique : imparable, ignore l'armure
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; }
cdLine(`Vous avez attaqué ${m.name} grâce à un sortilège.`);
cdLine(`Votre jet d'Attaque est : automatiquement réussi (imparable).`);
@@ -842,7 +841,7 @@ function useSort() {
if (m.pv <= 0) killMonster(m, "sort");
} else if (t.race === "Kastar") { // Vampirisme : dégâts + vol de vie
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; }
cdLine(`Vous avez attaqué ${m.name} grâce à un sortilège.`);
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)
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; }
cdLine(`Vous avez attaqué ${m.name} grâce à un sortilège.`);
const pseudo = { ...te, deg: te.reg, degBonus: 0 };
@@ -939,8 +938,14 @@ function nearestVisibleMonster() {
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() {
- 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() {
@@ -1017,10 +1022,7 @@ function dropBagItem(idx) {
const t = G.troll;
const item = t.bag[idx];
if (!item) return;
- if (G.items.some(i => i.x === t.x && i.y === t.y)) {
- log("Il y a déjà quelque chose à terre ici.", "info");
- return;
- }
+ // Empilement : plusieurs trésors peuvent cohabiter sur une case.
if (!spendPA(COSTS.drop)) return;
t.bag.splice(idx, 1);
G.items.push({ ...item, x: t.x, y: t.y });
@@ -1249,8 +1251,9 @@ function stepToward(m, t) {
function moveMonster(m, nx, ny) {
if (nx < 0 || ny < 0 || nx >= MAP_W || ny >= MAP_H) 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 (G.monsters.some(o => o !== m && o.x === nx && o.y === ny)) return false;
m.x = nx; m.y = ny;
return true;
}
@@ -1271,7 +1274,7 @@ function win() {
/* ================= Interface ================= */
const $ = id => document.getElementById(id);
-const TILE = 24;
+const TILE = 48;
function log(msg, cls = "info") {
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() {
const canvas = $("map");
const ctx = canvas.getContext("2d");
@@ -1313,50 +1328,67 @@ function render() {
if (t === T_WALL) ctx.fillStyle = visible ? "#4a3a22" : "#2c2315";
else ctx.fillStyle = visible ? "#7a6a45" : "#3d3522";
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
- const disc = (x, y, color) => {
+ // Empilement : une case peut contenir plusieurs monstres/trésors et un lieu
+ // (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.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();
+ 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;
- disc(i.x, i.y, i.kind === "gold" ? "#caa53d"
- : i.kind === "potion" || i.kind === "scroll" ? (i.color || "#5d8535") : "#7a8db0");
- ctx.fillStyle = "#1a140e";
- ctx.fillText(i.emoji, i.x * TILE + TILE / 2, i.y * TILE + TILE / 2 + 1);
+
+ for (const c of Object.values(cells)) {
+ if (c.youHere) corner(c, Q, Q, "#8fbf5a", "🧌", 1, true); // ↖
+ if (c.monsters.length) {
+ corner(c, TILE - Q, Q, c.monsters[0].boss ? "#7a2070" : "#8a3030", c.monsters[0].emoji, c.monsters.length); // ↗
+ 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));
+ ctx.fillStyle = "#b03030";
+ 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)
}
- 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.fillRect(m.x * TILE + 3, m.y * TILE + 1, w, 3);
- }
- 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) {
ctx.strokeStyle = "#8fbf5a";
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() {
@@ -1424,8 +1456,29 @@ function renderPanels() {
b.onclick = fn;
actions.appendChild(b);
};
- const adj = adjacentMonster();
- addBtn(`⚔️ Attaquer (${COSTS.attack} PA)`, () => adj && attackMonster(adj), adj && t.pa >= COSTS.attack);
+ // Cibles : monstres sur TA case. Toujours un menu déroulant dès qu'il y en a
+ // 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;
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);
@@ -1607,20 +1660,15 @@ if (typeof document !== "undefined") {
if (RACES[params.get("race")]) selectedRace = params.get("race");
if (params.get("name")) $("troll-name").value = params.get("name");
startGame();
- // ?demo=combat : matérialise un gobelin adjacent et enchaîne attaque + sortilège
- // (sert aux captures d'écran et au test visuel du détail du combat)
+ // ?demo=combat : matérialise un gobelin sur la case du troll (on n'attaque
+ // que sa propre case) et enchaîne attaque + sortilège (captures / test visuel)
if (params.get("demo") === "combat") {
const t = G.troll;
- const spot = [[1, 0], [-1, 0], [0, 1], [0, -1]]
- .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);
- render();
- attackMonster(m);
- if (G.monsters.includes(m)) useSort();
- }
+ const m = makeMonster(1, t.x, t.y);
+ G.monsters.push(m);
+ render();
+ attackMonster(m);
+ if (G.monsters.includes(m)) useSort();
}
// ?demo=sac : remplit le sac et équipe deux pièces (captures du sac trié)
if (params.get("demo") === "sac") {
diff --git a/js/mp-client.js b/js/mp-client.js
index b87a206..e9bf5cf 100644
--- a/js/mp-client.js
+++ b/js/mp-client.js
@@ -275,6 +275,7 @@ function mpRenderMap(st) {
ctx.strokeStyle = "#8fbf5a";
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) {
@@ -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)` })),
];
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");
wrap.className = "mp-attack-sel";
const sel = document.createElement("select");
@@ -362,8 +364,7 @@ function mpRenderPanels(st) {
wrap.appendChild(b);
actions.appendChild(wrap);
} else {
- const target = attackable[0];
- addBtn("⚔️ Attaquer (3 PA)", () => target && mpAction({ type: "attack", target: target.id }), canAttack);
+ addBtn("⚔️ Attaquer (3 PA)", () => {}, false); // aucune cible : bouton désactivé
}
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);