sotc-clash-arrow : flèches de clash entre combattants
This commit is contained in:
commit
ad63cafdc6
10 changed files with 2414 additions and 0 deletions
37
sotc-damage-wizard/lang/en.json
Normal file
37
sotc-damage-wizard/lang/en.json
Normal file
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
37
sotc-damage-wizard/lang/fr.json
Normal file
37
sotc-damage-wizard/lang/fr.json
Normal file
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
51
sotc-damage-wizard/module.json
Normal file
51
sotc-damage-wizard/module.json
Normal file
|
|
@ -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": {}
|
||||
}
|
||||
168
sotc-damage-wizard/module/ArrowBridge.js
Normal file
168
sotc-damage-wizard/module/ArrowBridge.js
Normal file
|
|
@ -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 };
|
||||
}
|
||||
798
sotc-damage-wizard/module/DamageEngine.js
Normal file
798
sotc-damage-wizard/module/DamageEngine.js
Normal file
|
|
@ -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"
|
||||
? `<span style="color:#4a4a7a;font-size:10px;"> VS </span>
|
||||
<span class="sotc-dw-tag-${m.oppType}">${m.oppType} ${m.oppPower}</span>`
|
||||
: `<span style="color:#4a4a6a;font-size:10px;"> (sans opposition)</span>`;
|
||||
const icon = `systems/sotc/assets/dice types/${m.dieType}.png`;
|
||||
return `
|
||||
<div style="display:flex;align-items:center;gap:5px;margin-bottom:3px;font-size:11px;">
|
||||
<img src="${icon}" style="width:18px;height:18px;border:none;flex-shrink:0;"/>
|
||||
<strong>${m.dieType} ${m.power}</strong>${vs}
|
||||
<span style="margin-left:auto;font-size:10px;color:#8888aa;">${m.effects.join(", ")}</span>
|
||||
</div>`;
|
||||
}).join("");
|
||||
|
||||
const defenderSection = (atkHpDamage > 0 || atkStaggerDamage > 0)
|
||||
? `<p>🎯 <strong>${defenderName}</strong> → HP -${atkHpDamage}, Stagger -${atkStaggerDamage}</p>`
|
||||
: `<p>🎯 <strong>${defenderName}</strong> → Aucun dégât</p>`;
|
||||
|
||||
const attackerSection = (defHpDamage > 0 || defStaggerDamage > 0)
|
||||
? `<p>🎯 <strong>${attackerName}</strong> → HP -${defHpDamage}, Stagger -${defStaggerDamage}</p>`
|
||||
: "";
|
||||
|
||||
const regainSection = (defStaggerRegain > 0 || atkStaggerRegain > 0)
|
||||
? `<p>💚 Stagger regain : ${defStaggerRegain > 0 ? `${defenderName} +${defStaggerRegain} ` : ""}${atkStaggerRegain > 0 ? `${attackerName} +${atkStaggerRegain}` : ""}</p>`
|
||||
: "";
|
||||
|
||||
const peSection = (atkPE > 0 || defPE > 0)
|
||||
? `<p>💜 PE : ${attackerName} +${atkPE}, ${defenderName} +${defPE}</p>`
|
||||
: "";
|
||||
|
||||
const content = `
|
||||
<div class="sotc-dw-chat">
|
||||
<h3>⚔ ${skillName ?? "?"} — ${attackerName} → ${defenderName}</h3>
|
||||
<div style="margin-bottom:6px;">${matchupLines}</div>
|
||||
<hr/>
|
||||
${defenderSection}
|
||||
${attackerSection}
|
||||
${regainSection}
|
||||
${peSection}
|
||||
</div>`;
|
||||
|
||||
await ChatMessage.create({ speaker: ChatMessage.getSpeaker(), content });
|
||||
}
|
||||
262
sotc-damage-wizard/module/DamageWizardApp.js
Normal file
262
sotc-damage-wizard/module/DamageWizardApp.js
Normal file
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
212
sotc-damage-wizard/module/main.js
Normal file
212
sotc-damage-wizard/module/main.js
Normal file
|
|
@ -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 = $(`
|
||||
<div class="dw-open-btn-wrapper" data-dw-injected="true"
|
||||
style="padding: 4px 8px;">
|
||||
<button class="dw-open-btn" data-combatant-id="${combatantId}"
|
||||
title="${game.i18n.localize('SOTC_DW.Title')}">
|
||||
⚔ ${game.i18n.localize("SOTC_DW.Title")}
|
||||
</button>
|
||||
</div>
|
||||
`);
|
||||
|
||||
// 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.");
|
||||
});
|
||||
585
sotc-damage-wizard/styles/sotc-dw.css
Normal file
585
sotc-damage-wizard/styles/sotc-dw.css
Normal file
|
|
@ -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; }
|
||||
249
sotc-damage-wizard/templates/damage-wizard.hbs
Normal file
249
sotc-damage-wizard/templates/damage-wizard.hbs
Normal file
|
|
@ -0,0 +1,249 @@
|
|||
{{!-- templates/damage-wizard.hbs --}}
|
||||
<div class="dw-root {{#if isClash}}dw-clash{{else}}dw-unilateral{{/if}}">
|
||||
|
||||
{{!-- ═══════════════════════════════════════════════════════════════ --}}
|
||||
{{!-- ZONE PRINCIPALE 3 COLONNES --}}
|
||||
{{!-- ═══════════════════════════════════════════════════════════════ --}}
|
||||
<div class="dw-main">
|
||||
|
||||
{{!-- ── COLONNE GAUCHE : ATTAQUANT ─────────────────────────────── --}}
|
||||
<div class="dw-col dw-col-attacker">
|
||||
<div class="dw-col-header dw-header-attacker">
|
||||
<span class="dw-header-label">{{attacker.name}}</span>
|
||||
</div>
|
||||
|
||||
{{!-- Stats --}}
|
||||
<div class="dw-stats-block">
|
||||
<div class="dw-stat-row">
|
||||
<span class="dw-stat-lbl">HP</span>
|
||||
<div class="dw-bar-wrap"><div class="dw-bar dw-bar-hp" style="width:{{attacker.hpPercent}}%"></div></div>
|
||||
<span class="dw-stat-val">{{attacker.hpValue}}/{{attacker.hpMax}}</span>
|
||||
</div>
|
||||
<div class="dw-stat-row">
|
||||
<span class="dw-stat-lbl">STG</span>
|
||||
<div class="dw-bar-wrap"><div class="dw-bar dw-bar-stg" style="width:{{attacker.staggerPercent}}%"></div></div>
|
||||
<span class="dw-stat-val">{{attacker.staggerValue}}/{{attacker.staggerMax}}</span>
|
||||
</div>
|
||||
<div class="dw-stat-inline">
|
||||
<span class="dw-stat-lbl">💡</span><span class="dw-stat-val">{{attacker.lightValue}}/{{attacker.lightMax}}</span>
|
||||
<span class="dw-stat-lbl" style="margin-left:8px;">💜</span><span class="dw-stat-val">{{attacker.emotion}}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{!-- Compétence --}}
|
||||
{{#if attacker.skillName}}
|
||||
<div class="dw-skill-row">
|
||||
<img class="dw-skill-icon" src="{{attacker.skillImg}}" alt="{{attacker.skillName}}" />
|
||||
<span class="dw-skill-label">{{attacker.skillName}}</span>
|
||||
</div>
|
||||
{{/if}}
|
||||
|
||||
{{!-- Dés de l'attaquant --}}
|
||||
{{#if matchupRows}}
|
||||
{{#each matchupRows}}
|
||||
{{#if (eq this.side "atk")}}
|
||||
<div class="dw-die-card dw-verdict-bg-{{this.verdict}}">
|
||||
<img class="dw-die-img" src="systems/sotc/assets/dice types/{{this.dieType}}.png" alt="{{this.dieType}}"/>
|
||||
<span class="dw-die-type dw-tag-{{this.dieType}}">{{this.dieType}}</span>
|
||||
<span class="dw-die-score">{{this.power}}</span>
|
||||
<span class="dw-die-formula-sm">{{this.formulaDisplay}}</span>
|
||||
</div>
|
||||
{{/if}}
|
||||
{{/each}}
|
||||
{{else}}
|
||||
{{#if attacker.dice}}
|
||||
{{#each attacker.dice}}
|
||||
<div class="dw-die-card dw-die-card-preview">
|
||||
<img class="dw-die-img" src="systems/sotc/assets/dice types/{{this.type}}.png" alt="{{this.type}}"/>
|
||||
<span class="dw-die-type dw-tag-{{this.type}}">{{this.type}}</span>
|
||||
<span class="dw-die-formula-sm">{{this.formula}}</span>
|
||||
</div>
|
||||
{{/each}}
|
||||
{{/if}}
|
||||
{{/if}}
|
||||
</div>
|
||||
|
||||
{{!-- ── COLONNE CENTRE : RÉSOLUTION ──────────────────────────────── --}}
|
||||
<div class="dw-col dw-col-resolution">
|
||||
<div class="dw-col-header dw-header-resolution">
|
||||
<span class="dw-header-label">Résolution</span>
|
||||
</div>
|
||||
|
||||
{{#if matchupRows}}
|
||||
{{#each matchupRows}}
|
||||
<div class="dw-matchup dw-matchup-{{this.side}}">
|
||||
|
||||
{{!-- Ligne principale : type + score VS type + score --}}
|
||||
<div class="dw-matchup-main">
|
||||
<span class="dw-matchup-tag dw-tag-{{this.dieType}}">{{this.dieType}}</span>
|
||||
<span class="dw-matchup-score">{{this.power}}</span>
|
||||
|
||||
{{#if (eq this.clashType "clash")}}
|
||||
<span class="dw-matchup-vs">⚔</span>
|
||||
<span class="dw-matchup-tag dw-tag-{{this.oppType}}">{{this.oppType}}</span>
|
||||
<span class="dw-matchup-score">{{this.oppPower}}</span>
|
||||
{{else}}
|
||||
<span class="dw-matchup-unopposed">— sans opposition —</span>
|
||||
{{/if}}
|
||||
</div>
|
||||
|
||||
{{!-- Verdict --}}
|
||||
<div class="dw-matchup-verdict dw-verdict-chip-{{this.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}}
|
||||
</div>
|
||||
|
||||
{{!-- Effets --}}
|
||||
{{#if this.effects.length}}
|
||||
<div class="dw-matchup-effects">
|
||||
{{#each this.effects}}<span class="dw-effect-tag">{{this}}</span>{{/each}}
|
||||
</div>
|
||||
{{/if}}
|
||||
|
||||
</div>
|
||||
{{/each}}
|
||||
{{else}}
|
||||
<div class="dw-resolution-hint">Lance les dés pour voir la résolution</div>
|
||||
{{/if}}
|
||||
</div>
|
||||
|
||||
{{!-- ── COLONNE DROITE : DÉFENSEUR ─────────────────────────────── --}}
|
||||
<div class="dw-col dw-col-defender">
|
||||
<div class="dw-col-header dw-header-defender">
|
||||
<span class="dw-header-label">{{defender.name}}</span>
|
||||
</div>
|
||||
|
||||
{{!-- Stats --}}
|
||||
<div class="dw-stats-block">
|
||||
<div class="dw-stat-row">
|
||||
<span class="dw-stat-lbl">HP</span>
|
||||
<div class="dw-bar-wrap"><div class="dw-bar dw-bar-hp" style="width:{{defender.hpPercent}}%"></div></div>
|
||||
<span class="dw-stat-val">{{defender.hpValue}}/{{defender.hpMax}}</span>
|
||||
</div>
|
||||
<div class="dw-stat-row">
|
||||
<span class="dw-stat-lbl">STG</span>
|
||||
<div class="dw-bar-wrap"><div class="dw-bar dw-bar-stg" style="width:{{defender.staggerPercent}}%"></div></div>
|
||||
<span class="dw-stat-val">{{defender.staggerValue}}/{{defender.staggerMax}}</span>
|
||||
</div>
|
||||
<div class="dw-stat-inline">
|
||||
<span class="dw-stat-lbl">💡</span><span class="dw-stat-val">{{defender.lightValue}}/{{defender.lightMax}}</span>
|
||||
<span class="dw-stat-lbl" style="margin-left:8px;">💜</span><span class="dw-stat-val">{{defender.emotion}}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{!-- Compétence défenseur --}}
|
||||
{{#if isClash}}
|
||||
{{#if defender.skillName}}
|
||||
<div class="dw-skill-row">
|
||||
<img class="dw-skill-icon" src="{{defender.skillImg}}" alt="{{defender.skillName}}" />
|
||||
<span class="dw-skill-label">{{defender.skillName}}</span>
|
||||
</div>
|
||||
{{else}}
|
||||
<div class="dw-skill-row dw-skill-none"><span class="dw-skill-label">— Aucune compétence —</span></div>
|
||||
{{/if}}
|
||||
{{else}}
|
||||
<div class="dw-skill-row dw-skill-none"><span class="dw-skill-label">— Attaque directe —</span></div>
|
||||
{{/if}}
|
||||
|
||||
{{!-- Dés du défenseur --}}
|
||||
{{#if matchupRows}}
|
||||
{{#each matchupRows}}
|
||||
{{#if (eq this.side "def")}}
|
||||
<div class="dw-die-card dw-verdict-bg-{{this.verdict}}">
|
||||
<img class="dw-die-img" src="systems/sotc/assets/dice types/{{this.dieType}}.png" alt="{{this.dieType}}"/>
|
||||
<span class="dw-die-type dw-tag-{{this.dieType}}">{{this.dieType}}</span>
|
||||
<span class="dw-die-score">{{this.power}}</span>
|
||||
<span class="dw-die-formula-sm">{{this.formulaDisplay}}</span>
|
||||
</div>
|
||||
{{/if}}
|
||||
{{/each}}
|
||||
{{else}}
|
||||
{{#if isClash}}
|
||||
{{#if defender.dice}}
|
||||
{{#each defender.dice}}
|
||||
<div class="dw-die-card dw-die-card-preview">
|
||||
<img class="dw-die-img" src="systems/sotc/assets/dice types/{{this.type}}.png" alt="{{this.type}}"/>
|
||||
<span class="dw-die-type dw-tag-{{this.type}}">{{this.type}}</span>
|
||||
<span class="dw-die-formula-sm">{{this.formula}}</span>
|
||||
</div>
|
||||
{{/each}}
|
||||
{{/if}}
|
||||
{{/if}}
|
||||
{{/if}}
|
||||
</div>
|
||||
|
||||
</div>{{!-- /.dw-main --}}
|
||||
|
||||
{{!-- ═══════════════════════════════════════════════════════════════ --}}
|
||||
{{!-- BARRE INFÉRIEURE --}}
|
||||
{{!-- ═══════════════════════════════════════════════════════════════ --}}
|
||||
<div class="dw-footer">
|
||||
|
||||
{{#if clashResult}}
|
||||
<div class="dw-summary">
|
||||
{{!-- Dégâts sur le défenseur --}}
|
||||
<span class="dw-summary-line">
|
||||
🎯 <strong>{{defender.name}}</strong> →
|
||||
<span class="dw-sum-hp">HP −{{clashResult.atkHpDamage}}</span>
|
||||
<span class="dw-sum-stg">Stagger −{{clashResult.atkStaggerDamage}}</span>
|
||||
{{#if clashResult.defStaggerRegain}}
|
||||
<span class="dw-sum-gain">Stagger +{{clashResult.defStaggerRegain}}</span>
|
||||
{{/if}}
|
||||
</span>
|
||||
{{!-- Dégâts sur l'attaquant (clash) --}}
|
||||
{{#if isClash}}
|
||||
<span class="dw-summary-line">
|
||||
🎯 <strong>{{attacker.name}}</strong> →
|
||||
<span class="dw-sum-hp">HP −{{clashResult.defHpDamage}}</span>
|
||||
<span class="dw-sum-stg">Stagger −{{clashResult.defStaggerDamage}}</span>
|
||||
{{#if clashResult.atkStaggerRegain}}
|
||||
<span class="dw-sum-gain">Stagger +{{clashResult.atkStaggerRegain}}</span>
|
||||
{{/if}}
|
||||
</span>
|
||||
{{/if}}
|
||||
{{!-- PE --}}
|
||||
{{#if clashResult.atkPE}}
|
||||
<span class="dw-summary-line dw-pe-line">
|
||||
💜 PE :
|
||||
<strong>{{attacker.name}}</strong> +{{clashResult.atkPE}}
|
||||
{{#if isClash}}<strong>{{defender.name}}</strong> +{{clashResult.defPE}}{{/if}}
|
||||
</span>
|
||||
{{/if}}
|
||||
{{!-- Dés sauvegardés --}}
|
||||
{{#if clashResult.defSavedDice}}
|
||||
<span class="dw-summary-line dw-saved-line">💾 {{defender.name}} → Saved Defense Die (état 2)</span>
|
||||
{{/if}}
|
||||
{{#if clashResult.atkSavedDice}}
|
||||
<span class="dw-summary-line dw-saved-line">💾 {{attacker.name}} → Saved Defense Die (état 2)</span>
|
||||
{{/if}}
|
||||
</div>
|
||||
{{/if}}
|
||||
|
||||
<div class="dw-footer-btns">
|
||||
<button class="dw-foot-btn dw-foot-cancel">✕ Annuler</button>
|
||||
{{#if hasRolled}}
|
||||
<button class="dw-foot-btn dw-foot-apply">✔ Appliquer</button>
|
||||
{{else}}
|
||||
<button class="dw-foot-btn dw-foot-roll">🎲 Lancer les dés</button>
|
||||
{{/if}}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
Loading…
Add table
Add a link
Reference in a new issue