diff --git a/README.md b/README.md index 93c2e39..bd66116 100644 --- a/README.md +++ b/README.md @@ -234,6 +234,15 @@ non affilié au jeu original de Mountyhall SARL. ## Versions +- **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 + d'attaque) que sur sa propre case ; seule exception, le **Projectile Magique** + (sortilège du Tomawak) reste à distance (jusqu'à la Vue). **PvP** : on peut + désormais attaquer les autres trolls comme les monstres (combat classique, + mise à mort → réapparition habituelle ; +1 PX par coup porté, pas de butin + transféré). Le menu déroulant de cible liste monstres **et** trolls présents + sur la case. - **2.8.0** (2026-06-15) — **Empilement sur les cases** dans le monde partagé. Une case accueille maintenant **plusieurs trolls, plusieurs monstres et plusieurs trésors** (un seul « lieu » à terme). Se déplacer ne fait **plus diff --git a/js/game.js b/js/game.js index 858ece8..d6fd723 100644 --- a/js/game.js +++ b/js/game.js @@ -4,7 +4,7 @@ "use strict"; -const APP_VERSION = "2.8.0"; +const APP_VERSION = "2.9.0"; /* Alpha : maîtrise initiale haute pour les tests. Remettre 15 % / 15 % à la v1.0 officielle. */ const START_COMP_PCT = 90; diff --git a/js/mp-client.js b/js/mp-client.js index 72f09bf..b87a206 100644 --- a/js/mp-client.js +++ b/js/mp-client.js @@ -5,7 +5,7 @@ "use strict"; -const MP_TILE = 24; +const MP_TILE = 48; const MP_KEY = "mc_mp_identity"; let MP = { @@ -224,26 +224,27 @@ function mpRenderMap(st) { for (const pl of (st.places || [])) cell(pl.x, pl.y).place = pl; if (!you.dead) cell(you.x, you.y).youHere = true; - const Q = MP_TILE / 4; // demi-coin + const Q = MP_TILE / 4; // centre d'un coin + const EMOJI_FONT = Math.round(MP_TILE / 3) + "px serif"; + const COUNT_FONT = "bold " + Math.round(MP_TILE / 4.5) + "px sans-serif"; const corner = (c, qx, qy, color, emoji, count, mine) => { const cx = c.x * MP_TILE + qx, cy = c.y * MP_TILE + qy; ctx.fillStyle = color; ctx.beginPath(); ctx.arc(cx, cy, Q, 0, Math.PI * 2); ctx.fill(); - if (mine) { ctx.strokeStyle = "#eaffdc"; ctx.lineWidth = 1.5; ctx.stroke(); } + if (mine) { ctx.strokeStyle = "#eaffdc"; ctx.lineWidth = 2; ctx.stroke(); } ctx.fillStyle = "#1a140e"; - ctx.font = "11px serif"; + ctx.font = EMOJI_FONT; ctx.fillText(emoji, cx, cy + 1); if (count > 1) { - ctx.font = "bold 9px sans-serif"; - ctx.fillStyle = "#ffe"; + ctx.font = COUNT_FONT; + ctx.lineWidth = 3; ctx.strokeStyle = "#1a140e"; - ctx.lineWidth = 2; - ctx.strokeText(count, cx + Q, cy + Q); - ctx.fillText(count, cx + Q, cy + Q); + 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); } - ctx.font = "18px serif"; }; for (const c of Object.values(cells)) { @@ -252,9 +253,9 @@ function mpRenderMap(st) { if (c.monsters.length) { corner(c, MP_TILE - Q, Q, "#8a3030", c.monsters[0].emoji, c.monsters.length); // ↗ const low = c.monsters.reduce((a, b) => (b.pvPct < a.pvPct ? b : a)); - const bw = Math.max(2, Math.round((MP_TILE - 4) * low.pvPct)); + const bw = Math.max(3, Math.round((MP_TILE - 6) * low.pvPct)); ctx.fillStyle = "#b03030"; - ctx.fillRect(c.x * MP_TILE + 2, c.y * MP_TILE + 1, bw, 2); + ctx.fillRect(c.x * MP_TILE + 3, c.y * MP_TILE + 2, bw, 3); } if (c.items.length) { const it = c.items[0]; @@ -265,11 +266,11 @@ function mpRenderMap(st) { // nom d'un autre troll seul sur sa case (au-dessus) ; masqué si empilement if (c.trolls.length === 1 && !c.youHere) { ctx.fillStyle = "#dfe8ff"; - ctx.font = "9px sans-serif"; - ctx.fillText(c.trolls[0].name, c.x * MP_TILE + MP_TILE / 2, c.y * MP_TILE - 4); - ctx.font = "18px serif"; + ctx.font = Math.round(MP_TILE / 2.6) + "px sans-serif"; + ctx.fillText(c.trolls[0].name, c.x * MP_TILE + MP_TILE / 2, c.y * MP_TILE - MP_TILE / 8); } } + ctx.font = "18px serif"; if (!you.dead && you.camo) { ctx.strokeStyle = "#8fbf5a"; ctx.strokeRect(you.x * MP_TILE + 1, you.y * MP_TILE + 1, MP_TILE - 2, MP_TILE - 2); @@ -336,23 +337,27 @@ function mpRenderPanels(st) { b.onclick = fn; actions.appendChild(b); }; - // Cibles atteignables : monstres sur ta case ou adjacents (cheby ≤ 1). S'il y - // en a plusieurs (empilement), on propose un menu déroulant pour choisir. - const attackable = st.monsters.filter(m => Math.max(Math.abs(m.x - you.x), Math.abs(m.y - you.y)) <= 1); + // Cibles : monstres ET autres trolls (PvP) sur TA case (même case). S'il y en + // a plusieurs, menu déroulant pour choisir lequel frapper. + const onCell = e => e.x === you.x && e.y === you.y; + const attackable = [ + ...st.monsters.filter(onCell).map(m => ({ id: m.id, label: `${m.emoji} ${m.name} (niv. ${m.level} · ${Math.round(m.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; if (attackable.length > 1) { const wrap = document.createElement("div"); wrap.className = "mp-attack-sel"; const sel = document.createElement("select"); - for (const m of attackable) { + for (const a of attackable) { const opt = document.createElement("option"); - opt.textContent = `${m.emoji} ${m.name} (niv. ${m.level} · ${Math.round(m.pvPct * 100)} % PV)`; + opt.textContent = a.label; sel.appendChild(opt); } const b = document.createElement("button"); b.textContent = "⚔️ Attaquer (3 PA)"; b.disabled = !canAttack; - b.onclick = () => { const m = attackable[sel.selectedIndex]; if (m) mpAction({ type: "attack", target: m.id }); }; + b.onclick = () => { const a = attackable[sel.selectedIndex]; if (a) mpAction({ type: "attack", target: a.id }); }; wrap.appendChild(sel); wrap.appendChild(b); actions.appendChild(wrap); diff --git a/mp.js b/mp.js index a401fa8..7b29378 100644 --- a/mp.js +++ b/mp.js @@ -479,6 +479,25 @@ function actAttack(world, t, m) { return { ok: true }; } +/* Attaque d'un troll sur un autre (PvP, 3 PA) — uniquement sur la même case. */ +function actAttackTroll(world, t, v, now = Date.now()) { + if (!spendPA(t, g.COSTS.attack)) return { error: "pas assez de PA" }; + const r = g.resolveAttack(effTrollMP(t), effTrollMP(v)); + if (t.camo) { t.camo = false; privLog(t, "Ton attaque brise le camouflage.", "info"); } + if (r.hit) { + v.pv -= r.damage; + gainPXMP(t, 1); + const armTxt = r.armorReduction ? ` (armure −${r.armorReduction})` : ""; + privLog(t, `Tu attaques ${v.name} : ${r.attRoll} vs ${r.esqRoll} → ${r.critical ? "CRITIQUE ! " : ""}${r.damage} dégâts${armTxt}.`, "combat"); + privLog(v, `⚔️ ${t.name} t'attaque : ${r.attRoll} vs ${r.esqRoll} → ${r.damage} dégâts${armTxt} !`, "bad"); + if (v.pv <= 0) { t.kills++; killTrollMP(world, v, t.name, now); } + } else { + privLog(t, `Tu attaques ${v.name} : ${r.attRoll} vs ${r.esqRoll} → esquivé !`, "combat"); + privLog(v, `⚔️ ${t.name} t'attaque… et tu esquives !`, "combat"); + } + return { ok: true }; +} + /* Talent (compétence/sortilège) : jet de maîtrise mutualisé avec le solo. */ function tryTalentMP(t, talent, cap, cost) { if (!spendPA(t, cost)) return { error: "pas assez de PA" }; @@ -685,17 +704,27 @@ function action(world, t, act) { } if (act.type === "attack") { + // On ne peut frapper (monstre OU troll) que sur SA PROPRE case (même case). const m = monsterById(world, act.target); - if (!m || chebyshev(m, t) > 1) return { error: "aucun monstre adjacent à attaquer" }; - return actAttack(world, t, m); + if (m) { + if (chebyshev(m, t) !== 0) return { error: "il faut être sur la case du monstre" }; + return actAttack(world, t, m); + } + const v = world.trolls[act.target]; + if (v && v !== t && !v.dead) { + if (chebyshev(v, t) !== 0) return { error: "il faut être sur la case du troll" }; + return actAttackTroll(world, t, v); + } + return { error: "aucune cible sur ta case" }; } if (act.type === "comp" || act.type === "sort") { let m = act.target != null ? monsterById(world, act.target) : null; if (!m) { - // cible implicite : adjacent (ou plus proche visible pour le Projectile) + // Cible implicite : un monstre sur TA case ; exception, le Projectile + // Magique (sortilège du Tomawak) frappe à distance (jusqu'à la Vue). const te = effTrollMP(t); - const range = act.type === "sort" && t.race === "Tomawak" ? te.vue : 1; + const range = act.type === "sort" && t.race === "Tomawak" ? te.vue : 0; let bestDist = Infinity; for (const cand of world.monsters) { const d = chebyshev(cand, t); @@ -846,7 +875,7 @@ function stateFor(world, t, now = Date.now()) { }, trolls: Object.values(world.trolls) .filter(o => o !== t && !o.dead && !o.camo && seen(o)) - .map(o => ({ name: o.name, race: o.race, x: o.x, y: o.y, level: g.levelFromTotalPI(o.totalPI), pvPct: Math.max(0, o.pv / o.pvMax) })), + .map(o => ({ id: o.id, name: o.name, race: o.race, x: o.x, y: o.y, level: g.levelFromTotalPI(o.totalPI), pvPct: Math.max(0, o.pv / o.pvMax) })), monsters: world.monsters.filter(seen) .map(m => ({ id: m.id, name: m.name, emoji: m.emoji, x: m.x, y: m.y, level: m.level, pvPct: Math.max(0, m.pv / m.pvMax), nextDlaIn: Math.max(0, m.nextDla - now) })), items: world.items.filter(seen) diff --git a/test/mp.js b/test/mp.js index 5609a31..7a0abe3 100644 --- a/test/mp.js +++ b/test/mp.js @@ -135,7 +135,7 @@ function makeWorld(over = {}) { const { troll: t } = mp.newTroll(w, "Tueur", "Kastar"); t.att = 50; t.deg = 20; // écrasant const m = w.monsters[0]; - m.x = t.x + 1; m.y = t.y; + m.x = t.x; m.y = t.y; // même case : l'attaque ne porte que sur sa propre case m.pv = 5; t.pa = 6; const r = mp.action(w, t, { type: "attack", target: m.id }); @@ -422,6 +422,24 @@ function makeWorld(over = {}) { assert(ra.ok, "attaque explicite possible sur la même case"); } +// PvP : un troll peut attaquer un autre troll sur sa case (et le terrasser) +{ + const w = makeWorld({ monsterTarget: 0, itemTarget: 0 }); + const { troll: a } = mp.newTroll(w, "Agresseur", "Kastar"); + const { troll: b } = mp.newTroll(w, "Victime", "Skrim"); + a.att = 50; a.deg = 20; a.pa = 6; + b.x = a.x; b.y = a.y; b.pv = 5; b.esq = 1; + // pas sur la même case → refusé + b.x = a.x + 5; + assert(mp.action(w, a, { type: "attack", target: b.id }).error, "PvP refusé hors de la case"); + // sur la même case → combat, mise à mort + b.x = a.x; b.y = a.y; + const r = mp.action(w, a, { type: "attack", target: b.id }); + assert(r.ok, "PvP résolu sur la même case"); + assert(b.dead, "le troll visé est terrassé"); + assert(a.kills === 1, "kill comptabilisé pour l'attaquant"); +} + // Base de référence : les retouches survivent au redémarrage (seed non destructif) { const os = require("os");