sotc-battle-show-ability : affichage des capacités en combat

This commit is contained in:
Yasser Boulayoune 2026-05-01 16:26:00 +02:00
parent 84aa6b3437
commit e35a3996ee
11 changed files with 1363 additions and 0 deletions

View file

@ -0,0 +1,269 @@
/**
* sotc-clash-arrow | ArrowStore.js
*
* Store centralisé et réactif des flèches de clash.
* Gère les données en mémoire + persistance via socket pour la synchronisation
* multi-clients + persistance via flags de scène (survit au F5).
*
* Structure d'une flèche :
* {
* id : string – identifiant unique "tokenId_dieIndex → tokenId_dieIndex"
* fromToken: string – id du token attaquant
* fromDie : number – index du dé source dans _initiativeMarkers[]
* toToken : string – id du token cible
* toDie : number – index du dé cible
* x1, y1 : number – coordonnées canvas du centre du dé source
* x2, y2 : number – coordonnées canvas du centre du dé cible
* mutual : boolean – true si clash mutuel (flèche jaune)
* used : boolean – true si le dé source est passé en Used (flèche marron)
* }
*/
const SOCKET_EVENT = "module.sotc-clash-arrow";
const SCENE_FLAG = "arrows";
// ── EventEmitter minimal ──────────────────────────────────────────────────────
class MiniEmitter {
constructor() { this._handlers = {}; }
on(evt, fn) { (this._handlers[evt] ??= []).push(fn); }
off(evt, fn) {
if (!fn) { delete this._handlers[evt]; return; }
this._handlers[evt] = (this._handlers[evt] ?? []).filter(h => h !== fn);
}
emit(evt, ...args) { (this._handlers[evt] ?? []).forEach(h => h(...args)); }
}
// ── ArrowStore ────────────────────────────────────────────────────────────────
class _ArrowStore extends MiniEmitter {
constructor() {
super();
/** @type {Map<string, Object>} */
this._arrows = new Map();
}
// ── API publique ────────────────────────────────────────────────────────────
/** Retourne toutes les flèches. */
all() { return Array.from(this._arrows.values()); }
/** Retourne une flèche par id. */
get(id) { return this._arrows.get(id); }
/**
* Marque une flèche comme "utilisée" (dé passé en Used).
* La flèche reste visible mais passe en marron semi-transparent.
*/
markUsed(id) {
const arrow = this._arrows.get(id);
if (!arrow) return;
arrow.used = true;
this._arrows.set(id, arrow);
this.emit("change");
this._saveToScene();
this._broadcast({ action: "markUsed", id });
}
/**
* Génère l'id canonique d'une flèche.
* L'id est directionnel : A→B ≠ B→A.
*/
static makeId(fromToken, fromDie, toToken, toDie) {
return `${fromToken}:${fromDie}→${toToken}:${toDie}`;
}
/** Id de la flèche inverse (pour détecter le clash mutuel). */
static reverseId(fromToken, fromDie, toToken, toDie) {
return `${toToken}:${toDie}→${fromToken}:${fromDie}`;
}
/**
* Ajoute ou met à jour une flèche.
* Détecte automatiquement le clash mutuel et met à jour les deux flèches.
* Broadcast socket aux autres clients.
*/
addArrow(data) {
const id = _ArrowStore.makeId(data.fromToken, data.fromDie, data.toToken, data.toDie);
const revId = _ArrowStore.reverseId(data.fromToken, data.fromDie, data.toToken, data.toDie);
const reverse = this._arrows.get(revId);
const arrow = { ...data, id, mutual: !!reverse };
this._arrows.set(id, arrow);
// Si une flèche inverse existe → clash mutuel des deux côtés
if (reverse) {
reverse.mutual = true;
this._arrows.set(revId, reverse);
}
this.emit("change");
this._saveToScene();
this._broadcast({ action: "add", arrow, reverseId: revId, reverseMutual: !!reverse });
return arrow;
}
/**
* Supprime une flèche.
* Si l'autre sens était en clash mutuel, on le repasse en bleu.
*/
removeArrow(id) {
const arrow = this._arrows.get(id);
if (!arrow) return;
this._arrows.delete(id);
// Dés-mutualiser la flèche inverse si elle existe encore
const revId = _ArrowStore.reverseId(arrow.fromToken, arrow.fromDie, arrow.toToken, arrow.toDie);
const reverse = this._arrows.get(revId);
if (reverse) {
reverse.mutual = false;
this._arrows.set(revId, reverse);
}
this.emit("change");
this._saveToScene();
this._broadcast({ action: "remove", id, reverseId: revId });
}
/** Supprime toutes les flèches. */
clear() {
this._arrows.clear();
this.emit("change");
this._saveToScene();
this._broadcast({ action: "clear" });
}
// ── Persistance via flags de scène ──────────────────────────────────────────
/**
* Sauvegarde l'état du store dans les flags de la scène courante.
* Seul le GM écrit les flags (évite les conflits de permission).
* Les coordonnées ne sont PAS sauvegardées — elles sont recalculées
* à la restauration depuis les positions actuelles des marqueurs SIO.
*/
_saveToScene() {
if (!game.user.isGM) return;
if (!canvas.scene) return;
const data = this.all().map(a => ({
id : a.id,
fromToken : a.fromToken,
fromDie : a.fromDie,
toToken : a.toToken,
toDie : a.toDie,
mutual : a.mutual,
used : a.used ?? false,
}));
canvas.scene.setFlag("sotc-clash-arrow", SCENE_FLAG, data);
}
/**
* Recharge les flèches depuis les flags de la scène courante.
* Appelé au canvasReady depuis Hooks.js.
* Les coordonnées sont recalculées depuis les marqueurs SIO actuels.
* @param {Function} getDieCenter – getSIODieCenter(tokenDoc, dieIndex)
*/
loadFromScene(getDieCenter) {
this._arrows.clear();
const data = canvas.scene?.getFlag("sotc-clash-arrow", SCENE_FLAG) ?? [];
if (!data.length) return;
for (const saved of data) {
const fromTokenDoc = canvas.scene.tokens.get(saved.fromToken);
const toTokenDoc = canvas.scene.tokens.get(saved.toToken);
// Si l'un des tokens n'existe plus, on abandonne cette flèche
if (!fromTokenDoc || !toTokenDoc) continue;
let x1, y1, x2, y2;
try {
({ x: x1, y: y1 } = getDieCenter(fromTokenDoc, saved.fromDie));
({ x: x2, y: y2 } = getDieCenter(toTokenDoc, saved.toDie));
} catch {
continue;
}
this._arrows.set(saved.id, { ...saved, x1, y1, x2, y2 });
}
this.emit("change");
}
// ── Synchronisation socket ──────────────────────────────────────────────────
/** Envoie une mise à jour à tous les autres clients. */
_broadcast(payload) {
game.socket.emit(SOCKET_EVENT, payload);
}
/**
* Demande au GM de broadcaster l'état actuel des flèches.
* Utilisé par les joueurs au canvasReady pour recevoir les flèches existantes.
*/
requestSync() {
game.socket.emit(SOCKET_EVENT, { action: "syncRequest" });
}
/**
* Traite les messages reçus depuis le socket (autres clients).
* On applique localement sans re-broadcaster.
*/
_applyRemote(payload) {
switch (payload.action) {
case "add": {
this._arrows.set(payload.arrow.id, payload.arrow);
if (payload.reverseMutual) {
const rev = this._arrows.get(payload.reverseId);
if (rev) { rev.mutual = true; this._arrows.set(payload.reverseId, rev); }
}
this.emit("change");
break;
}
case "remove": {
this._arrows.delete(payload.id);
const rev = this._arrows.get(payload.reverseId);
if (rev) { rev.mutual = false; this._arrows.set(payload.reverseId, rev); }
this.emit("change");
break;
}
case "markUsed": {
const arrow = this._arrows.get(payload.id);
if (arrow) { arrow.used = true; this._arrows.set(payload.id, arrow); }
this.emit("change");
break;
}
case "clear": {
this._arrows.clear();
this.emit("change");
break;
}
// Un joueur demande la resynchronisation → seul le GM répond avec l'état complet
case "syncRequest": {
if (!game.user.isGM) break;
game.socket.emit(SOCKET_EVENT, {
action: "syncResponse",
arrows: this.all(),
});
break;
}
// Le GM envoie l'état complet → les joueurs l'appliquent localement
case "syncResponse": {
if (game.user.isGM) break; // le GM n'a pas besoin de sa propre réponse
this._arrows.clear();
for (const arrow of payload.arrows) {
this._arrows.set(arrow.id, arrow);
}
this.emit("change");
break;
}
}
}
/** Initialise l'écoute socket. Appelé une seule fois depuis main.js. */
initSocket() {
game.socket.on(SOCKET_EVENT, payload => this._applyRemote(payload));
}
}
export const ArrowStore = new _ArrowStore();