feat: capacités de créature, blessures séquentielles et refonte fiche Groupe

- Nouvel item Capacité de créature (creaturecapacity) : DataModel, fiche
  AppV2, chat card, types Base/Survie/Cauchemar/Apocalypse, liste sur
  l'onglet Informations de la créature avec drag&drop et post au chat.
- Blessures : coches séquentielles respectant la gravité (onClickWound),
  malus de blessure -1D/-2D/-3D appliqué aux jets (Règles p.42),
  montée en gravité automatique si les cercles sont pleins.
- Fiche Groupe : objectifs majeurs/mineurs éditables, liste de membres
  simplifiée, notes de voyage (roadNotes), totem qui remplit
  instinct/interdits, refonte visuelle des onglets.
- Créature : Réaction lançable, seuils/cercles de blessures corrigés
  (Taille cumulée, Groupe sans seuil), libellés Taille/Meute.
- Corrections : icône de carte de chat plafonnée à 64px, slash orphelin
  d'entrave à 0, notes de matériel du Groupe éditable (formInput),
  bonus de Réaction non double-compté.
- Divers : suppression des captures de débogage, licence README.
This commit is contained in:
2026-08-05 09:38:36 +02:00
parent cf905cdcfb
commit d0373fab5a
59 changed files with 984 additions and 1211 deletions
+13 -2
View File
@@ -2,10 +2,11 @@ const { HandlebarsApplicationMixin, ApplicationV2 } = foundry.applications.api
export class TotemPicker extends HandlebarsApplicationMixin(ApplicationV2) {
constructor(linkEl, actor) {
constructor(linkEl, actor, options = {}) {
super();
this.linkEl = linkEl;
this.actor = actor;
this.fillInstincts = options.fillInstincts === true;
}
static get DEFAULT_OPTIONS() {
@@ -32,7 +33,17 @@ export class TotemPicker extends HandlebarsApplicationMixin(ApplicationV2) {
el.addEventListener('click', event => {
const link = event.target.closest('a');
if (!link?.dataset.totem) return;
this.actor.update({ 'system.identity.totem': link.dataset.totem });
const update = { 'system.identity.totem': link.dataset.totem };
// Remplit automatiquement Instincts/Interdits depuis la config du totem
// (utilisé pour le Totem du Groupe).
if (this.fillInstincts && this.actor.system?.identity) {
const totemKey = link.dataset.totem;
const instincts = game.i18n.localize(`TOTEMS.${totemKey}.instincts`);
const bans = game.i18n.localize(`TOTEMS.${totemKey}.bans`);
if (!instincts.startsWith("TOTEMS.")) update['system.identity.instincts'] = instincts;
if (!bans.startsWith("TOTEMS.")) update['system.identity.prohibits'] = bans;
}
this.actor.update(update);
this.close();
});
});
+31 -12
View File
@@ -129,30 +129,30 @@ VERMINE.npcRoleLevels = {
* Creature Pattern Levels configuration
*/
VERMINE.creaturePatternLevels = {
1: { "label": "PATTERN_LEVELS.insect", "attack": 2, "damage": 0, "minorWound": 0, "majorWound": 0, "deadlyWound": 1 },
2: { "label": "PATTERN_LEVELS.rat", "attack": 3, "damage": 1, "minorWound": 0, "majorWound": 1, "deadlyWound": 1 },
3: { "label": "PATTERN_LEVELS.dog", "attack": 4, "damage": 3, "minorWound": 1, "majorWound": 1, "deadlyWound": 1 },
4: { "label": "PATTERN_LEVELS.bear", "attack": 6, "damage": 6, "minorWound": 2, "majorWound": 2, "deadlyWound": 2 }
1: { "label": "PATTERN_LEVELS.insect", "attack": 2, "damage": 0, "minorThreshold": 0, "majorThreshold": 0, "deadlyThreshold": 1, "minorWound": 0, "majorWound": 0, "deadlyWound": 1 },
2: { "label": "PATTERN_LEVELS.rat", "attack": 3, "damage": 1, "minorThreshold": 0, "majorThreshold": 1, "deadlyThreshold": 3, "minorWound": 0, "majorWound": 1, "deadlyWound": 1 },
3: { "label": "PATTERN_LEVELS.dog", "attack": 4, "damage": 3, "minorThreshold": 1, "majorThreshold": 3, "deadlyThreshold": 5, "minorWound": 1, "majorWound": 1, "deadlyWound": 1 },
4: { "label": "PATTERN_LEVELS.bear", "attack": 6, "damage": 6, "minorThreshold": 3, "majorThreshold": 5, "deadlyThreshold": 7, "minorWound": 2, "majorWound": 2, "deadlyWound": 2 }
}
/**
* Creature Size Levels configuration
*/
VERMINE.creatureSizeLevels = {
0: { "label": "SIZE_LEVELS.tiny", "attack": 0, "vigor": 0, "minorWound": 0, "majorWound": 0, "deadlyWound": 0 },
1: { "label": "SIZE_LEVELS.small", "attack": 1, "vigor": 1, "minorWound": 0, "majorWound": 1, "deadlyWound": 1 },
2: { "label": "SIZE_LEVELS.medium", "attack": 2, "vigor": 2, "minorWound": 2, "majorWound": 2, "deadlyWound": 2 },
3: { "label": "SIZE_LEVELS.large", "attack": 3, "vigor": 3, "minorWound": 3, "majorWound": 3, "deadlyWound": 3 }
0: { "label": "SIZE_LEVELS.tiny", "attack": 0, "vigor": 0, "minorThreshold": 0, "majorThreshold": 0, "deadlyThreshold": 0, "minorWound": 0, "majorWound": 0, "deadlyWound": 0 },
1: { "label": "SIZE_LEVELS.small", "attack": 1, "vigor": 1, "minorThreshold": 0, "majorThreshold": 1, "deadlyThreshold": 1, "minorWound": 0, "majorWound": 1, "deadlyWound": 1 },
2: { "label": "SIZE_LEVELS.medium", "attack": 2, "vigor": 2, "minorThreshold": 2, "majorThreshold": 2, "deadlyThreshold": 2, "minorWound": 1, "majorWound": 1, "deadlyWound": 1 },
3: { "label": "SIZE_LEVELS.large", "attack": 3, "vigor": 3, "minorThreshold": 3, "majorThreshold": 3, "deadlyThreshold": 3, "minorWound": 1, "majorWound": 1, "deadlyWound": 1 }
}
/**
* Creature Pack Levels configuration
*/
VERMINE.creaturePackLevels = {
0: { "attack": 0, "damage": 0, "minorWound": 0, "majorWound": 0, "deadlyWound": 0 },
1: { "attack": 1, "damage": 1, "minorWound": 0, "majorWound": 0, "deadlyWound": 1 },
2: { "attack": 2, "damage": 2, "minorWound": 2, "majorWound": 2, "deadlyWound": 2 },
3: { "attack": 5, "damage": 5, "minorWound": 3, "majorWound": 3, "deadlyWound": 3 }
0: { "label": "VERMINE.none", "attack": 0, "damage": 0, "minorWound": 0, "majorWound": 0, "deadlyWound": 0 },
1: { "label": "PACK_LEVELS.small", "attack": 1, "damage": 1, "minorWound": 0, "majorWound": 0, "deadlyWound": 1 },
2: { "label": "PACK_LEVELS.large", "attack": 2, "damage": 2, "minorWound": 2, "majorWound": 2, "deadlyWound": 2 },
3: { "label": "PACK_LEVELS.giant", "attack": 5, "damage": 5, "minorWound": 3, "majorWound": 3, "deadlyWound": 3 }
}
/**
@@ -180,6 +180,25 @@ VERMINE.abilityCategories = {
}
}
/**
* Types de Capacités de créature (mode d'activation/disponibilité).
* Correspond aux Modes de jeu + une catégorie "base".
*/
VERMINE.creatureCapacityTypes = {
"base": {
"label": "CREATURE_CAPACITY_TYPES.base"
},
"survival": {
"label": "CREATURE_CAPACITY_TYPES.survival"
},
"nightmare": {
"label": "CREATURE_CAPACITY_TYPES.nightmare"
},
"apocalypse": {
"label": "CREATURE_CAPACITY_TYPES.apocalypse"
}
}
VERMINE.abilities = {
"vigor": "ABILITIES.vigor.name",
"health": "ABILITIES.health.name",
+10 -5
View File
@@ -260,8 +260,9 @@ export default class CombatDialog extends HandlebarsApplicationMixin(foundry.app
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 };
const malus = VermineExchange.woundMalus(this.actor);
const total = Math.max(0, this.basePool + selfControl + pools + specialty + help + tooling + group - malus);
return { ability, skill, selfControl, pools, specialty, help, tooling, group, malus, total };
}
getDicePool() {
@@ -297,7 +298,7 @@ export default class CombatDialog extends HandlebarsApplicationMixin(foundry.app
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;
if (bonusEl) bonusEl.textContent = b.selfControl + b.pools + b.specialty + b.help + b.tooling + b.group - b.malus;
const handSel = this.#el.querySelector("#handicap");
const handEl = this.#el.querySelector("#current-handicap");
@@ -367,7 +368,7 @@ export default class CombatDialog extends HandlebarsApplicationMixin(foundry.app
: null
};
await VermineUtils.roll({
const rollParams = {
actor: this.actor,
NoD: this.getDicePool(),
Reroll: this.getReroll(),
@@ -387,9 +388,13 @@ export default class CombatDialog extends HandlebarsApplicationMixin(foundry.app
targets: targets.map(t => t.name),
attack,
messageFlags: { "vermine-exchange": attack }
});
};
// 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);
}
#getRangeKey() {
+10 -4
View File
@@ -1,4 +1,5 @@
import { VermineUtils } from "../roll.mjs"
import { VermineExchange } from "../exchange.mjs"
const { HandlebarsApplicationMixin } = foundry.applications.api
@@ -161,7 +162,8 @@ export default class NpcRollDialog extends HandlebarsApplicationMixin(foundry.ap
getPoolBreakdown() {
const help = this.#getHelped()
const group = this.#getGroup()
return { pool: this.pool, help, group, total: this.pool + help + group }
const malus = VermineExchange.woundMalus(this.#actor)
return { pool: this.pool, help, group, malus, total: Math.max(0, this.pool + help + group - malus) }
}
#updateUI() {
@@ -169,7 +171,7 @@ export default class NpcRollDialog extends HandlebarsApplicationMixin(foundry.ap
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
if (bonusEl) bonusEl.textContent = b.help + b.group - b.malus
const freeEl = this.#el.querySelector("#free-successes")
if (freeEl) freeEl.textContent = String(this.freeSuccesses)
}
@@ -191,7 +193,7 @@ export default class NpcRollDialog extends HandlebarsApplicationMixin(foundry.ap
: game.i18n.localize("VERMINE.defense_esquive"))
: this.label
await VermineUtils.roll({
const rollParams = {
actor: this.#actor,
NoD: this.getPoolBreakdown().total,
Reroll: this.rerolls,
@@ -201,8 +203,12 @@ export default class NpcRollDialog extends HandlebarsApplicationMixin(foundry.ap
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)
}
}
+11 -5
View File
@@ -1,4 +1,5 @@
import { VermineUtils } from "../roll.mjs";
import { VermineExchange } from "../exchange.mjs";
const { HandlebarsApplicationMixin } = foundry.applications.api;
@@ -164,8 +165,9 @@ export default class RollDialog extends HandlebarsApplicationMixin(foundry.appli
const specialty = specChecked ? 1 : 0;
const help = helped ? 1 : 0;
const tooling = tools ? 1 : 0;
const total = ability + skillPool + selfControl + specialty + help + tooling + group + experience;
return { ability, skill: skillPool, selfControl, specialty, help, tooling, group, experience, total };
const malus = VermineExchange.woundMalus(this.#actor);
const total = Math.max(0, ability + skillPool + selfControl + specialty + help + tooling + group + experience - malus);
return { ability, skill: skillPool, selfControl, specialty, help, tooling, group, experience, malus, total };
}
getDicePool() {
@@ -293,7 +295,7 @@ export default class RollDialog extends HandlebarsApplicationMixin(foundry.appli
#calculateBonusCount() {
const b = this.getPoolBreakdown();
return b.specialty + b.help + b.tooling + b.group + b.selfControl + b.experience;
return b.specialty + b.help + b.tooling + b.group + b.selfControl + b.experience - b.malus;
}
#refreshExperienceUI() {
@@ -448,7 +450,7 @@ export default class RollDialog extends HandlebarsApplicationMixin(foundry.appli
await group.update({ "system.experience.dice": groupPool - pulled });
}
await VermineUtils.roll({
const rollParams = {
actor: this.#actor,
NoD: this.getDicePool(),
Reroll: this.getReroll(),
@@ -467,9 +469,13 @@ export default class RollDialog extends HandlebarsApplicationMixin(foundry.appli
weapon: this.weapon?.toObject() ?? null,
targets: [...game.user.targets].map(t => t.name),
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);
}
}
+50 -10
View File
@@ -204,20 +204,52 @@ export class VermineExchange {
return Boolean(actor) && (game.user.isGM || actor.isOwner)
}
/** Ordre de gravité des blessures (croissant). */
static WOUND_ORDER = ['minorWound', 'majorWound', 'deadlyWound']
/** Malus de la pire blessure (règles p.42) : -1D Légère, -2D Grave, -3D Mortelle. */
static WOUND_MALUS = { minorWound: 1, majorWound: 2, deadlyWound: 3 }
/**
* Applique +1 dans la catégorie de blessure indiquée (plafonné au max).
* Malus de blessure applicable à toutes les actions : seul le Malus de la
* Blessure la plus grave compte (non cumulatif).
* @param {Actor} actor
* @returns {number} 0, 1, 2 ou 3
*/
static woundMalus(actor) {
if (!actor?.system) return 0
if (actor.system.deadlyWound?.value > 0) return 3
if (actor.system.majorWound?.value > 0) return 2
if (actor.system.minorWound?.value > 0) return 1
return 0
}
/**
* Applique +1 cercle de blessure, en montant en gravité si les cercles du
* niveau correspondant sont déjà pleins (règles p.42 : si tous les cercles
* d'une Blessure sont cochés, la suivante est de gravité supérieure).
* @param {Actor} actor
* @param {string} woundCategory 'minorWound' | 'majorWound' | 'deadlyWound'
* @returns {Promise<number|boolean>} nouvelle valeur, ou false si refusé
* @returns {Promise<Object|boolean|null>}
* - false si refusé (permissions) ou null si invalide
* - { category, value, escalated, death } si appliqué (death si tout est plein)
*/
static async applyWound(actor, woundCategory) {
if (!actor || !woundCategory) return null
if (!this.canApplyWound(actor)) return false
const current = actor.system?.[woundCategory]?.value || 0
const max = actor.system?.[woundCategory]?.max ?? Number.POSITIVE_INFINITY
const next = Math.min(current + 1, max)
await actor.update({ [`system.${woundCategory}.value`]: next })
return next
const startIdx = this.WOUND_ORDER.indexOf(woundCategory)
if (startIdx === -1) return null
for (let i = startIdx; i < this.WOUND_ORDER.length; i++) {
const cat = this.WOUND_ORDER[i]
const sys = actor.system?.[cat]
const max = sys?.max ?? Number.POSITIVE_INFINITY
const value = sys?.value || 0
if (value < max) {
await actor.update({ [`system.${cat}.value`]: value + 1 })
return { category: cat, value: value + 1, escalated: cat !== woundCategory, death: false }
}
}
return { category: woundCategory, value: null, escalated: false, death: true }
}
// ── Résolution ──────────────────────────────────────────────────────
@@ -457,10 +489,18 @@ export class VermineExchange {
ui.notifications.warn(game.i18n.localize('VERMINE.error_no_permission'))
return
}
const next = await this.applyWound(defender, wound)
if (next === false) return
const result = await this.applyWound(defender, wound)
if (result === false) return
btn.disabled = true
btn.innerHTML = `<i class="fas fa-check"></i> ${game.i18n.localize('VERMINE.wound_applied')}`
let label = game.i18n.localize('VERMINE.wound_applied')
if (result?.death) {
label = game.i18n.localize('VERMINE.wound_death')
} else if (result?.escalated) {
const from = game.i18n.localize(this.woundLabelKey(wound))
const to = game.i18n.localize(this.woundLabelKey(result.category))
label = game.i18n.format('VERMINE.wound_escalated', { from, to })
}
btn.innerHTML = `<i class="fas fa-check"></i> ${label}`
// Persiste l'état du bouton seulement si l'utilisateur peut modifier le message
if (game.user.isGM || attackMessage.isOwner) {
const msgEl = btn.closest('.vermine-roll-message')
+11
View File
@@ -367,6 +367,17 @@ export const registerHandlebarsHelpers = function () {
Handlebars.registerHelper('ifStartsWith', function (arg1, arg2, options) {
return (typeof arg1 === 'string' && arg1.startsWith(arg2)) ? options.fn(this) : options.inverse(this);
});
// Chiffre → chiffre romain (handicaps, entraves). 0 → chaîne vide.
Handlebars.registerHelper('roman', function (n) {
const table = [[10, 'X'], [9, 'IX'], [5, 'V'], [4, 'IV'], [1, 'I']]
let num = Number(n)
if (!Number.isFinite(num) || num <= 0) return ''
let out = ''
for (const [v, s] of table) {
while (num >= v) { out += s; num -= v }
}
return out
});
//math operations