269 lines
9 KiB
JavaScript
269 lines
9 KiB
JavaScript
/**
|
||
* 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();
|