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