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