Files
vermine2047/module/system/dialogs/npcRollDialog.mjs
T
uberwald ef01651b1d feat(traits): tâche 0 + phases 2-4 — automatisation complète des Traits
Tâche 0 :
- item.mjs : champ equipped sur items génériques + toggle fiche
- nouveau module/system/traits.mjs (helpers partagés : equippedItems, lourdMalus, mobilityHandicap, feticheItem…)

Phase 2 — Combat :
- Lourd : malus cumulés auto (Vigueur < n) dans le dialogue + pool
- Rapide : handicap (n) pré-rempli dans la défense (character + PNJ) + note carte
- Rafale : case tir en rafale (+2 difficulté), +n réussites si réussi (bonusOnSuccess)
- Portée : portée effective n × Vigueur affichée
- Maniable/Mobilité : note de rappel (dialogue + carte)
- Incapacitant : bouton « Jet de Santé (n) » sur la carte d'échange
- getLabel() : libellés caractéristiques/compétences localisés

Phase 3 — Jets d'action :
- Ponctuel : annule jusqu'à n Handicaps, consommé au jet (Traits supprimé à 0)
- Pratique : outil sélectionné +2D au lieu de +1D
- Intimidant : case « Brandit » → -1D Psychologie ou Relance 1D
- Malus : champ « Malus temporaires » manuel déduit du pool
- Durée : note « n tours » sur la carte de jet d'arme
- roll() : transmission effectiveRange/mobilityHandicap/ponctuelUsed/intimidant (corrige l'affichage Portée/Mobilité)

Phase 4 — Acteur :
- Fétiche : +1D Effort ET Sang-Froid (max dérivé, config corrigé), alerte 2+ fétiches, action « Perdre le fétiche » (-2D, max -1D persistant feticheLoss)
- Zone : note + bouton « Appliquer aux cibles de la zone » (#onApplyZone)

CSS : styles LESS pour les nouvelles classes (fetiche-block, exchange-reminder/zone, traits-reminder, tool-bonus/ponctuel, duree-note)
i18n : 29 clés fr/en
TRAITS_PLAN.md : plan complet cochée
2026-08-07 15:30:41 +02:00

230 lines
7.4 KiB
JavaScript

import { VermineUtils } from "../roll.mjs"
import { VermineExchange } from "../exchange.mjs"
const { HandlebarsApplicationMixin } = foundry.applications.api
/**
* Dialogue de jet simplifié pour les PNJ.
*
* Un PNJ n'a ni caractéristiques ni compétences : toutes ses actions sont
* résolues par une valeur unique dérivée de son Profil (Menace / Expérience /
* Rôle), exposée dans system.computed. Ce dialogue fixe le pool selon le type
* de jet (Action, Spécialité, Attaque ou Réaction) et n'offre que la difficulté,
* le handicap et les bonus simples.
*
* Utilisé par :
* - la fiche PNJ (boutons de jet rapide Action / Spécialité / Réaction) ;
* - la défense des PNJ dans les échanges de coups (mode verrouillé).
*/
export default class NpcRollDialog extends HandlebarsApplicationMixin(foundry.applications.api.ApplicationV2) {
#actor
get title() {
return game.i18n.localize("VERMINE.roll")
}
static DEFAULT_OPTIONS = {
classes: ["vermine-roll"],
tag: "form",
window: {
icon: "fas fa-dice-d10",
resizable: false
},
position: {
width: 520,
height: 600
},
actions: {
roll: NpcRollDialog.#onRoll,
cancel: NpcRollDialog.#onCancel
}
}
static PARTS = {
main: { template: "systems/vermine2047/templates/dialogs/npc-roll-dialog.hbs" }
}
static async create(data = {}) {
const actor = data.actor instanceof Actor ? data.actor : await game.actors.get(data.actorId)
if (!actor) {
ui.notifications.warn(game.i18n.localize("VERMINE.error_no_actor_selected"))
return null
}
return new NpcRollDialog({
actor,
rolltype: data.rolltype ?? "action",
locks: data.locks ?? null,
defense: data.defense ?? null
})
}
constructor(options = {}) {
super(options)
this.#actor = options.actor
this.rolltype = options.rolltype ?? "action"
this.locks = options.locks ?? null
this.defense = options.defense ?? null
}
// ── Getters ──────────────────────────────────────────────────────────
get computed() { return this.#actor.system?.computed || {} }
/** Pool de dés selon le type de jet. */
get pool() {
const c = this.computed
switch (this.rolltype) {
case "specialty": return c.specialties || 0
case "attack": return c.attack || 0
case "reaction": return c.reaction || 0
default: return c.action || 3
}
}
/** Réussites automatiques (bonus de Réaction du rôle). */
get freeSuccesses() {
return this.rolltype === "reaction" ? (this.computed.reactionBonus || 0) : 0
}
/** Relances : utilisables uniquement sur les attaques ou les Spécialités. */
get rerolls() {
if (this.rolltype !== "specialty" && this.rolltype !== "attack") return 0
return this.computed.rerolls || 0
}
get label() {
const keys = {
action: "ADVERSITY.action",
specialty: "ADVERSITY.specialties",
attack: "ADVERSITY.attack",
reaction: "ADVERSITY.reaction"
}
return game.i18n.localize(keys[this.rolltype] || "VERMINE.roll")
}
// ── Rendu ────────────────────────────────────────────────────────────
async _prepareContext() {
const difficultyOptions = []
for (let d = 3; d <= 10; d++) {
difficultyOptions.push({ difficulty: d, label: String(d) })
}
let lockedDifficulty = null
let defaultDifficulty = 7
if (this.locks?.difficulty !== undefined && this.locks?.difficulty !== null) {
lockedDifficulty = parseInt(this.locks.difficulty, 10)
defaultDifficulty = lockedDifficulty
}
return {
actor: this.#actor,
system: this.#actor.system,
config: CONFIG.VERMINE,
rollLabel: this.label,
rolltype: this.rolltype,
pool: this.pool,
freeSuccesses: this.freeSuccesses,
rerolls: this.rerolls,
isDefense: Boolean(this.defense),
lockedDifficulty,
difficultyOptions,
defaultDifficulty,
speakerId: this.#actor.id,
availableItems: this.#actor.items.filter(i => i.type === "item")
}
}
async _onRender(context, options) {
this.element.dataset.actorId = this.#actor.id
for (const inp of this.element.querySelectorAll("[data-roll]")) {
inp.addEventListener("change", this.#onInputChange.bind(this))
}
this.element.querySelector("#handicap")?.addEventListener("change", () => this.#updateUI())
// Trait Rapide (n) : le défenseur subit (n) Handicaps sur sa défense.
if (this.locks?.handicap !== undefined && this.locks?.handicap !== null) {
const sel = this.#el.querySelector("#handicap")
if (sel) {
const locked = parseInt(this.locks.handicap, 10)
let opt = [...sel.options].find(o => parseInt(o.value, 10) === locked + 1)
if (!opt) {
opt = document.createElement("option")
opt.value = String(locked + 1)
opt.textContent = game.i18n.localize("VERMINE.handicap") + ` (${locked})`
sel.appendChild(opt)
}
opt.selected = true
}
}
this.#updateUI()
}
get #el() { return this.element }
#getHandicap() {
const sel = this.#el.querySelector("#handicap")
return Math.max(0, (parseInt(sel?.value, 10) || 1) - 1)
}
#getDifficulty() {
const sel = this.#el.querySelector("#difficulty")
return parseInt(sel?.value, 10) || 7
}
#getHelped() { return this.#el.querySelector("#helped")?.checked ? 1 : 0 }
#getGroup() { return parseInt(this.#el.querySelector("#group")?.value, 10) || 0 }
getPoolBreakdown() {
const help = this.#getHelped()
const group = this.#getGroup()
const malus = VermineExchange.woundMalus(this.#actor)
return { pool: this.pool, help, group, malus, total: Math.max(0, this.pool + help + group - malus) }
}
#updateUI() {
const b = this.getPoolBreakdown()
const totalEl = this.#el.querySelector("#dice-pool-total")
if (totalEl) totalEl.textContent = `${b.total}D`
const bonusEl = this.#el.querySelector("#total-bonus")
if (bonusEl) bonusEl.textContent = b.help + b.group - b.malus
const freeEl = this.#el.querySelector("#free-successes")
if (freeEl) freeEl.textContent = String(this.freeSuccesses)
}
#onInputChange() {
this.#updateUI()
}
// ── Actions ──────────────────────────────────────────────────────────
static async #onCancel(event, target) {
this.close()
}
static async #onRoll(event, target) {
const label = this.defense
? (this.defense.type === "parade"
? game.i18n.localize("VERMINE.defense_parade")
: game.i18n.localize("VERMINE.defense_esquive"))
: this.label
const rollParams = {
actor: this.#actor,
NoD: this.getPoolBreakdown().total,
Reroll: this.rerolls,
difficulty: this.#getDifficulty(),
handicap: this.#getHandicap(),
rollLabel: label,
bonusSuccesses: this.freeSuccesses,
poolBreakdown: this.getPoolBreakdown(),
messageFlags: this.defense ? { "vermine-defense": this.defense } : null
}
// Fermeture immédiate du dialogue : le jet (et l'animation 3D) continue
// en arrière-plan sans bloquer la fermeture.
this.close()
await VermineUtils.roll(rollParams)
}
}