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)
This commit is contained in:
@@ -0,0 +1,399 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user