From ad63cafdc652a5793fdc80f134c06a54739d48be Mon Sep 17 00:00:00 2001 From: Yasser Boulayoune Date: Fri, 1 May 2026 10:12:00 +0200 Subject: [PATCH] =?UTF-8?q?sotc-clash-arrow=20:=20fl=C3=A8ches=20de=20clas?= =?UTF-8?q?h=20entre=20combattants?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 15 + sotc-damage-wizard/lang/en.json | 37 + sotc-damage-wizard/lang/fr.json | 37 + sotc-damage-wizard/module.json | 51 ++ sotc-damage-wizard/module/ArrowBridge.js | 168 ++++ sotc-damage-wizard/module/DamageEngine.js | 798 ++++++++++++++++++ sotc-damage-wizard/module/DamageWizardApp.js | 262 ++++++ sotc-damage-wizard/module/main.js | 212 +++++ sotc-damage-wizard/styles/sotc-dw.css | 585 +++++++++++++ .../templates/damage-wizard.hbs | 249 ++++++ 10 files changed, 2414 insertions(+) create mode 100644 README.md create mode 100644 sotc-damage-wizard/lang/en.json create mode 100644 sotc-damage-wizard/lang/fr.json create mode 100644 sotc-damage-wizard/module.json create mode 100644 sotc-damage-wizard/module/ArrowBridge.js create mode 100644 sotc-damage-wizard/module/DamageEngine.js create mode 100644 sotc-damage-wizard/module/DamageWizardApp.js create mode 100644 sotc-damage-wizard/module/main.js create mode 100644 sotc-damage-wizard/styles/sotc-dw.css create mode 100644 sotc-damage-wizard/templates/damage-wizard.hbs diff --git a/README.md b/README.md new file mode 100644 index 0000000..6f17fe8 --- /dev/null +++ b/README.md @@ -0,0 +1,15 @@ +# sotc-foundry-suite + +Trois modules Foundry VTT pour le jeu de rôle Star of the City : affichage des capacités, assistant de dégâts et flèches de clash. + +## Stack + +- Foundry VTT v11 +- JavaScript ESM +- CSS + +## Installation + +``` +Copier chaque dossier `sotc-*` dans `Data/modules/` de Foundry, puis activer les modules dans le monde. +``` diff --git a/sotc-damage-wizard/lang/en.json b/sotc-damage-wizard/lang/en.json new file mode 100644 index 0000000..02d9fe1 --- /dev/null +++ b/sotc-damage-wizard/lang/en.json @@ -0,0 +1,37 @@ +{ + "SOTC_DW": { + "Title": "Damage Wizard", + "TitleClash": "Clash Resolution", + "BtnLaunch": "Roll Dice", + "BtnApply": "Apply Damage", + "BtnClose": "Close", + "Attacker": "Attacker", + "Defender": "Defender", + "Preview": "Result", + "SkillLabel": "Skill", + "DiceLabel": "Dice", + "DamagePrev": "Damage Preview", + "StaggerPrev": "Stagger Preview", + "WinnerLabel": "Winner", + "NoTarget": "No target. This die targets nobody.", + "NoSkill": "No skill assigned to this die.", + "AlreadyUsed": "This die is already used.", + "DamageApplied": "Damage applied.", + "HP": "HP", + "Stagger": "Stagger", + "Light": "Light", + "Emotion": "Emotion", + "AlreadyRolled": "Dice have already been rolled for this speed die.", + "RollFirst": "Roll the dice first.", + "AttackerWins": "Attacker wins!", + "DefenderWins": "Defender wins!", + "Tie": "Tie!", + "Offensive": "Offensive", + "Defensive": "Defensive", + "ClashWinnerApply": "Apply (winner attacks)", + "DieState": { + "Used": "Used" + }, + "Weight": "Attack Weight" + } +} \ No newline at end of file diff --git a/sotc-damage-wizard/lang/fr.json b/sotc-damage-wizard/lang/fr.json new file mode 100644 index 0000000..5db4b10 --- /dev/null +++ b/sotc-damage-wizard/lang/fr.json @@ -0,0 +1,37 @@ +{ + "SOTC_DW": { + "Title": "Assistant de Dégâts", + "TitleClash": "Résolution de Clash", + "BtnLaunch": "Lancer les dés", + "BtnApply": "Appliquer les dégâts", + "BtnClose": "Fermer", + "Attacker": "Attaquant", + "Defender": "Défenseur", + "Preview": "Résultat", + "SkillLabel": "Compétence", + "DiceLabel": "Dés", + "DamagePrev": "Dégâts prévisionnels", + "StaggerPrev": "Stagger prévisionnel", + "WinnerLabel": "Gagnant", + "NoTarget": "Aucune cible. Ce dé ne cible personne.", + "NoSkill": "Aucune compétence assignée à ce dé.", + "AlreadyUsed": "Ce dé est déjà utilisé.", + "DamageApplied": "Dégâts appliqués.", + "HP": "PV", + "Stagger": "Stagger", + "Light": "Lumière", + "Emotion": "Émotion", + "AlreadyRolled": "Les dés ont déjà été lancés pour ce dé de vitesse.", + "RollFirst": "Lance les dés d'abord.", + "AttackerWins": "L'attaquant gagne !", + "DefenderWins": "Le défenseur gagne !", + "Tie": "Égalité !", + "Offensive": "Offensif", + "Defensive": "Défensif", + "ClashWinnerApply": "Appliquer (gagnant attaque)", + "DieState": { + "Used": "Utilisé" + }, + "Weight": "Poids d'attaque" + } +} \ No newline at end of file diff --git a/sotc-damage-wizard/module.json b/sotc-damage-wizard/module.json new file mode 100644 index 0000000..a66b3c4 --- /dev/null +++ b/sotc-damage-wizard/module.json @@ -0,0 +1,51 @@ +{ + "id": "sotc-damage-wizard", + "title": "SotC Damage Wizard", + "description": "Assistant de résolution des dégâts pour SotC. Intègre SBSA pour lancer et appliquer les dés d'une compétence assignée, avec gestion du clash mutuel.", + "version": "1.0.0", + "compatibility": { + "minimum": "11", + "verified": "11.315" + }, + "authors": [ + { + "name": "Auteur" + } + ], + "esmodules": [ + "module/main.js" + ], + "styles": [ + "styles/sotc-dw.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" + } + } + ], + "modules": [ + { + "id": "sotc-battle-show-ability", + "type": "requires" + } + ] + }, + "flags": {} +} diff --git a/sotc-damage-wizard/module/ArrowBridge.js b/sotc-damage-wizard/module/ArrowBridge.js new file mode 100644 index 0000000..79ac0ec --- /dev/null +++ b/sotc-damage-wizard/module/ArrowBridge.js @@ -0,0 +1,168 @@ +/** + * sotc-damage-wizard | ArrowBridge.js + * + * Pont avec sotc-clash-arrow pour lire les flèches et détecter le clash mutuel. + * + * IMPORTANT : sotc-clash-arrow expose son ArrowStore via : + * globalThis.sotcClashArrow.ArrowStore + * et NON via game.modules.get(...).api + * + * Structure d'une flèche ArrowStore : + * { + * id : string "tokenId:dieIndex→tokenId:dieIndex" + * fromToken : string tokenId source + * fromDie : number index du dé source (ordre SIO = tri initiative desc) + * toToken : string tokenId cible + * toDie : number index du dé cible + * mutual : boolean true si clash mutuel + * used : boolean true si dé passé en Used + * } + */ + +const SBSA_MODULE = "sotc-battle-show-ability"; +const CA_MODULE = "sotc-clash-arrow"; + +/* ------------------------------------------------------------------ */ +/* Accès à ArrowStore */ +/* ------------------------------------------------------------------ */ + +/** + * Retourne l'instance ArrowStore de sotc-clash-arrow, ou null si inactif. + * sotc-clash-arrow expose son store sur globalThis.sotcClashArrow.ArrowStore + */ +function getArrowStore() { + if (!game.modules.get(CA_MODULE)?.active) return null; + return globalThis.sotcClashArrow?.ArrowStore ?? null; +} + +/* ------------------------------------------------------------------ */ +/* Index SIO d'un combatant */ +/* ------------------------------------------------------------------ */ + +/** + * Calcule le sioMarkerIndex d'un combatant. + * Identique à la logique de SIOBridge.js de clash-arrow : + * tri par initiative décroissante — marker[0] = initiative la plus haute. + * @param {Combatant} combatant + * @returns {number} + */ +function getSIOIndexForCombatant(combatant) { + if (!game.combat) return 0; + const sorted = game.combat.combatants + .filter(c => c.tokenId === combatant.tokenId) + .sort((a, b) => (b.initiative ?? -Infinity) - (a.initiative ?? -Infinity)); + const idx = sorted.findIndex(c => c.id === combatant.id); + return idx === -1 ? 0 : idx; +} + +/** + * Retourne le combatant correspondant à (tokenId, dieIndex). + * @param {string} tokenId + * @param {number} dieIndex + * @returns {Combatant|null} + */ +function getCombatantForDie(tokenId, dieIndex) { + if (!game.combat) return null; + const sorted = game.combat.combatants + .filter(c => c.tokenId === tokenId) + .sort((a, b) => (b.initiative ?? -Infinity) - (a.initiative ?? -Infinity)); + return sorted[dieIndex] ?? null; +} + +/* ------------------------------------------------------------------ */ +/* API publique */ +/* ------------------------------------------------------------------ */ + +/** + * Retourne la cible et le mode clash pour un combatant attaquant. + * + * Priorité : + * 1. ArrowStore (sotc-clash-arrow actif) — source de vérité + * 2. game.user.targets — fallback FoundryVTT natif + * + * @param {Combatant} attackerCombatant + * @returns {{ defenderCombatant: Combatant|null, isClash: boolean }} + */ +export function getTargetInfo(attackerCombatant) { + const arrowStore = getArrowStore(); + + if (arrowStore) { + const result = _getTargetFromArrowStore(arrowStore, attackerCombatant); + console.log("sotc-damage-wizard | ArrowBridge (ArrowStore) →", result); + return result; + } + + const result = _getTargetFromFoundry(attackerCombatant); + console.log("sotc-damage-wizard | ArrowBridge (targets fallback) →", result); + return result; +} + +/* ------------------------------------------------------------------ */ +/* Logique ArrowStore */ +/* ------------------------------------------------------------------ */ + +function _getTargetFromArrowStore(arrowStore, attackerCombatant) { + const tokenId = attackerCombatant.tokenId; + const dieIndex = getSIOIndexForCombatant(attackerCombatant); + + const arrows = arrowStore.all(); + + console.log( + `sotc-damage-wizard | ArrowStore: ${arrows.length} fleche(s). Cherche fromToken=${tokenId} fromDie=${dieIndex}` + ); + + // Trouver la flèche sortante de ce dé précis + const outgoing = arrows.find( + a => a.fromToken === tokenId && a.fromDie === dieIndex + ); + + if (!outgoing) { + console.log("sotc-damage-wizard | Aucune fleche sortante trouvee."); + return { defenderCombatant: null, isClash: false }; + } + + console.log("sotc-damage-wizard | Fleche trouvee ->", outgoing); + + const defenderCombatant = getCombatantForDie(outgoing.toToken, outgoing.toDie); + if (!defenderCombatant) { + console.log("sotc-damage-wizard | Combatant defenseur introuvable pour", outgoing.toToken, outgoing.toDie); + return { defenderCombatant: null, isClash: false }; + } + + // Clash mutuel : flèche marquée mutual OU flèche inverse présente + const reverseExists = arrows.some( + a => a.fromToken === outgoing.toToken + && a.fromDie === outgoing.toDie + && a.toToken === tokenId + && a.toDie === dieIndex + ); + const isClash = outgoing.mutual || reverseExists; + + return { defenderCombatant, isClash }; +} + +/* ------------------------------------------------------------------ */ +/* Fallback game.user.targets */ +/* ------------------------------------------------------------------ */ + +function _getTargetFromFoundry(attackerCombatant) { + const target = game.user.targets.first(); + if (!target) { + return { defenderCombatant: null, isClash: false }; + } + + // Premier combatant du token ciblé (tri initiative desc) + const defenderCombatant = game.combat?.combatants + .filter(c => c.tokenId === target.document.id) + .sort((a, b) => (b.initiative ?? -Infinity) - (a.initiative ?? -Infinity))[0] ?? null; + + if (!defenderCombatant) { + return { defenderCombatant: null, isClash: false }; + } + + // Clash si le défenseur a dieState === 3 (compétence assignée) + const defDieState = defenderCombatant.getFlag(SBSA_MODULE, "dieState") ?? 0; + const isClash = defDieState === 3; + + return { defenderCombatant, isClash }; +} diff --git a/sotc-damage-wizard/module/DamageEngine.js b/sotc-damage-wizard/module/DamageEngine.js new file mode 100644 index 0000000..ddef576 --- /dev/null +++ b/sotc-damage-wizard/module/DamageEngine.js @@ -0,0 +1,798 @@ +/** + * sotc-damage-wizard | DamageEngine.js + * + * Moteur de résolution des clashes selon les règles officielles SotC. + * + * ────────────────────────────────────────────────────────────────────── + * RÈGLES DE CLASH (séquence linéaire) + * ────────────────────────────────────────────────────────────────────── + * + * Les dés clashent dans l'ordre : dé[0] vs dé[0], dé[1] vs dé[1], etc. + * Les dés restants (sans opposant) sont "sans opposition" (Unopposed). + * Après un clash, les DEUX dés sont détruits sauf exception. + * + * Offensive (Slash/Pierce/Blunt) : + * WIN ou UNOPPOSED → HP damage = Power, Stagger damage = Power + * TIE → Rien + * LOSE → Rien + * + * Défensive – Block : + * WIN → Stagger = Power_block - Power_opposing + * TIE → Rien + * LOSE vs Off → Réduit HP damage et Stagger pris = Power_block + * UNOPPOSED → Saved Defense Die (état 2) + * + * Défensive – Evade : + * WIN vs Off → Recycle ce dé (réutilisé), aucun autre effet WIN + * WIN vs Def → Regain Stagger Resist = Power_evade - Power_opposing + * TIE → Rien + * LOSE vs Off → Réduit Stagger pris = Power_evade + * UNOPPOSED → Saved Defense Die (état 2), sauf si recyclé + * + * Counter Dice (counter-slash/pierce/blunt/block/evade) : + * - Ne se déclenchent PAS si Unopposed + * - Si WIN → réutilisé contre le prochain dé adverse + * - Détruits à la fin de la compétence adverse + * → Donnent état 2 (Saved Defense Die) sur le Dé de Vitesse + * + * PE (Points d'Émotion) : + * Chaque clash (entre deux dés, peu importe le résultat) donne +1 PE + * aux DEUX combatants. + * + * ────────────────────────────────────────────────────────────────────── + */ + +/* ------------------------------------------------------------------ */ +/* Constantes */ +/* ------------------------------------------------------------------ */ + +export const OFFENSIVE_TYPES = new Set([ + "slash", "pierce", "blunt" +]); + +export const COUNTER_OFFENSIVE_TYPES = new Set([ + "counter-slash", "counter-pierce", "counter-blunt" +]); + +export const BLOCK_TYPES = new Set([ + "block" +]); + +export const COUNTER_BLOCK_TYPES = new Set([ + "counter-block" +]); + +export const EVADE_TYPES = new Set([ + "evade" +]); + +export const COUNTER_EVADE_TYPES = new Set([ + "counter-evade" +]); + +// Tous les types offensifs (normaux + counter) +export const ALL_OFFENSIVE = new Set([ + ...OFFENSIVE_TYPES, ...COUNTER_OFFENSIVE_TYPES +]); + +// Tous les types défensifs (normaux + counter) +export const ALL_DEFENSIVE = new Set([ + ...BLOCK_TYPES, ...COUNTER_BLOCK_TYPES, + ...EVADE_TYPES, ...COUNTER_EVADE_TYPES +]); + +// Tous les types Counter +export const ALL_COUNTER = new Set([ + ...COUNTER_OFFENSIVE_TYPES, ...COUNTER_BLOCK_TYPES, ...COUNTER_EVADE_TYPES +]); + +/* ------------------------------------------------------------------ */ +/* Helpers de typage */ +/* ------------------------------------------------------------------ */ + +export function isOffensive(type) { return ALL_OFFENSIVE.has(type); } +export function isDefensive(type) { return ALL_DEFENSIVE.has(type); } +export function isCounter(type) { return ALL_COUNTER.has(type); } +export function isBlock(type) { return BLOCK_TYPES.has(type) || COUNTER_BLOCK_TYPES.has(type); } +export function isEvade(type) { return EVADE_TYPES.has(type) || COUNTER_EVADE_TYPES.has(type); } + +/* ------------------------------------------------------------------ */ +/* Récupération de la compétence assignée */ +/* ------------------------------------------------------------------ */ + +export function getAssignedSkill(combatant) { + const skillId = combatant?.getFlag("sotc-battle-show-ability", "assignedSkillId") ?? null; + if (!skillId) return null; + return combatant.actor?.items.get(skillId) ?? null; +} + +export function getSkillDice(skill) { + const raw = skill.system.dice?.die ?? []; + const arr = Array.isArray(raw) ? raw : Object.values(raw); + return arr.map(d => ({ + type: d.type ?? "slash", + formula: d.formula ?? "1d6", + mods: Array.isArray(d.mods) + ? d.mods.filter(Boolean) + : Object.values(d.mods ?? {}).filter(Boolean) + })); +} + +/* ------------------------------------------------------------------ */ +/* Résolution d'un dé (roll + modificateurs de statuts) */ +/* ------------------------------------------------------------------ */ + +export async function resolveDie(die, actor, extraMod = 0) { + const mods = actor.system.modifiers ?? {}; + + const match = die.formula.match(/^\s*(\d+)\s*d\s*(\d+)((?:\s*[+-]\s*\d+)*)\s*$/); + if (!match) { + const r = await new Roll("0").roll({ async: true }); + return { roll: r, total: 0, formulaDisplay: `Formule invalide: ${die.formula}` }; + } + + const numDice = parseInt(match[1]); + const dieSize = parseInt(match[2]); + const modStr = (match[3] || "").replace(/\s+/g, ""); + const baseMod = modStr.split(/(?=[+-])/).filter(s => s.length) + .reduce((sum, s) => sum + parseInt(s), 0); + + let statusMod = mods.all_mod ?? 0; + if (isOffensive(die.type)) statusMod += mods.off_mod ?? 0; + if (isDefensive(die.type)) statusMod += mods.def_mod ?? 0; + + switch (die.type) { + case "slash": case "counter-slash": statusMod += mods.slash_mod ?? 0; break; + case "pierce": case "counter-pierce": statusMod += mods.pierce_mod ?? 0; break; + case "blunt": case "counter-blunt": statusMod += mods.blunt_mod ?? 0; break; + case "block": case "counter-block": statusMod += mods.block_mod ?? 0; break; + case "evade": case "counter-evade": statusMod += mods.evade_mod ?? 0; break; + } + + const totalMod = baseMod + extraMod + statusMod; + const formula = `${numDice}d${dieSize} + ${totalMod}`; + const roll = await new Roll(formula).roll({ async: true }); + + let display = `${numDice}d${dieSize}`; + if (baseMod !== 0) display += baseMod > 0 ? ` + ${baseMod}` : ` - ${Math.abs(baseMod)}`; + if (extraMod !== 0) display += extraMod > 0 ? ` + ${extraMod}` : ` - ${Math.abs(extraMod)}`; + if (statusMod !== 0) display += statusMod > 0 ? ` + ${statusMod}` : ` - ${Math.abs(statusMod)}`; + display += ` = ${roll.total}`; + + return { roll, total: roll.total, formulaDisplay: display }; +} + +/* ------------------------------------------------------------------ */ +/* Résolution des clashes (séquence linéaire) */ +/* ------------------------------------------------------------------ */ + +/** + * Résout la séquence de clashes entre deux ensembles de dés rollés. + * + * Retourne un tableau de matchups, un résumé des effets, et les compteurs + * de PE gagnés. + * + * @param {Array} atkRolls [{die, total, roll, formulaDisplay}] + * @param {Array} defRolls [] si attaque unilatérale + * @param {Actor} defenderActor + * @param {Actor} attackerActor + * @returns {ClashResult} + * + * ClashResult : + * { + * matchups : DieMatchup[] — dé par dé + * atkHpDamage : number — HP infligés au défenseur + * atkStaggerDamage : number — Stagger infligé au défenseur + * defHpDamage : number — HP infligés à l'attaquant (clash) + * defStaggerDamage : number — Stagger infligé à l'attaquant (clash) + * defStaggerRegain : number — Stagger récupéré par le défenseur (evade win) + * atkStaggerRegain : number — Stagger récupéré par l'attaquant (evade win) + * atkPE : number — PE gagnés par l'attaquant + * defPE : number — PE gagnés par le défenseur + * hasCounter : boolean — true si des counter-dice sont présents + * atkSavedDice : boolean — l'attaquant a des dés à sauvegarder (état 2) + * defSavedDice : boolean — le défenseur a des dés à sauvegarder (état 2) + * } + * + * DieMatchup : + * { + * side : "atk" | "def" + * dieType : string + * power : number + * formulaDisplay : string + * oppType : string|null + * oppPower : number|null + * clashType : "clash" | "unopposed" + * verdict : string — voir constantes VERDICT_* ci-dessous + * effects : string[] — descriptions texte des effets + * } + */ +export function resolveClash(atkRolls, defRolls, defenderActor, attackerActor) { + + // Affinités des acteurs + const defAff = _getAffinities(defenderActor); + const atkAff = attackerActor ? _getAffinities(attackerActor) : null; + + const matchups = []; + + // ── Compteurs d'effets ────────────────────────────────────────────── + let atkHpDamage = 0; + let atkStaggerDamage = 0; + let defHpDamage = 0; + let defStaggerDamage = 0; + let defStaggerRegain = 0; + let atkStaggerRegain = 0; + let atkPE = 0; + let defPE = 0; + let atkSavedDice = false; + let defSavedDice = false; + + // ── Séquence linéaire côté attaquant ─────────────────────────────── + // On travaille sur des copies mutables pour gérer les counter-dice recyclés + const defQueue = [...defRolls]; // dés du défenseur disponibles pour clash + + // Compteur de clashes par dé défenseur (counter-dice : max 1 clash par dé adverse) + // Clé : index dans defRolls d'origine → nombre de fois utilisé + const defUsageCount = new Array(defRolls.length).fill(0); + + // Index courant dans defQueue pour la séquence linéaire + let defIdx = 0; + + for (let atkIdx = 0; atkIdx < atkRolls.length; atkIdx++) { + const atk = atkRolls[atkIdx]; + const atkType = atk.die.type; + + // Trouver l'opposant du défenseur à cet index (séquence linéaire) + const opp = defQueue[defIdx] ?? null; + const hasOpp = opp !== null; + + let matchup; + + if (!hasOpp) { + // ── UNOPPOSED côté attaquant ── + matchup = _resolveAtkUnopposed(atk, defAff, atkIdx); + atkHpDamage += matchup._hpDmg ?? 0; + atkStaggerDamage += matchup._staggerDmg ?? 0; + if (matchup._saved) atkSavedDice = true; + + } else { + // ── CLASH ── + const oppType = opp.die.type; + + // PE : chaque clash donne +1 aux deux + atkPE++; + defPE++; + + matchup = _resolveAtkClash(atk, opp, defAff, atkAff); + atkHpDamage += matchup._hpDmg ?? 0; + atkStaggerDamage += matchup._staggerDmg ?? 0; + + // Effets défensifs du dé opposant sur l'attaquant (block lose / evade lose) + defHpDamage += matchup._reverseHpDmg ?? 0; + defStaggerDamage += matchup._reverseStaggerDmg ?? 0; + defStaggerRegain += matchup._defStaggerRegain ?? 0; + atkStaggerRegain += matchup._atkStaggerRegain ?? 0; + + // Avancer dans la file défenseur (sauf si counter recyclé) + if (!matchup._oppRecycled) { + defIdx++; + } + // Si le dé défenseur est un counter et perd → il est quand même consommé + if (matchup._oppConsumed) { + defIdx++; + } + } + + matchups.push(matchup); + } + + // ── Dés du défenseur restants (défenseur a plus de dés que l'attaquant) ── + for (let i = defIdx; i < defQueue.length; i++) { + const def = defQueue[i]; + const matchup = _resolveDefUnopposed(def, atkAff, i); + defHpDamage += matchup._hpDmg ?? 0; + defStaggerDamage += matchup._staggerDmg ?? 0; + defStaggerRegain += matchup._defStaggerRegain ?? 0; + if (matchup._saved) defSavedDice = true; + matchups.push(matchup); + } + + // Présence de counter-dice + const hasCounter = [...atkRolls, ...defRolls].some(r => isCounter(r.die.type)); + + return { + matchups, + atkHpDamage, + atkStaggerDamage, + defHpDamage, + defStaggerDamage, + defStaggerRegain, + atkStaggerRegain, + atkPE, + defPE, + hasCounter, + atkSavedDice, + defSavedDice, + }; +} + +/* ------------------------------------------------------------------ */ +/* Résolution d'un dé attaquant sans opposition */ +/* ------------------------------------------------------------------ */ + +function _resolveAtkUnopposed(atk, defAff, idx) { + const type = atk.die.type; + const power = atk.total; + const effects = []; + let _hpDmg = 0, _staggerDmg = 0, _saved = false; + + if (isOffensive(type)) { + // Offensive Unopposed → deal damage = power + const aff = defAff[type.replace("counter-", "")] ?? { damage: 0, stagger: 0 }; + _hpDmg = Math.max(0, power - aff.damage); + _staggerDmg = Math.max(0, power - aff.stagger); + effects.push(`HP -${_hpDmg}, Stagger -${_staggerDmg}`); + + } else if (isBlock(type)) { + if (!isCounter(type)) { + // Block Unopposed → Saved Defense Die + _saved = true; + effects.push("Saved Defense Die"); + } else { + // Counter-Block Unopposed → ne se déclenche pas, détruit + effects.push("Counter : pas de déclenchement sans opposition"); + } + + } else if (isEvade(type)) { + if (!isCounter(type)) { + // Evade Unopposed → Saved Defense Die (sauf si recyclé, géré ailleurs) + _saved = true; + effects.push("Saved Defense Die"); + } else { + // Counter-Evade Unopposed → ne se déclenche pas, détruit + effects.push("Counter : pas de déclenchement sans opposition"); + } + } + + return { + side: "atk", dieType: type, power, formulaDisplay: atk.formulaDisplay, + oppType: null, oppPower: null, + clashType: "unopposed", + verdict: _getUnopposedVerdict(type), + effects, + _hpDmg, _staggerDmg, _saved, + _oppRecycled: false, _oppConsumed: false, + }; +} + +/* ------------------------------------------------------------------ */ +/* Résolution d'un dé attaquant en clash */ +/* ------------------------------------------------------------------ */ + +function _resolveAtkClash(atk, opp, defAff, atkAff) { + const atkType = atk.die.type; + const oppType = opp.die.type; + const atkPower = atk.total; + const oppPower = opp.total; + const effects = []; + + let _hpDmg = 0, _staggerDmg = 0; + let _reverseHpDmg = 0, _reverseStaggerDmg = 0; + let _defStaggerRegain = 0, _atkStaggerRegain = 0; + let _oppRecycled = false, _oppConsumed = false; + let verdict; + + // Résultat du clash + const result = atkPower > oppPower ? "win" + : atkPower < oppPower ? "lose" + : "tie"; + + // ── Attaquant offensif vs dé défenseur ────────────────────────────── + if (isOffensive(atkType)) { + + if (isOffensive(oppType)) { + // Offensive vs Offensive — les deux clashent + verdict = result === "win" ? "off-win" + : result === "lose" ? "off-lose" + : "tie"; + + if (result === "win") { + // Attaquant gagne → deal damage + const aff = defAff[atkType.replace("counter-", "")] ?? { damage: 0, stagger: 0 }; + _hpDmg = Math.max(0, atkPower - aff.damage); + _staggerDmg = Math.max(0, atkPower - aff.stagger); + effects.push(`WIN → HP -${_hpDmg}, Stagger -${_staggerDmg}`); + // Le dé défenseur offensif perd → rien de sa part + } else if (result === "lose") { + // Défenseur offensif gagne → il deal damage sur l'attaquant + if (atkAff) { + const aff = atkAff[oppType.replace("counter-", "")] ?? { damage: 0, stagger: 0 }; + _reverseHpDmg = Math.max(0, oppPower - aff.damage); + _reverseStaggerDmg = Math.max(0, oppPower - aff.stagger); + effects.push(`LOSE → ennemi HP -${_reverseHpDmg}, ennemi Stagger -${_reverseStaggerDmg}`); + } + } else { + effects.push("TIE → Rien"); + } + + } else if (isBlock(oppType)) { + // Offensive vs Block + verdict = result === "win" ? "off-win-vs-block" + : result === "lose" ? "off-lose-vs-block" + : "tie"; + + if (result === "win") { + // Block perd → réduit dégâts de sa Power + const aff = defAff[atkType.replace("counter-", "")] ?? { damage: 0, stagger: 0 }; + const rawHp = Math.max(0, atkPower - aff.damage); + const rawStg = Math.max(0, atkPower - aff.stagger); + // Block lose : réduit HP et Stagger pris de Power_block + const reduction = oppPower; + _hpDmg = Math.max(0, rawHp - reduction); + _staggerDmg = Math.max(0, rawStg - reduction); + effects.push(`WIN vs Block → HP -${_hpDmg} (réduit de ${reduction}), Stagger -${_staggerDmg}`); + // Block Lose → réduit damage taken = Power_block (déjà géré ci-dessus dans notre sens) + } else if (result === "lose") { + // Attaque gagne → dégâts plein + const aff = defAff[atkType.replace("counter-", "")] ?? { damage: 0, stagger: 0 }; + _hpDmg = Math.max(0, atkPower - aff.damage); + _staggerDmg = Math.max(0, atkPower - aff.stagger); + effects.push(`LOSE vs Block → HP -${_hpDmg}, Stagger -${_staggerDmg}`); + // Block win → stagger on attacker = Power_block - Power_off + _reverseStaggerDmg = Math.max(0, oppPower - atkPower); + if (_reverseStaggerDmg > 0) effects.push(`Block WIN → ennemi Stagger -${_reverseStaggerDmg}`); + } else { + effects.push("TIE → Rien"); + } + + } else if (isEvade(oppType)) { + // Offensive vs Evade + verdict = result === "win" ? "off-win-vs-evade" + : result === "lose" ? "off-lose-vs-evade" + : "tie"; + + if (result === "win") { + // Evade perd → dégâts plein, Stagger réduit de Power_evade + const aff = defAff[atkType.replace("counter-", "")] ?? { damage: 0, stagger: 0 }; + _hpDmg = Math.max(0, atkPower - aff.damage); + const rawStg = Math.max(0, atkPower - aff.stagger); + _staggerDmg = Math.max(0, rawStg - oppPower); // Evade Lose réduit Stagger + effects.push(`WIN vs Evade → HP -${_hpDmg}, Stagger -${_staggerDmg} (réduit de ${oppPower})`); + } else if (result === "lose") { + // Evade gagne vs Offensive → recycle le dé evade, aucun dégât + _oppRecycled = !isCounter(oppType); // Counter-Evade : ne se recycle pas vraiment + effects.push("LOSE vs Evade → Evade gagne, recyclé (aucun dégât)"); + } else { + effects.push("TIE → Rien"); + } + } + + // ── Attaquant défensif vs dé défenseur ───────────────────────────── + } else if (isBlock(atkType)) { + + if (isOffensive(oppType)) { + // Block vs Offensive + verdict = result === "win" ? "block-win-vs-off" + : result === "lose" ? "block-lose-vs-off" + : "tie"; + + if (result === "win") { + // Block Win → stagger sur le défenseur (opposant offensif) + _reverseStaggerDmg = Math.max(0, atkPower - oppPower); + effects.push(`Block WIN → ennemi Stagger -${_reverseStaggerDmg}`); + // Le dé offensif perd → dégâts réduits de Power_block + // (l'attaque adverse est annulée ici : on n'inflige rien) + } else if (result === "lose") { + // Block Lose vs Off → réduit HP damage et Stagger pris de Power_block + // L'offensif adverse inflige ses dégâts, réduits de notre Power + if (atkAff) { + const aff = atkAff[oppType.replace("counter-", "")] ?? { damage: 0, stagger: 0 }; + const rawHp = Math.max(0, oppPower - aff.damage); + const rawStg = Math.max(0, oppPower - aff.stagger); + _reverseHpDmg = Math.max(0, rawHp - atkPower); + _reverseStaggerDmg = Math.max(0, rawStg - atkPower); + effects.push(`Block LOSE → dégâts réduits de ${atkPower} → HP -${_reverseHpDmg}, Stagger -${_reverseStaggerDmg}`); + } + } else { + effects.push("TIE → Rien"); + } + + } else { + // Block vs Block ou Block vs Evade → clash normal (Win = stagger diff) + verdict = result === "win" ? "block-win-vs-def" : result === "lose" ? "block-lose-vs-def" : "tie"; + if (result === "win") { + _reverseStaggerDmg = Math.max(0, atkPower - oppPower); + effects.push(`Block WIN → ennemi Stagger -${_reverseStaggerDmg}`); + } else { + effects.push(result === "tie" ? "TIE → Rien" : "Block LOSE → Rien"); + } + } + + } else if (isEvade(atkType)) { + + if (isOffensive(oppType)) { + // Evade vs Offensive + verdict = result === "win" ? "evade-win-vs-off" + : result === "lose" ? "evade-lose-vs-off" + : "tie"; + + if (result === "win") { + // Evade Win vs Off → Recycle ce dé, aucun autre effet WIN + // (le dé offensif adverse ne deal pas de dégâts) + effects.push("Evade WIN vs Off → Recyclé, aucun dégât"); + // Note : _oppRecycled n'est pas l'attaquant ici mais le dé evade lui-même + // On note ça dans le matchup via le verdict + } else if (result === "lose") { + // Evade Lose vs Off → réduit Stagger pris de Power_evade + if (atkAff) { + const aff = atkAff[oppType.replace("counter-", "")] ?? { damage: 0, stagger: 0 }; + const rawHp = Math.max(0, oppPower - aff.damage); + const rawStg = Math.max(0, oppPower - aff.stagger); + _reverseHpDmg = rawHp; + _reverseStaggerDmg = Math.max(0, rawStg - atkPower); + effects.push(`Evade LOSE → Stagger réduit de ${atkPower} → HP -${_reverseHpDmg}, Stagger -${_reverseStaggerDmg}`); + } + } else { + effects.push("TIE → Rien"); + } + + } else { + // Evade vs Defensive + verdict = result === "win" ? "evade-win-vs-def" : result === "lose" ? "evade-lose-vs-def" : "tie"; + if (result === "win") { + // Evade Win vs Def → regain stagger resist = Power_evade - Power_def + _atkStaggerRegain = Math.max(0, atkPower - oppPower); + effects.push(`Evade WIN vs Def → Stagger regain +${_atkStaggerRegain}`); + } else { + effects.push(result === "tie" ? "TIE → Rien" : "Evade LOSE → Rien"); + } + } + } + + // Counter : après un clash perdu, le counter est consommé (pas recyclé) + if (isCounter(oppType) && result === "lose") { + _oppConsumed = true; + } + + return { + side: "atk", dieType: atkType, power: atkPower, formulaDisplay: atk.formulaDisplay, + oppType, oppPower, oppFormulaDisplay: opp.formulaDisplay, + clashType: "clash", + verdict, + effects, + _hpDmg, _staggerDmg, + _reverseHpDmg, _reverseStaggerDmg, + _defStaggerRegain, _atkStaggerRegain, + _oppRecycled, _oppConsumed, + }; +} + +/* ------------------------------------------------------------------ */ +/* Résolution d'un dé défenseur sans opposition */ +/* ------------------------------------------------------------------ */ + +function _resolveDefUnopposed(def, atkAff, idx) { + const type = def.die.type; + const power = def.total; + const effects = []; + let _hpDmg = 0, _staggerDmg = 0, _defStaggerRegain = 0, _saved = false; + + if (isOffensive(type)) { + // Offensif défenseur Unopposed → deal damage sur l'attaquant + const aff = atkAff?.[type.replace("counter-", "")] ?? { damage: 0, stagger: 0 }; + _hpDmg = Math.max(0, power - aff.damage); + _staggerDmg = Math.max(0, power - aff.stagger); + effects.push(`HP -${_hpDmg}, Stagger -${_staggerDmg}`); + + } else if (isBlock(type) || isEvade(type)) { + if (!isCounter(type)) { + _saved = true; + effects.push("Saved Defense Die"); + } else { + effects.push("Counter : pas de déclenchement sans opposition"); + } + } + + return { + side: "def", dieType: type, power, formulaDisplay: def.formulaDisplay, + oppType: null, oppPower: null, + clashType: "unopposed", + verdict: _getUnopposedVerdict(type), + effects, + _hpDmg, _staggerDmg, _defStaggerRegain, _saved, + _oppRecycled: false, _oppConsumed: false, + }; +} + +/* ------------------------------------------------------------------ */ +/* Verdict Unopposed */ +/* ------------------------------------------------------------------ */ + +function _getUnopposedVerdict(type) { + if (isOffensive(type)) return "unopposed-off"; + if ((isBlock(type) || isEvade(type)) && !isCounter(type)) return "unopposed-saved"; + return "unopposed-counter-noop"; +} + +/* ------------------------------------------------------------------ */ +/* Affinités d'un acteur */ +/* ------------------------------------------------------------------ */ + +function _getAffinities(actor) { + const m = actor.system.modifiers ?? {}; + return { + slash: { damage: m.slash_damage_affinity ?? 0, stagger: m.slash_stagger_affinity ?? 0 }, + pierce: { damage: m.pierce_damage_affinity ?? 0, stagger: m.pierce_stagger_affinity ?? 0 }, + blunt: { damage: m.blunt_damage_affinity ?? 0, stagger: m.blunt_stagger_affinity ?? 0 }, + }; +} + +/* ------------------------------------------------------------------ */ +/* Application des effets sur les acteurs */ +/* ------------------------------------------------------------------ */ + +export async function applyClashResults(result, attackerActor, defenderActor, attackerCombatant, defenderCombatant) { + + // Dégâts sur le défenseur + if (result.atkHpDamage > 0 || result.atkStaggerDamage > 0) { + await _applyDamage(defenderActor, result.atkHpDamage, result.atkStaggerDamage); + } + + // Dégâts sur l'attaquant (clash) + if (result.defHpDamage > 0 || result.defStaggerDamage > 0) { + await _applyDamage(attackerActor, result.defHpDamage, result.defStaggerDamage); + } + + // Regain Stagger du défenseur (Evade Win) + if (result.defStaggerRegain > 0) { + await _applyStaggerRegain(defenderActor, result.defStaggerRegain); + } + + // Regain Stagger de l'attaquant (Evade Win vs Def) + if (result.atkStaggerRegain > 0) { + await _applyStaggerRegain(attackerActor, result.atkStaggerRegain); + } + + // PE + if (result.atkPE > 0) await _applyPE(attackerActor, result.atkPE); + if (result.defPE > 0) await _applyPE(defenderActor, result.defPE); + + // États des dés de vitesse + // Attaquant → toujours Used (état 1) après résolution + await markDieState(attackerCombatant, 1); + + // Défenseur : + // - Si des dés sauvegardés → état 2 (Saved Defense Die) + // - Sinon → état 1 (Used) + if (defenderCombatant) { + const defState = result.defSavedDice ? 2 : 1; + await markDieState(defenderCombatant, defState); + } + + // Counter dice côté attaquant → état 2 aussi + if (result.atkSavedDice) { + await markDieState(attackerCombatant, 2); + } +} + +async function _applyDamage(actor, hpDmg, staggerDmg) { + const sys = actor.system; + let hp = (sys.health?.value ?? 0) - hpDmg; + let stg = (sys.stagger?.value ?? 0) - staggerDmg; + + // Cascade stagger → HP si stagger < 0 + if (stg < 0) { + hp -= Math.floor(Math.abs(stg) / 2); + stg = 0; + } + + await actor.update({ + "system.health.value": Math.max(0, hp), + "system.stagger.value": Math.max(0, stg), + }); +} + +async function _applyStaggerRegain(actor, amount) { + const sys = actor.system; + const curr = sys.stagger?.value ?? 0; + const max = sys.stagger?.max ?? curr; + await actor.update({ "system.stagger.value": Math.min(max, curr + amount) }); +} + +async function _applyPE(actor, amount) { + const curr = actor.system.emotion ?? 0; + await actor.update({ "system.emotion": curr + amount }); +} + +/* ------------------------------------------------------------------ */ +/* Gestion de l'état d'un Dé de Vitesse */ +/* ------------------------------------------------------------------ */ + +/** + * Passe un combatant à l'état donné dans SBSA + SIO, et désassigne la compétence. + * @param {Combatant} combatant + * @param {number} state 0=Unused, 1=Used, 2=Saved Defense Die, 3=Assigned + */ +export async function markDieState(combatant, state) { + if (!combatant) return; + + // Désassigner la compétence (sauf si on passe en état 3) + if (state !== 3) { + await combatant.unsetFlag("sotc-battle-show-ability", "assignedSkillId"); + } + + await combatant.setFlag("sotc-battle-show-ability", "dieState", state); + await combatant.setFlag("sotc", "used", state !== 0); + + // Synchroniser SIO + const tokenDoc = combatant.token; + if (tokenDoc && game.modules.get("stars-initiative-overlay")?.active) { + 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); + if (dieIndex === -1) return; + + 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] = state; + await tokenDoc.setFlag("stars-initiative-overlay", "diceStates", states); + } +} + +// Alias conservé pour compatibilité avec main.js +export const markDieAsUsed = (combatant) => markDieState(combatant, 1); + +/* ------------------------------------------------------------------ */ +/* Chat message résumé */ +/* ------------------------------------------------------------------ */ + +export async function sendResultToChat({ attackerName, defenderName, skillName, result }) { + const { matchups, atkHpDamage, atkStaggerDamage, defHpDamage, defStaggerDamage, + defStaggerRegain, atkStaggerRegain, atkPE, defPE } = result; + + const matchupLines = matchups.map(m => { + const sideLabel = m.side === "atk" ? attackerName : defenderName; + const vs = m.clashType === "clash" + ? ` VS + ${m.oppType} ${m.oppPower}` + : ` (sans opposition)`; + const icon = `systems/sotc/assets/dice types/${m.dieType}.png`; + return ` +
+ + ${m.dieType} ${m.power}${vs} + ${m.effects.join(", ")} +
`; + }).join(""); + + const defenderSection = (atkHpDamage > 0 || atkStaggerDamage > 0) + ? `

🎯 ${defenderName} → HP -${atkHpDamage}, Stagger -${atkStaggerDamage}

` + : `

🎯 ${defenderName} → Aucun dégât

`; + + const attackerSection = (defHpDamage > 0 || defStaggerDamage > 0) + ? `

🎯 ${attackerName} → HP -${defHpDamage}, Stagger -${defStaggerDamage}

` + : ""; + + const regainSection = (defStaggerRegain > 0 || atkStaggerRegain > 0) + ? `

💚 Stagger regain : ${defStaggerRegain > 0 ? `${defenderName} +${defStaggerRegain} ` : ""}${atkStaggerRegain > 0 ? `${attackerName} +${atkStaggerRegain}` : ""}

` + : ""; + + const peSection = (atkPE > 0 || defPE > 0) + ? `

💜 PE : ${attackerName} +${atkPE}, ${defenderName} +${defPE}

` + : ""; + + const content = ` +
+

⚔ ${skillName ?? "?"} — ${attackerName} → ${defenderName}

+
${matchupLines}
+
+ ${defenderSection} + ${attackerSection} + ${regainSection} + ${peSection} +
`; + + await ChatMessage.create({ speaker: ChatMessage.getSpeaker(), content }); +} diff --git a/sotc-damage-wizard/module/DamageWizardApp.js b/sotc-damage-wizard/module/DamageWizardApp.js new file mode 100644 index 0000000..90be87d --- /dev/null +++ b/sotc-damage-wizard/module/DamageWizardApp.js @@ -0,0 +1,262 @@ +/** + * sotc-damage-wizard | DamageWizardApp.js + * + * Fenêtre Application FoundryVTT gérant la résolution des dégâts. + * Utilise le moteur DamageEngine.resolveClash() qui implémente les + * règles officielles SotC (séquence linéaire, counter-dice, PE, etc.) + */ + +import { + getAssignedSkill, + getSkillDice, + resolveDie, + resolveClash, + applyClashResults, + sendResultToChat, +} from "./DamageEngine.js"; + +/** + * Cache statique des résultats : persiste entre fermetures/réouvertures. + * Clé : attackerCombatantId + * Valeur : { atkRolls, defRolls, clashResult } + */ +export const _rollCache = new Map(); + +export class DamageWizardApp extends Application { + + constructor(attackerCombatantId, defenderCombatantId, isClash = false, options = {}) { + super(options); + this.attackerCombatantId = attackerCombatantId; + this.defenderCombatantId = defenderCombatantId; + this.isClash = isClash; + + // Restaurer depuis le cache si déjà rollé + const cached = _rollCache.get(attackerCombatantId); + this._atkRolls = cached?.atkRolls ?? null; + this._defRolls = cached?.defRolls ?? null; + this._clashResult = cached?.clashResult ?? null; + } + + /* ------------------------------------------------------------------ */ + /* defaultOptions */ + /* ------------------------------------------------------------------ */ + + static get defaultOptions() { + return foundry.utils.mergeObject(super.defaultOptions, { + id: "sotc-dw-app", + template: "modules/sotc-damage-wizard/templates/damage-wizard.hbs", + classes: ["sotc-dw-window"], + width: 820, + height: "auto", + resizable: false, + popOut: true, + }); + } + + /* ------------------------------------------------------------------ */ + /* Accesseurs */ + /* ------------------------------------------------------------------ */ + + get attackerCombatant() { return game.combat?.combatants.get(this.attackerCombatantId) ?? null; } + get defenderCombatant() { return game.combat?.combatants.get(this.defenderCombatantId) ?? null; } + get attackerActor() { return this.attackerCombatant?.actor ?? null; } + get defenderActor() { return this.defenderCombatant?.actor ?? null; } + + /* ------------------------------------------------------------------ */ + /* _buildSideData */ + /* ------------------------------------------------------------------ */ + + _buildSideData(combatant) { + if (!combatant) return null; + const actor = combatant.actor; + if (!actor) return null; + const sys = actor.system; + const skill = getAssignedSkill(combatant); + const dice = skill ? getSkillDice(skill) : []; + + return { + combatantId: combatant.id, + actorId: actor.id, + name: combatant.name, + img: actor.img, + hpValue: sys.health?.value ?? 0, + hpMax: sys.health?.max ?? 1, + hpPercent: Math.round(((sys.health?.value ?? 0) / (sys.health?.max ?? 1)) * 100), + staggerValue: sys.stagger?.value ?? 0, + staggerMax: sys.stagger?.max ?? 1, + staggerPercent: Math.round(((sys.stagger?.value ?? 0) / (sys.stagger?.max ?? 1)) * 100), + lightValue: sys.light?.value ?? 0, + lightMax: sys.light?.max ?? 1, + emotion: sys.emotion ?? 0, + skillName: skill?.name ?? null, + skillImg: skill?.img ?? null, + dice, + }; + } + + /* ------------------------------------------------------------------ */ + /* getData */ + /* ------------------------------------------------------------------ */ + + async getData(options = {}) { + const attacker = this._buildSideData(this.attackerCombatant); + const defender = this._buildSideData(this.defenderCombatant); + const hasRolled = !!this._clashResult; + + // ── Construire les lignes de matchup pour le template ────────────── + let matchupRows = null; + let summaryAtk = null; + let summaryDef = null; + let summaryPE = null; + + if (this._clashResult) { + const r = this._clashResult; + + matchupRows = r.matchups.map(m => ({ + side: m.side, + dieType: m.dieType, + power: m.power, + formulaDisplay: m.formulaDisplay, + oppType: m.oppType, + oppPower: m.oppPower, + oppFormulaDisplay: m.oppFormulaDisplay ?? null, + clashType: m.clashType, + verdict: m.verdict, + effects: m.effects, + })); + + // Résumé dégâts + summaryAtk = { + name: attacker?.name ?? "?", + hpDmg: r.atkHpDamage, + stgDmg: r.atkStaggerDamage, + stgGain: r.defStaggerRegain, // regain du défenseur (evade win) + }; + summaryDef = { + name: attacker?.name ?? "?", // dégâts SUR l'attaquant + hpDmg: r.defHpDamage, + stgDmg: r.defStaggerDamage, + stgGain: r.atkStaggerRegain, + }; + summaryPE = { atkPE: r.atkPE, defPE: r.defPE }; + } + + return { + isClash: this.isClash, + attacker, + defender, + hasRolled, + matchupRows, + summaryAtk, + summaryDef, + summaryPE, + clashResult: this._clashResult, + }; + } + + /* ------------------------------------------------------------------ */ + /* activateListeners */ + /* ------------------------------------------------------------------ */ + + activateListeners(html) { + super.activateListeners(html); + html.find(".dw-foot-roll").click(() => this._onRoll()); + html.find(".dw-foot-apply").click(() => this._onApply()); + html.find(".dw-foot-cancel").click(() => this.close()); + } + + /* ------------------------------------------------------------------ */ + /* Roll */ + /* ------------------------------------------------------------------ */ + + async _onRoll() { + if (this._clashResult) { + ui.notifications.warn(game.i18n.localize("SOTC_DW.AlreadyRolled")); + return; + } + + const atkActor = this.attackerActor; + const defActor = this.defenderActor; + if (!atkActor || !defActor) return; + + const atkSkill = getAssignedSkill(this.attackerCombatant); + if (!atkSkill) { + ui.notifications.warn(game.i18n.localize("SOTC_DW.NoSkill")); + return; + } + + // ── Roll attaquant ───────────────────────────────────────────────── + const atkDice = getSkillDice(atkSkill); + this._atkRolls = []; + for (const die of atkDice) { + const res = await resolveDie(die, atkActor); + this._atkRolls.push({ die, ...res }); + } + + // ── Roll défenseur (clash uniquement) ────────────────────────────── + this._defRolls = []; + if (this.isClash) { + const defSkill = getAssignedSkill(this.defenderCombatant); + if (defSkill) { + for (const die of getSkillDice(defSkill)) { + const res = await resolveDie(die, defActor); + this._defRolls.push({ die, ...res }); + } + } + } + + // ── Résolution des clashes ───────────────────────────────────────── + this._clashResult = resolveClash(this._atkRolls, this._defRolls, defActor, atkActor); + + // Persister dans le cache + _rollCache.set(this.attackerCombatantId, { + atkRolls: this._atkRolls, + defRolls: this._defRolls, + clashResult: this._clashResult, + }); + + this.render(false); + } + + /* ------------------------------------------------------------------ */ + /* Apply */ + /* ------------------------------------------------------------------ */ + + async _onApply() { + if (!this._clashResult) { + ui.notifications.warn(game.i18n.localize("SOTC_DW.RollFirst")); + return; + } + + const atkActor = this.attackerActor; + const defActor = this.defenderActor; + if (!atkActor || !defActor) return; + + // Appliquer tous les effets (dégâts, PE, états des dés) + await applyClashResults( + this._clashResult, + atkActor, + defActor, + this.attackerCombatant, + this.defenderCombatant + ); + + // Message de chat + const atkSkill = getAssignedSkill(this.attackerCombatant); + await sendResultToChat({ + attackerName: this.attackerCombatant?.name ?? "?", + defenderName: this.defenderCombatant?.name ?? "?", + skillName: atkSkill?.name ?? "?", + result: this._clashResult, + }); + + ui.notifications.info(game.i18n.localize("SOTC_DW.DamageApplied")); + + // Vider le cache et réinitialiser l'état local + _rollCache.delete(this.attackerCombatantId); + this._atkRolls = null; + this._defRolls = null; + this._clashResult = null; + this.render(false); + } +} diff --git a/sotc-damage-wizard/module/main.js b/sotc-damage-wizard/module/main.js new file mode 100644 index 0000000..f078a85 --- /dev/null +++ b/sotc-damage-wizard/module/main.js @@ -0,0 +1,212 @@ +/** + * sotc-damage-wizard | main.js + * + * Point d'entrée du module. + * + * Stratégie d'intégration SBSA : + * On utilise le hook "renderApplication" pour détecter quand SBSA rend + * son panneau et on injecte dynamiquement le bouton "⚔ Damage Wizard" + * dans la section de la compétence assignée. + * + * Compatibilité sotc-clash-arrow : + * clash-arrow écoute aussi renderApplication sur SBSA pour injecter + * ses propres boutons debug. On utilise un attribut data pour éviter + * les injections en double. + * + * Condition d'activation du bouton : + * - dieState === 3 (compétence assignée) + * - Une cible est détectable (ArrowStore ou game.user.targets) + */ + +import { DamageWizardApp, _rollCache } from "./DamageWizardApp.js"; +import { getTargetInfo } from "./ArrowBridge.js"; +import { getAssignedSkill } from "./DamageEngine.js"; + +const SBSA_MODULE = "sotc-battle-show-ability"; + +/* ------------------------------------------------------------------ */ +/* Init */ +/* ------------------------------------------------------------------ */ + +Hooks.once("init", () => { + console.log("sotc-damage-wizard | Initialisation"); + + loadTemplates([ + "modules/sotc-damage-wizard/templates/damage-wizard.hbs" + ]); + + // "eq" est déjà enregistré par le système SotC — on enregistre "gt" si absent + if (!Handlebars.helpers["gt"]) { + Handlebars.registerHelper("gt", (a, b) => a > b); + } +}); + +/* ------------------------------------------------------------------ */ +/* Détection d'un panneau SBSA */ +/* ------------------------------------------------------------------ */ + +/** + * Retourne true si l'Application donnée est un SpeedDiePanelApp SBSA. + * Trois méthodes de détection pour couvrir tous les cas. + */ +function _isSBSAPanel(app) { + // 1. Instance directe via l'API publique SBSA + const sbsaApi = game.modules.get(SBSA_MODULE)?.api; + if (sbsaApi?.SpeedDiePanelApp && app instanceof sbsaApi.SpeedDiePanelApp) return true; + + // 2. L'id de l'Application FoundryVTT + if (app.id === "sotc-bsa-panel") return true; + + // 3. Nom du constructeur (robuste si l'API n'est pas encore montée) + if (app.constructor?.name === "SpeedDiePanelApp") return true; + + return false; +} + +/* ------------------------------------------------------------------ */ +/* Injection du bouton dans le panneau SBSA */ +/* ------------------------------------------------------------------ */ + +Hooks.on("renderApplication", (app, html, data) => { + if (!_isSBSAPanel(app)) return; + _injectDamageButton(app, html); +}); + +/** + * Injecte le bouton "⚔ Damage Wizard" dans le panneau SBSA. + * N'injecte que si une compétence est assignée (dieState === 3). + * Utilise data-dw-injected pour éviter les doublons lors des re-renders. + */ +function _injectDamageButton(app, html) { + const combatantId = app.combatantId; + if (!combatantId) return; + + const combatant = game.combat?.combatants.get(combatantId); + if (!combatant) return; + + // Vérifier qu'une compétence est assignée + const skill = getAssignedSkill(combatant); + if (!skill) return; + + // Retirer un ancien bouton si déjà présent (re-render propre) + html.find(".dw-open-btn-wrapper").remove(); + + // Construire le bouton + const $wrapper = $(` +
+ +
+ `); + + // Insérer après la section compétence assignée, ou en haut du panneau + const $assigned = html.find(".bsa-assigned-section"); + if ($assigned.length) { + $assigned.after($wrapper); + } else { + html.find(".sotc-bsa-panel").prepend($wrapper); + } + + // Listener + $wrapper.find(".dw-open-btn").on("click", ev => { + ev.stopPropagation(); + _openDamageWizard(ev.currentTarget.dataset.combatantId); + }); +} + +/* ------------------------------------------------------------------ */ +/* Ouverture du DamageWizardApp */ +/* ------------------------------------------------------------------ */ + +function _openDamageWizard(attackerCombatantId) { + const attackerCombatant = game.combat?.combatants.get(attackerCombatantId); + if (!attackerCombatant) { + ui.notifications.warn("sotc-damage-wizard : Combatant introuvable."); + return; + } + + // Compétence assignée requise + const skill = getAssignedSkill(attackerCombatant); + if (!skill) { + ui.notifications.warn(game.i18n.localize("SOTC_DW.NoSkill")); + return; + } + + // Cible via ArrowBridge (ArrowStore ou game.user.targets) + const { defenderCombatant, isClash } = getTargetInfo(attackerCombatant); + + if (!defenderCombatant) { + ui.notifications.warn(game.i18n.localize("SOTC_DW.NoTarget")); + return; + } + + // Réutiliser une fenêtre existante si possible + const existing = Object.values(ui.windows).find( + w => w instanceof DamageWizardApp + && w.attackerCombatantId === attackerCombatantId + && w.defenderCombatantId === defenderCombatant.id + ); + if (existing) { + existing.render(true); + existing.bringToTop(); + return; + } + + // Titre + const title = isClash + ? `${game.i18n.localize("SOTC_DW.TitleClash")} — ${attackerCombatant.name} ⚔ ${defenderCombatant.name}` + : `${game.i18n.localize("SOTC_DW.Title")} — ${attackerCombatant.name} → ${defenderCombatant.name}`; + + const wizard = new DamageWizardApp( + attackerCombatantId, + defenderCombatant.id, + isClash, + { title } + ); + wizard.render(true); +} + +/* ------------------------------------------------------------------ */ +/* Réactivité : rafraîchir le bouton si le dé change d'état */ +/* ------------------------------------------------------------------ */ + +Hooks.on("updateCombatant", (combatant, changes, options, userId) => { + if (game.system.id !== "sotc") return; + + for (const win of Object.values(ui.windows)) { + if (_isSBSAPanel(win) && win.combatantId === combatant.id) { + win.render(false); + break; + } + } +}); + +/* ------------------------------------------------------------------ */ +/* Nettoyage en fin de combat / nouveau round */ +/* ------------------------------------------------------------------ */ + +Hooks.on("deleteCombat", () => { + _rollCache.clear(); + for (const win of Object.values(ui.windows)) { + if (win instanceof DamageWizardApp) win.close(); + } +}); + +Hooks.on("combatRound", () => { + _rollCache.clear(); +}); + +/* ------------------------------------------------------------------ */ +/* API publique */ +/* ------------------------------------------------------------------ */ + +Hooks.once("ready", () => { + game.modules.get("sotc-damage-wizard").api = { + DamageWizardApp, + openDamageWizard: _openDamageWizard + }; + console.log("sotc-damage-wizard | Module pret."); +}); diff --git a/sotc-damage-wizard/styles/sotc-dw.css b/sotc-damage-wizard/styles/sotc-dw.css new file mode 100644 index 0000000..2da7381 --- /dev/null +++ b/sotc-damage-wizard/styles/sotc-dw.css @@ -0,0 +1,585 @@ +/* ================================================================== */ +/* sotc-damage-wizard | sotc-dw.css — Style "Résolution de Clash" */ +/* ================================================================== */ + +/* ------------------------------------------------------------------ */ +/* Fenêtre FoundryVTT */ +/* ------------------------------------------------------------------ */ + +.sotc-dw-window { + background: #0e1018; + border: 1px solid #2a2a3a; + box-shadow: 0 0 30px rgba(0,0,0,0.9); + font-family: 'Barlow Condensed', 'Arial Narrow', sans-serif; + color: #c8c0d8; + min-width: 560px; +} + +.sotc-dw-window .window-header { + background: #12141e; + border-bottom: 1px solid #2a2a3a; + color: #e0d8f0; + font-family: 'Cinzel', serif; + letter-spacing: 0.06em; +} + +.sotc-dw-window .window-title { font-size: 13px; color: #e0d8f0; } + +.sotc-dw-window .window-content { + background: transparent; + padding: 0; + overflow: hidden; +} + +/* ------------------------------------------------------------------ */ +/* Racine */ +/* ------------------------------------------------------------------ */ + +.dw-root { + display: flex; + flex-direction: column; + background: #0e1018; +} + +/* ------------------------------------------------------------------ */ +/* Zone principale 3 colonnes */ +/* ------------------------------------------------------------------ */ + +.dw-main { + display: grid; + grid-template-columns: 1fr 220px 1fr; + min-height: 200px; +} + +/* ------------------------------------------------------------------ */ +/* Colonnes */ +/* ------------------------------------------------------------------ */ + +.dw-col { + display: flex; + flex-direction: column; + border-right: 1px solid #1e2030; +} + +.dw-col:last-child { border-right: none; } + +/* En-têtes colonnes */ +.dw-col-header { + display: flex; + align-items: center; + padding: 7px 10px; + border-bottom: 1px solid #1e2030; +} + +.dw-header-attacker { background: #181028; border-bottom-color: #4a2a8a; } +.dw-header-resolution { background: #101018; border-bottom-color: #2a2a3a; justify-content: center; } +.dw-header-defender { background: #101828; border-bottom-color: #1a4a7a; } + +.dw-header-label { + font-family: 'Cinzel', serif; + font-size: 12px; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; + color: #e0d8f0; +} + +/* ------------------------------------------------------------------ */ +/* Ligne compétence */ +/* ------------------------------------------------------------------ */ + +.dw-skill-row { + display: flex; + align-items: center; + gap: 6px; + padding: 6px 10px; + border-bottom: 1px solid #1a1a28; + background: rgba(255,255,255,0.02); + min-height: 36px; +} + +.dw-skill-none { opacity: 0.5; font-style: italic; } + +.dw-skill-icon { + width: 22px; + height: 22px; + border-radius: 2px; + border: 1px solid #3a3a5a; + object-fit: cover; + flex-shrink: 0; +} + +.dw-skill-label { + font-size: 11px; + color: #b0a8c8; + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +/* ------------------------------------------------------------------ */ +/* Cartes de dés */ +/* ------------------------------------------------------------------ */ + +.dw-die-card { + display: flex; + align-items: center; + gap: 6px; + padding: 5px 10px; + border-bottom: 1px solid #1a1a28; + min-height: 34px; +} + +.dw-die-card-preview { opacity: 0.6; } + +.dw-die-card-win { background: rgba(40, 80, 40, 0.25); } +.dw-die-card-lose { background: rgba(80, 20, 20, 0.25); } +.dw-die-card-free { background: rgba(60, 40, 80, 0.20); } + +/* Tag type de dé */ +.dw-die-type-tag { + font-size: 10px; + font-weight: 700; + letter-spacing: 0.06em; + text-transform: uppercase; + padding: 2px 5px; + border-radius: 2px; + flex-shrink: 0; +} + +/* Score */ +.dw-die-score { + font-family: 'Cinzel', serif; + font-size: 18px; + font-weight: 700; + min-width: 28px; + text-align: center; + color: #e0d8f0; +} + +/* Formule (aperçu avant roll) */ +.dw-die-formula-sm { + font-size: 11px; + color: #6a6a8a; +} + +/* Verdict sur la carte */ +.dw-die-verdict { + font-size: 10px; + font-weight: 700; + letter-spacing: 0.05em; + margin-left: auto; +} + +/* Couleurs des verdicts */ +.dw-verdict-win, .dw-die-card-defender .dw-die-verdict { color: #60d060; } +.dw-verdict-lose { color: #e04040; } +.dw-verdict-free { color: #a060e0; } +.dw-verdict-def-win { color: #4080e0; } +.dw-verdict-def-lose { color: #e08040; } + +/* ------------------------------------------------------------------ */ +/* Couleurs par type de dé (tag) */ +/* ------------------------------------------------------------------ */ + +.dw-tag-slash, .dw-tag-counter-slash { background: rgba(180,40,40,0.3); color: #e06060; border: 1px solid #8a2020; } +.dw-tag-pierce,.dw-tag-counter-pierce { background: rgba(180,120,40,0.3); color: #e0a040; border: 1px solid #8a6020; } +.dw-tag-blunt, .dw-tag-counter-blunt { background: rgba(100,60,180,0.3); color: #a080e0; border: 1px solid #503090; } +.dw-tag-block, .dw-tag-counter-block { background: rgba(40,80,180,0.3); color: #6090e0; border: 1px solid #204090; } +.dw-tag-evade, .dw-tag-counter-evade { background: rgba(40,160,100,0.3); color: #50c080; border: 1px solid #206040; } + +/* ------------------------------------------------------------------ */ +/* Colonne Résolution — matchups */ +/* ------------------------------------------------------------------ */ + +.dw-matchup { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 4px; + padding: 5px 8px; + border-bottom: 1px solid #1a1a28; + min-height: 34px; +} + +.dw-matchup-defender { background: rgba(20,40,60,0.3); } + +.dw-matchup-type, +.dw-matchup-vstype { + font-size: 9px; + font-weight: 700; + text-transform: uppercase; + padding: 2px 4px; + border-radius: 2px; + flex-shrink: 0; +} + +.dw-matchup-score, +.dw-matchup-vsscore { + font-family: 'Cinzel', serif; + font-size: 15px; + font-weight: 700; + color: #e0d8f0; + min-width: 22px; + text-align: center; +} + +.dw-matchup-vs { + font-size: 9px; + color: #4a4a6a; + font-weight: 700; + letter-spacing: 0.04em; +} + +.dw-matchup-none { + font-size: 13px; + color: #3a3a5a; +} + +.dw-matchup-verdict { + width: 100%; + font-size: 10px; + font-weight: 700; + letter-spacing: 0.06em; + text-transform: uppercase; + text-align: center; + padding: 2px 4px; + border-radius: 2px; + margin-top: 2px; +} + +/* Fonds de verdict */ +.dw-verdict-bg-win { background: rgba(40,100,40,0.4); color: #70e070; border: 1px solid #306030; } +.dw-verdict-bg-lose { background: rgba(100,20,20,0.4); color: #e06060; border: 1px solid #602020; } +.dw-verdict-bg-free { background: rgba(60,30,100,0.4); color: #b080f0; border: 1px solid #402060; } +.dw-verdict-bg-def-win { background: rgba(30,60,120,0.4); color: #70a0f0; border: 1px solid #204080; } +.dw-verdict-bg-def-lose { background: rgba(100,60,20,0.4); color: #e0a060; border: 1px solid #604020; } +.dw-verdict-bg-def-atk-win { background: rgba(80,40,120,0.4); color: #c080f0; border: 1px solid #502080; } +.dw-verdict-bg-def-atk-lose { background: rgba(60,20,20,0.4); color: #c06060; border: 1px solid #401010; } + +.dw-resolution-hint { + padding: 20px 10px; + text-align: center; + color: #3a3a5a; + font-size: 11px; + font-style: italic; +} + +/* ------------------------------------------------------------------ */ +/* Pied de page */ +/* ------------------------------------------------------------------ */ + +.dw-footer { + border-top: 1px solid #1e2030; + background: #0c0e16; + padding: 8px 12px; + display: flex; + flex-direction: column; + gap: 6px; +} + +/* Résumé des dégâts */ +.dw-summary { + display: flex; + flex-direction: column; + gap: 2px; + font-size: 12px; + padding: 4px 0; +} + +.dw-summary-defender { color: #c0a0e0; } +.dw-summary-attacker { color: #e0b080; } +.dw-summary-clash { color: #a080d0; font-size: 11px; } + +.dw-sum-hp { color: #e04040; font-weight: 700; } +.dw-sum-stg { color: #4080c0; font-weight: 700; } + +/* Boutons */ +.dw-footer-btns { + display: flex; + gap: 8px; +} + +.dw-foot-btn { + flex: 1; + padding: 9px 12px; + border-radius: 3px; + font-family: 'Barlow Condensed', 'Arial Narrow', sans-serif; + font-size: 13px; + font-weight: 700; + letter-spacing: 0.05em; + cursor: pointer; + border: 1px solid; + transition: background 0.15s, box-shadow 0.15s; +} + +.dw-foot-btn:active:not([disabled]) { transform: scale(0.97); } +.dw-foot-btn[disabled] { opacity: 0.4; cursor: default; } + +.dw-foot-cancel { + background: #1a1a28; + border-color: #3a3a5a; + color: #8080a0; +} +.dw-foot-cancel:hover { background: #222238; } + +.dw-foot-roll { + background: linear-gradient(135deg, #1a301a, #0e1e0e); + border-color: #3a7a3a; + color: #70d070; +} +.dw-foot-roll:hover { background: linear-gradient(135deg, #243424, #162016); box-shadow: 0 0 8px rgba(60,120,60,0.4); } + +.dw-foot-apply { + background: linear-gradient(135deg, #3a3010, #221e08); + border-color: #c0a020; + color: #e8d060; + text-shadow: 0 0 6px rgba(232,208,96,0.3); +} +.dw-foot-apply:hover { background: linear-gradient(135deg, #4a4018, #2c2810); box-shadow: 0 0 8px rgba(192,160,32,0.4); } +.dw-foot-apply[disabled] { background: #1a1a1a; border-color: #3a3a3a; color: #5a5a5a; } + +/* ------------------------------------------------------------------ */ +/* Bouton injecté dans le panneau SBSA */ +/* ------------------------------------------------------------------ */ + +.dw-open-btn-wrapper { padding: 4px 8px; } + +.dw-open-btn { + width: 100%; + background: linear-gradient(135deg, #3a0a0a, #1e0505); + border: 1px solid #8b3a3a; + color: #e8c97a; + font-weight: bold; + padding: 6px 8px; + cursor: pointer; + border-radius: 3px; + font-family: 'Cinzel Decorative', 'Cinzel', serif; + font-size: 12px; + letter-spacing: 0.05em; + transition: background 0.15s, box-shadow 0.15s; +} + +.dw-open-btn:hover { + background: linear-gradient(135deg, #5a1a1a, #2e0808); + box-shadow: 0 0 8px rgba(139,58,58,0.5); +} + +.dw-open-btn:active { transform: scale(0.97); } + +/* ------------------------------------------------------------------ */ +/* Chat message */ +/* ------------------------------------------------------------------ */ + +.sotc-dw-chat { + background: #0e1018; + border: 1px solid #2a2a3a; + border-radius: 4px; + padding: 8px 10px; + color: #c8c0d8; + font-family: 'Barlow Condensed', 'Arial Narrow', sans-serif; +} + +.sotc-dw-chat h3 { + font-family: 'Cinzel', serif; + color: #e0d8f0; + font-size: 13px; + margin: 0 0 8px 0; +} + +.sotc-dw-chat p { margin: 3px 0; font-size: 12px; } +.sotc-dw-chat hr { border: none; border-top: 1px solid #2a2a3a; margin: 6px 0; } + +/* ------------------------------------------------------------------ */ +/* Classes manquantes / nouvelles (v2 - logique officielle) */ +/* ------------------------------------------------------------------ */ + +/* Stats block dans les colonnes */ +.dw-stats-block { + padding: 6px 10px; + border-bottom: 1px solid #1a1a28; + display: flex; + flex-direction: column; + gap: 4px; +} + +.dw-stat-row { + display: flex; + align-items: center; + gap: 5px; + font-size: 11px; +} + +.dw-stat-inline { + display: flex; + align-items: center; + gap: 4px; + font-size: 11px; +} + +.dw-stat-lbl { + font-size: 10px; + color: #6a6a8a; + text-transform: uppercase; + letter-spacing: 0.04em; + min-width: 24px; +} + +.dw-stat-val { + font-size: 11px; + color: #b0a8c8; + min-width: 36px; + text-align: right; +} + +.dw-bar-wrap { + flex: 1; + height: 5px; + background: rgba(0,0,0,0.4); + border-radius: 3px; + overflow: hidden; + border: 1px solid #2a2a3a; +} + +.dw-bar { height: 100%; border-radius: 3px; } +.dw-bar-hp { background: linear-gradient(90deg, #7a1a1a, #c04040); } +.dw-bar-stg { background: linear-gradient(90deg, #1a3a7a, #3a6abf); } + +/* Image dé dans les cartes */ +.dw-die-img { + width: 20px; + height: 20px; + border: none; + object-fit: contain; + flex-shrink: 0; +} + +/* Formule petite */ +.dw-die-formula-sm { + font-size: 10px; + color: #5a5a7a; + margin-left: auto; + white-space: nowrap; +} + +/* ── Matchup colonne centre v2 ── */ +.dw-matchup { + padding: 6px 8px; + border-bottom: 1px solid #1a1a28; + display: flex; + flex-direction: column; + gap: 3px; +} + +.dw-matchup-atk { background: rgba(30,20,50,0.3); } +.dw-matchup-def { background: rgba(20,30,50,0.3); } + +.dw-matchup-main { + display: flex; + align-items: center; + gap: 5px; + flex-wrap: wrap; +} + +.dw-matchup-tag { + font-size: 9px; + font-weight: 700; + text-transform: uppercase; + padding: 2px 4px; + border-radius: 2px; + flex-shrink: 0; +} + +.dw-matchup-score { + font-family: 'Cinzel', serif; + font-size: 16px; + font-weight: 700; + color: #e0d8f0; + min-width: 22px; + text-align: center; +} + +.dw-matchup-vs { + font-size: 12px; + color: #6a6a8a; +} + +.dw-matchup-unopposed { + font-size: 10px; + color: #4a4a6a; + font-style: italic; +} + +/* Verdict chip */ +.dw-matchup-verdict { + font-size: 10px; + font-weight: 700; + letter-spacing: 0.05em; + text-align: center; + padding: 2px 6px; + border-radius: 2px; + width: 100%; +} + +/* Effets */ +.dw-matchup-effects { + display: flex; + flex-wrap: wrap; + gap: 3px; +} + +.dw-effect-tag { + font-size: 9px; + background: rgba(255,255,255,0.04); + border: 1px solid #2a2a3a; + border-radius: 2px; + padding: 1px 4px; + color: #8080a0; +} + +/* ── Chips de verdict ── */ +.dw-verdict-chip-off-win { background: rgba(40,100,40,0.4); color: #70e070; border: 1px solid #306030; } +.dw-verdict-chip-off-lose { background: rgba(80,20,20,0.4); color: #e06060; border: 1px solid #601818; } +.dw-verdict-chip-tie { background: rgba(60,60,30,0.4); color: #c0c060; border: 1px solid #606018; } +.dw-verdict-chip-off-win-vs-block { background: rgba(40,80,40,0.4); color: #60d060; border: 1px solid #204020; } +.dw-verdict-chip-off-lose-vs-block { background: rgba(40,60,120,0.4); color: #70a0f0; border: 1px solid #204080; } +.dw-verdict-chip-off-win-vs-evade { background: rgba(40,80,40,0.4); color: #60d060; border: 1px solid #204020; } +.dw-verdict-chip-off-lose-vs-evade { background: rgba(60,30,100,0.4); color: #c080f0; border: 1px solid #402070; } +.dw-verdict-chip-block-win-vs-off { background: rgba(40,60,120,0.4); color: #70a0f0; border: 1px solid #204080; } +.dw-verdict-chip-block-lose-vs-off { background: rgba(80,40,20,0.4); color: #e0a060; border: 1px solid #604020; } +.dw-verdict-chip-block-win-vs-def { background: rgba(40,60,120,0.4); color: #70a0f0; border: 1px solid #204080; } +.dw-verdict-chip-block-lose-vs-def { background: rgba(60,30,20,0.4); color: #c08060; border: 1px solid #402010; } +.dw-verdict-chip-evade-win-vs-off { background: rgba(60,30,100,0.4); color: #c080f0; border: 1px solid #402070; } +.dw-verdict-chip-evade-lose-vs-off { background: rgba(80,40,20,0.4); color: #e0a060; border: 1px solid #604020; } +.dw-verdict-chip-evade-win-vs-def { background: rgba(30,80,50,0.4); color: #60d090; border: 1px solid #204030; } +.dw-verdict-chip-evade-lose-vs-def { background: rgba(60,30,20,0.4); color: #c08060; border: 1px solid #402010; } +.dw-verdict-chip-unopposed-off { background: rgba(40,100,40,0.4); color: #70e070; border: 1px solid #306030; } +.dw-verdict-chip-unopposed-saved { background: rgba(40,60,100,0.4); color: #70a8f0; border: 1px solid #204060; } +.dw-verdict-chip-unopposed-counter-noop { background: rgba(30,30,30,0.4); color: #606080; border: 1px solid #303030; } + +/* ── Fonds de carte de dé par verdict ── */ +.dw-verdict-bg-off-win, +.dw-verdict-bg-unopposed-off { background: rgba(40,80,40,0.15); } +.dw-verdict-bg-off-lose { background: rgba(80,20,20,0.15); } +.dw-verdict-bg-tie { background: rgba(60,60,20,0.10); } +.dw-verdict-bg-unopposed-saved { background: rgba(30,50,100,0.15); } +.dw-verdict-bg-evade-win-vs-off { background: rgba(60,30,100,0.15); } +.dw-verdict-bg-block-win-vs-off, +.dw-verdict-bg-block-win-vs-def { background: rgba(30,60,120,0.15); } + +/* ── Résumé footer ── */ +.dw-summary { + display: flex; + flex-direction: column; + gap: 3px; + font-size: 12px; + padding: 4px 0; +} + +.dw-summary-line { color: #b0a8c8; } +.dw-pe-line { color: #c080f0; } +.dw-saved-line { color: #70a8f0; } +.dw-sum-hp { color: #e04040; font-weight: 700; margin: 0 4px; } +.dw-sum-stg { color: #4080c0; font-weight: 700; margin: 0 4px; } +.dw-sum-gain { color: #60d090; font-weight: 700; margin: 0 4px; } diff --git a/sotc-damage-wizard/templates/damage-wizard.hbs b/sotc-damage-wizard/templates/damage-wizard.hbs new file mode 100644 index 0000000..f92df14 --- /dev/null +++ b/sotc-damage-wizard/templates/damage-wizard.hbs @@ -0,0 +1,249 @@ +{{!-- templates/damage-wizard.hbs --}} +
+ + {{!-- ═══════════════════════════════════════════════════════════════ --}} + {{!-- ZONE PRINCIPALE 3 COLONNES --}} + {{!-- ═══════════════════════════════════════════════════════════════ --}} +
+ + {{!-- ── COLONNE GAUCHE : ATTAQUANT ─────────────────────────────── --}} +
+
+ {{attacker.name}} +
+ + {{!-- Stats --}} +
+
+ HP +
+ {{attacker.hpValue}}/{{attacker.hpMax}} +
+
+ STG +
+ {{attacker.staggerValue}}/{{attacker.staggerMax}} +
+
+ 💡{{attacker.lightValue}}/{{attacker.lightMax}} + 💜{{attacker.emotion}} +
+
+ + {{!-- Compétence --}} + {{#if attacker.skillName}} +
+ {{attacker.skillName}} + {{attacker.skillName}} +
+ {{/if}} + + {{!-- Dés de l'attaquant --}} + {{#if matchupRows}} + {{#each matchupRows}} + {{#if (eq this.side "atk")}} +
+ {{this.dieType}} + {{this.dieType}} + {{this.power}} + {{this.formulaDisplay}} +
+ {{/if}} + {{/each}} + {{else}} + {{#if attacker.dice}} + {{#each attacker.dice}} +
+ {{this.type}} + {{this.type}} + {{this.formula}} +
+ {{/each}} + {{/if}} + {{/if}} +
+ + {{!-- ── COLONNE CENTRE : RÉSOLUTION ──────────────────────────────── --}} +
+
+ Résolution +
+ + {{#if matchupRows}} + {{#each matchupRows}} +
+ + {{!-- Ligne principale : type + score VS type + score --}} +
+ {{this.dieType}} + {{this.power}} + + {{#if (eq this.clashType "clash")}} + ⚔ + {{this.oppType}} + {{this.oppPower}} + {{else}} + — sans opposition — + {{/if}} +
+ + {{!-- Verdict --}} +
+ {{#if (eq this.verdict "off-win")}}✓ VICTOIRE{{/if}} + {{#if (eq this.verdict "off-lose")}}✗ DÉFAITE{{/if}} + {{#if (eq this.verdict "tie")}}= ÉGALITÉ{{/if}} + {{#if (eq this.verdict "off-win-vs-block")}}✓ vs Block{{/if}} + {{#if (eq this.verdict "off-lose-vs-block")}}↑ Block WIN{{/if}} + {{#if (eq this.verdict "off-win-vs-evade")}}✓ vs Evade{{/if}} + {{#if (eq this.verdict "off-lose-vs-evade")}}🔄 Evade WIN{{/if}} + {{#if (eq this.verdict "block-win-vs-off")}}🛡 Block WIN{{/if}} + {{#if (eq this.verdict "block-lose-vs-off")}}🛡↓ Block LOSE{{/if}} + {{#if (eq this.verdict "block-win-vs-def")}}🛡 Block WIN{{/if}} + {{#if (eq this.verdict "block-lose-vs-def")}}🛡↓ Block LOSE{{/if}} + {{#if (eq this.verdict "evade-win-vs-off")}}🔄 RECYCLÉ{{/if}} + {{#if (eq this.verdict "evade-lose-vs-off")}}↓ Evade LOSE{{/if}} + {{#if (eq this.verdict "evade-win-vs-def")}}💚 Stagger Regain{{/if}} + {{#if (eq this.verdict "evade-lose-vs-def")}}↓ Evade LOSE{{/if}} + {{#if (eq this.verdict "unopposed-off")}}◆ SANS OPP.{{/if}} + {{#if (eq this.verdict "unopposed-saved")}}💾 SAUVEGARDÉ{{/if}} + {{#if (eq this.verdict "unopposed-counter-noop")}}— Counter inactif{{/if}} +
+ + {{!-- Effets --}} + {{#if this.effects.length}} +
+ {{#each this.effects}}{{this}}{{/each}} +
+ {{/if}} + +
+ {{/each}} + {{else}} +
Lance les dés pour voir la résolution
+ {{/if}} +
+ + {{!-- ── COLONNE DROITE : DÉFENSEUR ─────────────────────────────── --}} +
+
+ {{defender.name}} +
+ + {{!-- Stats --}} +
+
+ HP +
+ {{defender.hpValue}}/{{defender.hpMax}} +
+
+ STG +
+ {{defender.staggerValue}}/{{defender.staggerMax}} +
+
+ 💡{{defender.lightValue}}/{{defender.lightMax}} + 💜{{defender.emotion}} +
+
+ + {{!-- Compétence défenseur --}} + {{#if isClash}} + {{#if defender.skillName}} +
+ {{defender.skillName}} + {{defender.skillName}} +
+ {{else}} +
— Aucune compétence —
+ {{/if}} + {{else}} +
— Attaque directe —
+ {{/if}} + + {{!-- Dés du défenseur --}} + {{#if matchupRows}} + {{#each matchupRows}} + {{#if (eq this.side "def")}} +
+ {{this.dieType}} + {{this.dieType}} + {{this.power}} + {{this.formulaDisplay}} +
+ {{/if}} + {{/each}} + {{else}} + {{#if isClash}} + {{#if defender.dice}} + {{#each defender.dice}} +
+ {{this.type}} + {{this.type}} + {{this.formula}} +
+ {{/each}} + {{/if}} + {{/if}} + {{/if}} +
+ +
{{!-- /.dw-main --}} + + {{!-- ═══════════════════════════════════════════════════════════════ --}} + {{!-- BARRE INFÉRIEURE --}} + {{!-- ═══════════════════════════════════════════════════════════════ --}} + + +