/** * 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; } }