From e35a3996ee93dcee8025a0c35cdd16ecb210df96 Mon Sep 17 00:00:00 2001 From: Yasser Boulayoune Date: Fri, 1 May 2026 16:26:00 +0200 Subject: [PATCH] =?UTF-8?q?sotc-battle-show-ability=20:=20affichage=20des?= =?UTF-8?q?=20capacit=C3=A9s=20en=20combat?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- sotc-clash-arrow/lang/en.json | 22 ++ sotc-clash-arrow/lang/fr.json | 22 ++ sotc-clash-arrow/module.json | 52 ++++ sotc-clash-arrow/scripts/ArrowStore.js | 269 +++++++++++++++++++ sotc-clash-arrow/scripts/ClashArrowLayer.js | 150 +++++++++++ sotc-clash-arrow/scripts/DieInteraction.js | 135 ++++++++++ sotc-clash-arrow/scripts/Hooks.js | 231 ++++++++++++++++ sotc-clash-arrow/scripts/SIOBridge.js | 228 ++++++++++++++++ sotc-clash-arrow/scripts/main.js | 35 +++ sotc-clash-arrow/sotc-clash-arrow-spec.md | 160 +++++++++++ sotc-clash-arrow/styles/sotc-clash-arrow.css | 59 ++++ 11 files changed, 1363 insertions(+) create mode 100644 sotc-clash-arrow/lang/en.json create mode 100644 sotc-clash-arrow/lang/fr.json create mode 100644 sotc-clash-arrow/module.json create mode 100644 sotc-clash-arrow/scripts/ArrowStore.js create mode 100644 sotc-clash-arrow/scripts/ClashArrowLayer.js create mode 100644 sotc-clash-arrow/scripts/DieInteraction.js create mode 100644 sotc-clash-arrow/scripts/Hooks.js create mode 100644 sotc-clash-arrow/scripts/SIOBridge.js create mode 100644 sotc-clash-arrow/scripts/main.js create mode 100644 sotc-clash-arrow/sotc-clash-arrow-spec.md create mode 100644 sotc-clash-arrow/styles/sotc-clash-arrow.css diff --git a/sotc-clash-arrow/lang/en.json b/sotc-clash-arrow/lang/en.json new file mode 100644 index 0000000..7f5b31f --- /dev/null +++ b/sotc-clash-arrow/lang/en.json @@ -0,0 +1,22 @@ +{ + "SOTCCA": { + "ArrowDeleted": "Arrow deleted.", + "ArrowsReset": "All clash arrows have been reset.", + "NoSkillOnDie": "This Speed Die has no skill assigned.", + "CannotTargetSelf": "Cannot target your own Speed Die.", + "MutualClash": "Mutual clash detected between {attacker} and {defender}!", + "ArrowBlue": "Direct attack (defender has no skill)", + "ArrowYellow": "Mutual clash (both tokens target each other)", + "AttackWeightExceeded": "Limit reached: this skill can only target {max} enemy/enemies.", + "Debug": { + "Title": "Debug — Force state", + "StateForced": "Die state forced to {state}." + }, + "Settings": { + "DebugForceState": { + "Name": "Debug Mode — Force Die State", + "Hint": "Shows a button panel in the Speed Die Panel (SBSA) allowing manual override of a die's state, even when a skill is assigned. Reserved for the GM for debugging purposes." + } + } + } +} \ No newline at end of file diff --git a/sotc-clash-arrow/lang/fr.json b/sotc-clash-arrow/lang/fr.json new file mode 100644 index 0000000..aceefd8 --- /dev/null +++ b/sotc-clash-arrow/lang/fr.json @@ -0,0 +1,22 @@ +{ + "SOTCCA": { + "ArrowDeleted": "Flèche supprimée.", + "ArrowsReset": "Toutes les flèches de clash ont été réinitialisées.", + "NoSkillOnDie": "Ce Dé de Vitesse ne contient aucune compétence.", + "CannotTargetSelf": "Impossible de cibler son propre Dé de Vitesse.", + "MutualClash": "Clash mutuel détecté entre {attacker} et {defender} !", + "ArrowBlue": "Attaque directe (défenseur sans compétence)", + "ArrowYellow": "Clash mutuel (les deux tokens se ciblent)", + "AttackWeightExceeded": "Limite atteinte : cette compétence ne peut cibler que {max} ennemi(s).", + "Debug": { + "Title": "Debug — Forcer l'état", + "StateForced": "État du dé forcé à {state}." + }, + "Settings": { + "DebugForceState": { + "Name": "Mode Debug — Forcer l'état du dé", + "Hint": "Affiche un panneau de boutons dans le Speed Die Panel (SBSA) permettant de forcer manuellement l'état d'un dé, même lorsqu'une compétence est assignée. Réservé au MJ pour le débogage." + } + } + } +} \ No newline at end of file diff --git a/sotc-clash-arrow/module.json b/sotc-clash-arrow/module.json new file mode 100644 index 0000000..eea108b --- /dev/null +++ b/sotc-clash-arrow/module.json @@ -0,0 +1,52 @@ +{ + "id": "sotc-clash-arrow", + "title": "SotC Clash Arrow", + "description": "Tracé de flèches courbées entre Dés de Vitesse (Stars Initiative Overlay) pour visualiser les clashes dans Star of the City.", + "version": "1.0.0", + "compatibility": { + "minimum": "11", + "verified": "11", + "maximum": "12" + }, + "authors": [ + { + "name": "Guillaume" + } + ], + "relationships": { + "requires": [ + { + "id": "stars-initiative-overlay", + "type": "module", + "reason": "Fournit les marqueurs Dés de Vitesse sur le canvas." + } + ], + "recommends": [ + { + "id": "sotc-battle-show-ability", + "type": "module", + "reason": "Fournit les données de compétences assignées aux Dés de Vitesse (dieState, assignedSkillId)." + } + ] + }, + "esmodules": [ + "scripts/main.js" + ], + "styles": [ + "styles/sotc-clash-arrow.css" + ], + "languages": [ + { + "lang": "fr", + "name": "Français", + "path": "lang/fr.json" + }, + { + "lang": "en", + "name": "English", + "path": "lang/en.json" + } + ], + "flags": {}, + "socket": true +} diff --git a/sotc-clash-arrow/scripts/ArrowStore.js b/sotc-clash-arrow/scripts/ArrowStore.js new file mode 100644 index 0000000..f807e47 --- /dev/null +++ b/sotc-clash-arrow/scripts/ArrowStore.js @@ -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} */ + 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(); diff --git a/sotc-clash-arrow/scripts/ClashArrowLayer.js b/sotc-clash-arrow/scripts/ClashArrowLayer.js new file mode 100644 index 0000000..345968d --- /dev/null +++ b/sotc-clash-arrow/scripts/ClashArrowLayer.js @@ -0,0 +1,150 @@ +/** + * sotc-clash-arrow | ClashArrowLayer.js + * + * En Foundry v11, les CanvasLayer custom ne se montent pas via CONFIG.Canvas.layers. + * Solution : attacher deux PIXI.Graphics directement sur canvas.interface + * (le groupe PIXI qui contient tokens, drawings, etc.). + * + * API publique (identique à l'ancienne classe) : + * ClashArrowLayer.init() – à appeler au canvasReady + * ClashArrowLayer.tearDown() – à appeler au canvasTearDown + * ClashArrowLayer.redraw() – redessine toutes les flèches du store + * ClashArrowLayer.drawPreview() – flèche temporaire pendant le drag + * ClashArrowLayer.clearPreview() + */ + +import { ArrowStore } from "./ArrowStore.js"; + +// ── Constantes visuelles ────────────────────────────────────────────────────── +const COLOR_BLUE = 0x4a9eff; +const COLOR_YELLOW = 0xffd84a; +const COLOR_PREVIEW = 0x888899; +const COLOR_USED = 0x8B4513; // marron "Used" + +const ALPHA_USED = 0.35; // légèrement visible + +const LINE_WIDTH = 3; +const LINE_ALPHA = 0.92; +const ARROW_HEAD_SIZE = 14; +const BEZIER_CURVE = 0.35; + +// ── Helpers géométriques ────────────────────────────────────────────────────── + +function bezierControlPoints(x1, y1, x2, y2, curvature = BEZIER_CURVE) { + const mx = (x1 + x2) / 2; + const my = (y1 + y2) / 2; + const dx = x2 - x1; + const dy = y2 - y1; + const nx = -dy * curvature; + const ny = dx * curvature; + return { cp1x: mx + nx, cp1y: my + ny }; +} + +function bezierPoint(t, p0, p1, p2, p3) { + const mt = 1 - t; + return mt*mt*mt*p0 + 3*mt*mt*t*p1 + 3*mt*t*t*p2 + t*t*t*p3; +} + +function drawArrowHead(gfx, tx, ty, dx, dy, size, color, alpha) { + const angle = Math.atan2(dy, dx); + const spread = Math.PI / 6; + const lx = tx - size * Math.cos(angle - spread); + const ly = ty - size * Math.sin(angle - spread); + const rx = tx - size * Math.cos(angle + spread); + const ry = ty - size * Math.sin(angle + spread); + gfx.lineStyle(0); + gfx.beginFill(color, alpha); + gfx.drawPolygon([tx, ty, lx, ly, rx, ry]); + gfx.endFill(); +} + +// ── Module singleton ────────────────────────────────────────────────────────── + +/** @type {PIXI.Graphics|null} */ +let _arrowsGfx = null; +/** @type {PIXI.Graphics|null} */ +let _previewGfx = null; + +function _drawArrow(gfx, x1, y1, x2, y2, color, alpha = LINE_ALPHA) { + const { cp1x, cp1y } = bezierControlPoints(x1, y1, x2, y2); + + gfx.lineStyle(LINE_WIDTH, color, alpha); + gfx.moveTo(x1, y1); + gfx.bezierCurveTo(cp1x, cp1y, cp1x, cp1y, x2, y2); + + const t = 0.98; + const px = bezierPoint(t, x1, cp1x, cp1x, x2); + const py = bezierPoint(t, y1, cp1y, cp1y, y2); + drawArrowHead(gfx, x2, y2, x2 - px, y2 - py, ARROW_HEAD_SIZE, color, alpha); +} + +export const ClashArrowLayer = { + + /** + * Initialise les Graphics PIXI et les attache au canvas. + * Appelé au canvasReady depuis Hooks.js. + */ + init() { + // Si déjà initialisé et Graphics toujours valides, juste redessiner + if (_arrowsGfx && !_arrowsGfx.destroyed) { + this.redraw(); + return; + } + + // Nettoyer si les Graphics sont corrompus + this.tearDown(); + + const parent = canvas.interface ?? canvas.stage; + + _arrowsGfx = parent.addChild(new PIXI.Graphics()); + _previewGfx = parent.addChild(new PIXI.Graphics()); + + _arrowsGfx.zIndex = 900; + _previewGfx.zIndex = 901; + + ArrowStore.on("change", () => this.redraw()); + + // Dessiner les flèches déjà présentes dans le store + this.redraw(); + + console.log("sotc-clash-arrow | ClashArrowLayer initialisé sur canvas.interface."); + }, + + /** Nettoie et retire les Graphics du canvas. */ + tearDown() { + ArrowStore.off("change"); + if (_arrowsGfx) { _arrowsGfx.parent?.removeChild(_arrowsGfx); _arrowsGfx.destroy(); _arrowsGfx = null; } + if (_previewGfx) { _previewGfx.parent?.removeChild(_previewGfx); _previewGfx.destroy(); _previewGfx = null; } + }, + + /** Redessine toutes les flèches persistantes. */ + redraw() { + if (!_arrowsGfx) return; + _arrowsGfx.clear(); + for (const arrow of ArrowStore.all()) { + if (arrow.used) { + _drawArrow(_arrowsGfx, arrow.x1, arrow.y1, arrow.x2, arrow.y2, COLOR_USED, ALPHA_USED); + } else { + const color = arrow.mutual ? COLOR_YELLOW : COLOR_BLUE; + _drawArrow(_arrowsGfx, arrow.x1, arrow.y1, arrow.x2, arrow.y2, color); + } + } + }, + + /** + * Flèche de preview pendant le drag. + * @param {number} x1 @param {number} y1 Origine (coords monde) + * @param {number} x2 @param {number} y2 Position courante (coords monde) + * @param {number} color + */ + drawPreview(x1, y1, x2, y2, color = COLOR_PREVIEW) { + if (!_previewGfx) return; + _previewGfx.clear(); + _drawArrow(_previewGfx, x1, y1, x2, y2, color, 0.55); + }, + + /** Efface la flèche de preview. */ + clearPreview() { + _previewGfx?.clear(); + }, +}; diff --git a/sotc-clash-arrow/scripts/DieInteraction.js b/sotc-clash-arrow/scripts/DieInteraction.js new file mode 100644 index 0000000..c02a6e4 --- /dev/null +++ b/sotc-clash-arrow/scripts/DieInteraction.js @@ -0,0 +1,135 @@ +/** + * sotc-clash-arrow | DieInteraction.js + * + * Gère la logique de drag entre dés de vitesse. + * Reçoit les événements depuis SIOBridge (mousedown/move/up natifs sur #board). + * + * Coordonnées : + * - Les hooks sotcca:* reçoivent des coords ÉCRAN (relatives à #board) + * - ClashArrowLayer.drawPreview() et ArrowStore travaillent en coords MONDE + * - getSIODieCenter() retourne des coords monde + * - _screenToWorld() convertit écran → monde + */ + +import { ArrowStore } from "./ArrowStore.js"; +import { getSIODieCenter, findDieUnderScreenPoint, + tokenHasActiveSkillOnDie, getAttackWeightForDie, + getDieState } from "./SIOBridge.js"; +import { ClashArrowLayer } from "./ClashArrowLayer.js"; + +// ── Couleurs ────────────────────────────────────────────────────────────────── +const COLOR_BLUE = 0x4a9eff; // attaque directe +const COLOR_YELLOW = 0xffd84a; // clash mutuel potentiel +const COLOR_PREVIEW = 0x888899; // drag en cours, cible indéfinie + +// ── État du drag ────────────────────────────────────────────────────────────── +let _dragging = false; +let _srcToken = null; // TokenDocument source +let _srcDieIdx = null; // index du dé source +let _srcWorldX = 0; // coords monde du centre du dé source +let _srcWorldY = 0; + +// ── Init ────────────────────────────────────────────────────────────────────── + +export function initDieInteraction() { + Hooks.on("sotcca:diePointerDown", _onDieDown); + Hooks.on("sotcca:boardMouseMove", _onMove); + Hooks.on("sotcca:boardMouseUp", _onUp); + Hooks.on("sotcca:dieRightClick", _onRightClick); +} + +// ── Handlers ────────────────────────────────────────────────────────────────── + +function _onDieDown(tokenDoc, dieIndex, screenX, screenY) { + // Bloquer si le dé source n'a pas de compétence assignée + if (!tokenHasActiveSkillOnDie(tokenDoc, dieIndex)) { + ui.notifications.warn(game.i18n.localize("SOTCCA.NoSkillOnDie")); + return; + } + + _dragging = true; + _srcToken = tokenDoc; + _srcDieIdx = dieIndex; + + const world = getSIODieCenter(tokenDoc, dieIndex); + _srcWorldX = world.x; + _srcWorldY = world.y; +} + +function _onMove(screenX, screenY) { + if (!_dragging) return; + + const worldPos = _screenToWorld(screenX, screenY); + const hit = findDieUnderScreenPoint(screenX, screenY); + const color = _previewColor(hit?.tokenDoc, hit?.dieIndex); + + ClashArrowLayer.drawPreview(_srcWorldX, _srcWorldY, worldPos.x, worldPos.y, color); +} + +function _onUp(screenX, screenY) { + if (!_dragging) return; + _dragging = false; + ClashArrowLayer.clearPreview(); + + const hit = findDieUnderScreenPoint(screenX, screenY); + if (!hit) return; + + const { tokenDoc: tgtToken, dieIndex: tgtDie } = hit; + + if (tgtToken.id === _srcToken.id) { + ui.notifications.warn(game.i18n.localize("SOTCCA.CannotTargetSelf")); + return; + } + + // Vérifier la limite d'attack weight + // En état Saved Defense Dice (2) : toujours limité à 1 flèche + const isSavedDefense = getDieState(_srcToken, _srcDieIdx) === 2; + const maxArrows = isSavedDefense ? 1 : getAttackWeightForDie(_srcToken, _srcDieIdx); + const existingArrows = ArrowStore.all().filter( + a => a.fromToken === _srcToken.id && a.fromDie === _srcDieIdx + ); + if (existingArrows.length >= maxArrows) { + ui.notifications.warn( + game.i18n.format("SOTCCA.AttackWeightExceeded", { max: maxArrows }) + ); + return; + } + + const src = getSIODieCenter(_srcToken, _srcDieIdx); + const tgt = getSIODieCenter(tgtToken, tgtDie); + + ArrowStore.addArrow({ + fromToken: _srcToken.id, + fromDie : _srcDieIdx, + toToken : tgtToken.id, + toDie : tgtDie, + x1: src.x, y1: src.y, + x2: tgt.x, y2: tgt.y, + }); +} + +function _onRightClick(_tokenDoc, _dieIndex) { + // Suppression par clic droit désactivée. + // Les flèches sont supprimées uniquement via le changement de dieState (Used/Unused). +} + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +/** + * Détermine la couleur de la flèche preview en fonction de la cible survolée. + */ +function _previewColor(targetTokenDoc, targetDieIdx) { + if (!targetTokenDoc || targetTokenDoc.id === _srcToken?.id) return COLOR_PREVIEW; + if (tokenHasActiveSkillOnDie(targetTokenDoc, targetDieIdx)) return COLOR_YELLOW; + return COLOR_BLUE; +} + +/** + * Convertit des coordonnées écran (#board) en coordonnées monde (canvas). + * @param {number} sx + * @param {number} sy + * @returns {{ x: number, y: number }} + */ +function _screenToWorld(sx, sy) { + return canvas.stage.toLocal({ x: sx, y: sy }); +} diff --git a/sotc-clash-arrow/scripts/Hooks.js b/sotc-clash-arrow/scripts/Hooks.js new file mode 100644 index 0000000..b1b7705 --- /dev/null +++ b/sotc-clash-arrow/scripts/Hooks.js @@ -0,0 +1,231 @@ +/** + * sotc-clash-arrow | Hooks.js + * + * Enregistrement de tous les hooks FoundryVTT : + * - canvasReady : initialisation du layer + interactions + * - canvasTearDown : nettoyage + * - updateCombat : reset des flèches au début d'un nouveau round + * - deleteCombat : reset à la fin du combat + * - renderSceneControls (optionnel) : bouton reset pour le GM + */ + +import { ClashArrowLayer } from "./ClashArrowLayer.js"; +import { ArrowStore } from "./ArrowStore.js"; +import { initDieInteraction } from "./DieInteraction.js"; +import { hookSIOMarkers, getSIODieCenter } from "./SIOBridge.js"; + +const SBSA_MODULE_ID = "sotc-battle-show-ability"; + +// ── Setting : mode debug (GM uniquement) ───────────────────────────────────── + +export function registerSettings() { + game.settings.register("sotc-clash-arrow", "debugForceState", { + name: game.i18n.localize("SOTCCA.Settings.DebugForceState.Name"), + hint: game.i18n.localize("SOTCCA.Settings.DebugForceState.Hint"), + scope: "world", + config: true, + type: Boolean, + default: false, + restricted: true, + requiresReload: false, + }); +} + +export function registerHooks() { + + // ── Initialisation globale (une seule fois) ──────────────────────────── + + Hooks.once("ready", () => { + ArrowStore.initSocket(); + initDieInteraction(); + hookSIOMarkers(); + }); + + // ── Canvas teardown ──────────────────────────────────────────────────── + + let _currentSceneId = null; + + Hooks.on("canvasReady", () => { + const newSceneId = canvas.scene?.id; + if (_currentSceneId && _currentSceneId !== newSceneId) { + // Vrai changement de scène → vider le store + ArrowStore.clear(); + } + _currentSceneId = newSceneId; + ClashArrowLayer.init(); + + // Restaurer les flèches persistées (survit au F5) + // Léger délai pour laisser SIO finir d'initialiser ses marqueurs PIXI + setTimeout(() => { + if (game.user.isGM) { + // Le GM recharge depuis les flags de scène (source de vérité) + ArrowStore.loadFromScene(getSIODieCenter); + } else { + // Les joueurs demandent au GM de leur envoyer l'état courant + ArrowStore.requestSync(); + } + }, 200); + }); + + Hooks.on("canvasTearDown", () => { + // Ne détruire les Graphics que si la scène change (géré dans canvasReady) + // On ne touche pas au store ici pour préserver les flèches lors des refreshs + ClashArrowLayer.tearDown(); + }); + + // ── Reset au nouveau round ───────────────────────────────────────────── + + // Aligné sur SBSA : combatRound, GM uniquement + Hooks.on("combatRound", (combat, _roundData, _options) => { + if (!game.user.isGM) return; + ArrowStore.clear(); + }); + + Hooks.on("deleteCombat", () => { + ArrowStore.clear(); + }); + + // ── Suppression des flèches quand un dé passe en Used/Unused ───────────── + + Hooks.on("updateCombatant", (combatant, changes) => { + // On ne réagit que si dieState a changé + const newState = foundry.utils.getProperty(changes, `flags.${SBSA_MODULE_ID}.dieState`); + if (newState === undefined) return; + + // On ne réagit pas si le dé est dans l'état Applied Skill (3) + if (newState === 3) return; + + // Retrouver le token et l'index du dé via le même tri que _getCombatantForDie + const tokenId = combatant.tokenId; + const tokenDoc = canvas.scene?.tokens?.get(tokenId); + if (!tokenDoc) return; + + const sortedCombatants = game.combat.combatants + .filter(c => c.tokenId === tokenId) + .sort((a, b) => (b.initiative ?? -Infinity) - (a.initiative ?? -Infinity)); + + const dieIndex = sortedCombatants.indexOf(combatant); + if (dieIndex === -1) return; + + // dieState 1 (Used) → marquer les flèches sortantes en marron + // dieState 0 (Unused) → supprimer les flèches sortantes + // dieState 2 (Saved Defense) → supprimer les flèches sortantes (on pourra en retracer 1) + const isUsed = newState === 1; + + for (const arrow of ArrowStore.all()) { + if (arrow.fromToken === tokenId && arrow.fromDie === dieIndex) { + if (isUsed) { + ArrowStore.markUsed(arrow.id); + } else { + ArrowStore.removeArrow(arrow.id); + } + } + } + }); + + // ── Bouton de reset manuel (GM uniquement) ───────────────────────────── + + Hooks.on("getSceneControlButtons", (controls) => { + if (!game.user.isGM) return; + + // Ajouter au groupe "token" (première barre) + const tokenControls = controls.find(c => c.name === "token"); + if (!tokenControls) return; + + tokenControls.tools.push({ + name : "sotcca-reset", + title : game.i18n.localize("SOTCCA.ArrowsReset"), + icon : "fas fa-times-circle", + button : true, + onClick: () => { + ArrowStore.clear(); + ui.notifications.info(game.i18n.localize("SOTCCA.ArrowsReset")); + }, + }); + }); + + // ── Bouton debug : forcer l'état du dé (visible seulement si l'option est activée) ── + + Hooks.on("renderApplication", (app, html) => { + // Vérifier que l'option debug est activée + if (!game.settings.get("sotc-clash-arrow", "debugForceState")) return; + + // On cible uniquement le panneau SBSA + if (!html[0]?.querySelector(".sotc-bsa-panel")) return; + + const panel = html[0].querySelector(".sotc-bsa-panel"); + const combatantId = panel?.dataset?.combatantId; + if (!combatantId) return; + + const combatant = game.combat?.combatants.get(combatantId); + if (!combatant) return; + + // Afficher sur tous les états (pas seulement quand assigné) + // → le bloc debug permet de forcer l'état peu importe la situation + + // Injecter le bloc debug après la section d'état + const stateSection = html[0].querySelector(".bsa-state-section"); + if (!stateSection) return; + + const debugHtml = ` +
+
+ 🛠 ${game.i18n.localize("SOTCCA.Debug.Title")} +
+
+ + + + +
+
+ `; + + stateSection.insertAdjacentHTML("afterend", debugHtml); + + // Brancher les listeners sur les nouveaux boutons + html[0].querySelectorAll(".sotcca-debug-state-btn").forEach(btn => { + btn.addEventListener("click", async (e) => { + e.preventDefault(); + e.stopPropagation(); + const state = parseInt(btn.dataset.state); + await combatant.setFlag(SBSA_MODULE_ID, "dieState", state); + ui.notifications.info( + game.i18n.format("SOTCCA.Debug.StateForced", { state }) + ); + }); + }); + }); + + ArrowStore.on?.("change", () => { + // Détecter les nouvelles flèches qui viennent de passer en mutuel + // (géré dans ArrowStore.addArrow → on écoute ici pour le message chat) + }); + + // Hook interne émis par ArrowStore pour les nouveaux clashes mutuels + Hooks.on("sotcca:mutualClash", (arrow) => { + const attacker = canvas.scene.tokens.get(arrow.fromToken)?.name ?? arrow.fromToken; + const defender = canvas.scene.tokens.get(arrow.toToken)?.name ?? arrow.toToken; + + ChatMessage.create({ + content: `
+ ⚔️ ${game.i18n.format("SOTCCA.MutualClash", { attacker, defender })} +
`, + type: CONST.CHAT_MESSAGE_TYPES.OTHER, + }); + }); +} diff --git a/sotc-clash-arrow/scripts/SIOBridge.js b/sotc-clash-arrow/scripts/SIOBridge.js new file mode 100644 index 0000000..5d4d4d0 --- /dev/null +++ b/sotc-clash-arrow/scripts/SIOBridge.js @@ -0,0 +1,228 @@ +/** + * sotc-clash-arrow | SIOBridge.js + * + * Pont d'intégration avec SIO et SBSA. + * + * LEÇON CLUE (apprise du module SBSA) : + * L'API PIXI events ne fonctionne pas correctement pour ajouter des + * listeners supplémentaires sur les marqueurs SIO en Foundry v11. + * La solution fiable est d'écouter les events souris NATIFS sur + * l'élément HTML #board, exactement comme le fait SBSA. + * + * Correspondance index → combatant (identique à SIO) : + * Les combatants d'un token sont triés par initiative décroissante. + * marker[i] correspond à combatants[i] dans ce tri. + * + * Compétence assignée (flags SBSA réels) : + * combatant.getFlag("sotc-battle-show-ability", "assignedSkillId") + * combatant.getFlag("sotc-battle-show-ability", "dieState") → 0/1/2/3 + * dieState === 3 signifie "compétence assignée" + */ + +const SIO_FLAG_SCOPE = "stars-initiative-overlay"; +const SBSA_MODULE_ID = "sotc-battle-show-ability"; +const HIT_RADIUS = 28; // pixels écran (getBounds retourne des coords écran sur #board) + +// ── API publique ────────────────────────────────────────────────────────────── + +/** + * Installe le handler mousedown natif sur #board. + * Appelé une fois depuis Hooks.js au canvasReady. + */ +export function hookSIOMarkers() { + _installBoardHandler(); + // Réinstaller si la scène change + Hooks.on("canvasReady", () => _installBoardHandler()); +} + +/** + * Retourne les coordonnées MONDE (canvas) du centre d'un marqueur SIO. + * Utilisé pour dessiner les flèches dans ClashArrowLayer. + * @param {TokenDocument} tokenDoc + * @param {number} dieIndex + * @returns {{ x: number, y: number }} + */ +export function getSIODieCenter(tokenDoc, dieIndex) { + const token = tokenDoc.object; + const marker = token?._initiativeMarkers?.[dieIndex]; + if (marker) { + const b = marker.getBounds(); + // getBounds() retourne des coordonnées écran (#board) → convertir en coords monde + const screenCenter = { x: b.x + b.width / 2, y: b.y + b.height / 2 }; + return canvas.stage.toLocal(screenCenter); + } + // Fallback : centre du token en coords monde + return { x: token.center.x, y: token.center.y }; +} + +/** + * Cherche quel dé SIO se trouve sous les coordonnées écran (x, y). + * Retourne { tokenDoc, dieIndex, combatant } ou null. + * @param {number} screenX – coordonnées relatives à #board + * @param {number} screenY + * @returns {{ tokenDoc: TokenDocument, dieIndex: number, combatant: Combatant }|null} + */ +export function findDieUnderScreenPoint(screenX, screenY) { + for (const token of canvas.tokens.placeables) { + const markers = token._initiativeMarkers; + if (!markers?.length) continue; + + for (let i = 0; i < markers.length; i++) { + const b = markers[i].getBounds(); + const cx = b.x + b.width / 2; + const cy = b.y + b.height / 2; + + if (Math.hypot(screenX - cx, screenY - cy) > HIT_RADIUS) continue; + + const combatant = _getCombatantForDie(token.document, i); + if (!combatant) continue; + + return { tokenDoc: token.document, dieIndex: i, combatant }; + } + } + return null; +} + +/** + * Retourne le dieState SBSA du dé `dieIndex` d'un token. + * @param {TokenDocument} tokenDoc + * @param {number} dieIndex + * @returns {number} 0=Unused, 1=Used, 2=Saved Defense, 3=Applied Skill + */ +export function getDieState(tokenDoc, dieIndex) { + const combatant = _getCombatantForDie(tokenDoc, dieIndex); + if (!combatant) return 0; + return combatant.getFlag(SBSA_MODULE_ID, "dieState") ?? 0; +} + +/** + * Retourne l'attack_weight de la compétence assignée au dé `dieIndex`. + * - Si aucune compétence ou pas d'attack_weight → retourne 1 (1 flèche max) + * - Sinon retourne la valeur numérique de attack_weight + * @param {TokenDocument} tokenDoc + * @param {number} dieIndex + * @returns {number} + */ +export function getAttackWeightForDie(tokenDoc, dieIndex) { + const combatant = _getCombatantForDie(tokenDoc, dieIndex); + if (!combatant) return 1; + + const skillId = combatant.getFlag(SBSA_MODULE_ID, "assignedSkillId") ?? null; + if (!skillId) return 1; + + const actor = tokenDoc.actor; + if (!actor) return 1; + + const skill = actor.items.get(skillId); + if (!skill) return 1; + + const weight = skill.system?.weight ?? null; + if (!weight || weight <= 0) return 1; + + return weight; +} + +/** + * Retourne true si le dé `dieIndex` d'un token a une compétence assignée. + * États autorisés à créer une flèche : + * - dieState === 3 (Applied Skill) + * - dieState === 2 (Saved Defense Dice) — le dé sauvegardé réutilise sa compétence + * @param {TokenDocument} tokenDoc + * @param {number} dieIndex + * @returns {boolean} + */ +export function tokenHasActiveSkillOnDie(tokenDoc, dieIndex) { + const combatant = _getCombatantForDie(tokenDoc, dieIndex); + if (!combatant) return false; + const dieState = combatant.getFlag(SBSA_MODULE_ID, "dieState") ?? 0; + const assignedId = combatant.getFlag(SBSA_MODULE_ID, "assignedSkillId") ?? null; + return (dieState === 3 || dieState === 2) && !!assignedId; +} + +// ── Helpers internes ────────────────────────────────────────────────────────── + +/** + * Retourne le combatant correspondant à marker[dieIndex] selon le tri SIO. + * Identique au getter sioMarkerIndex de SpeedDiePanelApp (nouvelle version SBSA) : + * tri par initiative décroissante, marker[0] = initiative la plus haute. + * @param {TokenDocument} tokenDoc + * @param {number} dieIndex + * @returns {Combatant|null} + */ +function _getCombatantForDie(tokenDoc, dieIndex) { + if (!game.combat) return null; + const combatants = game.combat.combatants + .filter(c => c.tokenId === tokenDoc.id) + .sort((a, b) => (b.initiative ?? -Infinity) - (a.initiative ?? -Infinity)); + return combatants[dieIndex] ?? null; +} + +/** + * Installe (ou réinstalle) les listeners natifs sur #board. + */ +function _installBoardHandler() { + const board = document.getElementById("board"); + if (!board) return; + + // Nettoyer les anciens handlers + if (board._sotcca_downHandler) { + board.removeEventListener("mousedown", board._sotcca_downHandler); + board.removeEventListener("mouseup", board._sotcca_upHandler); + board.removeEventListener("mousemove", board._sotcca_moveHandler); + } + + board._sotcca_downHandler = _onBoardMouseDown; + board._sotcca_upHandler = _onBoardMouseUp; + board._sotcca_moveHandler = _onBoardMouseMove; + + board.addEventListener("mousedown", board._sotcca_downHandler); + board.addEventListener("mouseup", board._sotcca_upHandler); + board.addEventListener("mousemove", board._sotcca_moveHandler, { passive: true }); + + // Bloquer le menu contextuel navigateur sur les dés + if (!board._sotcca_ctxHandler) { + board._sotcca_ctxHandler = (e) => { + const rect = board.getBoundingClientRect(); + const hit = findDieUnderScreenPoint(e.clientX - rect.left, e.clientY - rect.top); + if (hit) e.preventDefault(); + }; + board.addEventListener("contextmenu", board._sotcca_ctxHandler); + } + + console.log("sotc-clash-arrow | Handler mousedown installé sur #board."); +} + +// ── Handlers DOM → délèguent via Hooks à DieInteraction ────────────────────── + +function _onBoardMouseDown(event) { + const board = document.getElementById("board"); + const rect = board.getBoundingClientRect(); + const sx = event.clientX - rect.left; + const sy = event.clientY - rect.top; + + const hit = findDieUnderScreenPoint(sx, sy); + if (!hit) return; + + if (event.button === 0) { + event.preventDefault(); + event.stopPropagation(); + Hooks.callAll("sotcca:diePointerDown", hit.tokenDoc, hit.dieIndex, sx, sy); + } else if (event.button === 2) { + event.preventDefault(); + event.stopPropagation(); + Hooks.callAll("sotcca:dieRightClick", hit.tokenDoc, hit.dieIndex); + } +} + +function _onBoardMouseMove(event) { + const board = document.getElementById("board"); + const rect = board.getBoundingClientRect(); + Hooks.callAll("sotcca:boardMouseMove", event.clientX - rect.left, event.clientY - rect.top); +} + +function _onBoardMouseUp(event) { + if (event.button !== 0) return; + const board = document.getElementById("board"); + const rect = board.getBoundingClientRect(); + Hooks.callAll("sotcca:boardMouseUp", event.clientX - rect.left, event.clientY - rect.top); +} diff --git a/sotc-clash-arrow/scripts/main.js b/sotc-clash-arrow/scripts/main.js new file mode 100644 index 0000000..a0c06c8 --- /dev/null +++ b/sotc-clash-arrow/scripts/main.js @@ -0,0 +1,35 @@ +/** + * sotc-clash-arrow | main.js + * + * Point d'entrée du module. + * Importe et initialise tous les sous-systèmes. + */ + +import { registerHooks, registerSettings } from "./Hooks.js"; +import { ArrowStore } from "./ArrowStore.js"; +import { ClashArrowLayer } from "./ClashArrowLayer.js"; + +// ── Exposition globale ──────────────────────────────────────────────────────── +globalThis.sotcClashArrow = { + ArrowStore, + ClashArrowLayer, // singleton objet, pas une classe +}; + +// ── Démarrage ───────────────────────────────────────────────────────────────── +Hooks.once("init", () => { + console.log("sotc-clash-arrow | Initialisation du module."); + registerSettings(); + registerHooks(); +}); + +Hooks.once("ready", () => { + // Vérification des dépendances + if (!game.modules.get("stars-initiative-overlay")?.active) { + ui.notifications.error( + "sotc-clash-arrow : Le module 'Stars Initiative Overlay' (SIO) est requis et doit être activé.", + { permanent: true } + ); + } + + console.log("sotc-clash-arrow | Module prêt."); +}); diff --git a/sotc-clash-arrow/sotc-clash-arrow-spec.md b/sotc-clash-arrow/sotc-clash-arrow-spec.md new file mode 100644 index 0000000..bb9bfce --- /dev/null +++ b/sotc-clash-arrow/sotc-clash-arrow-spec.md @@ -0,0 +1,160 @@ +# sotc-clash-arrow — Spécification Technique v1.0 + +## Vue d'ensemble + +Module FoundryVTT v11 permettant de tracer des flèches courbées entre les **Dés de Vitesse** du module *Stars Initiative Overlay* (SIO) de Vani, pour visualiser les clashes dans *Star of the City* (SotC). + +S'appuie sur le module **SBSA (clash-resolver)** pour lire les compétences assignées aux dés. + +--- + +## Dépendances + +| Module | Rôle | +|---|---| +| `stars-initiative-overlay` | Fournit les marqueurs PIXI des Dés de Vitesse | +| `clash-resolver` (SBSA) | Fournit les compétences stockées par dé via flags | + +--- + +## Logique des couleurs + +| Couleur | Condition | +|---|---| +| **Bleu** `#4a9eff` | A cible B, mais B n'a **pas** de compétence active sur son dé | +| **Jaune** `#ffd84a` | A→B **ET** B→A existent simultanément (clash mutuel automatique) | +| **Gris** `#888899` | Flèche de preview pendant le drag (avant relâchement) | + +La détection du clash mutuel est **automatique** : dès que la flèche A→B est créée alors que B→A existe déjà, les **deux** flèches passent en jaune. + +--- + +## Interactions utilisateur + +### Tracer une flèche +1. Maintenir **clic gauche** sur un Dé de Vitesse SIO +2. Déplacer la souris vers un autre dé — la flèche preview change de couleur en temps réel selon la cible survolée +3. Relâcher sur le dé cible → la flèche devient persistante + +**Qui peut tracer ?** Tous les utilisateurs (GM et joueurs), depuis n'importe quel token. + +### Supprimer une flèche +- **Clic droit** sur un Dé de Vitesse → supprime toutes les flèches depuis/vers ce dé + +### Reset global +- Automatique au début d'un nouveau round (hook `updateCombat` + hook SIO `sioRoundReset`) +- Automatique à la fin du combat (`deleteCombat`) +- Manuel via le bouton **GM** dans la barre de contrôles de scène (icône `×`) + +--- + +## Architecture des fichiers + +``` +sotc-clash-arrow/ +├── module.json +├── styles/ +│ └── sotc-clash-arrow.css +├── lang/ +│ ├── fr.json +│ └── en.json +└── scripts/ + ├── main.js ← point d'entrée + ├── Hooks.js ← enregistrement des hooks FoundryVTT + ├── ArrowStore.js ← store centralisé + sync socket + ├── ClashArrowLayer.js ← couche PIXI (dessin des flèches) + ├── DieInteraction.js ← gestion du drag souris + └── SIOBridge.js ← intégration SIO + détection compétences SBSA +``` + +--- + +## Description des modules JS + +### `main.js` +- Point d'entrée unique chargé par `module.json` +- Expose `globalThis.sotcClashArrow` pour débogage console +- Vérifie la présence du module SIO au `ready` + +### `ArrowStore.js` +- Store en mémoire (`Map`) +- Détecte automatiquement le clash mutuel à chaque `addArrow()` +- Synchronise via `game.socket` (événement `module.sotc-clash-arrow`) pour que tous les clients voient les mêmes flèches +- Émet un événement `change` → `ClashArrowLayer` se redessine + +**Structure d'une flèche :** +```js +{ + id : "tokenA:0→tokenB:1", + fromToken : "tokenA_id", + fromDie : 0, + toToken : "tokenB_id", + toDie : 1, + x1, y1 : number, // coords canvas du centre du dé source + x2, y2 : number, // coords canvas du centre du dé cible + mutual : boolean, // true = flèche jaune +} +``` + +### `ClashArrowLayer.js` +- Hérite de `CanvasLayer` (FoundryVTT v11) +- `zIndex: 180` → au-dessus des tokens (100), sous l'UI (900+) +- Deux `PIXI.Graphics` séparés : flèches persistantes + preview +- Courbe de Bézier cubique avec pointe de flèche trigonométrique +- `BEZIER_CURVE = 0.35` : facteur de courbure ajustable + +### `DieInteraction.js` +- Écoute `pointerdown` (via hook interne `sotcca:diePointerDown`) → démarre le drag +- `pointermove` → met à jour la preview avec la couleur calculée en temps réel +- `pointerup` → crée la flèche dans `ArrowStore` si une cible valide est sous le curseur + +### `SIOBridge.js` +- `getSIODieCenter()` : lit `token._initiativeMarkers[i].getBounds()` pour les coords canvas +- `findDieUnderPoint()` : parcourt tous les tokens/marqueurs, hit-test à ±24px +- `tokenHasActiveSkillOnDie()` : combine état SIO (`diceStates[i] !== 0`) ET présence d'une compétence SBSA (`flag "clash-resolver" → "dieSkills"[i]`) +- `hookSIOMarkers()` : attache les listeners sur les marqueurs PIXI existants + futurs (via hook `refreshToken`) + +### `Hooks.js` +- `init` : enregistrement du layer dans `CONFIG.Canvas.layers` +- `canvasReady` : init socket + interactions + hook SIO markers +- `canvasTearDown` : `ArrowStore.clear()` +- `updateCombat` (nouveau round) + `sioRoundReset` + `deleteCombat` → reset +- `getSceneControlButtons` : bouton reset GM +- `sotcca:mutualClash` : message chat signalant un clash mutuel + +--- + +## Chemins de données supposés (à vérifier) + +> ⚠️ Ces chemins sont des hypothèses basées sur l'analyse du module SIO et SBSA. +> À valider lors des tests avec les modules installés. + +| Donnée | Chemin supposé | +|---|---| +| Marqueurs PIXI SIO | `token._initiativeMarkers[]` | +| États des dés SIO | `tokenDoc.getFlag("stars-initiative-overlay", "diceStates")` → `int[]` | +| Nombre de dés | `actor.system.speed_dice.num_dice` | +| Compétences par dé (SBSA) | `tokenDoc.getFlag("clash-resolver", "dieSkills")` → `string[]` | +| Hook reset SIO | `"sioRoundReset"` (à confirmer dans la source SIO) | + +--- + +## Synchronisation multi-clients + +Toutes les modifications du store sont broadcastées via `game.socket.emit("module.sotc-clash-arrow", payload)`. + +Les autres clients reçoivent le payload et appliquent la même modification localement sans re-broadcaster, pour éviter les boucles. + +**Actions socket :** +- `add` : nouvelle flèche (+ mise à jour éventuelle de la flèche inverse) +- `remove` : suppression d'une flèche (+ dés-mutualisation de l'inverse) +- `clear` : reset total + +--- + +## Notes d'implémentation + +- Le layer PIXI est enregistré dans `CONFIG.Canvas.layers` au hook `init`, avant la construction du canvas +- Les listeners sur les marqueurs SIO sont attachés avec un délai de 50ms après `refreshToken` pour laisser SIO finir de recréer ses containers PIXI +- Un marqueur déjà hookié est marqué `marker._sotcca_hooked = true` pour éviter les doublons +- La flèche de preview utilise `alpha = 0.55` pour la distinguer des flèches persistantes (`alpha = 0.92`) diff --git a/sotc-clash-arrow/styles/sotc-clash-arrow.css b/sotc-clash-arrow/styles/sotc-clash-arrow.css new file mode 100644 index 0000000..cbc8d16 --- /dev/null +++ b/sotc-clash-arrow/styles/sotc-clash-arrow.css @@ -0,0 +1,59 @@ +/* ============================================================ + SotC Clash Arrow — Styles + Thème : sombre / industriel, cohérent avec clash-resolver + ============================================================ */ + +/* --- Tooltip de survol sur flèche --- */ +.sotcca-arrow-tooltip { + position: fixed; + pointer-events: none; + background: rgba(10, 10, 14, 0.92); + border: 1px solid rgba(255, 255, 255, 0.12); + border-radius: 4px; + padding: 4px 10px; + font-family: 'Barlow Condensed', sans-serif; + font-size: 12px; + letter-spacing: 0.06em; + color: #d0d0d8; + z-index: 9999; + white-space: nowrap; + box-shadow: 0 2px 8px rgba(0,0,0,0.6); + transition: opacity 0.1s ease; +} + +.sotcca-arrow-tooltip.blue { border-color: #4a9eff; color: #4a9eff; } +.sotcca-arrow-tooltip.yellow { border-color: #ffd84a; color: #ffd84a; } + +/* --- Notification de clash mutuel dans le chat --- */ +.sotcca-mutual-clash-msg { + background: rgba(255, 216, 74, 0.08); + border-left: 3px solid #ffd84a; + padding: 6px 10px; + font-family: 'Barlow Condensed', sans-serif; + font-size: 13px; + color: #ffd84a; + letter-spacing: 0.04em; +} + +/* --- Contrôles GM (bouton reset) dans la sidebar --- */ +#sotcca-reset-btn { + display: inline-flex; + align-items: center; + gap: 6px; + background: rgba(255,255,255,0.05); + border: 1px solid rgba(255,255,255,0.15); + border-radius: 3px; + color: #b0b0b8; + font-family: 'Barlow Condensed', sans-serif; + font-size: 12px; + letter-spacing: 0.06em; + padding: 4px 10px; + cursor: pointer; + transition: background 0.15s, color 0.15s, border-color 0.15s; +} + +#sotcca-reset-btn:hover { + background: rgba(255, 80, 80, 0.15); + border-color: rgba(255, 80, 80, 0.5); + color: #ff7070; +}