sotc-damage-wizard : assistant de calcul des dégâts

This commit is contained in:
Yasser Boulayoune 2026-05-01 13:19:00 +02:00
parent ad63cafdc6
commit 84aa6b3437
7 changed files with 1532 additions and 0 deletions

View file

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

View file

@ -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: '<i class="fas fa-dice-d6"></i>',
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.");
});