Corrections diverses autout du combat

This commit is contained in:
2026-04-13 14:19:24 +02:00
parent 44cc07db73
commit d69144f506
46 changed files with 1340 additions and 241 deletions

View File

@@ -40,6 +40,8 @@ export default class CelestopolActorSheet extends HandlebarsApplicationMixin(fou
delete: CelestopolActorSheet.#onItemDelete,
attack: CelestopolActorSheet.#onAttack,
rangedDefense: CelestopolActorSheet.#onRangedDefense,
unarmedAttack: CelestopolActorSheet.#onUnarmedAttack,
baseRangedDefense: CelestopolActorSheet.#onBaseRangedDefense,
trackBox: CelestopolActorSheet.#onTrackBox,
skillLevel: CelestopolActorSheet.#onSkillLevel,
factionLevel: CelestopolActorSheet.#onFactionLevel,
@@ -197,6 +199,16 @@ export default class CelestopolActorSheet extends HandlebarsApplicationMixin(fou
await this.document.system.rollRangedDefense(itemId)
}
static async #onUnarmedAttack() {
if (typeof this.document.system.rollUnarmedAttack !== "function") return
await this.document.system.rollUnarmedAttack()
}
static async #onBaseRangedDefense() {
if (typeof this.document.system.rollRangedDefenseBase !== "function") return
await this.document.system.rollRangedDefenseBase()
}
/** Met à jour une jauge de piste (blessures/destin/spleen) par clic sur une case. */
static #onTrackBox(_event, target) {
if (!this.isEditable) return

View File

@@ -42,6 +42,7 @@ export default class CelestopolCharacterSheet extends CelestopolActorSheet {
competences:{ template: "systems/fvtt-celestopol/templates/character-competences.hbs" },
blessures: { template: "systems/fvtt-celestopol/templates/character-blessures.hbs" },
factions: { template: "systems/fvtt-celestopol/templates/character-factions.hbs" },
combat: { template: "systems/fvtt-celestopol/templates/character-combat.hbs" },
equipement: { template: "systems/fvtt-celestopol/templates/character-equipement.hbs" },
biography: { template: "systems/fvtt-celestopol/templates/character-biography.hbs" },
}
@@ -53,6 +54,7 @@ export default class CelestopolCharacterSheet extends CelestopolActorSheet {
competences:{ id: "competences", group: "sheet", icon: "fa-solid fa-dice-d6", label: "CELESTOPOL.Tab.competences" },
blessures: { id: "blessures", group: "sheet", icon: "fa-solid fa-heart-crack", label: "CELESTOPOL.Tab.blessures" },
factions: { id: "factions", group: "sheet", icon: "fa-solid fa-flag", label: "CELESTOPOL.Tab.factions" },
combat: { id: "combat", group: "sheet", icon: "fa-solid fa-khanda", label: "CELESTOPOL.Tab.combat" },
equipement: { id: "equipement", group: "sheet", icon: "fa-solid fa-shield-halved",label: "CELESTOPOL.Tab.equipement" },
biography: { id: "biography", group: "sheet", icon: "fa-solid fa-book", label: "CELESTOPOL.Tab.biography" },
}
@@ -156,6 +158,12 @@ export default class CelestopolCharacterSheet extends CelestopolActorSheet {
})
break
case "combat":
context.tab = context.tabs.combat
context.weapons = doc.itemTypes.weapon.sort((a, b) => a.name.localeCompare(b.name))
context.armures = doc.itemTypes.armure.sort((a, b) => a.name.localeCompare(b.name))
break
case "biography":
context.tab = context.tabs.biography
context.xpLogEmpty = (doc.system.xp?.log?.length ?? 0) === 0
@@ -173,8 +181,6 @@ export default class CelestopolCharacterSheet extends CelestopolActorSheet {
case "equipement":
context.tab = context.tabs.equipement
context.weapons = doc.itemTypes.weapon.sort((a, b) => a.name.localeCompare(b.name))
context.armures = doc.itemTypes.armure.sort((a, b) => a.name.localeCompare(b.name))
context.equipments= doc.itemTypes.equipment.sort((a, b) => a.name.localeCompare(b.name))
break
}

View File

@@ -35,6 +35,7 @@ export default class CelestopolNPCSheet extends CelestopolActorSheet {
tabs: { template: "templates/generic/tab-navigation.hbs" },
competences: { template: "systems/fvtt-celestopol/templates/npc-competences.hbs" },
blessures: { template: "systems/fvtt-celestopol/templates/npc-blessures.hbs" },
combat: { template: "systems/fvtt-celestopol/templates/npc-combat.hbs" },
equipement: { template: "systems/fvtt-celestopol/templates/npc-equipement.hbs" },
biographie: { template: "systems/fvtt-celestopol/templates/npc-biographie.hbs" },
}
@@ -45,6 +46,7 @@ export default class CelestopolNPCSheet extends CelestopolActorSheet {
const tabs = {
competences: { id: "competences", group: "sheet", icon: "fa-solid fa-dice-d6", label: "CELESTOPOL.Tab.competences" },
blessures: { id: "blessures", group: "sheet", icon: "fa-solid fa-heart-crack", label: "CELESTOPOL.Tab.blessures" },
combat: { id: "combat", group: "sheet", icon: "fa-solid fa-khanda", label: "CELESTOPOL.Tab.combat" },
equipement: { id: "equipement", group: "sheet", icon: "fa-solid fa-shield-halved",label: "CELESTOPOL.Tab.equipement" },
biographie: { id: "biographie", group: "sheet", icon: "fa-solid fa-book-open", label: "CELESTOPOL.Tab.biographie" },
}
@@ -106,6 +108,9 @@ export default class CelestopolNPCSheet extends CelestopolActorSheet {
context.system.notes, { relativeTo: this.document }
)
break
case "combat":
context.tab = context.tabs.combat
break
case "equipement":
context.tab = context.tabs.equipement
break

View File

@@ -78,6 +78,22 @@ export class CelestopolRoll extends Roll {
.reduce((sum, a) => sum + Math.abs(a.system.protection ?? a.system.malus ?? 0), 0)
}
/**
* Résout un acteur à partir de son UUID si disponible, sinon via son identifiant monde.
* @param {object} ref
* @param {string|null} ref.actorUuid
* @param {string|null} ref.actorId
* @returns {Promise<Actor|null>}
*/
static async resolveActor({ actorUuid = null, actorId = null } = {}) {
if (actorUuid) {
const actorFromUuid = await fromUuid(actorUuid)
if (actorFromUuid) return actorFromUuid
}
if (actorId) return game.actors.get(actorId) ?? null
return null
}
/**
* Ouvre le dialogue de configuration du jet via DialogV2 et exécute le jet.
* @param {object} options
@@ -298,11 +314,14 @@ export class CelestopolRoll extends Roll {
const useFortune = fortuneValue > 0 && (rollContext.useFortune === true || rollContext.useFortune === "true")
const puiserRessources = rollContext.puiserRessources === true || rollContext.puiserRessources === "true"
const rollMoonDie = rollContext.rollMoonDie === true || rollContext.rollMoonDie === "true"
const selectedCombatTargetId = typeof rollContext.targetSelect === "string" ? rollContext.targetSelect : ""
const selectedCombatTarget = selectedCombatTargetId
? availableTargets.find(t => t.id === selectedCombatTargetId) ?? null
const selectedCombatTargetRef = typeof rollContext.targetSelect === "string" ? rollContext.targetSelect : ""
const selectedCombatTarget = selectedCombatTargetRef
? availableTargets.find(t => t.uuid === selectedCombatTargetRef || t.id === selectedCombatTargetRef) ?? null
: null
const resolvedWeaponName = (isRangedDefense && selectedCombatTarget?.weaponName) ? selectedCombatTarget.weaponName : weaponName
const resolvedWeaponDegats = (isRangedDefense && selectedCombatTarget?.weaponDegats) ? selectedCombatTarget.weaponDegats : weaponDegats
const targetActorId = selectedCombatTarget?.id || ""
const targetActorUuid = selectedCombatTarget?.uuid || ""
const targetActorName = selectedCombatTarget?.name || ""
// En résistance : forcer puiser=false, lune=false, fortune=false, destin=false
@@ -352,9 +371,10 @@ export class CelestopolRoll extends Roll {
isCombat,
isRangedDefense,
weaponType,
weaponName,
weaponDegats,
weaponName: resolvedWeaponName,
weaponDegats: resolvedWeaponDegats,
targetActorId,
targetActorUuid,
targetActorName,
availableTargets,
rangedMod: effectiveRangedMod,
@@ -375,7 +395,10 @@ export class CelestopolRoll extends Roll {
roll.computeResult()
// Test de résistance échoué → cocher automatiquement la prochaine case de blessure
const actor = game.actors.get(options.actorId)
const actor = await this.resolveActor({
actorUuid: options.actorUuid ?? null,
actorId: options.actorId ?? null,
})
if (isResistance && actor && roll.options.resultType === "failure") {
const nextLvl = (actor.system.blessures.lvl ?? 0) + 1
if (nextLvl <= 8) {
@@ -384,16 +407,25 @@ export class CelestopolRoll extends Roll {
}
}
// Mêlée échouée OU défense à distance échouée → joueur prend une blessure
// Mêlée échouée OU défense à distance échouée → le protagoniste subit les dégâts de l'arme PNJ
if (isCombat && (weaponType === "melee" || isRangedDefense) && actor && roll.options.resultType === "failure") {
const nextLvl = (actor.system.blessures.lvl ?? 0) + 1
if (nextLvl <= 8) {
const incomingWounds = this.getIncomingWounds(resolvedWeaponDegats)
const protection = this.getActorArmorProtection(actor)
const appliedWounds = incomingWounds === null
? 1
: Math.max(0, incomingWounds - protection)
if (appliedWounds > 0) {
const nextLvl = Math.min(8, (actor.system.blessures.lvl ?? 0) + appliedWounds)
await actor.update({ "system.blessures.lvl": nextLvl })
roll.options.woundTaken = nextLvl
roll.options.woundTakenCount = appliedWounds
roll.options.incomingWounds = incomingWounds
roll.options.selectedTargetProtection = protection
roll.options.selectedTargetAppliedWounds = appliedWounds
}
}
await roll.toMessage({}, { rollMode: rollData.rollMode })
await roll.toMessage({}, { messageMode: rollData.rollMode })
// Batching de toutes les mises à jour de l'acteur en un seul appel réseau
if (actor) {
@@ -506,12 +538,16 @@ export class CelestopolRoll extends Roll {
const incomingWounds = isWeaponHit ? this.constructor.getIncomingWounds(weaponDegats) : null
const hasVariableDamage = isWeaponHit && incomingWounds === null
const targetActorId = this.options.targetActorId ?? ""
const targetActorUuid = this.options.targetActorUuid ?? ""
const targetActorName = this.options.targetActorName ?? ""
const availableTargets = (this.options.availableTargets ?? []).map(target => ({
...target,
selected: target.id === targetActorId,
selected: target.uuid === targetActorUuid || target.id === targetActorId,
}))
const selectedTargetActor = targetActorId ? game.actors.get(targetActorId) : null
const selectedTargetActor = await this.constructor.resolveActor({
actorUuid: targetActorUuid,
actorId: targetActorId,
})
const selectedTargetProtection = selectedTargetActor
? this.constructor.getActorArmorProtection(selectedTargetActor)
: null
@@ -569,6 +605,7 @@ export class CelestopolRoll extends Roll {
weaponType: this.options.weaponType ?? null,
isRangedDefense: this.options.isRangedDefense ?? false,
woundTaken: this.options.woundTaken ?? null,
woundTakenCount: this.options.woundTakenCount ?? null,
situationMod: this.options.situationMod ?? 0,
rangedMod: this.options.rangedMod ?? 0,
hasDamageSummary: isWeaponHit,
@@ -577,6 +614,7 @@ export class CelestopolRoll extends Roll {
hasVariableDamage,
canApplyWeaponDamage: incomingWounds !== null,
targetActorId,
targetActorUuid,
targetActorName,
selectedTargetProtection,
selectedTargetAppliedWounds,
@@ -595,14 +633,40 @@ export class CelestopolRoll extends Roll {
}
/** @override */
async toMessage(messageData = {}, { rollMode, create = true } = {}) {
async toMessage(messageData = {}, { messageMode, rollMode, create = true } = {}) {
if (rollMode) {
messageMode = Roll._mapLegacyRollMode(rollMode)
}
messageMode ||= game.settings.get("core", "messageMode")
if (!this._evaluated) await this.evaluate({ allowInteractive: messageMode !== "blind" })
const skillLocalized = this.skillLabel ? game.i18n.localize(this.skillLabel) : ""
const statLocalized = this.options.statLabel
? game.i18n.localize(this.options.statLabel) : ""
const flavor = statLocalized
? `<strong>${statLocalized} ${skillLocalized}</strong>`
: `<strong>${skillLocalized}</strong>`
return super.toMessage({ flavor, ...messageData }, { rollMode })
const speakerActor = await this.constructor.resolveActor({
actorUuid: this.options.actorUuid ?? null,
actorId: this.options.actorId ?? null,
})
const content = await this.render({ isPrivate: messageMode !== "public" })
const chatData = foundry.utils.mergeObject({
author: game.user.id,
content,
flavor,
sound: CONFIG.sounds.dice,
rolls: [this],
speaker: speakerActor ? ChatMessage.getSpeaker({ actor: speakerActor }) : undefined,
style: CONST.CHAT_MESSAGE_STYLES.OTHER,
}, messageData)
const cls = foundry.utils.getDocumentClass("ChatMessage")
const msg = new cls(chatData)
msg.applyMode(messageMode)
if (create) return cls.create(msg)
return msg.toObject()
}
/**
@@ -641,7 +705,7 @@ export class CelestopolRoll extends Roll {
content,
speaker,
rolls: [roll],
style: CONST.CHAT_MESSAGE_STYLES?.ROLL ?? 5,
style: CONST.CHAT_MESSAGE_STYLES.OTHER,
})
}
}

View File

@@ -13,6 +13,8 @@
import { SYSTEM } from "../config/system.mjs"
const WEAPON_DAMAGE_PRIORITY = { "0": 0, "1": 1, "2": 2, X: 3 }
export default class CelestopolCharacter extends foundry.abstract.TypeDataModel {
static defineSchema() {
const fields = foundry.data.fields
@@ -218,6 +220,7 @@ export default class CelestopolCharacter extends foundry.abstract.TypeDataModel
return CelestopolRoll.prompt({
actorId: this.parent.id,
actorUuid: this.parent.uuid,
actorName: this.parent.name,
actorImage: this.parent.img,
statId,
@@ -249,6 +252,7 @@ export default class CelestopolCharacter extends foundry.abstract.TypeDataModel
return CelestopolRoll.prompt({
actorId: this.parent.id,
actorUuid: this.parent.uuid,
actorName: this.parent.name,
actorImage: this.parent.img,
statId,
@@ -270,21 +274,47 @@ export default class CelestopolCharacter extends foundry.abstract.TypeDataModel
/**
* Collecte les cibles de combat sur la scène active.
* Pour un PJ attaquant, seules les cibles PNJ présentes sur la scène sont proposées.
* @returns {Array<{id:string, name:string, corps:number}>}
* @param {object} options
* @param {boolean} [options.onlyRanged=false]
* @param {boolean} [options.fallbackToAll=false]
* @returns {Array<{id:string, uuid:string, name:string, corps:number, weaponName?:string, weaponDegats?:string}>}
*/
_getCombatTargets() {
_getCombatTargets({ onlyRanged = false, fallbackToAll = false } = {}) {
const getBestRangedWeapon = actor => {
const rangedWeapons = actor.itemTypes?.weapon?.filter(item => item.system.type === "distance") ?? []
if (!rangedWeapons.length) return null
return rangedWeapons.reduce((best, item) => {
if (!best) return item
const bestPriority = WEAPON_DAMAGE_PRIORITY[best.system.degats] ?? -1
const itemPriority = WEAPON_DAMAGE_PRIORITY[item.system.degats] ?? -1
if (itemPriority !== bestPriority) return itemPriority > bestPriority ? item : best
return item.name.localeCompare(best.name) < 0 ? item : best
}, null)
}
const toEntry = actor => ({
id: actor.id,
uuid: actor.uuid,
name: actor.name,
corps: actor.system.stats?.corps?.res ?? 0,
...(onlyRanged ? (() => {
const weapon = getBestRangedWeapon(actor)
return weapon ? {
weaponName: weapon.name,
weaponDegats: weapon.system.degats,
} : {}
})() : {}),
})
const sceneTokens = canvas?.scene?.isView ? (canvas.tokens?.placeables ?? []) : []
return [...new Map(sceneTokens
const targets = [...new Map(sceneTokens
.filter(t => t.actor?.type === "npc" && t.actor.id !== this.parent.id)
.filter(t => !onlyRanged || getBestRangedWeapon(t.actor))
.map(t => {
const actor = t.actor
return [actor.id, toEntry(actor)]
return [actor.uuid, toEntry(actor)]
})).values()]
if (!targets.length && onlyRanged && fallbackToAll) return this._getCombatTargets()
return targets
}
/**
@@ -304,6 +334,7 @@ export default class CelestopolCharacter extends foundry.abstract.TypeDataModel
return CelestopolRoll.prompt({
actorId: this.parent.id,
actorUuid: this.parent.uuid,
actorName: this.parent.name,
actorImage: this.parent.img,
statId: "corps",
@@ -326,6 +357,40 @@ export default class CelestopolCharacter extends foundry.abstract.TypeDataModel
})
}
/**
* Lance une attaque de mêlée à mains nues.
* @returns {Promise<import("../documents/roll.mjs").CelestopolRoll|null>}
*/
async rollUnarmedAttack() {
const { CelestopolRoll } = await import("../documents/roll.mjs")
const echauffouree = this.stats.corps.echauffouree
if (!echauffouree) return null
return CelestopolRoll.prompt({
actorId: this.parent.id,
actorUuid: this.parent.uuid,
actorName: this.parent.name,
actorImage: this.parent.img,
statId: "corps",
skillId: "echauffouree",
statLabel: SYSTEM.STATS.corps.label,
skillLabel: SYSTEM.SKILLS.corps.echauffouree.label,
skillValue: echauffouree.value,
woundMalus: this.getWoundMalus(),
armorMalus: this.getArmorMalusForRoll("corps", "echauffouree"),
woundLevel: this.blessures.lvl,
rollMoonDie: this.prefs.rollMoonDie ?? false,
destGaugeFull: this.destin.lvl > 0,
fortuneValue: this.attributs.fortune.value,
isCombat: true,
isRangedDefense: false,
weaponType: "melee",
weaponName: game.i18n.localize("CELESTOPOL.Combat.unarmedAttack"),
weaponDegats: "0",
availableTargets: this._getCombatTargets(),
})
}
/**
* Lance un jet de défense contre une attaque à distance (test Mobilité vs Corps PNJ).
* Succès → esquive réussie.
@@ -342,6 +407,7 @@ export default class CelestopolCharacter extends foundry.abstract.TypeDataModel
return CelestopolRoll.prompt({
actorId: this.parent.id,
actorUuid: this.parent.uuid,
actorName: this.parent.name,
actorImage: this.parent.img,
statId: "corps",
@@ -363,4 +429,38 @@ export default class CelestopolCharacter extends foundry.abstract.TypeDataModel
availableTargets: this._getCombatTargets(),
})
}
/**
* Lance une esquive d'attaque à distance sans dépendre d'une arme possédée par le PJ.
* @returns {Promise<import("../documents/roll.mjs").CelestopolRoll|null>}
*/
async rollRangedDefenseBase() {
const { CelestopolRoll } = await import("../documents/roll.mjs")
const mobilite = this.stats.corps.mobilite
if (!mobilite) return null
return CelestopolRoll.prompt({
actorId: this.parent.id,
actorUuid: this.parent.uuid,
actorName: this.parent.name,
actorImage: this.parent.img,
statId: "corps",
skillId: "mobilite",
statLabel: SYSTEM.STATS.corps.label,
skillLabel: SYSTEM.SKILLS.corps.mobilite.label,
skillValue: mobilite.value,
woundMalus: this.getWoundMalus(),
armorMalus: this.getArmorMalusForRoll("corps", "mobilite"),
woundLevel: this.blessures.lvl,
rollMoonDie: this.prefs.rollMoonDie ?? false,
destGaugeFull: this.destin.lvl > 0,
fortuneValue: this.attributs.fortune.value,
isCombat: true,
isRangedDefense: true,
weaponType: "distance",
weaponName: game.i18n.localize("CELESTOPOL.Combat.rangedAttack"),
weaponDegats: "0",
availableTargets: this._getCombatTargets({ onlyRanged: true, fallbackToAll: true }),
})
}
}

View File

@@ -112,6 +112,7 @@ export default class CelestopolNPC extends foundry.abstract.TypeDataModel {
return CelestopolRoll.prompt({
actorId: this.parent.id,
actorUuid: this.parent.uuid,
actorName: this.parent.name,
actorImage: this.parent.img,
statId,
@@ -127,4 +128,95 @@ export default class CelestopolNPC extends foundry.abstract.TypeDataModel {
async rollResistance(statId) {
return this.roll(statId)
}
/**
* Collecte les cibles protagonistes de la scène active pour les jets de combat PNJ.
* @returns {Array<{id:string, name:string, corps:number}>}
*/
_getCombatTargets() {
const toEntry = actor => ({
id: actor.id,
uuid: actor.uuid,
name: actor.name,
corps: actor.system.stats?.corps?.res ?? 0,
})
const sceneTokens = canvas?.scene?.isView ? (canvas.tokens?.placeables ?? []) : []
return [...new Map(sceneTokens
.filter(t => t.actor?.type === "character" && t.actor.id !== this.parent.id)
.map(t => {
const actor = t.actor
return [actor.id, toEntry(actor)]
})).values()]
}
/**
* Lance une attaque PNJ avec une arme.
* Le test utilise le domaine Corps et transmet explicitement les dégâts de l'arme.
* @param {string} itemId
* @returns {Promise<import("../documents/roll.mjs").CelestopolRoll|null>}
*/
async rollAttack(itemId) {
const { CelestopolRoll } = await import("../documents/roll.mjs")
const item = this.parent.items.get(itemId)
if (!item || item.type !== "weapon") return null
return CelestopolRoll.prompt({
actorId: this.parent.id,
actorUuid: this.parent.uuid,
actorName: this.parent.name,
actorImage: this.parent.img,
statId: "corps",
skillId: null,
statLabel: SYSTEM.STATS.corps.label,
skillLabel: "CELESTOPOL.Combat.attack",
skillValue: this.stats.corps.res,
woundMalus: this.getWoundMalus(),
armorMalus: this.getArmorMalusForRoll("corps"),
woundLevel: this.blessures.lvl,
rollMoonDie: false,
destGaugeFull: false,
fortuneValue: 0,
isCombat: true,
isRangedDefense: false,
weaponType: item.system.type,
weaponName: item.name,
weaponDegats: item.system.degats,
availableTargets: this._getCombatTargets(),
})
}
/**
* Lance un jet de tir/esquive PNJ avec une arme à distance.
* @param {string} itemId
* @returns {Promise<import("../documents/roll.mjs").CelestopolRoll|null>}
*/
async rollRangedDefense(itemId) {
const { CelestopolRoll } = await import("../documents/roll.mjs")
const item = this.parent.items.get(itemId)
if (!item || item.type !== "weapon" || item.system.type !== "distance") return null
return CelestopolRoll.prompt({
actorId: this.parent.id,
actorUuid: this.parent.uuid,
actorName: this.parent.name,
actorImage: this.parent.img,
statId: "corps",
skillId: null,
statLabel: SYSTEM.STATS.corps.label,
skillLabel: "CELESTOPOL.Combat.rangedDefenseTitle",
skillValue: this.stats.corps.res,
woundMalus: this.getWoundMalus(),
armorMalus: this.getArmorMalusForRoll("corps"),
woundLevel: this.blessures.lvl,
rollMoonDie: false,
destGaugeFull: false,
fortuneValue: 0,
isCombat: true,
isRangedDefense: true,
weaponType: "distance",
weaponName: item.name,
weaponDegats: item.system.degats,
availableTargets: this._getCombatTargets(),
})
}
}