diff --git a/sotc-battle-show-ability/lang/en.json b/sotc-battle-show-ability/lang/en.json new file mode 100644 index 0000000..53cd1d9 --- /dev/null +++ b/sotc-battle-show-ability/lang/en.json @@ -0,0 +1,45 @@ +{ + "SOTC_BSA": { + "Panel": { + "Title": "Speed Die", + "TokenInfo": "Information", + "Stats": "Statistics", + "Skills": "Skills", + "SkillsTab": "Skills", + "EgoTab": "E.G.O", + "DieState": "Die State", + "AssignSkill": "Assign", + "SelectedSkill": "Selected Skill", + "NoCombat": "No active combat.", + "NoCombatant": "This token is not in combat.", + "SpeedDie": "Speed Die", + "NoSkills": "No skills available.", + "NoEgo": "No E.G.O skills available.", + "Cost": "Cost", + "LightCost": "Light", + "EmotionCost": "Emotion", + "DicePreview": "Skill Dice", + "Tags": "Tags / Modules", + "Assign": "Assign to Die", + "AssignSuccess": "Skill assigned to Speed Die.", + "InsufficientLight": "Insufficient Light (need: {cost}, available: {current}).", + "InsufficientEmotion": "Insufficient Emotion (need: {cost}, available: {current}).", + "AlreadyAssigned": "This Speed Die already has a skill assigned.", + "StateUnused": "Unused", + "StateUsed": "Used", + "StateSaved": "Saved Defense Die", + "StateAssigned": "Assigned Skills", + "StateChanged": "Die state updated.", + "OpenSheet": "Open sheet", + "HP": "HP", + "Stagger": "Stagger", + "Light": "Light", + "Emotion": "Emotion", + "AssignedSkill": "Assigned Skill", + "None": "None", + "Unassign": "Unassign", + "Die": "Die", + "RemoveArrows": "Remove arrows" + } + } +} \ No newline at end of file diff --git a/sotc-battle-show-ability/lang/fr.json b/sotc-battle-show-ability/lang/fr.json new file mode 100644 index 0000000..55a8ef3 --- /dev/null +++ b/sotc-battle-show-ability/lang/fr.json @@ -0,0 +1,45 @@ +{ + "SOTC_BSA": { + "Panel": { + "Title": "Dé de Vitesse", + "TokenInfo": "Informations", + "Stats": "Statistiques", + "Skills": "Compétences", + "SkillsTab": "Compétences", + "EgoTab": "E.G.O", + "DieState": "État du Dé", + "AssignSkill": "Assigner", + "SelectedSkill": "Compétence sélectionnée", + "NoCombat": "Aucun combat actif.", + "NoCombatant": "Ce token n'est pas dans le combat.", + "SpeedDie": "Dé de Vitesse", + "NoSkills": "Aucune compétence disponible.", + "NoEgo": "Aucune compétence E.G.O disponible.", + "Cost": "Coût", + "LightCost": "Lumière", + "EmotionCost": "Émotion", + "DicePreviw": "Dés de la compétence", + "Tags": "Tags / Modules", + "Assign": "Assigner au Dé", + "AssignSuccess": "Compétence assignée au Dé de Vitesse.", + "InsufficientLight": "Lumière insuffisante (besoin : {cost}, disponible : {current}).", + "InsufficientEmotion": "Émotion insuffisante (besoin : {cost}, disponible : {current}).", + "AlreadyAssigned": "Ce Dé de Vitesse a déjà une compétence assignée.", + "StateUnused": "Non Utilisé", + "StateUsed": "Utilisé", + "StateSaved": "Dé de Défense Sauvegardé", + "StateAssigned": "Compétence Assignée", + "StateChanged": "État du Dé mis à jour.", + "OpenSheet": "Ouvrir la fiche", + "HP": "PV", + "Stagger": "Stagger", + "Light": "Lumière", + "Emotion": "Émotion", + "AssignedSkill": "Compétence assignée", + "None": "Aucune", + "Unassign": "Désassigner", + "Die": "Dé", + "RemoveArrows": "Retirer les flèches" + } + } +} \ No newline at end of file diff --git a/sotc-battle-show-ability/module.json b/sotc-battle-show-ability/module.json new file mode 100644 index 0000000..96d976b --- /dev/null +++ b/sotc-battle-show-ability/module.json @@ -0,0 +1,45 @@ +{ + "id": "sotc-battle-show-ability", + "title": "SotC Battle Show Ability", + "description": "Panneau contextuel sur les Dés de Vitesse dans le tracker de combat SotC. Permet d'assigner des compétences (skill/ego) à chaque Dé de Vitesse, avec validation des coûts en Lumière et Émotion, et gestion manuelle des états.", + "version": "1.0.0", + "compatibility": { + "minimum": "11", + "verified": "11.315" + }, + "authors": [ + { + "name": "Auteur" + } + ], + "esmodules": [ + "module/main.js" + ], + "styles": [ + "styles/sotc-bsa.css" + ], + "languages": [ + { + "lang": "fr", + "name": "Français", + "path": "lang/fr.json" + }, + { + "lang": "en", + "name": "English", + "path": "lang/en.json" + } + ], + "relationships": { + "systems": [ + { + "id": "sotc", + "type": "requires", + "compatibility": { + "minimum": "1.05" + } + } + ] + }, + "flags": {} +} diff --git a/sotc-battle-show-ability/module/SpeedDiePanelApp.js b/sotc-battle-show-ability/module/SpeedDiePanelApp.js new file mode 100644 index 0000000..13b49f2 --- /dev/null +++ b/sotc-battle-show-ability/module/SpeedDiePanelApp.js @@ -0,0 +1,401 @@ +/** + * sotc-battle-show-ability — SpeedDiePanelApp + * Fenêtre Application FoundryVTT affichant le panneau d'un Dé de Vitesse. + * + * Architecture SotC : + * - 1 token avec N dés de vitesse → N combatants dans le tracker + * - Combatant principal : flags.sotc.isSpeedDieClone absent/false + * - Clones : flags.sotc.isSpeedDieClone = true, flags.sotc.speedDieIndex = i + * - État utilisé : combatant.flags.sotc.used (boolean) + * - Compétence assignée : flag module "sotc-battle-show-ability" → "assignedSkillId" + */ + +export class SpeedDiePanelApp extends Application { + + constructor(combatantId, options = {}) { + super(options); + this.combatantId = combatantId; + this._activeTab = "skills"; + this._previewSkillId = null; + } + + static get defaultOptions() { + return foundry.utils.mergeObject(super.defaultOptions, { + id: "sotc-bsa-panel", + title: game.i18n.localize("SOTC_BSA.Panel.Title"), + template: "modules/sotc-battle-show-ability/templates/speed-die-panel.hbs", + classes: ["sotc-bsa-window"], + width: 320, + height: "auto", + resizable: false, + popOut: true + }); + } + + /* ------------------------------------------------------------------ */ + /* Helpers */ + /* ------------------------------------------------------------------ */ + + get combatant() { + return game.combat?.combatants.get(this.combatantId) ?? null; + } + + get actor() { + return this.combatant?.actor ?? null; + } + + get dieNumber() { + // Position visuelle 1-based dans l'ordre SIO (tri par initiative décroissante) + return this.sioMarkerIndex + 1; + } + + get dieState() { + const c = this.combatant; + if (!c) return 0; + // Si une compétence est assignée, l'état est toujours forcé à 3 (Assigned) + if (this.assignedSkillId) return 3; + const state = c.getFlag("sotc-battle-show-ability", "dieState"); + if (state === undefined || state === null) { + return c.getFlag("sotc", "used") ? 1 : 0; + } + return state; + } + + get assignedSkillId() { + return this.combatant?.getFlag("sotc-battle-show-ability", "assignedSkillId") ?? null; + } + + /** + * Calcule l'index du marqueur SIO pour ce combatant. + * SIO trie les combatants par initiative décroissante — le combatant avec + * la plus haute initiative est à l'index 0 (marqueur le plus à gauche). + * speedDieIndex est fixe à la création et ne reflète pas cet ordre. + */ + get sioMarkerIndex() { + const combatant = this.combatant; + if (!combatant) return 0; + const tokenId = combatant.tokenId; + const sorted = game.combat.combatants + .filter(c => c.tokenId === tokenId) + .sort((a, b) => (b.initiative ?? -Infinity) - (a.initiative ?? -Infinity)); + const idx = sorted.findIndex(c => c.id === combatant.id); + return idx === -1 ? 0 : idx; + } + + /* ------------------------------------------------------------------ */ + /* getData */ + /* ------------------------------------------------------------------ */ + + async getData(options = {}) { + const actor = this.actor; + const combatant = this.combatant; + + if (!actor || !combatant) return { error: true }; + + const sys = actor.system; + + const hpValue = sys.health?.value ?? 0; + const hpMax = sys.health?.max ?? 1; + const staggerValue = sys.stagger?.value ?? 0; + const staggerMax = sys.stagger?.max ?? 1; + const lightValue = sys.light?.value ?? 0; + const lightMax = sys.light?.max ?? 1; + const emotion = sys.emotion ?? 0; + + const assignedId = this.assignedSkillId; + + const mapItem = (item) => { + const lc = item.system.light_cost ?? 0; + const ec = item.system.emotion_cost ?? 0; + return { + id: item.id, + name: item.name, + img: item.img, + lightCost: lc, + emotionCost: ec, + canAffordLight: lightValue >= lc, + canAffordEmotion: emotion >= ec, + selected: item.id === this._previewSkillId, + weight: item.system.weight ?? 0 + }; + }; + + const skills = actor.items.filter(i => i.type === "skill").map(mapItem); + const egos = actor.items.filter(i => i.type === "ego").map(mapItem); + + let assignedSkill = null; + if (assignedId) { + const item = actor.items.get(assignedId); + if (item) assignedSkill = { id: item.id, name: item.name, img: item.img }; + } + + let selectedSkillPreview = null; + if (this._previewSkillId) { + const item = actor.items.get(this._previewSkillId); + if (item) { + const rawDice = item.system.dice?.die ?? []; + const diceArr = Array.isArray(rawDice) ? rawDice : Object.values(rawDice); + selectedSkillPreview = { + name: item.name, + dice: diceArr.map(d => ({ + type: d.type ?? "slash", + formula: d.formula ?? "", + mods: Array.isArray(d.mods) ? d.mods.filter(m => m) : Object.values(d.mods ?? {}).filter(m => m) + })), + modules: item.system.skill_modules?.mods + ? (Array.isArray(item.system.skill_modules.mods) + ? item.system.skill_modules.mods.filter(m => m).join(" | ") + : String(item.system.skill_modules.mods)) + : "" + }; + } + } + + const arrowStore = globalThis.sotcClashArrow?.ArrowStore ?? null; + const sioIdx = this.sioMarkerIndex; + const hasArrows = arrowStore && combatant?.tokenId + ? arrowStore.all().some(a => a.fromToken === combatant.tokenId && a.fromDie === sioIdx) + : false; + + return { + tokenId: combatant.tokenId, + combatantId: this.combatantId, + actorId: actor.id, + tokenName: combatant.name, + tokenImg: actor.img, + isClone: !!combatant.getFlag("sotc", "isSpeedDieClone"), + dieNumber: this.dieNumber, + hpValue, hpMax, + hpPercent: Math.max(0, Math.min(100, Math.round((hpValue / hpMax) * 100))), + staggerValue, staggerMax, + staggerPercent: Math.max(0, Math.min(100, Math.round((staggerValue / staggerMax) * 100))), + lightValue, lightMax, + emotion, + skills, + egos, + assignedSkill, + selectedSkillPreview, + dieState: this.dieState, + activeTab: this._activeTab, + hasArrows, + }; + } + + /* ------------------------------------------------------------------ */ + /* Listeners */ + /* ------------------------------------------------------------------ */ + + activateListeners(html) { + super.activateListeners(html); + + html.find(".open-sheet").click(ev => { + const actorId = ev.currentTarget.dataset.actorId; + const actor = game.actors.get(actorId); + actor?.sheet?.render(true); + }); + + html.find(".bsa-tab").click(ev => { + const tab = ev.currentTarget.dataset.tab; + this._activeTab = tab; + html.find(".bsa-tab").removeClass("active"); + html.find(".bsa-tab-content").removeClass("active"); + ev.currentTarget.classList.add("active"); + html.find(`.bsa-tab-${tab}`).addClass("active"); + }); + + html.find(".bsa-skill-row").click(ev => { + if (ev.target.closest(".bsa-btn-assign")) return; + const skillId = ev.currentTarget.dataset.skillId; + this._previewSkillId = (this._previewSkillId === skillId) ? null : skillId; + this.render(false); + }); + + html.find(".bsa-btn-assign").click(ev => { + ev.stopPropagation(); + const skillId = ev.currentTarget.dataset.skillId; + this._onAssignSkill(skillId); + }); + + html.find(".bsa-btn-unassign").click(() => { + this._onUnassignSkill(); + }); + + html.find(".bsa-btn-remove-arrows").click(() => { + this._onRemoveArrows(); + }); + + html.find(".bsa-state-btn").click(ev => { + const state = parseInt(ev.currentTarget.dataset.state); + this._onSetDieState(state); + }); + } + + /* ------------------------------------------------------------------ */ + /* Actions */ + /* ------------------------------------------------------------------ */ + + // Gérer l'assignation d'une compétence au dé de vitesse + async _onAssignSkill(skillId) { + const actor = this.actor; + const combatant = this.combatant; + if (!actor || !combatant) return; + + const item = actor.items.get(skillId); + if (!item) return; + + if (this.assignedSkillId) { + ui.notifications.warn(game.i18n.localize("SOTC_BSA.Panel.AlreadyAssigned")); + return; + } + + const lightCost = item.system.light_cost ?? 0; + const emotionCost = item.system.emotion_cost ?? 0; + const currentLight = actor.system.light?.value ?? 0; + const currentEmotion = actor.system.emotion ?? 0; + + if (lightCost > 0 && currentLight < lightCost) { + ui.notifications.error( + game.i18n.format("SOTC_BSA.Panel.InsufficientLight", { cost: lightCost, current: currentLight }) + ); + return; + } + + if (emotionCost > 0 && currentEmotion < emotionCost) { + ui.notifications.error( + game.i18n.format("SOTC_BSA.Panel.InsufficientEmotion", { cost: emotionCost, current: currentEmotion }) + ); + return; + } + + const updates = {}; + if (lightCost > 0) updates["system.light.value"] = Math.max(0, currentLight - lightCost); + if (emotionCost > 0) updates["system.emotion"] = Math.max(0, currentEmotion - emotionCost); + if (Object.keys(updates).length > 0) await actor.update(updates); + + await combatant.setFlag("sotc-battle-show-ability", "assignedSkillId", skillId); + // Toujours forcer l'état à 3 (Assigned) dès qu'une compétence est assignée + await combatant.setFlag("sotc-battle-show-ability", "dieState", 3); + await combatant.setFlag("sotc", "used", true); + + // Mettre le marqueur SIO à l'état 3 (DiceSlotIcon4) pour indiquer l'assignation + const tokenDocAssign = combatant.token; + if (tokenDocAssign && game.modules.get("stars-initiative-overlay")?.active) { + const dieIndexAssign = this.sioMarkerIndex; + const numDiceAssign = combatant.actor?.system?.speed_dice?.num_dice ?? 1; + const statesAssign = [...(tokenDocAssign.getFlag("stars-initiative-overlay", "diceStates") || [])]; + while (statesAssign.length < numDiceAssign) statesAssign.push(0); + statesAssign[dieIndexAssign] = 3; + await tokenDocAssign.setFlag("stars-initiative-overlay", "diceStates", statesAssign); + } + + ui.notifications.info(game.i18n.localize("SOTC_BSA.Panel.AssignSuccess")); + this._previewSkillId = null; + this.render(false); + } + + // Gérer la désassignation de la compétence du dé de vitesse + async _onUnassignSkill() { + const combatant = this.combatant; + const actor = this.actor; + if (!combatant || !actor) return; + + const skillId = this.assignedSkillId; + if (skillId) { + const item = actor.items.get(skillId); + if (item) { + const lightCost = item.system.light_cost ?? 0; + const emotionCost = item.system.emotion_cost ?? 0; + const updates = {}; + + if (lightCost > 0) { + const currentLight = actor.system.light?.value ?? 0; + const maxLight = actor.system.light?.max ?? currentLight; + updates["system.light.value"] = Math.min(currentLight + lightCost, maxLight); + } + if (emotionCost > 0) { + const currentEmotion = actor.system.emotion ?? 0; + updates["system.emotion"] = currentEmotion + emotionCost; + } + + if (Object.keys(updates).length > 0) await actor.update(updates); + } + } + + // On force l'ordre : d'abord on retire l'assignation, puis on met l'état à 0 + await combatant.unsetFlag("sotc-battle-show-ability", "assignedSkillId"); + await combatant.setFlag("sotc-battle-show-ability", "dieState", 0); + await combatant.setFlag("sotc", "used", false); + + // Synchronisation SIO + const tokenDoc = combatant.token; + if (tokenDoc && game.modules.get("stars-initiative-overlay")?.active) { + const dieIndex = this.sioMarkerIndex; + const numDice = combatant.actor?.system?.speed_dice?.num_dice ?? 1; + const states = [...(tokenDoc.getFlag("stars-initiative-overlay", "diceStates") || [])]; + while (states.length < numDice) states.push(0); + states[dieIndex] = 0; // on force explicitement à Unused + + await tokenDoc.setFlag("stars-initiative-overlay", "diceStates", states); + } + + this._previewSkillId = null; + this.render(false); + } + + // Gérer le changement d'état du dé de vitesse (Unused, Used, Saved, Assigned) + _onRemoveArrows() { + const arrowStore = globalThis.sotcClashArrow?.ArrowStore ?? null; + if (!arrowStore) { ui.notifications.warn("sotc-clash-arrow n'est pas actif."); return; } + const combatant = this.combatant; + if (!combatant) return; + const tokenId = combatant.tokenId; + const sioIdx = this.sioMarkerIndex; + const toRemove = arrowStore.all() + .filter(a => a.fromToken === tokenId && a.fromDie === sioIdx) + .map(a => a.id); + if (toRemove.length === 0) { ui.notifications.info("Aucune flèche à retirer pour ce dé."); return; } + for (const id of toRemove) arrowStore.removeArrow(id); + ui.notifications.info(`${toRemove.length} flèche(s) retirée(s).`); + this.render(false); + } + + async _onSetDieState(state) { + const combatant = this.combatant; + if (!combatant) return; + + // Si une compétence est assignée, l'état est verrouillé à 3 + const effectiveState = this.assignedSkillId ? 3 : state; + + await combatant.setFlag("sotc-battle-show-ability", "dieState", effectiveState); + + const isUsed = (effectiveState === 1 || effectiveState === 2 || effectiveState === 3); + await combatant.setFlag("sotc", "used", isUsed); + + // Synchroniser le flag SIO pour mettre à jour l'icône PIXI + const tokenDoc = combatant.token; + if (tokenDoc && game.modules.get("stars-initiative-overlay")?.active) { + const dieIndex = this.sioMarkerIndex; + const numDice = combatant.actor?.system?.speed_dice?.num_dice ?? 1; + const currentStates = tokenDoc.getFlag("stars-initiative-overlay", "diceStates") || []; + const states = [...currentStates]; + while (states.length < numDice) states.push(0); + states[dieIndex] = effectiveState; + await tokenDoc.setFlag("stars-initiative-overlay", "diceStates", states); + } + + ui.notifications.info(game.i18n.localize("SOTC_BSA.Panel.StateChanged")); + this.render(false); + } + + /* ------------------------------------------------------------------ */ + /* Validité */ + /* ------------------------------------------------------------------ */ + + _checkStillValid() { + if (!game.combat || !this.combatant) { + this.close(); + return false; + } + return true; + } +} diff --git a/sotc-battle-show-ability/module/main.js b/sotc-battle-show-ability/module/main.js new file mode 100644 index 0000000..34ef35e --- /dev/null +++ b/sotc-battle-show-ability/module/main.js @@ -0,0 +1,263 @@ +/** + * sotc-battle-show-ability — main.js + * Point d'entrée du module. + */ + +import { SpeedDiePanelApp } from "./SpeedDiePanelApp.js"; + +/* ------------------------------------------------------------------ */ +/* Initialisation */ +/* ------------------------------------------------------------------ */ + +Hooks.once("init", () => { + console.log("sotc-battle-show-ability | Initialisation du module"); + + loadTemplates([ + "modules/sotc-battle-show-ability/templates/speed-die-panel.hbs" + ]); + + // Note : le helper Handlebars "eq" est déjà enregistré par le système SotC. +}); + +/* ------------------------------------------------------------------ */ +/* Menu contextuel dans le Combat Tracker */ +/* ------------------------------------------------------------------ */ + +Hooks.on("getCombatTrackerEntryContext", (html, options) => { + if (game.system.id !== "sotc") return; + + options.push({ + name: game.i18n.localize("SOTC_BSA.Panel.Title"), + icon: '', + condition: (li) => { + if (!game.combat) return false; + const combatantId = li.data("combatant-id"); + const combatant = game.combat.combatants.get(combatantId); + if (!combatant) return false; + return combatant.isOwner || game.user.isGM; + }, + callback: (li) => { + const combatantId = li.data("combatant-id"); + _openSpeedDiePanel(combatantId); + } + }); +}); + +/* ------------------------------------------------------------------ */ +/* Ouverture du panneau */ +/* ------------------------------------------------------------------ */ + +function _openSpeedDiePanel(combatantId) { + const existing = Object.values(ui.windows).find( + w => w instanceof SpeedDiePanelApp && w.combatantId === combatantId + ); + + if (existing) { + existing.render(true); + existing.bringToTop(); + return; + } + + const panel = new SpeedDiePanelApp(combatantId); + + const combatant = game.combat?.combatants.get(combatantId); + if (combatant) { + const numDice = combatant.actor?.system?.speed_dice?.num_dice ?? 1; + // Index visuel : position dans le tri par initiative décroissante + const sorted = game.combat.combatants + .filter(c => c.tokenId === combatant.tokenId) + .sort((a, b) => (b.initiative ?? -Infinity) - (a.initiative ?? -Infinity)); + const dieIndex = sorted.findIndex(c => c.id === combatant.id) + 1; + panel.options.title = numDice > 1 + ? `${game.i18n.localize("SOTC_BSA.Panel.SpeedDie")} #${dieIndex} — ${combatant.name}` + : `${game.i18n.localize("SOTC_BSA.Panel.Title")} — ${combatant.name}`; + } + + panel.render(true); +} + +/* ------------------------------------------------------------------ */ +/* Clic droit sur les marqueurs PIXI de SIO */ +/* ------------------------------------------------------------------ */ + +function _installSIORightClickHandler() { + const board = document.getElementById("board"); + if (!board) return; + + if (board._bsaRightClickHandler) { + board.removeEventListener("mousedown", board._bsaRightClickHandler); + } + + const handler = async (event) => { + if (event.button !== 2) return; + if (!game.combat?.active) return; + + const rect = board.getBoundingClientRect(); + const screenX = event.clientX - rect.left; + const screenY = event.clientY - rect.top; + + for (const token of canvas.tokens.placeables) { + const markers = token._initiativeMarkers; + if (!markers?.length) continue; + + for (let i = 0; i < markers.length; i++) { + const m = markers[i]; + const b = m.getBounds(); + + const inside = + screenX >= b.x && + screenX <= b.x + b.width && + screenY >= b.y && + screenY <= b.y + b.height; + + if (!inside) continue; + + const combatants = game.combat.combatants + .filter(c => c.tokenId === token.document.id) + .sort((a, b) => (b.initiative ?? -Infinity) - (a.initiative ?? -Infinity)); + + const combatant = combatants[i]; + if (!combatant) return; + + if (!combatant.isOwner && !game.user.isGM) return; + + event.preventDefault(); + event.stopPropagation(); + + _openSpeedDiePanel(combatant.id); + return; + } + } + }; + + board._bsaRightClickHandler = handler; + board.addEventListener("mousedown", handler); + + if (!board._bsaContextMenuHandler) { + board._bsaContextMenuHandler = (event) => { + const rect = board.getBoundingClientRect(); + const screenX = event.clientX - rect.left; + const screenY = event.clientY - rect.top; + for (const token of canvas.tokens.placeables) { + const markers = token._initiativeMarkers; + if (!markers?.length) continue; + for (const m of markers) { + const b = m.getBounds(); + if (screenX >= b.x && screenX <= b.x + b.width && + screenY >= b.y && screenY <= b.y + b.height) { + event.preventDefault(); + return; + } + } + } + }; + board.addEventListener("contextmenu", board._bsaContextMenuHandler); + } +} + +Hooks.on("canvasReady", () => { + _installSIORightClickHandler(); +}); + +/* ------------------------------------------------------------------ */ +/* Réinitialisation au début d'un nouveau round */ +/* ------------------------------------------------------------------ */ + +async function _resetAllCombatants(combat) { + if (!game.user.isGM) return; + + const updates = []; + + for (const combatant of combat.combatants) { + const actor = combatant.actor; + if (!actor?.system?.speed_dice) continue; + + updates.push({ + _id: combatant.id, + "flags.sotc-battle-show-ability.dieState": 0, + "flags.sotc-battle-show-ability.assignedSkillId": null, + "flags.sotc.used": false + }); + } + + if (updates.length > 0) { + await combat.updateEmbeddedDocuments("Combatant", updates); + console.log(`sotc-battle-show-ability | ${updates.length} dé(s) réinitialisé(s)`); + } + + _refreshOpenPanels(); +} + +Hooks.on("combatRound", async (combat, roundData, options) => { + if (game.system.id !== "sotc") return; + console.log("sotc-battle-show-ability | Nouveau round — réinitialisation des dés de vitesse"); + await _resetAllCombatants(combat); +}); + +Hooks.on("deleteCombat", async (combat, options, userId) => { + // Fermer les panneaux ouverts + for (const win of Object.values(ui.windows)) { + if (win instanceof SpeedDiePanelApp) win.close(); + } + // Reset des flags (utile si le combat est supprimé sans être terminé) + console.log("sotc-battle-show-ability | Fin du combat — réinitialisation des dés de vitesse"); + await _resetAllCombatants(combat); +}); + +/* ------------------------------------------------------------------ */ +/* Rafraîchissement réactif des panneaux ouverts */ +/* ------------------------------------------------------------------ */ + +function _refreshOpenPanels() { + for (const win of Object.values(ui.windows)) { + if (win instanceof SpeedDiePanelApp) { + if (!win._checkStillValid()) continue; + win.render(false); + } + } +} + +Hooks.on("updateCombatant", (combatant, changes, options, userId) => { + if (game.system.id !== "sotc") return; + + for (const win of Object.values(ui.windows)) { + if (win instanceof SpeedDiePanelApp && win.combatantId === combatant.id) { + win.render(false); + break; + } + } +}); + +Hooks.on("updateActor", (actor, changes, options, userId) => { + if (game.system.id !== "sotc") return; + + for (const win of Object.values(ui.windows)) { + if (win instanceof SpeedDiePanelApp && win.actor?.id === actor.id) { + win.render(false); + } + } +}); + +Hooks.on("deleteCombatant", (combatant, options, userId) => { + for (const win of Object.values(ui.windows)) { + if (win instanceof SpeedDiePanelApp && win.combatantId === combatant.id) { + win.close(); + break; + } + } +}); + + + +/* ------------------------------------------------------------------ */ +/* API publique */ +/* ------------------------------------------------------------------ */ + +Hooks.once("ready", () => { + game.modules.get("sotc-battle-show-ability").api = { + openSpeedDiePanel: _openSpeedDiePanel, + SpeedDiePanelApp + }; + + console.log("sotc-battle-show-ability | Module prêt."); +}); diff --git a/sotc-battle-show-ability/styles/sotc-bsa.css b/sotc-battle-show-ability/styles/sotc-bsa.css new file mode 100644 index 0000000..fe8ccea --- /dev/null +++ b/sotc-battle-show-ability/styles/sotc-bsa.css @@ -0,0 +1,543 @@ +/* ========================================================= + sotc-battle-show-ability — Styles principaux + Thème sombre/industriel, cohérent avec SotC + ========================================================= */ + +/* --- Fenêtre Application --- */ +.sotc-bsa-window .window-content { + padding: 0; + background: #1a1a1e; + color: #d4c9b0; + font-family: 'Barlow Condensed', 'Signika', sans-serif; + overflow-y: auto; +} + +.sotc-bsa-window .window-header { + background: #111116; + border-bottom: 1px solid #8b0000; + color: #e8d5a3; +} + +/* --- Panneau principal --- */ +.sotc-bsa-panel { + display: flex; + flex-direction: column; + gap: 6px; + padding: 8px; + min-width: 300px; +} + +/* --- Header : token image + nom --- */ +.bsa-header { + display: flex; + align-items: center; + padding: 6px 4px; + border-bottom: 1px solid #3a2a2a; +} + +.bsa-token-info { + display: flex; + align-items: center; + gap: 10px; +} + +.bsa-token-img { + width: 48px; + height: 48px; + border-radius: 4px; + border: 2px solid #8b0000; + cursor: pointer; + object-fit: cover; + transition: border-color 0.2s; +} + +.bsa-token-img:hover { + border-color: #e8d5a3; +} + +.bsa-token-name { + display: flex; + flex-direction: column; +} + +.bsa-token-name span:first-child { + font-size: 1.1em; + font-weight: 600; + color: #e8d5a3; + letter-spacing: 0.05em; +} + +.bsa-die-index { + font-size: 0.8em; + color: #c09060; + font-style: italic; +} + +/* --- Stats --- */ +.bsa-stats { + display: flex; + flex-direction: column; + gap: 4px; + padding: 6px 4px; + border-bottom: 1px solid #3a2a2a; +} + +.bsa-stat-row { + display: flex; + align-items: center; + gap: 6px; + height: 20px; +} + +.bsa-stat-icon { + width: 16px; + height: 16px; + object-fit: contain; + flex-shrink: 0; +} + +.bsa-stat-label { + width: 55px; + font-size: 0.8em; + color: #a09080; + flex-shrink: 0; + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.bsa-bar-wrap { + flex: 1; + height: 10px; + background: #2a2a2e; + border-radius: 3px; + overflow: hidden; + border: 1px solid #3a3a3e; +} + +.bsa-bar { + height: 100%; + border-radius: 3px; + transition: width 0.3s ease; +} + +.bsa-bar-hp { + background: linear-gradient(90deg, #8b0000, #cc2200); +} + +.bsa-bar-stagger { + background: linear-gradient(90deg, #005577, #0088bb); +} + +.bsa-stat-value { + font-size: 0.8em; + color: #d4c9b0; + width: 50px; + text-align: right; + flex-shrink: 0; +} + +.bsa-stat-simple .bsa-stat-value { + flex: 1; +} + +.bsa-light-value { + color: #f0e060; +} + +.bsa-emotion-value { + color: #a060c0; +} + +/* --- Compétence assignée --- */ +.bsa-assigned-section { + padding: 6px 4px; + border-bottom: 1px solid #3a2a2a; +} + +.bsa-section-title { + font-size: 0.75em; + text-transform: uppercase; + letter-spacing: 0.1em; + color: #8b0000; + margin-bottom: 5px; + font-weight: 600; +} + +.bsa-assigned-skill { + display: flex; + align-items: center; + gap: 8px; + background: #252530; + border-radius: 4px; + padding: 4px 6px; + border: 1px solid #4a3a3a; +} + +.bsa-assigned-img { + width: 28px; + height: 28px; + border-radius: 3px; + object-fit: cover; +} + +.bsa-assigned-name { + flex: 1; + font-size: 0.9em; + color: #e8d5a3; +} + +.bsa-assigned-none { + font-size: 0.85em; + color: #5a5a5a; + font-style: italic; + text-align: center; + padding: 4px; +} + +/* --- Onglets --- */ +.bsa-tabs { + display: flex; + gap: 2px; + padding: 0 4px; +} + +.bsa-tab { + flex: 1; + padding: 5px 4px; + background: #252530; + border: 1px solid #3a3a4a; + border-bottom: none; + color: #a09080; + font-size: 0.8em; + cursor: pointer; + text-transform: uppercase; + letter-spacing: 0.08em; + border-radius: 3px 3px 0 0; + transition: background 0.15s, color 0.15s; +} + +.bsa-tab:hover { + background: #30303a; + color: #d4c9b0; +} + +.bsa-tab.active { + background: #1a1a2a; + color: #e8d5a3; + border-color: #8b0000; + border-bottom-color: #1a1a2a; +} + +/* --- Contenu d'onglet --- */ +.bsa-tab-content { + display: none; + flex-direction: column; + gap: 3px; + max-height: 200px; + overflow-y: auto; + padding: 4px; + background: #1a1a2a; + border: 1px solid #8b0000; + border-top: none; + border-radius: 0 0 3px 3px; +} + +.bsa-tab-content.active { + display: flex; +} + +.bsa-tab-content::-webkit-scrollbar { + width: 4px; +} +.bsa-tab-content::-webkit-scrollbar-track { + background: #1a1a2a; +} +.bsa-tab-content::-webkit-scrollbar-thumb { + background: #8b0000; + border-radius: 2px; +} + +/* --- Ligne de compétence --- */ +.bsa-skill-row { + display: flex; + align-items: center; + gap: 6px; + padding: 4px 6px; + border-radius: 3px; + background: #22222c; + border: 1px solid transparent; + cursor: pointer; + transition: background 0.15s, border-color 0.15s; +} + +.bsa-skill-row:hover { + background: #2e2e3a; + border-color: #4a3a3a; +} + +.bsa-skill-row.bsa-skill-selected { + background: #2a1a1a; + border-color: #8b0000; +} + +.bsa-skill-img { + width: 28px; + height: 28px; + border-radius: 3px; + object-fit: cover; + flex-shrink: 0; +} + +/* Zone centrale : nom + coûts empilés verticalement */ +.bsa-skill-info { + flex: 1; + display: flex; + flex-direction: column; + gap: 3px; + min-width: 0; +} + +.bsa-skill-name { + font-size: 0.85em; + color: #d4c9b0; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +/* Coûts sous le nom, pas écrasés par le bouton */ +.bsa-skill-costs { + display: flex; + gap: 5px; + flex-wrap: wrap; +} + +.bsa-cost { + font-size: 0.75em; + padding: 1px 5px; + border-radius: 2px; + font-weight: 600; +} + +.bsa-cost-light { + color: #f0e060; + background: #2a2800; +} + +.bsa-cost-emotion { + color: #c080ff; + background: #1a0028; +} + +.bsa-cost-insufficient { + color: #ff4040 !important; + background: #280000 !important; + text-decoration: line-through; +} + +/* --- Boutons --- */ +.bsa-btn { + padding: 3px 8px; + border-radius: 3px; + font-size: 0.75em; + cursor: pointer; + border: 1px solid; + text-transform: uppercase; + letter-spacing: 0.05em; + font-weight: 600; + transition: background 0.15s, color 0.15s; + white-space: nowrap; + flex-shrink: 0; +} + +.bsa-btn-assign { + background: #1a0a0a !important; + border-color: #8b0000 !important; + color: #e8d5a3 !important; + padding: 4px 7px !important; + font-size: 0.7em !important; + width: auto !important; + min-width: unset !important; + max-width: 36px !important; + flex-shrink: 0 !important; + flex-grow: 0 !important; + align-self: center !important; +} + +.bsa-btn-assign:hover { + background: #8b0000; + color: #fff; +} + +.bsa-btn-unassign { + background: #1a0a0a !important; + border-color: #8b0000 !important; + color: #e8d5a3 !important; + padding: 4px 7px !important; + font-size: 0.7em !important; + width: auto !important; + min-width: unset !important; + max-width: 36px !important; + flex-shrink: 0 !important; + flex-grow: 0 !important; + align-self: center !important; +} + +.bsa-btn-unassign:hover { + background: #8b0000 !important; + color: #fff !important; +} + +/* --- Aperçu des dés de la compétence --- */ +.bsa-dice-preview { + padding: 6px 4px; + border-bottom: 1px solid #3a2a2a; +} + +.bsa-dice-list { + display: flex; + flex-direction: column; + gap: 4px; +} + +.bsa-die-entry { + display: flex; + align-items: center; + gap: 8px; + padding: 3px 6px; + background: #22222c; + border-radius: 3px; + border: 1px solid #3a3a4a; +} + +.bsa-die-type-img { + width: 20px; + height: 20px; + object-fit: contain; +} + +.bsa-die-formula { + font-size: 0.9em; + color: #e8d5a3; + font-family: monospace; + font-weight: 600; +} + +.bsa-die-tags { + font-size: 0.75em; + color: #a0a0c0; + font-style: italic; +} + +.bsa-skill-modules { + margin-top: 4px; + font-size: 0.78em; + color: #8090a0; + font-style: italic; + padding: 2px 6px; + border-left: 2px solid #3a3a5a; +} + +/* --- État du dé --- */ +.bsa-state-section { + padding: 6px 4px; +} + +.bsa-state-buttons { + display: flex; + gap: 4px; +} + +.bsa-state-btn { + flex: 1; + padding: 5px 4px; + border-radius: 3px; + font-size: 0.72em; + cursor: pointer; + border: 1px solid #3a3a4a; + background: #22222c; + color: #a09080; + text-transform: uppercase; + letter-spacing: 0.05em; + transition: all 0.15s; + text-align: center; + line-height: 1.2; +} + +.bsa-state-btn:hover { + background: #2e2e3a; + color: #d4c9b0; + border-color: #5a5a6a; +} + +.bsa-state-btn.active { + background: #8b0000; + color: #fff; + border-color: #cc2200; + font-weight: 600; +} + +.bsa-state-btn:disabled { + opacity: 0.45; + cursor: not-allowed; + pointer-events: none; +} + +.bsa-state-btn.active:disabled { + opacity: 1; + background: #1a6b2a; + border-color: #2ecc71; + color: #fff; +} + +/* --- Vide --- */ +.bsa-empty { + font-size: 0.82em; + color: #5a5a5a; + font-style: italic; + text-align: center; + padding: 12px 4px; +} + +/* --- Scrollbar globale du panneau --- */ +.sotc-bsa-panel::-webkit-scrollbar { + width: 4px; +} +.sotc-bsa-panel::-webkit-scrollbar-track { + background: #1a1a1e; +} +.sotc-bsa-panel::-webkit-scrollbar-thumb { + background: #8b0000; + border-radius: 2px; +} + +/* ── Badge Attack Weight ── */ +.bsa-cost-weight { + background: rgba(180, 140, 20, 0.18); + border: 1px solid #b8860b; + color: #f0c040; + font-size: 10px; + font-weight: 700; + padding: 1px 5px; + border-radius: 3px; + letter-spacing: 0.04em; +} + +/* ── Bouton retirer les flèches ── */ +.bsa-btn-remove-arrows { + width: 100%; + margin-top: 5px; + background: rgba(100, 30, 30, 0.25); + border: 1px solid #7a3030; + color: #c07070; + font-size: 11px; + padding: 3px 6px; + border-radius: 3px; + cursor: pointer; + text-align: center; + transition: background .15s; +} +.bsa-btn-remove-arrows:hover { + background: rgba(140, 40, 40, 0.4); + color: #e08080; +} diff --git a/sotc-battle-show-ability/templates/speed-die-panel.hbs b/sotc-battle-show-ability/templates/speed-die-panel.hbs new file mode 100644 index 0000000..eb2f951 --- /dev/null +++ b/sotc-battle-show-ability/templates/speed-die-panel.hbs @@ -0,0 +1,190 @@ +{{!-- templates/speed-die-panel.hbs --}} +
+ {{this.formula}}
+ {{#if this.mods.length}}
+
+ {{/if}}
+