v2.9.0 : cases x2, portee d'attaque = meme case (sauf Projectile Magique), PvP
mp-client.js : MP_TILE 24->48 + polices de coins proportionnelles. mp.js : action attack ne porte que sur la meme case (cheby 0) et dispatch monstre/troll ; actAttackTroll (PvP : combat, +1 PX/coup, kill -> killTrollMP, pas de butin) ; comp/sort portee 0 (meme case) sauf sort Tomawak (Projectile Magique, portee = Vue) ; stateFor envoie l'id des trolls. Client : cibles = monstres + trolls sur ta case, deroulant pour choisir. Tests : combat meme case, deplacement libre, PvP. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
|
||||
+1
-1
@@ -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;
|
||||
|
||||
+26
-21
@@ -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);
|
||||
|
||||
@@ -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)
|
||||
|
||||
+19
-1
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user