sotc-battle-show-ability : affichage des capacités en combat
This commit is contained in:
parent
84aa6b3437
commit
e35a3996ee
11 changed files with 1363 additions and 0 deletions
22
sotc-clash-arrow/lang/en.json
Normal file
22
sotc-clash-arrow/lang/en.json
Normal file
|
|
@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
22
sotc-clash-arrow/lang/fr.json
Normal file
22
sotc-clash-arrow/lang/fr.json
Normal file
|
|
@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
52
sotc-clash-arrow/module.json
Normal file
52
sotc-clash-arrow/module.json
Normal file
|
|
@ -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
|
||||
}
|
||||
269
sotc-clash-arrow/scripts/ArrowStore.js
Normal file
269
sotc-clash-arrow/scripts/ArrowStore.js
Normal 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();
|
||||
150
sotc-clash-arrow/scripts/ClashArrowLayer.js
Normal file
150
sotc-clash-arrow/scripts/ClashArrowLayer.js
Normal 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();
|
||||
},
|
||||
};
|
||||
135
sotc-clash-arrow/scripts/DieInteraction.js
Normal file
135
sotc-clash-arrow/scripts/DieInteraction.js
Normal 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 });
|
||||
}
|
||||
231
sotc-clash-arrow/scripts/Hooks.js
Normal file
231
sotc-clash-arrow/scripts/Hooks.js
Normal 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,
|
||||
});
|
||||
});
|
||||
}
|
||||
228
sotc-clash-arrow/scripts/SIOBridge.js
Normal file
228
sotc-clash-arrow/scripts/SIOBridge.js
Normal 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);
|
||||
}
|
||||
35
sotc-clash-arrow/scripts/main.js
Normal file
35
sotc-clash-arrow/scripts/main.js
Normal 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.");
|
||||
});
|
||||
160
sotc-clash-arrow/sotc-clash-arrow-spec.md
Normal file
160
sotc-clash-arrow/sotc-clash-arrow-spec.md
Normal file
|
|
@ -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<id, Arrow>`)
|
||||
- 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`)
|
||||
59
sotc-clash-arrow/styles/sotc-clash-arrow.css
Normal file
59
sotc-clash-arrow/styles/sotc-clash-arrow.css
Normal file
|
|
@ -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;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue