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

View file

@ -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();
},
};

View file

@ -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 });
}

View file

@ -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 = `
<div class="sotcca-debug-section" style="
margin-top: 6px;
padding: 4px 6px;
border: 1px dashed #888;
border-radius: 4px;
opacity: 0.75;
">
<div style="font-size: 10px; color: #aaa; margin-bottom: 4px;">
🛠 ${game.i18n.localize("SOTCCA.Debug.Title")}
</div>
<div style="display: flex; gap: 4px; flex-wrap: wrap;">
<button class="sotcca-debug-state-btn" data-state="0" style="flex:1; font-size:11px; padding:2px 4px;">
${game.i18n.localize("SOTC_BSA.Panel.StateUnused")}
</button>
<button class="sotcca-debug-state-btn" data-state="1" style="flex:1; font-size:11px; padding:2px 4px;">
${game.i18n.localize("SOTC_BSA.Panel.StateUsed")}
</button>
<button class="sotcca-debug-state-btn" data-state="2" style="flex:1; font-size:11px; padding:2px 4px;">
${game.i18n.localize("SOTC_BSA.Panel.StateSaved")}
</button>
<button class="sotcca-debug-state-btn" data-state="3" style="flex:1; font-size:11px; padding:2px 4px;">
${game.i18n.localize("SOTC_BSA.Panel.StateAssigned")}
</button>
</div>
</div>
`;
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: `<div class="sotcca-mutual-clash-msg">
⚔️ ${game.i18n.format("SOTCCA.MutualClash", { attacker, defender })}
</div>`,
type: CONST.CHAT_MESSAGE_TYPES.OTHER,
});
});
}

View file

@ -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);
}

View file

@ -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.");
});