Files
vermine2047/module/system/dialogs/combatDialog.mjs
T
uberwald f2c8cfe246 feat: équipement, échanges de coups et améliorations des fiches
- équipement des armes et protections (champ equipped, liste des équipés, toggle)
- échanges de coups automatisés (attaque/défense/résolution/blessures dans le chat)
- dialogue d'attaque par type (personnage/PNJ/créature), portées et difficultés
- conservation du scroll des fiches lors des modifs (option scrollable + classe active)
- verrouillage des compétences PNJ en mode jeu
- corrections review combat (défenseur, difficulté >10, multi-cibles, permissions)
2026-08-04 14:14:03 +02:00

400 lines
14 KiB
JavaScript

import { VermineUtils } from "../roll.mjs";
import { VermineExchange } from "../exchange.mjs";
const { HandlebarsApplicationMixin } = foundry.applications.api;
/**
* Dialogue d'attaque pour les échanges de coups.
* Prépare le pool, la difficulté (contact / distance) et le contexte
* d'échange (cibles, dégâts de base) selon le type d'acteur :
* - Personnage : capacité + compétence de l'arme.
* - PNJ : pool d'attaque du niveau de menace + réserves de rôle.
* - Créature : pool d'attaque calculé, difficulté du statut de combat.
*/
export default class CombatDialog extends HandlebarsApplicationMixin(foundry.applications.api.ApplicationV2) {
static DEFAULT_OPTIONS = {
classes: ["vermine-roll"],
tag: "form",
window: {
icon: "fas fa-crosshairs",
resizable: false
},
position: {
width: 520,
height: 600
},
actions: {
roll: CombatDialog.#onRoll,
cancel: CombatDialog.#onCancel
}
};
static PARTS = {
main: { template: "systems/vermine2047/templates/dialogs/combat-dialog.hbs" }
};
static async create({ actorId, weapon = null }) {
const actor = await game.actors.get(actorId);
if (!actor) {
ui.notifications.warn(game.i18n.localize("VERMINE.error_no_actor_selected"));
return null;
}
const mode = actor.type === "creature" ? "contact" : VermineExchange.modeForWeapon(weapon);
return new CombatDialog({ actor, weapon, mode });
}
constructor(options = {}) {
super(options);
this.actor = options.actor;
this.weapon = options.weapon ?? null;
this.mode = options.mode ?? "contact";
this.abilityKey = VermineExchange.attackAbility(this.mode);
this.skillKey = this.weapon?.system?.skill || null;
this.skillValue = this.skillKey ? (this.actor.system?.skills?.[this.skillKey]?.value || 0) : 0;
this.skillPool = this.skillValue ? (CONFIG.VERMINE.SkillLevels?.[this.skillValue]?.dicePool || 0) : 0;
this.skillReroll = this.skillValue ? (CONFIG.VERMINE.SkillLevels?.[this.skillValue]?.reroll || 0) : 0;
this.basePool = VermineExchange.baseAttackPool(this.actor, this.weapon, this.mode);
this.baseDamage = VermineExchange.baseDamage(this.actor, this.weapon);
}
get title() {
return game.i18n.localize("VERMINE.attack");
}
async _prepareContext() {
const actor = this.actor;
const isCharacter = actor.type === "character";
const isNpc = actor.type === "npc";
const isCreature = actor.type === "creature";
const targets = [...game.user.targets].map(t => ({
id: t.actor?.id ?? null,
uuid: t.actor?.uuid ?? null,
name: t.name,
canDefend: t.actor?.type !== "creature"
}));
let difficultyOptions = [];
let lockedDifficulty = null;
let defaultDifficulty = null;
if (isCreature) {
const d = parseInt(actor.system?.combatStatus?.difficulty, 10) || 9;
lockedDifficulty = d;
difficultyOptions = [{ difficulty: d, label: "", locked: true }];
defaultDifficulty = d;
} else if (this.mode === "ranged") {
difficultyOptions = VermineExchange.rangeDifficultyOptions().map(o => ({
difficulty: o.difficulty,
label: game.i18n.localize(o.label),
rangeKey: o.key
}));
defaultDifficulty = difficultyOptions[0]?.difficulty ?? 7;
} else if (isNpc) {
difficultyOptions = VermineExchange.npcDifficultyOptions(actor).map(d => ({
difficulty: d,
label: String(d)
}));
defaultDifficulty = difficultyOptions[0]?.difficulty ?? 7;
} else {
difficultyOptions = VermineExchange.contactDifficultyOptions(this.skillValue).map(d => ({
difficulty: d,
label: String(d)
}));
defaultDifficulty = difficultyOptions[0]?.difficulty ?? 7;
}
// Cible de type créature : défense passive, la difficulté d'attaque est
// forcée au statut de combat de la créature (toute réussite ≥1 touche).
const creatureTargets = targets.filter(t => t.id && !t.canDefend);
const creatureLocked = !isCreature && creatureTargets.length && creatureTargets.length === targets.length;
if (creatureLocked) {
const ref = creatureTargets[0];
const creature = ref.id ? game.actors.get(ref.id) : null;
const actor = creature ?? (ref.uuid ? await fromUuid(ref.uuid) : null);
const d = parseInt(actor?.system?.combatStatus?.difficulty, 10) || 7;
lockedDifficulty = d;
difficultyOptions = [{ difficulty: d, label: "", locked: true }];
defaultDifficulty = d;
}
const threat = actor.system?.threat?.value || 1;
const role = actor.system?.role?.value || 1;
return {
actor,
system: actor.system,
config: CONFIG.VERMINE,
actorType: actor.type,
mode: this.mode,
weapon: this.weapon,
abilityKey: this.abilityKey,
abilityValue: actor.system?.abilities?.[this.abilityKey]?.value || 0,
skillKey: this.skillKey,
skillValue: this.skillValue,
basePool: this.basePool,
baseDamage: this.baseDamage,
isCharacter,
isNpc,
isCreature,
creatureLocked,
npcThreatPool: isNpc ? (CONFIG.VERMINE.npcThreatLevels?.[threat]?.attack || 0) : 0,
npcPoolMax: isNpc ? (CONFIG.VERMINE.npcRoleLevels?.[role]?.pools || 0) : 0,
difficultyOptions,
lockedDifficulty,
defaultDifficulty,
hasAmmo: this.mode === "ranged" && (this.weapon?.system?.ammo ?? 0) > 0,
availableSpecialties: actor.items.filter(i => i.type === "specialty"),
availableItems: actor.items.filter(i => i.type === "item"),
targets
};
}
async _onRender(context, options) {
this.element.dataset.actorId = this.actor.id;
this.element.dataset.weaponId = this.weapon?.id ?? "";
this.element.dataset.mode = this.mode;
for (const inp of this.element.querySelectorAll("[data-roll]")) {
inp.addEventListener("change", this.#onInputChange.bind(this));
}
const selfControl = this.element.querySelector("#self_control");
if (selfControl) {
selfControl.addEventListener("change", this.#onChangeSelfControl.bind(this));
}
this.element.querySelector("#second-action")?.addEventListener("change", () => this.#updateUI());
this.element.querySelector("#handicap")?.addEventListener("change", () => this.#updateUI());
this.element.querySelector("#human-totem")?.addEventListener("change", () => this.#updateUI());
this.element.querySelector("#adapted-totem")?.addEventListener("change", () => this.#updateUI());
this.element.querySelector("#pools-spent")?.addEventListener("change", () => this.#updateUI());
const ability = this.element.querySelector("#ability");
if (ability && this.actor.type === "character") {
const sc = this.element.querySelector("#self_control");
if (sc) sc.max = ability.value;
}
this.#updateUI();
}
// ── Getters ──────────────────────────────────────────────────────────
get #el() { return this.element; }
#getDifficultyBase() {
const el = this.#el.querySelector('input[name="combat-difficulty"]:checked');
return parseInt(el?.value, 10) || 7;
}
#getSecondAction() {
return this.#el.querySelector("#second-action")?.checked ?? false;
}
#getHandicap() {
const sel = this.#el.querySelector("#handicap");
return Math.max(0, (parseInt(sel?.value, 10) || 1) - 1);
}
#getSelfCtrl() {
return parseInt(this.#el.querySelector("#self_control")?.value, 10) || 0;
}
#getPoolsSpent() {
return parseInt(this.#el.querySelector("#pools-spent")?.value, 10) || 0;
}
#getUseAmmo() {
return this.#el.querySelector("#use-ammo")?.checked ?? false;
}
#getTotems() {
return {
human: this.#el.querySelector("#human-totem")?.checked ?? false,
adapted: this.#el.querySelector("#adapted-totem")?.checked ?? false
};
}
#getKeepTotem() {
return this.#el.querySelector("#keep-totem-select")?.value ?? null;
}
getDifficultySelect() {
return this.#getDifficultyBase() + (this.#getSecondAction() ? 2 : 0);
}
getSkillCategory() {
if (this.actor.type !== "character" || !this.skillKey) return null;
return this.actor.system.skills[this.skillKey]?.category ?? null;
}
getSkillLevel() {
return this.actor.type === "character" && this.skillValue ? this.skillValue : null;
}
getReroll() {
if (this.actor.type === "npc") {
const exp = this.actor.system?.experience?.value || 1;
return CONFIG.VERMINE.npcExperienceLevels?.[exp]?.rerolls || 0;
}
if (this.actor.type === "creature") return 0;
return this.skillReroll;
}
getMaxEffort() {
if (this.actor.type === "character") return this.actor.system?.abilities?.[this.abilityKey]?.value || 0;
return 0;
}
getPoolBreakdown() {
const ability = this.actor.type === "character"
? (this.actor.system?.abilities?.[this.abilityKey]?.value || 0)
: 0;
const skill = this.actor.type === "character" ? this.skillPool : 0;
const selfControl = this.#getSelfCtrl();
const pools = this.actor.type === "npc" ? this.#getPoolsSpent() : 0;
const specialty = this.#hasSpecialtySelected() ? 1 : 0;
const help = this.#el.querySelector("#helped")?.checked ? 1 : 0;
const toolsChecked = this.#el.querySelector("input[name='usingTools']:checked");
const tooling = toolsChecked && toolsChecked.value !== "0" ? 1 : 0;
const group = parseInt(this.#el.querySelector("#group")?.value, 10) || 0;
const total = this.basePool + selfControl + pools + specialty + help + tooling + group;
return { ability, skill, selfControl, pools, specialty, help, tooling, group, total };
}
getDicePool() {
return this.getPoolBreakdown().total;
}
#hasSpecialtySelected() {
const checked = this.#el.querySelector("input[name='usingSpecialization']:checked");
return Boolean(checked && checked.value !== "aucune");
}
getSpecialtyName() {
const checked = this.#el.querySelector("input[name='usingSpecialization']:checked");
return checked && checked.value !== "aucune" ? checked.value : null;
}
getLabel() {
const base = this.mode === "ranged"
? game.i18n.localize("VERMINE.attack_ranged")
: game.i18n.localize("VERMINE.attack_contact");
return this.weapon ? `${base} (${this.weapon.name})` : base;
}
// ── UI ───────────────────────────────────────────────────────────────
#updateUI() {
const total = this.getDicePool();
const totalEl = this.#el.querySelector("#dice-pool-total");
if (totalEl) totalEl.textContent = `${total}D`;
const finalEls = this.#el.querySelectorAll("#final-difficulty");
for (const el of finalEls) el.textContent = this.getDifficultySelect();
const b = this.getPoolBreakdown();
const bonusEl = this.#el.querySelector("#total-bonus");
if (bonusEl) bonusEl.textContent = b.selfControl + b.pools + b.specialty + b.help + b.tooling + b.group;
const handSel = this.#el.querySelector("#handicap");
const handEl = this.#el.querySelector("#current-handicap");
if (handSel && handEl) {
handEl.textContent = handSel.options[handSel.selectedIndex]?.text ?? "";
}
}
#onInputChange() {
this.#updateUI();
}
#onChangeSelfControl(ev) {
const valEl = this.#el.querySelector("#self_control_value");
if (valEl) valEl.textContent = ev.currentTarget.value;
this.#updateUI();
}
static async #onCancel(event, target) {
this.close();
}
static async #onRoll(event, target) {
const selfCtrl = this.getPoolBreakdown().selfControl;
if (selfCtrl > 0) {
const current = this.actor?.system?.attributes?.self_control?.value ?? 0;
if (current < selfCtrl) {
ui.notifications.warn(game.i18n.localize("VERMINE.error_not_enough_self_control"));
return;
}
}
const finalDifficulty = this.getDifficultySelect();
// Consommation de munitions (armes à distance)
if (this.mode === "ranged" && this.#getUseAmmo() && this.weapon) {
const ammo = this.weapon.system.ammo || 0;
if (ammo > 0) {
await this.weapon.update({ "system.ammo": ammo - 1 });
}
}
if (selfCtrl > 0) {
const newVal = this.actor.system.attributes.self_control.value - selfCtrl;
await this.actor.update({ "system.attributes.self_control.value": newVal });
}
const targets = [...game.user.targets].map(t => ({
id: t.actor?.id ?? null,
uuid: t.actor?.uuid ?? null,
name: t.name,
canDefend: t.actor?.type !== "creature"
}));
const attack = {
id: foundry.utils.randomID(),
attackerId: this.actor.id,
attackerName: this.actor.name,
mode: this.mode,
difficulty: finalDifficulty,
range: this.mode === "ranged" ? this.#getRangeKey() : null,
secondAction: this.#getSecondAction(),
baseDamage: this.baseDamage,
targets,
weapon: this.weapon
? { name: this.weapon.name, img: this.weapon.img, damage: foundry.utils.duplicate(this.weapon.system.damage) }
: null
};
await VermineUtils.roll({
actor: this.actor,
NoD: this.getDicePool(),
Reroll: this.getReroll(),
difficulty: finalDifficulty,
handicap: this.#getHandicap(),
rollLabel: this.getLabel(),
totems: this.#getTotems(),
self_control: selfCtrl,
max_effort: this.getMaxEffort(),
keepTotem: this.#getKeepTotem(),
skillCategory: this.getSkillCategory(),
skillLevel: this.getSkillLevel(),
hasSpecialty: this.#hasSpecialtySelected(),
poolBreakdown: this.getPoolBreakdown(),
specialtyName: this.getSpecialtyName(),
weapon: this.weapon?.toObject() ?? null,
targets: targets.map(t => t.name),
attack,
messageFlags: { "vermine-exchange": attack }
});
this.close();
}
#getRangeKey() {
const el = this.#el.querySelector('input[name="combat-difficulty"]:checked');
return el?.dataset?.rangeKey ?? null;
}
}