sotc-battle-show-ability : affichage des capacités en combat

This commit is contained in:
Yasser Boulayoune 2026-05-01 16:26:00 +02:00
parent 84aa6b3437
commit e35a3996ee
11 changed files with 1363 additions and 0 deletions

View file

@ -0,0 +1,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."
}
}
}
}

View 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."
}
}
}
}

View 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
}

View file

@ -0,0 +1,269 @@
/**
* sotc-clash-arrow | ArrowStore.js
*
* Store centralisé et réactif des flèches de clash.
* Gère les données en mémoire + persistance via socket pour la synchronisation
* multi-clients + persistance via flags de scène (survit au F5).
*
* Structure d'une flèche :
* {
* id : string – identifiant unique "tokenId_dieIndex → tokenId_dieIndex"
* fromToken: string – id du token attaquant
* fromDie : number – index du dé source dans _initiativeMarkers[]
* toToken : string – id du token cible
* toDie : number – index du dé cible
* x1, y1 : number – coordonnées canvas du centre du dé source
* x2, y2 : number – coordonnées canvas du centre du dé cible
* mutual : boolean – true si clash mutuel (flèche jaune)
* used : boolean – true si le dé source est passé en Used (flèche marron)
* }
*/
const SOCKET_EVENT = "module.sotc-clash-arrow";
const SCENE_FLAG = "arrows";
// ── EventEmitter minimal ──────────────────────────────────────────────────────
class MiniEmitter {
constructor() { this._handlers = {}; }
on(evt, fn) { (this._handlers[evt] ??= []).push(fn); }
off(evt, fn) {
if (!fn) { delete this._handlers[evt]; return; }
this._handlers[evt] = (this._handlers[evt] ?? []).filter(h => h !== fn);
}
emit(evt, ...args) { (this._handlers[evt] ?? []).forEach(h => h(...args)); }
}
// ── ArrowStore ────────────────────────────────────────────────────────────────
class _ArrowStore extends MiniEmitter {
constructor() {
super();
/** @type {Map<string, Object>} */
this._arrows = new Map();
}
// ── API publique ────────────────────────────────────────────────────────────
/** Retourne toutes les flèches. */
all() { return Array.from(this._arrows.values()); }
/** Retourne une flèche par id. */
get(id) { return this._arrows.get(id); }
/**
* Marque une flèche comme "utilisée" (dé passé en Used).
* La flèche reste visible mais passe en marron semi-transparent.
*/
markUsed(id) {
const arrow = this._arrows.get(id);
if (!arrow) return;
arrow.used = true;
this._arrows.set(id, arrow);
this.emit("change");
this._saveToScene();
this._broadcast({ action: "markUsed", id });
}
/**
* Génère l'id canonique d'une flèche.
* L'id est directionnel : A→B ≠ B→A.
*/
static makeId(fromToken, fromDie, toToken, toDie) {
return `${fromToken}:${fromDie}→${toToken}:${toDie}`;
}
/** Id de la flèche inverse (pour détecter le clash mutuel). */
static reverseId(fromToken, fromDie, toToken, toDie) {
return `${toToken}:${toDie}→${fromToken}:${fromDie}`;
}
/**
* Ajoute ou met à jour une flèche.
* Détecte automatiquement le clash mutuel et met à jour les deux flèches.
* Broadcast socket aux autres clients.
*/
addArrow(data) {
const id = _ArrowStore.makeId(data.fromToken, data.fromDie, data.toToken, data.toDie);
const revId = _ArrowStore.reverseId(data.fromToken, data.fromDie, data.toToken, data.toDie);
const reverse = this._arrows.get(revId);
const arrow = { ...data, id, mutual: !!reverse };
this._arrows.set(id, arrow);
// Si une flèche inverse existe → clash mutuel des deux côtés
if (reverse) {
reverse.mutual = true;
this._arrows.set(revId, reverse);
}
this.emit("change");
this._saveToScene();
this._broadcast({ action: "add", arrow, reverseId: revId, reverseMutual: !!reverse });
return arrow;
}
/**
* Supprime une flèche.
* Si l'autre sens était en clash mutuel, on le repasse en bleu.
*/
removeArrow(id) {
const arrow = this._arrows.get(id);
if (!arrow) return;
this._arrows.delete(id);
// Dés-mutualiser la flèche inverse si elle existe encore
const revId = _ArrowStore.reverseId(arrow.fromToken, arrow.fromDie, arrow.toToken, arrow.toDie);
const reverse = this._arrows.get(revId);
if (reverse) {
reverse.mutual = false;
this._arrows.set(revId, reverse);
}
this.emit("change");
this._saveToScene();
this._broadcast({ action: "remove", id, reverseId: revId });
}
/** Supprime toutes les flèches. */
clear() {
this._arrows.clear();
this.emit("change");
this._saveToScene();
this._broadcast({ action: "clear" });
}
// ── Persistance via flags de scène ──────────────────────────────────────────
/**
* Sauvegarde l'état du store dans les flags de la scène courante.
* Seul le GM écrit les flags (évite les conflits de permission).
* Les coordonnées ne sont PAS sauvegardées — elles sont recalculées
* à la restauration depuis les positions actuelles des marqueurs SIO.
*/
_saveToScene() {
if (!game.user.isGM) return;
if (!canvas.scene) return;
const data = this.all().map(a => ({
id : a.id,
fromToken : a.fromToken,
fromDie : a.fromDie,
toToken : a.toToken,
toDie : a.toDie,
mutual : a.mutual,
used : a.used ?? false,
}));
canvas.scene.setFlag("sotc-clash-arrow", SCENE_FLAG, data);
}
/**
* Recharge les flèches depuis les flags de la scène courante.
* Appelé au canvasReady depuis Hooks.js.
* Les coordonnées sont recalculées depuis les marqueurs SIO actuels.
* @param {Function} getDieCenter – getSIODieCenter(tokenDoc, dieIndex)
*/
loadFromScene(getDieCenter) {
this._arrows.clear();
const data = canvas.scene?.getFlag("sotc-clash-arrow", SCENE_FLAG) ?? [];
if (!data.length) return;
for (const saved of data) {
const fromTokenDoc = canvas.scene.tokens.get(saved.fromToken);
const toTokenDoc = canvas.scene.tokens.get(saved.toToken);
// Si l'un des tokens n'existe plus, on abandonne cette flèche
if (!fromTokenDoc || !toTokenDoc) continue;
let x1, y1, x2, y2;
try {
({ x: x1, y: y1 } = getDieCenter(fromTokenDoc, saved.fromDie));
({ x: x2, y: y2 } = getDieCenter(toTokenDoc, saved.toDie));
} catch {
continue;
}
this._arrows.set(saved.id, { ...saved, x1, y1, x2, y2 });
}
this.emit("change");
}
// ── Synchronisation socket ──────────────────────────────────────────────────
/** Envoie une mise à jour à tous les autres clients. */
_broadcast(payload) {
game.socket.emit(SOCKET_EVENT, payload);
}
/**
* Demande au GM de broadcaster l'état actuel des flèches.
* Utilisé par les joueurs au canvasReady pour recevoir les flèches existantes.
*/
requestSync() {
game.socket.emit(SOCKET_EVENT, { action: "syncRequest" });
}
/**
* Traite les messages reçus depuis le socket (autres clients).
* On applique localement sans re-broadcaster.
*/
_applyRemote(payload) {
switch (payload.action) {
case "add": {
this._arrows.set(payload.arrow.id, payload.arrow);
if (payload.reverseMutual) {
const rev = this._arrows.get(payload.reverseId);
if (rev) { rev.mutual = true; this._arrows.set(payload.reverseId, rev); }
}
this.emit("change");
break;
}
case "remove": {
this._arrows.delete(payload.id);
const rev = this._arrows.get(payload.reverseId);
if (rev) { rev.mutual = false; this._arrows.set(payload.reverseId, rev); }
this.emit("change");
break;
}
case "markUsed": {
const arrow = this._arrows.get(payload.id);
if (arrow) { arrow.used = true; this._arrows.set(payload.id, arrow); }
this.emit("change");
break;
}
case "clear": {
this._arrows.clear();
this.emit("change");
break;
}
// Un joueur demande la resynchronisation → seul le GM répond avec l'état complet
case "syncRequest": {
if (!game.user.isGM) break;
game.socket.emit(SOCKET_EVENT, {
action: "syncResponse",
arrows: this.all(),
});
break;
}
// Le GM envoie l'état complet → les joueurs l'appliquent localement
case "syncResponse": {
if (game.user.isGM) break; // le GM n'a pas besoin de sa propre réponse
this._arrows.clear();
for (const arrow of payload.arrows) {
this._arrows.set(arrow.id, arrow);
}
this.emit("change");
break;
}
}
}
/** Initialise l'écoute socket. Appelé une seule fois depuis main.js. */
initSocket() {
game.socket.on(SOCKET_EVENT, payload => this._applyRemote(payload));
}
}
export const ArrowStore = new _ArrowStore();

View file

@ -0,0 +1,150 @@
/**
* sotc-clash-arrow | ClashArrowLayer.js
*
* En Foundry v11, les CanvasLayer custom ne se montent pas via CONFIG.Canvas.layers.
* Solution : attacher deux PIXI.Graphics directement sur canvas.interface
* (le groupe PIXI qui contient tokens, drawings, etc.).
*
* API publique (identique à l'ancienne classe) :
* ClashArrowLayer.init() – à appeler au canvasReady
* ClashArrowLayer.tearDown() – à appeler au canvasTearDown
* ClashArrowLayer.redraw() – redessine toutes les flèches du store
* ClashArrowLayer.drawPreview() – flèche temporaire pendant le drag
* ClashArrowLayer.clearPreview()
*/
import { ArrowStore } from "./ArrowStore.js";
// ── Constantes visuelles ──────────────────────────────────────────────────────
const COLOR_BLUE = 0x4a9eff;
const COLOR_YELLOW = 0xffd84a;
const COLOR_PREVIEW = 0x888899;
const COLOR_USED = 0x8B4513; // marron "Used"
const ALPHA_USED = 0.35; // légèrement visible
const LINE_WIDTH = 3;
const LINE_ALPHA = 0.92;
const ARROW_HEAD_SIZE = 14;
const BEZIER_CURVE = 0.35;
// ── Helpers géométriques ──────────────────────────────────────────────────────
function bezierControlPoints(x1, y1, x2, y2, curvature = BEZIER_CURVE) {
const mx = (x1 + x2) / 2;
const my = (y1 + y2) / 2;
const dx = x2 - x1;
const dy = y2 - y1;
const nx = -dy * curvature;
const ny = dx * curvature;
return { cp1x: mx + nx, cp1y: my + ny };
}
function bezierPoint(t, p0, p1, p2, p3) {
const mt = 1 - t;
return mt*mt*mt*p0 + 3*mt*mt*t*p1 + 3*mt*t*t*p2 + t*t*t*p3;
}
function drawArrowHead(gfx, tx, ty, dx, dy, size, color, alpha) {
const angle = Math.atan2(dy, dx);
const spread = Math.PI / 6;
const lx = tx - size * Math.cos(angle - spread);
const ly = ty - size * Math.sin(angle - spread);
const rx = tx - size * Math.cos(angle + spread);
const ry = ty - size * Math.sin(angle + spread);
gfx.lineStyle(0);
gfx.beginFill(color, alpha);
gfx.drawPolygon([tx, ty, lx, ly, rx, ry]);
gfx.endFill();
}
// ── Module singleton ──────────────────────────────────────────────────────────
/** @type {PIXI.Graphics|null} */
let _arrowsGfx = null;
/** @type {PIXI.Graphics|null} */
let _previewGfx = null;
function _drawArrow(gfx, x1, y1, x2, y2, color, alpha = LINE_ALPHA) {
const { cp1x, cp1y } = bezierControlPoints(x1, y1, x2, y2);
gfx.lineStyle(LINE_WIDTH, color, alpha);
gfx.moveTo(x1, y1);
gfx.bezierCurveTo(cp1x, cp1y, cp1x, cp1y, x2, y2);
const t = 0.98;
const px = bezierPoint(t, x1, cp1x, cp1x, x2);
const py = bezierPoint(t, y1, cp1y, cp1y, y2);
drawArrowHead(gfx, x2, y2, x2 - px, y2 - py, ARROW_HEAD_SIZE, color, alpha);
}
export const ClashArrowLayer = {
/**
* Initialise les Graphics PIXI et les attache au canvas.
* Appelé au canvasReady depuis Hooks.js.
*/
init() {
// Si déjà initialisé et Graphics toujours valides, juste redessiner
if (_arrowsGfx && !_arrowsGfx.destroyed) {
this.redraw();
return;
}
// Nettoyer si les Graphics sont corrompus
this.tearDown();
const parent = canvas.interface ?? canvas.stage;
_arrowsGfx = parent.addChild(new PIXI.Graphics());
_previewGfx = parent.addChild(new PIXI.Graphics());
_arrowsGfx.zIndex = 900;
_previewGfx.zIndex = 901;
ArrowStore.on("change", () => this.redraw());
// Dessiner les flèches déjà présentes dans le store
this.redraw();
console.log("sotc-clash-arrow | ClashArrowLayer initialisé sur canvas.interface.");
},
/** Nettoie et retire les Graphics du canvas. */
tearDown() {
ArrowStore.off("change");
if (_arrowsGfx) { _arrowsGfx.parent?.removeChild(_arrowsGfx); _arrowsGfx.destroy(); _arrowsGfx = null; }
if (_previewGfx) { _previewGfx.parent?.removeChild(_previewGfx); _previewGfx.destroy(); _previewGfx = null; }
},
/** Redessine toutes les flèches persistantes. */
redraw() {
if (!_arrowsGfx) return;
_arrowsGfx.clear();
for (const arrow of ArrowStore.all()) {
if (arrow.used) {
_drawArrow(_arrowsGfx, arrow.x1, arrow.y1, arrow.x2, arrow.y2, COLOR_USED, ALPHA_USED);
} else {
const color = arrow.mutual ? COLOR_YELLOW : COLOR_BLUE;
_drawArrow(_arrowsGfx, arrow.x1, arrow.y1, arrow.x2, arrow.y2, color);
}
}
},
/**
* Flèche de preview pendant le drag.
* @param {number} x1 @param {number} y1 Origine (coords monde)
* @param {number} x2 @param {number} y2 Position courante (coords monde)
* @param {number} color
*/
drawPreview(x1, y1, x2, y2, color = COLOR_PREVIEW) {
if (!_previewGfx) return;
_previewGfx.clear();
_drawArrow(_previewGfx, x1, y1, x2, y2, color, 0.55);
},
/** Efface la flèche de preview. */
clearPreview() {
_previewGfx?.clear();
},
};

View file

@ -0,0 +1,135 @@
/**
* sotc-clash-arrow | DieInteraction.js
*
* Gère la logique de drag entre dés de vitesse.
* Reçoit les événements depuis SIOBridge (mousedown/move/up natifs sur #board).
*
* Coordonnées :
* - Les hooks sotcca:* reçoivent des coords ÉCRAN (relatives à #board)
* - ClashArrowLayer.drawPreview() et ArrowStore travaillent en coords MONDE
* - getSIODieCenter() retourne des coords monde
* - _screenToWorld() convertit écran → monde
*/
import { ArrowStore } from "./ArrowStore.js";
import { getSIODieCenter, findDieUnderScreenPoint,
tokenHasActiveSkillOnDie, getAttackWeightForDie,
getDieState } from "./SIOBridge.js";
import { ClashArrowLayer } from "./ClashArrowLayer.js";
// ── Couleurs ──────────────────────────────────────────────────────────────────
const COLOR_BLUE = 0x4a9eff; // attaque directe
const COLOR_YELLOW = 0xffd84a; // clash mutuel potentiel
const COLOR_PREVIEW = 0x888899; // drag en cours, cible indéfinie
// ── État du drag ──────────────────────────────────────────────────────────────
let _dragging = false;
let _srcToken = null; // TokenDocument source
let _srcDieIdx = null; // index du dé source
let _srcWorldX = 0; // coords monde du centre du dé source
let _srcWorldY = 0;
// ── Init ──────────────────────────────────────────────────────────────────────
export function initDieInteraction() {
Hooks.on("sotcca:diePointerDown", _onDieDown);
Hooks.on("sotcca:boardMouseMove", _onMove);
Hooks.on("sotcca:boardMouseUp", _onUp);
Hooks.on("sotcca:dieRightClick", _onRightClick);
}
// ── Handlers ──────────────────────────────────────────────────────────────────
function _onDieDown(tokenDoc, dieIndex, screenX, screenY) {
// Bloquer si le dé source n'a pas de compétence assignée
if (!tokenHasActiveSkillOnDie(tokenDoc, dieIndex)) {
ui.notifications.warn(game.i18n.localize("SOTCCA.NoSkillOnDie"));
return;
}
_dragging = true;
_srcToken = tokenDoc;
_srcDieIdx = dieIndex;
const world = getSIODieCenter(tokenDoc, dieIndex);
_srcWorldX = world.x;
_srcWorldY = world.y;
}
function _onMove(screenX, screenY) {
if (!_dragging) return;
const worldPos = _screenToWorld(screenX, screenY);
const hit = findDieUnderScreenPoint(screenX, screenY);
const color = _previewColor(hit?.tokenDoc, hit?.dieIndex);
ClashArrowLayer.drawPreview(_srcWorldX, _srcWorldY, worldPos.x, worldPos.y, color);
}
function _onUp(screenX, screenY) {
if (!_dragging) return;
_dragging = false;
ClashArrowLayer.clearPreview();
const hit = findDieUnderScreenPoint(screenX, screenY);
if (!hit) return;
const { tokenDoc: tgtToken, dieIndex: tgtDie } = hit;
if (tgtToken.id === _srcToken.id) {
ui.notifications.warn(game.i18n.localize("SOTCCA.CannotTargetSelf"));
return;
}
// Vérifier la limite d'attack weight
// En état Saved Defense Dice (2) : toujours limité à 1 flèche
const isSavedDefense = getDieState(_srcToken, _srcDieIdx) === 2;
const maxArrows = isSavedDefense ? 1 : getAttackWeightForDie(_srcToken, _srcDieIdx);
const existingArrows = ArrowStore.all().filter(
a => a.fromToken === _srcToken.id && a.fromDie === _srcDieIdx
);
if (existingArrows.length >= maxArrows) {
ui.notifications.warn(
game.i18n.format("SOTCCA.AttackWeightExceeded", { max: maxArrows })
);
return;
}
const src = getSIODieCenter(_srcToken, _srcDieIdx);
const tgt = getSIODieCenter(tgtToken, tgtDie);
ArrowStore.addArrow({
fromToken: _srcToken.id,
fromDie : _srcDieIdx,
toToken : tgtToken.id,
toDie : tgtDie,
x1: src.x, y1: src.y,
x2: tgt.x, y2: tgt.y,
});
}
function _onRightClick(_tokenDoc, _dieIndex) {
// Suppression par clic droit désactivée.
// Les flèches sont supprimées uniquement via le changement de dieState (Used/Unused).
}
// ── Helpers ───────────────────────────────────────────────────────────────────
/**
* Détermine la couleur de la flèche preview en fonction de la cible survolée.
*/
function _previewColor(targetTokenDoc, targetDieIdx) {
if (!targetTokenDoc || targetTokenDoc.id === _srcToken?.id) return COLOR_PREVIEW;
if (tokenHasActiveSkillOnDie(targetTokenDoc, targetDieIdx)) return COLOR_YELLOW;
return COLOR_BLUE;
}
/**
* Convertit des coordonnées écran (#board) en coordonnées monde (canvas).
* @param {number} sx
* @param {number} sy
* @returns {{ x: number, y: number }}
*/
function _screenToWorld(sx, sy) {
return canvas.stage.toLocal({ x: sx, y: sy });
}

View file

@ -0,0 +1,231 @@
/**
* sotc-clash-arrow | Hooks.js
*
* Enregistrement de tous les hooks FoundryVTT :
* - canvasReady : initialisation du layer + interactions
* - canvasTearDown : nettoyage
* - updateCombat : reset des flèches au début d'un nouveau round
* - deleteCombat : reset à la fin du combat
* - renderSceneControls (optionnel) : bouton reset pour le GM
*/
import { ClashArrowLayer } from "./ClashArrowLayer.js";
import { ArrowStore } from "./ArrowStore.js";
import { initDieInteraction } from "./DieInteraction.js";
import { hookSIOMarkers, getSIODieCenter } from "./SIOBridge.js";
const SBSA_MODULE_ID = "sotc-battle-show-ability";
// ── Setting : mode debug (GM uniquement) ─────────────────────────────────────
export function registerSettings() {
game.settings.register("sotc-clash-arrow", "debugForceState", {
name: game.i18n.localize("SOTCCA.Settings.DebugForceState.Name"),
hint: game.i18n.localize("SOTCCA.Settings.DebugForceState.Hint"),
scope: "world",
config: true,
type: Boolean,
default: false,
restricted: true,
requiresReload: false,
});
}
export function registerHooks() {
// ── Initialisation globale (une seule fois) ────────────────────────────
Hooks.once("ready", () => {
ArrowStore.initSocket();
initDieInteraction();
hookSIOMarkers();
});
// ── Canvas teardown ────────────────────────────────────────────────────
let _currentSceneId = null;
Hooks.on("canvasReady", () => {
const newSceneId = canvas.scene?.id;
if (_currentSceneId && _currentSceneId !== newSceneId) {
// Vrai changement de scène → vider le store
ArrowStore.clear();
}
_currentSceneId = newSceneId;
ClashArrowLayer.init();
// Restaurer les flèches persistées (survit au F5)
// Léger délai pour laisser SIO finir d'initialiser ses marqueurs PIXI
setTimeout(() => {
if (game.user.isGM) {
// Le GM recharge depuis les flags de scène (source de vérité)
ArrowStore.loadFromScene(getSIODieCenter);
} else {
// Les joueurs demandent au GM de leur envoyer l'état courant
ArrowStore.requestSync();
}
}, 200);
});
Hooks.on("canvasTearDown", () => {
// Ne détruire les Graphics que si la scène change (géré dans canvasReady)
// On ne touche pas au store ici pour préserver les flèches lors des refreshs
ClashArrowLayer.tearDown();
});
// ── Reset au nouveau round ─────────────────────────────────────────────
// Aligné sur SBSA : combatRound, GM uniquement
Hooks.on("combatRound", (combat, _roundData, _options) => {
if (!game.user.isGM) return;
ArrowStore.clear();
});
Hooks.on("deleteCombat", () => {
ArrowStore.clear();
});
// ── Suppression des flèches quand un dé passe en Used/Unused ─────────────
Hooks.on("updateCombatant", (combatant, changes) => {
// On ne réagit que si dieState a changé
const newState = foundry.utils.getProperty(changes, `flags.${SBSA_MODULE_ID}.dieState`);
if (newState === undefined) return;
// On ne réagit pas si le dé est dans l'état Applied Skill (3)
if (newState === 3) return;
// Retrouver le token et l'index du dé via le même tri que _getCombatantForDie
const tokenId = combatant.tokenId;
const tokenDoc = canvas.scene?.tokens?.get(tokenId);
if (!tokenDoc) return;
const sortedCombatants = game.combat.combatants
.filter(c => c.tokenId === tokenId)
.sort((a, b) => (b.initiative ?? -Infinity) - (a.initiative ?? -Infinity));
const dieIndex = sortedCombatants.indexOf(combatant);
if (dieIndex === -1) return;
// dieState 1 (Used) → marquer les flèches sortantes en marron
// dieState 0 (Unused) → supprimer les flèches sortantes
// dieState 2 (Saved Defense) → supprimer les flèches sortantes (on pourra en retracer 1)
const isUsed = newState === 1;
for (const arrow of ArrowStore.all()) {
if (arrow.fromToken === tokenId && arrow.fromDie === dieIndex) {
if (isUsed) {
ArrowStore.markUsed(arrow.id);
} else {
ArrowStore.removeArrow(arrow.id);
}
}
}
});
// ── Bouton de reset manuel (GM uniquement) ─────────────────────────────
Hooks.on("getSceneControlButtons", (controls) => {
if (!game.user.isGM) return;
// Ajouter au groupe "token" (première barre)
const tokenControls = controls.find(c => c.name === "token");
if (!tokenControls) return;
tokenControls.tools.push({
name : "sotcca-reset",
title : game.i18n.localize("SOTCCA.ArrowsReset"),
icon : "fas fa-times-circle",
button : true,
onClick: () => {
ArrowStore.clear();
ui.notifications.info(game.i18n.localize("SOTCCA.ArrowsReset"));
},
});
});
// ── Bouton debug : forcer l'état du dé (visible seulement si l'option est activée) ──
Hooks.on("renderApplication", (app, html) => {
// Vérifier que l'option debug est activée
if (!game.settings.get("sotc-clash-arrow", "debugForceState")) return;
// On cible uniquement le panneau SBSA
if (!html[0]?.querySelector(".sotc-bsa-panel")) return;
const panel = html[0].querySelector(".sotc-bsa-panel");
const combatantId = panel?.dataset?.combatantId;
if (!combatantId) return;
const combatant = game.combat?.combatants.get(combatantId);
if (!combatant) return;
// Afficher sur tous les états (pas seulement quand assigné)
// → le bloc debug permet de forcer l'état peu importe la situation
// Injecter le bloc debug après la section d'état
const stateSection = html[0].querySelector(".bsa-state-section");
if (!stateSection) return;
const debugHtml = `
<div class="sotcca-debug-section" style="
margin-top: 6px;
padding: 4px 6px;
border: 1px dashed #888;
border-radius: 4px;
opacity: 0.75;
">
<div style="font-size: 10px; color: #aaa; margin-bottom: 4px;">
🛠 ${game.i18n.localize("SOTCCA.Debug.Title")}
</div>
<div style="display: flex; gap: 4px; flex-wrap: wrap;">
<button class="sotcca-debug-state-btn" data-state="0" style="flex:1; font-size:11px; padding:2px 4px;">
${game.i18n.localize("SOTC_BSA.Panel.StateUnused")}
</button>
<button class="sotcca-debug-state-btn" data-state="1" style="flex:1; font-size:11px; padding:2px 4px;">
${game.i18n.localize("SOTC_BSA.Panel.StateUsed")}
</button>
<button class="sotcca-debug-state-btn" data-state="2" style="flex:1; font-size:11px; padding:2px 4px;">
${game.i18n.localize("SOTC_BSA.Panel.StateSaved")}
</button>
<button class="sotcca-debug-state-btn" data-state="3" style="flex:1; font-size:11px; padding:2px 4px;">
${game.i18n.localize("SOTC_BSA.Panel.StateAssigned")}
</button>
</div>
</div>
`;
stateSection.insertAdjacentHTML("afterend", debugHtml);
// Brancher les listeners sur les nouveaux boutons
html[0].querySelectorAll(".sotcca-debug-state-btn").forEach(btn => {
btn.addEventListener("click", async (e) => {
e.preventDefault();
e.stopPropagation();
const state = parseInt(btn.dataset.state);
await combatant.setFlag(SBSA_MODULE_ID, "dieState", state);
ui.notifications.info(
game.i18n.format("SOTCCA.Debug.StateForced", { state })
);
});
});
});
ArrowStore.on?.("change", () => {
// Détecter les nouvelles flèches qui viennent de passer en mutuel
// (géré dans ArrowStore.addArrow → on écoute ici pour le message chat)
});
// Hook interne émis par ArrowStore pour les nouveaux clashes mutuels
Hooks.on("sotcca:mutualClash", (arrow) => {
const attacker = canvas.scene.tokens.get(arrow.fromToken)?.name ?? arrow.fromToken;
const defender = canvas.scene.tokens.get(arrow.toToken)?.name ?? arrow.toToken;
ChatMessage.create({
content: `<div class="sotcca-mutual-clash-msg">
⚔️ ${game.i18n.format("SOTCCA.MutualClash", { attacker, defender })}
</div>`,
type: CONST.CHAT_MESSAGE_TYPES.OTHER,
});
});
}

View file

@ -0,0 +1,228 @@
/**
* sotc-clash-arrow | SIOBridge.js
*
* Pont d'intégration avec SIO et SBSA.
*
* LEÇON CLUE (apprise du module SBSA) :
* L'API PIXI events ne fonctionne pas correctement pour ajouter des
* listeners supplémentaires sur les marqueurs SIO en Foundry v11.
* La solution fiable est d'écouter les events souris NATIFS sur
* l'élément HTML #board, exactement comme le fait SBSA.
*
* Correspondance index → combatant (identique à SIO) :
* Les combatants d'un token sont triés par initiative décroissante.
* marker[i] correspond à combatants[i] dans ce tri.
*
* Compétence assignée (flags SBSA réels) :
* combatant.getFlag("sotc-battle-show-ability", "assignedSkillId")
* combatant.getFlag("sotc-battle-show-ability", "dieState") → 0/1/2/3
* dieState === 3 signifie "compétence assignée"
*/
const SIO_FLAG_SCOPE = "stars-initiative-overlay";
const SBSA_MODULE_ID = "sotc-battle-show-ability";
const HIT_RADIUS = 28; // pixels écran (getBounds retourne des coords écran sur #board)
// ── API publique ──────────────────────────────────────────────────────────────
/**
* Installe le handler mousedown natif sur #board.
* Appelé une fois depuis Hooks.js au canvasReady.
*/
export function hookSIOMarkers() {
_installBoardHandler();
// Réinstaller si la scène change
Hooks.on("canvasReady", () => _installBoardHandler());
}
/**
* Retourne les coordonnées MONDE (canvas) du centre d'un marqueur SIO.
* Utilisé pour dessiner les flèches dans ClashArrowLayer.
* @param {TokenDocument} tokenDoc
* @param {number} dieIndex
* @returns {{ x: number, y: number }}
*/
export function getSIODieCenter(tokenDoc, dieIndex) {
const token = tokenDoc.object;
const marker = token?._initiativeMarkers?.[dieIndex];
if (marker) {
const b = marker.getBounds();
// getBounds() retourne des coordonnées écran (#board) → convertir en coords monde
const screenCenter = { x: b.x + b.width / 2, y: b.y + b.height / 2 };
return canvas.stage.toLocal(screenCenter);
}
// Fallback : centre du token en coords monde
return { x: token.center.x, y: token.center.y };
}
/**
* Cherche quel dé SIO se trouve sous les coordonnées écran (x, y).
* Retourne { tokenDoc, dieIndex, combatant } ou null.
* @param {number} screenX – coordonnées relatives à #board
* @param {number} screenY
* @returns {{ tokenDoc: TokenDocument, dieIndex: number, combatant: Combatant }|null}
*/
export function findDieUnderScreenPoint(screenX, screenY) {
for (const token of canvas.tokens.placeables) {
const markers = token._initiativeMarkers;
if (!markers?.length) continue;
for (let i = 0; i < markers.length; i++) {
const b = markers[i].getBounds();
const cx = b.x + b.width / 2;
const cy = b.y + b.height / 2;
if (Math.hypot(screenX - cx, screenY - cy) > HIT_RADIUS) continue;
const combatant = _getCombatantForDie(token.document, i);
if (!combatant) continue;
return { tokenDoc: token.document, dieIndex: i, combatant };
}
}
return null;
}
/**
* Retourne le dieState SBSA du dé `dieIndex` d'un token.
* @param {TokenDocument} tokenDoc
* @param {number} dieIndex
* @returns {number} 0=Unused, 1=Used, 2=Saved Defense, 3=Applied Skill
*/
export function getDieState(tokenDoc, dieIndex) {
const combatant = _getCombatantForDie(tokenDoc, dieIndex);
if (!combatant) return 0;
return combatant.getFlag(SBSA_MODULE_ID, "dieState") ?? 0;
}
/**
* Retourne l'attack_weight de la compétence assignée au dé `dieIndex`.
* - Si aucune compétence ou pas d'attack_weight → retourne 1 (1 flèche max)
* - Sinon retourne la valeur numérique de attack_weight
* @param {TokenDocument} tokenDoc
* @param {number} dieIndex
* @returns {number}
*/
export function getAttackWeightForDie(tokenDoc, dieIndex) {
const combatant = _getCombatantForDie(tokenDoc, dieIndex);
if (!combatant) return 1;
const skillId = combatant.getFlag(SBSA_MODULE_ID, "assignedSkillId") ?? null;
if (!skillId) return 1;
const actor = tokenDoc.actor;
if (!actor) return 1;
const skill = actor.items.get(skillId);
if (!skill) return 1;
const weight = skill.system?.weight ?? null;
if (!weight || weight <= 0) return 1;
return weight;
}
/**
* Retourne true si le dé `dieIndex` d'un token a une compétence assignée.
* États autorisés à créer une flèche :
* - dieState === 3 (Applied Skill)
* - dieState === 2 (Saved Defense Dice) — le dé sauvegardé réutilise sa compétence
* @param {TokenDocument} tokenDoc
* @param {number} dieIndex
* @returns {boolean}
*/
export function tokenHasActiveSkillOnDie(tokenDoc, dieIndex) {
const combatant = _getCombatantForDie(tokenDoc, dieIndex);
if (!combatant) return false;
const dieState = combatant.getFlag(SBSA_MODULE_ID, "dieState") ?? 0;
const assignedId = combatant.getFlag(SBSA_MODULE_ID, "assignedSkillId") ?? null;
return (dieState === 3 || dieState === 2) && !!assignedId;
}
// ── Helpers internes ──────────────────────────────────────────────────────────
/**
* Retourne le combatant correspondant à marker[dieIndex] selon le tri SIO.
* Identique au getter sioMarkerIndex de SpeedDiePanelApp (nouvelle version SBSA) :
* tri par initiative décroissante, marker[0] = initiative la plus haute.
* @param {TokenDocument} tokenDoc
* @param {number} dieIndex
* @returns {Combatant|null}
*/
function _getCombatantForDie(tokenDoc, dieIndex) {
if (!game.combat) return null;
const combatants = game.combat.combatants
.filter(c => c.tokenId === tokenDoc.id)
.sort((a, b) => (b.initiative ?? -Infinity) - (a.initiative ?? -Infinity));
return combatants[dieIndex] ?? null;
}
/**
* Installe (ou réinstalle) les listeners natifs sur #board.
*/
function _installBoardHandler() {
const board = document.getElementById("board");
if (!board) return;
// Nettoyer les anciens handlers
if (board._sotcca_downHandler) {
board.removeEventListener("mousedown", board._sotcca_downHandler);
board.removeEventListener("mouseup", board._sotcca_upHandler);
board.removeEventListener("mousemove", board._sotcca_moveHandler);
}
board._sotcca_downHandler = _onBoardMouseDown;
board._sotcca_upHandler = _onBoardMouseUp;
board._sotcca_moveHandler = _onBoardMouseMove;
board.addEventListener("mousedown", board._sotcca_downHandler);
board.addEventListener("mouseup", board._sotcca_upHandler);
board.addEventListener("mousemove", board._sotcca_moveHandler, { passive: true });
// Bloquer le menu contextuel navigateur sur les dés
if (!board._sotcca_ctxHandler) {
board._sotcca_ctxHandler = (e) => {
const rect = board.getBoundingClientRect();
const hit = findDieUnderScreenPoint(e.clientX - rect.left, e.clientY - rect.top);
if (hit) e.preventDefault();
};
board.addEventListener("contextmenu", board._sotcca_ctxHandler);
}
console.log("sotc-clash-arrow | Handler mousedown installé sur #board.");
}
// ── Handlers DOM → délèguent via Hooks à DieInteraction ──────────────────────
function _onBoardMouseDown(event) {
const board = document.getElementById("board");
const rect = board.getBoundingClientRect();
const sx = event.clientX - rect.left;
const sy = event.clientY - rect.top;
const hit = findDieUnderScreenPoint(sx, sy);
if (!hit) return;
if (event.button === 0) {
event.preventDefault();
event.stopPropagation();
Hooks.callAll("sotcca:diePointerDown", hit.tokenDoc, hit.dieIndex, sx, sy);
} else if (event.button === 2) {
event.preventDefault();
event.stopPropagation();
Hooks.callAll("sotcca:dieRightClick", hit.tokenDoc, hit.dieIndex);
}
}
function _onBoardMouseMove(event) {
const board = document.getElementById("board");
const rect = board.getBoundingClientRect();
Hooks.callAll("sotcca:boardMouseMove", event.clientX - rect.left, event.clientY - rect.top);
}
function _onBoardMouseUp(event) {
if (event.button !== 0) return;
const board = document.getElementById("board");
const rect = board.getBoundingClientRect();
Hooks.callAll("sotcca:boardMouseUp", event.clientX - rect.left, event.clientY - rect.top);
}

View file

@ -0,0 +1,35 @@
/**
* sotc-clash-arrow | main.js
*
* Point d'entrée du module.
* Importe et initialise tous les sous-systèmes.
*/
import { registerHooks, registerSettings } from "./Hooks.js";
import { ArrowStore } from "./ArrowStore.js";
import { ClashArrowLayer } from "./ClashArrowLayer.js";
// ── Exposition globale ────────────────────────────────────────────────────────
globalThis.sotcClashArrow = {
ArrowStore,
ClashArrowLayer, // singleton objet, pas une classe
};
// ── Démarrage ─────────────────────────────────────────────────────────────────
Hooks.once("init", () => {
console.log("sotc-clash-arrow | Initialisation du module.");
registerSettings();
registerHooks();
});
Hooks.once("ready", () => {
// Vérification des dépendances
if (!game.modules.get("stars-initiative-overlay")?.active) {
ui.notifications.error(
"sotc-clash-arrow : Le module 'Stars Initiative Overlay' (SIO) est requis et doit être activé.",
{ permanent: true }
);
}
console.log("sotc-clash-arrow | Module prêt.");
});

View 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`)

View 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;
}