Files
mountycrawl/js/mp-client.js
T
percoandClaude Opus 4.8 a0547bf9a3 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>
2026-06-15 15:45:53 +02:00

448 lines
19 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* Client multijoueur MountyCrawl — polling de l'état serveur (/api/mp/*),
* rendu canvas et actions. L'identité du troll (id + secret) est gardée en
* localStorage : on retrouve son troll d'une session à l'autre.
* Partage les globales de game.js (RACES, GEAR_SLOTS, fmtStatLine…). */
"use strict";
const MP_TILE = 48;
const MP_KEY = "mc_mp_identity";
let MP = {
id: null, secret: null,
state: null,
timer: null,
seen: new Set(), // brouillard : cases déjà vues (mémoire locale)
lastLogT: 0,
pending: false,
};
/* ---------- Identité ---------- */
function mpLoadIdentity() {
try { return JSON.parse(localStorage.getItem(MP_KEY)); } catch { return null; }
}
function mpSaveIdentity(id, secret) {
localStorage.setItem(MP_KEY, JSON.stringify({ id, secret }));
}
/* ---------- Réseau ---------- */
async function mpJoin(name, password) {
const res = await fetch("api/mp/join", {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name, race: selectedRace, password }),
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || "connexion refusée");
MP.id = data.id; MP.secret = data.secret;
mpSaveIdentity(data.id, data.secret);
return data.state;
}
async function mpLogin(name, password) {
const res = await fetch("api/mp/login", {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name, password }),
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || "connexion refusée");
MP.id = data.id; MP.secret = data.secret;
mpSaveIdentity(data.id, data.secret);
return data.state;
}
async function mpFetchState() {
const res = await fetch(`api/mp/state?id=${MP.id}&secret=${MP.secret}`);
if (res.status === 403) return null; // identité périmée (reset serveur ?)
if (!res.ok) throw new Error("état indisponible");
return res.json();
}
async function mpAction(action) {
if (MP.pending) return;
MP.pending = true;
try {
const res = await fetch("api/mp/action", {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ id: MP.id, secret: MP.secret, action }),
});
const data = await res.json();
if (data.error) mpToast(data.error);
if (data.state) { MP.state = data.state; mpRender(); }
} catch { mpToast("le serveur ne répond pas"); }
MP.pending = false;
}
function mpToast(msg) {
const el = document.getElementById("mp-status");
if (el) {
el.textContent = `⚠️ ${msg}`;
setTimeout(() => mpRenderStatus(), 2500);
}
}
/* ---------- Entrée / sortie de l'écran ---------- */
function mpShowJoin(show) {
document.getElementById("mp-join").classList.toggle("hidden", !show);
// #layout et #bottom-row existent aussi dans l'écran solo : on scope au multi
document.querySelector("#screen-mp #layout").classList.toggle("hidden", show);
document.querySelector("#screen-mp #bottom-row").classList.toggle("hidden", show);
if (show) {
document.getElementById("mp-join-name").value = document.getElementById("troll-name").value.trim() || "Trõllinet";
document.getElementById("mp-join-race").textContent = `${RACES[selectedRace].emoji} ${selectedRace}`;
document.getElementById("mp-join-msg").textContent = "";
}
}
async function mpEnter() {
document.getElementById("screen-create").classList.add("hidden");
document.getElementById("screen-mp").classList.remove("hidden");
switchLeftTab("stats", "mp-"); // ouvrir sur les caractéristiques
MP.seen = new Set();
try {
const saved = mpLoadIdentity();
if (saved && saved.id) {
MP.id = saved.id; MP.secret = saved.secret;
MP.state = await mpFetchState();
}
if (!MP.state) { mpShowJoin(true); return; } // pas d'identité valable : créer ou retrouver
mpShowJoin(false);
mpRender();
mpSchedule();
} catch (e) {
mpToast(e.message || "connexion impossible");
setTimeout(mpLeave, 2500);
}
}
/* Création ou reconnexion depuis le panneau « Rejoindre ». */
async function mpJoinSubmit(isLogin) {
const name = document.getElementById("mp-join-name").value.trim() || "Trõllinet";
const password = document.getElementById("mp-join-pass").value;
const msg = document.getElementById("mp-join-msg");
try {
MP.state = isLogin ? await mpLogin(name, password) : await mpJoin(name, password);
MP.seen = new Set();
mpShowJoin(false);
mpRender();
mpSchedule();
} catch (e) {
msg.textContent = `⚠️ ${e.message}`;
}
}
function mpLeave() {
if (MP.timer) { clearTimeout(MP.timer); MP.timer = null; }
document.getElementById("screen-mp").classList.add("hidden");
document.getElementById("screen-create").classList.remove("hidden");
}
function mpSchedule() {
if (MP.timer) clearTimeout(MP.timer);
const sec = MP.state?.config?.pollSec || 5;
MP.timer = setTimeout(async () => {
if (document.getElementById("screen-mp").classList.contains("hidden")) return;
try {
const st = await mpFetchState();
if (!st) { // identité refusée (reset serveur ?) : repasser par le panneau
localStorage.removeItem(MP_KEY);
MP.state = null;
mpShowJoin(true);
return;
}
MP.state = st;
mpRender();
} catch { /* on retentera au prochain tour */ }
mpSchedule();
}, sec * 1000);
}
/* ---------- Rendu ---------- */
function mpFmtDelay(ms) {
const s = Math.ceil(ms / 1000);
if (s < 60) return `${s} s`;
return `${Math.floor(s / 60)} min ${String(s % 60).padStart(2, "0")} s`;
}
function mpRenderStatus() {
const st = MP.state;
const el = document.getElementById("mp-status");
if (!st || !el) return;
el.textContent = st.you.dead
? `☠️ Terrassé — retour dans ${mpFmtDelay(st.you.respawnIn)}`
: `DLA n°${st.you.dla} · PA dans ${mpFmtDelay(st.you.nextDlaIn)} · 🔄 ${st.config.pollSec} s`;
}
function mpRender() {
const st = MP.state;
if (!st) return;
mpRenderStatus();
mpRenderMap(st);
mpRenderPanels(st);
mpRenderLogs(st);
}
function mpRenderMap(st) {
const canvas = document.getElementById("mp-map");
const w = st.config.mapW, h = st.config.mapH;
canvas.width = w * MP_TILE;
canvas.height = h * MP_TILE;
const ctx = canvas.getContext("2d");
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.font = "18px serif";
ctx.textAlign = "center";
ctx.textBaseline = "middle";
const you = st.you;
const vue = you.eff.vue;
const visible = (x, y) => Math.max(Math.abs(x - you.x), Math.abs(y - you.y)) <= vue;
for (let y = 0; y < h; y++) {
for (let x = 0; x < w; x++) {
if (visible(x, y)) MP.seen.add(y * w + x);
const px = x * MP_TILE, py = y * MP_TILE;
if (!MP.seen.has(y * w + x)) { ctx.fillStyle = "#0d0a06"; ctx.fillRect(px, py, MP_TILE, MP_TILE); continue; }
const vis = visible(x, y);
const wall = st.map[y][x] === "#";
ctx.fillStyle = wall ? (vis ? "#4a3a22" : "#2c2315") : (vis ? "#7a6a45" : "#3d3522");
ctx.fillRect(px, py, MP_TILE - 1, MP_TILE - 1);
}
}
// Empilement : une case peut contenir plusieurs trolls/monstres/trésors et un
// lieu. 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, trolls: [], monsters: [], items: [], place: null, youHere: false });
for (const i of st.items) cell(i.x, i.y).items.push(i);
for (const m of st.monsters) cell(m.x, m.y).monsters.push(m);
for (const o of st.trolls) cell(o.x, o.y).trolls.push(o);
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; // 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 = 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 c of Object.values(cells)) {
const nTrolls = c.trolls.length + (c.youHere ? 1 : 0);
if (nTrolls) corner(c, Q, Q, c.youHere ? "#8fbf5a" : "#5a7abf", "🧌", nTrolls, c.youHere); // ↖
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(3, Math.round((MP_TILE - 6) * low.pvPct));
ctx.fillStyle = "#b03030";
ctx.fillRect(c.x * MP_TILE + 3, c.y * MP_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, MP_TILE - Q, col, it.emoji, c.items.length); // ↙
}
if (c.place) corner(c, MP_TILE - Q, MP_TILE - Q, "#6a5a8a", c.place.emoji, 1); // ↘
// 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 = 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);
}
}
function mpRenderPanels(st) {
const you = st.you;
const e = you.eff;
document.getElementById("mp-troll-title").textContent =
`${RACES[you.race].emoji} ${you.name}, ${you.race} niv. ${you.level}`;
const pct = Math.max(0, you.pv / you.pvMax);
const hpClass = pct > 0.6 ? "high" : pct > 0.3 ? "mid" : "";
document.getElementById("mp-stats").innerHTML = `
<div><span>PV</span><span class="stat-val">${you.pv} / ${you.pvMax}</span></div>
<div class="hp-bar-wrap"><div class="hp-bar ${hpClass}" style="width:${pct * 100}%"></div></div>
<div><span>Attaque</span><span class="stat-val">${fmtStatLine(you.att, e.att, 6, e.attFlat, 0, { phys: e.attFlatPhys, mag: e.attFlatMag })}</span></div>
<div><span>Esquive</span><span class="stat-val">${fmtStatLine(you.esq, e.esq, 6, e.esqFlat, 0, { phys: e.esqFlatPhys, mag: e.esqFlatMag })}</span></div>
<div><span>Dégâts</span><span class="stat-val">${fmtStatLine(you.deg, e.deg, 3, e.degFlat, e.degBonus, { phys: e.degFlatPhys, mag: e.degFlatMag })}</span></div>
<div><span>Régénération</span><span class="stat-val">${fmtStatLine(you.reg, e.reg, 3, e.regFlat, 0, { phys: e.regFlatPhys, mag: e.regFlatMag })}</span></div>
<div><span>Armure</span><span class="stat-val">${fmtArmorLine(you.armorDice, e.armorPhys, e.armorMag)}</span></div>
<div><span>Vue</span><span class="stat-val">${e.vue}</span></div>
<div><span>${RACES[you.race].comp.name}</span><span class="stat-val">${you.comp.pct} %</span></div>
<div><span>${RACES[you.race].sort.name}</span><span class="stat-val">${you.sort.pct} %</span></div>
${you.race === "Kastar" ? `<div><span>Fatigue</span><span class="stat-val">${you.fatigue}</span></div>` : ""}
${you.camo ? '<div><span>🌫️ Camouflé</span><span class="stat-val">oui</span></div>' : ""}
<div><span>PI</span><span class="stat-val">${you.pi}</span></div>
<div><span>Mountyzédons</span><span class="stat-val">${you.gold}</span></div>
<div><span>Monstres tués</span><span class="stat-val">${you.kills}</span></div>`;
const improve = document.getElementById("mp-improve");
improve.innerHTML = "";
const labels = { att: "Attaque +1D6", esq: "Esquive +1D6", deg: "Dégâts +1D3", reg: "Régén. +1D3", pv: "PV max +10", vue: "Vue +1", armor: "Armure phy. +1D3" };
for (const stat of Object.keys(labels)) {
const cost = improveCost(stat, you.bought[stat], you.race);
const btn = document.createElement("button");
const fav = RACES[you.race].favored === stat ? " ★" : "";
btn.textContent = `${labels[stat]}${cost} PI${fav}`;
btn.disabled = you.pi < cost || you.dead;
btn.onclick = () => mpAction({ type: "train", stat });
improve.appendChild(btn);
}
// Effets : on réutilise tel quel le panneau du solo (renderEffectsPanel +
// badge), pour que les deux modes soient strictement identiques.
const fxTroll = { potionEffects: you.potionEffects || [], blockCamoTurns: you.blockCamoTurns || 0 };
document.getElementById("mp-effects").innerHTML = renderEffectsPanel(fxTroll);
const fxBadge = document.getElementById("mp-fx-badge");
if (fxBadge) {
const n = countActiveEffects(fxTroll);
fxBadge.textContent = n;
fxBadge.classList.toggle("hidden", n === 0);
}
document.getElementById("mp-pa-bar").textContent = "⚡".repeat(you.pa) + "▫️".repeat(Math.max(0, 6 - you.pa));
const actions = document.getElementById("mp-actions");
actions.innerHTML = "";
const addBtn = (label, fn, enabled) => {
const b = document.createElement("button");
b.textContent = label;
b.disabled = !enabled || you.dead;
b.onclick = fn;
actions.appendChild(b);
};
// 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 a of attackable) {
const opt = document.createElement("option");
opt.textContent = a.label;
sel.appendChild(opt);
}
const b = document.createElement("button");
b.textContent = "⚔️ Attaquer (3 PA)";
b.disabled = !canAttack;
b.onclick = () => { const a = attackable[sel.selectedIndex]; if (a) mpAction({ type: "attack", target: a.id }); };
wrap.appendChild(sel);
wrap.appendChild(b);
actions.appendChild(wrap);
} else {
const target = attackable[0];
addBtn("⚔️ Attaquer (3 PA)", () => target && mpAction({ type: "attack", target: target.id }), canAttack);
}
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(`🔮 ${sort.name} (${sort.cost} PA · ${you.sort.pct} %)`, () => mpAction({ type: "sort" }), you.pa >= sort.cost);
const onItem = st.items.some(i => i.x === you.x && i.y === you.y);
addBtn(`🖐️ Ramasser (1 PA)`, () => mpAction({ type: "pickup" }), onItem && you.pa >= 1);
const equipEl = document.getElementById("mp-equipment");
equipEl.innerHTML = Object.entries(GEAR_SLOTS).map(([slot, info]) => {
const it = you.equip[slot];
if (!it) return `<div class="eq-line"><span class="eq-slot">${info.label}</span> —</div>`;
const fxs = formatGearMods(it.mods);
return `<div class="eq-line"><span class="eq-slot">${info.label}</span> ${it.emoji} ${esc(it.name)}` +
`${it.twoHanded ? " <small>(2 mains)</small>" : ""}` +
` <button class="unequip-btn" data-slot="${slot}" title="Déséquiper (${COSTS.unequip} PA) : revient dans le sac">↩️</button>` +
`${fxs ? `<div class="eq-mods">${fxs}</div>` : ""}</div>`;
}).join("");
for (const b of equipEl.querySelectorAll(".unequip-btn")) {
b.disabled = you.dead || you.pa < COSTS.unequip;
b.onclick = () => mpAction({ type: "unequip", slot: b.dataset.slot });
}
renderBag(document.getElementById("mp-inventory"), you.bag, {
disabled: you.dead,
pa: you.pa,
onUse: idx => mpAction({ type: "use", idx }),
onEat: idx => mpAction({ type: "eat", idx }),
onDrop: idx => mpAction({ type: "drop", idx }),
});
}
function mpRenderLogs(st) {
const logEl = document.getElementById("mp-log");
logEl.innerHTML = st.privLog.map(l =>
`<div class="l-${l.cls}">${new Date(l.t).toLocaleTimeString("fr-FR")}${l.msg}</div>`).join("");
logEl.scrollTop = logEl.scrollHeight;
const wl = document.getElementById("mp-world-log");
wl.innerHTML = '<div class="cd-header">🌍 Échos du Monde Souterrain</div>' + st.log.map(l =>
`<div class="l-${l.cls}">${new Date(l.t).toLocaleTimeString("fr-FR")}${l.msg}</div>`).join("");
wl.scrollTop = wl.scrollHeight;
}
/* ---------- Clavier & branchement ---------- */
if (typeof document !== "undefined") {
document.addEventListener("DOMContentLoaded", () => {
const btn = document.getElementById("btn-mp");
if (btn) btn.onclick = mpEnter;
document.getElementById("mp-leave").onclick = mpLeave;
document.getElementById("mp-join-create").onclick = () => mpJoinSubmit(false);
document.getElementById("mp-join-login").onclick = () => mpJoinSubmit(true);
// Onglets « Caractéristiques / Effets » — même mécanique que le solo
document.getElementById("mp-tab-stats")?.addEventListener("click", () => switchLeftTab("stats", "mp-"));
document.getElementById("mp-tab-effects")?.addEventListener("click", () => switchLeftTab("effects", "mp-"));
document.addEventListener("keydown", e => {
if (document.getElementById("screen-mp").classList.contains("hidden")) return;
const k = e.key.toLowerCase();
const moves = {
arrowup: [0, -1], z: [0, -1],
arrowdown: [0, 1], s: [0, 1],
arrowleft: [-1, 0], q: [-1, 0],
arrowright: [1, 0], d: [1, 0],
};
if (moves[k]) { e.preventDefault(); mpAction({ type: "move", dx: moves[k][0], dy: moves[k][1] }); }
else if (k === "e") mpAction({ type: "pickup" });
});
// compteur de la Taverne : « X trõlls en ligne »
fetch("api/mp/info").then(r => r.json()).then(info => {
const el = document.getElementById("mp-info");
if (el && info && typeof info.online === "number")
el.textContent = ${info.online} trõll(s) en ligne`;
}).catch(() => {});
// ?screen=mp : entrer directement dans le monde partagé (tests/captures)
if (new URLSearchParams(location.search).get("screen") === "mp") mpEnter();
});
}