- é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)
400 lines
14 KiB
JavaScript
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;
|
|
}
|
|
}
|