sotc-foundry-suite/sotc-clash-arrow/scripts/ArrowStore.js

269 lines
9 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.

/**
* 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();