feat: gestion de l'expérience (XP)
- Schéma xp dans CelestopolCharacter : actuel (éditable), log[] ({montant, raison, date}), depense (calculé dans prepareDerivedData)
- Bouton 'Dépenser XP' → DialogV2 (montant + raison) : décrémente actuel, logge l'entrée
- Suppression d'entrée de log avec remboursement des points (mode édition)
- Section XP en haut de l'onglet Biographie : compteurs, tableau du log, référentiel des coûts
- i18n : section CELESTOPOL.XP.* complète
- CSS : .xp-section avec compteurs, tableau de log et accordéon de référence
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -1,3 +1,5 @@
|
||||
import { SYSTEM } from "../../config/system.mjs"
|
||||
|
||||
const { HandlebarsApplicationMixin } = foundry.applications.api
|
||||
|
||||
export default class CelestopolActorSheet extends HandlebarsApplicationMixin(foundry.applications.sheets.ActorSheetV2) {
|
||||
@@ -18,11 +20,15 @@ export default class CelestopolActorSheet extends HandlebarsApplicationMixin(fou
|
||||
window: { resizable: true },
|
||||
dragDrop: [{ dragSelector: '[data-drag="true"], .rollable', dropSelector: null }],
|
||||
actions: {
|
||||
editImage: CelestopolActorSheet.#onEditImage,
|
||||
toggleSheet: CelestopolActorSheet.#onToggleSheet,
|
||||
edit: CelestopolActorSheet.#onItemEdit,
|
||||
delete: CelestopolActorSheet.#onItemDelete,
|
||||
attack: CelestopolActorSheet.#onAttack,
|
||||
editImage: CelestopolActorSheet.#onEditImage,
|
||||
toggleSheet: CelestopolActorSheet.#onToggleSheet,
|
||||
edit: CelestopolActorSheet.#onItemEdit,
|
||||
delete: CelestopolActorSheet.#onItemDelete,
|
||||
attack: CelestopolActorSheet.#onAttack,
|
||||
rangedDefense: CelestopolActorSheet.#onRangedDefense,
|
||||
trackBox: CelestopolActorSheet.#onTrackBox,
|
||||
skillLevel: CelestopolActorSheet.#onSkillLevel,
|
||||
factionLevel: CelestopolActorSheet.#onFactionLevel,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -42,6 +48,7 @@ export default class CelestopolActorSheet extends HandlebarsApplicationMixin(fou
|
||||
isEditMode: this.isEditMode,
|
||||
isPlayMode: this.isPlayMode,
|
||||
isEditable: this.isEditable,
|
||||
woundLevels: SYSTEM.WOUND_LEVELS,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,28 +58,9 @@ export default class CelestopolActorSheet extends HandlebarsApplicationMixin(fou
|
||||
this.element.querySelectorAll(".rollable").forEach(el => {
|
||||
el.addEventListener("click", this._onRoll.bind(this))
|
||||
})
|
||||
|
||||
// Setup sequential checkbox logic for wound tracks
|
||||
this._setupSequentialCheckboxes()
|
||||
|
||||
// Setup sequential checkbox logic for factions
|
||||
this._setupFactionCheckboxes()
|
||||
}
|
||||
|
||||
/** @override */
|
||||
_onClick(event) {
|
||||
// Skip checkbox clicks in edit mode
|
||||
if (this.isEditMode && event.target.classList.contains('skill-level-checkbox')) {
|
||||
return
|
||||
}
|
||||
super._onClick(event)
|
||||
}
|
||||
|
||||
async _onRoll(event) {
|
||||
// Don't roll if clicking on a checkbox
|
||||
if (event.target.classList.contains('skill-level-checkbox')) {
|
||||
return
|
||||
}
|
||||
if (!this.isPlayMode) return
|
||||
const el = event.currentTarget
|
||||
const statId = el.dataset.statId
|
||||
@@ -158,132 +146,39 @@ export default class CelestopolActorSheet extends HandlebarsApplicationMixin(fou
|
||||
await this.document.system.rollAttack(itemId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup sequential checkbox logic for wound/destin/spleen tracks
|
||||
* Only allows checking the next checkbox in sequence
|
||||
*/
|
||||
_setupSequentialCheckboxes() {
|
||||
this.element.querySelectorAll('.wound-checkbox').forEach(checkbox => {
|
||||
checkbox.addEventListener('change', (event) => {
|
||||
this._handleSequentialCheckboxChange(event)
|
||||
})
|
||||
})
|
||||
static async #onRangedDefense(_event, target) {
|
||||
const itemId = target.getAttribute("data-item-id")
|
||||
if (!itemId) return
|
||||
await this.document.system.rollRangedDefense(itemId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle sequential checkbox change logic
|
||||
* @param {Event} event - The change event
|
||||
*/
|
||||
_handleSequentialCheckboxChange(event) {
|
||||
const checkbox = event.target
|
||||
if (!checkbox.classList.contains('wound-checkbox') || checkbox.disabled) return
|
||||
|
||||
const track = checkbox.dataset.track
|
||||
const currentIndex = parseInt(checkbox.dataset.index)
|
||||
const isChecked = checkbox.checked
|
||||
|
||||
// Get all checkboxes in this track
|
||||
const trackCheckboxes = Array.from(this.element.querySelectorAll(`.wound-checkbox[data-track="${track}"]`))
|
||||
|
||||
if (isChecked) {
|
||||
// Checking a box: uncheck all boxes after this one
|
||||
for (let i = currentIndex + 1; i < trackCheckboxes.length; i++) {
|
||||
trackCheckboxes[i].checked = false
|
||||
}
|
||||
// Check all boxes before this one
|
||||
for (let i = 0; i < currentIndex; i++) {
|
||||
trackCheckboxes[i].checked = true
|
||||
}
|
||||
} else {
|
||||
// Unchecking a box: uncheck all boxes after this one
|
||||
for (let i = currentIndex; i < trackCheckboxes.length; i++) {
|
||||
trackCheckboxes[i].checked = false
|
||||
}
|
||||
}
|
||||
|
||||
// Update the visual state
|
||||
this._updateTrackVisualState()
|
||||
/** Met à jour une jauge de piste (blessures/destin/spleen) par clic sur une case. */
|
||||
static #onTrackBox(_event, target) {
|
||||
if (!this.isEditable) return
|
||||
const path = target.dataset.path
|
||||
const index = parseInt(target.dataset.index)
|
||||
const current = foundry.utils.getProperty(this.document, path) ?? 0
|
||||
const newValue = (index <= current) ? index - 1 : index
|
||||
this.document.update({ [path]: Math.max(0, newValue) })
|
||||
}
|
||||
|
||||
/**
|
||||
* Update visual state of track boxes based on checkbox states
|
||||
*/
|
||||
_updateTrackVisualState() {
|
||||
this.element.querySelectorAll('.track-box').forEach(box => {
|
||||
const checkbox = box.querySelector('.wound-checkbox')
|
||||
if (checkbox) {
|
||||
if (checkbox.checked) {
|
||||
box.classList.add('checked')
|
||||
} else {
|
||||
box.classList.remove('checked')
|
||||
}
|
||||
}
|
||||
})
|
||||
/** Met à jour la valeur d'un domaine par clic sur un point de niveau. */
|
||||
static #onSkillLevel(_event, target) {
|
||||
if (!this.isEditable) return
|
||||
const { statId, skillId } = target.dataset
|
||||
const index = parseInt(target.dataset.index)
|
||||
const current = this.document.system.stats[statId]?.[skillId]?.value ?? 0
|
||||
const newValue = (index <= current) ? index - 1 : index
|
||||
this.document.update({ [`system.stats.${statId}.${skillId}.value`]: Math.max(0, newValue) })
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup sequential checkbox logic for faction tracks
|
||||
*/
|
||||
_setupFactionCheckboxes() {
|
||||
this.element.querySelectorAll('.faction-checkbox').forEach(checkbox => {
|
||||
checkbox.addEventListener('change', (event) => {
|
||||
this._handleFactionCheckboxChange(event)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle faction checkbox change logic
|
||||
* @param {Event} event - The change event
|
||||
*/
|
||||
_handleFactionCheckboxChange(event) {
|
||||
const checkbox = event.target
|
||||
if (!checkbox.classList.contains('faction-checkbox') || checkbox.disabled) return
|
||||
|
||||
const factionId = checkbox.dataset.faction
|
||||
const currentLevel = parseInt(checkbox.dataset.level)
|
||||
const isChecked = checkbox.checked
|
||||
|
||||
// Get all checkboxes for this faction
|
||||
const factionCheckboxes = Array.from(this.element.querySelectorAll(`.faction-checkbox[data-faction="${factionId}"]`))
|
||||
|
||||
if (isChecked) {
|
||||
// Checking a box: check all boxes before this one, uncheck all boxes after this one
|
||||
for (let i = 0; i < currentLevel; i++) {
|
||||
factionCheckboxes[i].checked = true
|
||||
}
|
||||
for (let i = currentLevel; i < factionCheckboxes.length; i++) {
|
||||
factionCheckboxes[i].checked = false
|
||||
}
|
||||
} else {
|
||||
// Unchecking a box: uncheck all boxes after this one
|
||||
for (let i = currentLevel - 1; i < factionCheckboxes.length; i++) {
|
||||
factionCheckboxes[i].checked = false
|
||||
}
|
||||
}
|
||||
|
||||
// Update the count display
|
||||
this._updateFactionCount(factionId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the faction count display based on checked checkboxes
|
||||
* @param {string} factionId - The faction ID
|
||||
*/
|
||||
_updateFactionCount(factionId) {
|
||||
const checkboxes = Array.from(this.element.querySelectorAll(`.faction-checkbox[data-faction="${factionId}"]:checked`))
|
||||
const count = checkboxes.length
|
||||
|
||||
// Update the hidden input field
|
||||
const input = this.element.querySelector(`input[name="system.factions.${factionId}.value"]`)
|
||||
if (input) {
|
||||
input.value = count
|
||||
}
|
||||
|
||||
// Update the visual count display
|
||||
const countDisplay = this.element.querySelector(`.faction-row[data-faction="${factionId}"] .faction-count`)
|
||||
if (countDisplay) {
|
||||
countDisplay.textContent = count
|
||||
}
|
||||
/** Met à jour le score d'une faction par clic sur un point. */
|
||||
static #onFactionLevel(_event, target) {
|
||||
if (!this.isEditable) return
|
||||
const factionId = target.dataset.faction
|
||||
const index = parseInt(target.dataset.index)
|
||||
const current = this.document.system.factions[factionId]?.value ?? 0
|
||||
const newValue = (index <= current) ? index - 1 : index
|
||||
this.document.update({ [`system.factions.${factionId}.value`]: Math.max(0, newValue) })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,6 +15,8 @@ export default class CelestopolCharacterSheet extends CelestopolActorSheet {
|
||||
createArmure: CelestopolCharacterSheet.#onCreateArmure,
|
||||
useAnomaly: CelestopolCharacterSheet.#onUseAnomaly,
|
||||
resetAnomalyUses: CelestopolCharacterSheet.#onResetAnomalyUses,
|
||||
depenseXp: CelestopolCharacterSheet.#onDepenseXp,
|
||||
supprimerXpLog: CelestopolCharacterSheet.#onSupprimerXpLog,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -95,6 +97,7 @@ export default class CelestopolCharacterSheet extends CelestopolActorSheet {
|
||||
|
||||
case "biography":
|
||||
context.tab = context.tabs.biography
|
||||
context.xpLogEmpty = (doc.system.xp?.log?.length ?? 0) === 0
|
||||
context.enrichedDescription = await foundry.applications.ux.TextEditor.implementation.enrichHTML(
|
||||
doc.system.description, { async: true })
|
||||
context.enrichedNotes = await foundry.applications.ux.TextEditor.implementation.enrichHTML(
|
||||
@@ -111,36 +114,36 @@ export default class CelestopolCharacterSheet extends CelestopolActorSheet {
|
||||
return context
|
||||
}
|
||||
|
||||
static #onCreateAnomaly() {
|
||||
static async #onCreateAnomaly() {
|
||||
if (this.document.itemTypes.anomaly.length > 0) {
|
||||
ui.notifications.warn(game.i18n.localize("CELESTOPOL.Anomaly.maxAnomaly"))
|
||||
return
|
||||
}
|
||||
this.document.createEmbeddedDocuments("Item", [{
|
||||
await this.document.createEmbeddedDocuments("Item", [{
|
||||
name: game.i18n.localize("CELESTOPOL.Item.newAnomaly"), type: "anomaly",
|
||||
}])
|
||||
}
|
||||
|
||||
static #onCreateAspect() {
|
||||
this.document.createEmbeddedDocuments("Item", [{
|
||||
static async #onCreateAspect() {
|
||||
await this.document.createEmbeddedDocuments("Item", [{
|
||||
name: game.i18n.localize("CELESTOPOL.Item.newAspect"), type: "aspect",
|
||||
}])
|
||||
}
|
||||
|
||||
static #onCreateEquipment() {
|
||||
this.document.createEmbeddedDocuments("Item", [{
|
||||
static async #onCreateEquipment() {
|
||||
await this.document.createEmbeddedDocuments("Item", [{
|
||||
name: game.i18n.localize("TYPES.Item.equipment"), type: "equipment",
|
||||
}])
|
||||
}
|
||||
|
||||
static #onCreateWeapon() {
|
||||
this.document.createEmbeddedDocuments("Item", [{
|
||||
static async #onCreateWeapon() {
|
||||
await this.document.createEmbeddedDocuments("Item", [{
|
||||
name: game.i18n.localize("TYPES.Item.weapon"), type: "weapon",
|
||||
}])
|
||||
}
|
||||
|
||||
static #onCreateArmure() {
|
||||
this.document.createEmbeddedDocuments("Item", [{
|
||||
static async #onCreateArmure() {
|
||||
await this.document.createEmbeddedDocuments("Item", [{
|
||||
name: game.i18n.localize("TYPES.Item.armure"), type: "armure",
|
||||
}])
|
||||
}
|
||||
@@ -163,4 +166,73 @@ export default class CelestopolCharacterSheet extends CelestopolActorSheet {
|
||||
if (!anomaly) return
|
||||
await anomaly.update({ "system.usesRemaining": anomaly.system.level })
|
||||
}
|
||||
|
||||
/** Ouvre un dialogue pour dépenser de l'XP. */
|
||||
static async #onDepenseXp() {
|
||||
const actor = this.document
|
||||
const currentXp = actor.system.xp?.actuel ?? 0
|
||||
const i18n = game.i18n
|
||||
|
||||
const content = `
|
||||
<form class="cel-dialog-form">
|
||||
<div class="form-group">
|
||||
<label>${i18n.localize("CELESTOPOL.XP.montant")}</label>
|
||||
<input type="number" name="montant" value="1" min="1" max="${currentXp}" autofocus />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>${i18n.localize("CELESTOPOL.XP.raison")}</label>
|
||||
<input type="text" name="raison" placeholder="${i18n.localize("CELESTOPOL.XP.raisonPlaceholder")}" />
|
||||
</div>
|
||||
<p class="xp-dialog-hint">${i18n.format("CELESTOPOL.XP.disponible", { n: currentXp })}</p>
|
||||
</form>`
|
||||
|
||||
const result = await foundry.applications.api.DialogV2.prompt({
|
||||
window: { title: i18n.localize("CELESTOPOL.XP.depenser") },
|
||||
content,
|
||||
ok: {
|
||||
label: i18n.localize("CELESTOPOL.XP.confirmer"),
|
||||
callback: (event, button) => {
|
||||
const form = button.form
|
||||
return {
|
||||
montant: parseInt(form.querySelector("[name=montant]").value) || 0,
|
||||
raison: form.querySelector("[name=raison]").value.trim(),
|
||||
}
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
if (!result) return
|
||||
const { montant, raison } = result
|
||||
|
||||
if (montant <= 0) {
|
||||
ui.notifications.warn(i18n.localize("CELESTOPOL.XP.montantInvalide"))
|
||||
return
|
||||
}
|
||||
if (montant > currentXp) {
|
||||
ui.notifications.warn(i18n.format("CELESTOPOL.XP.insuffisant", { n: currentXp }))
|
||||
return
|
||||
}
|
||||
|
||||
const date = new Date().toLocaleDateString("fr-FR")
|
||||
const log = [...(actor.system.xp.log ?? []), { montant, raison, date }]
|
||||
await actor.update({
|
||||
"system.xp.actuel": currentXp - montant,
|
||||
"system.xp.log": log,
|
||||
})
|
||||
}
|
||||
|
||||
/** Supprime une entrée du log XP et rembourse les points (mode édition). */
|
||||
static async #onSupprimerXpLog(event, target) {
|
||||
const idx = parseInt(target.dataset.idx)
|
||||
const actor = this.document
|
||||
const log = [...(actor.system.xp.log ?? [])]
|
||||
if (isNaN(idx) || idx < 0 || idx >= log.length) return
|
||||
|
||||
const entry = log[idx]
|
||||
log.splice(idx, 1)
|
||||
await actor.update({
|
||||
"system.xp.actuel": (actor.system.xp?.actuel ?? 0) + entry.montant,
|
||||
"system.xp.log": log,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -90,15 +90,15 @@ export const FACTIONS = {
|
||||
|
||||
/** Niveaux de blessures avec leur malus associé. */
|
||||
export const WOUND_LEVELS = [
|
||||
{ id: 0, label: "CELESTOPOL.Wound.none", malus: 0 },
|
||||
{ id: 1, label: "CELESTOPOL.Wound.anodin", malus: 0 },
|
||||
{ id: 2, label: "CELESTOPOL.Wound.derisoire", malus: 0 },
|
||||
{ id: 3, label: "CELESTOPOL.Wound.negligeable", malus: -1 },
|
||||
{ id: 4, label: "CELESTOPOL.Wound.superficiel", malus: -1 },
|
||||
{ id: 5, label: "CELESTOPOL.Wound.leger", malus: -2 },
|
||||
{ id: 6, label: "CELESTOPOL.Wound.modere", malus: -2 },
|
||||
{ id: 7, label: "CELESTOPOL.Wound.grave", malus: -3 },
|
||||
{ id: 8, label: "CELESTOPOL.Wound.dramatique", malus: -999 },
|
||||
{ id: 0, label: "CELESTOPOL.Wound.none", malus: 0, duration: "" },
|
||||
{ id: 1, label: "CELESTOPOL.Wound.anodin", malus: 0, duration: "CELESTOPOL.Wound.duration1min" },
|
||||
{ id: 2, label: "CELESTOPOL.Wound.negligeable", malus: 0, duration: "CELESTOPOL.Wound.duration1min" },
|
||||
{ id: 3, label: "CELESTOPOL.Wound.derisoire", malus: -1, duration: "CELESTOPOL.Wound.duration10min" },
|
||||
{ id: 4, label: "CELESTOPOL.Wound.superficiel", malus: -1, duration: "CELESTOPOL.Wound.duration10min" },
|
||||
{ id: 5, label: "CELESTOPOL.Wound.leger", malus: -2, duration: "CELESTOPOL.Wound.duration30min" },
|
||||
{ id: 6, label: "CELESTOPOL.Wound.modere", malus: -2, duration: "CELESTOPOL.Wound.duration30min" },
|
||||
{ id: 7, label: "CELESTOPOL.Wound.grave", malus: -3, duration: "CELESTOPOL.Wound.duration1jour" },
|
||||
{ id: 8, label: "CELESTOPOL.Wound.dramatique", malus: -999, duration: "" },
|
||||
]
|
||||
|
||||
/** Seuils de difficulté pour les jets de dés. */
|
||||
|
||||
@@ -1,10 +1,4 @@
|
||||
export default class CelestopolActor extends Actor {
|
||||
/** @override */
|
||||
prepareDerivedData() {
|
||||
super.prepareDerivedData()
|
||||
this.system.prepareDerivedData?.()
|
||||
}
|
||||
|
||||
/** @override */
|
||||
getRollData() {
|
||||
return this.toObject(false).system
|
||||
|
||||
@@ -1,6 +1 @@
|
||||
export default class CelestopolChatMessage extends ChatMessage {
|
||||
/** @override */
|
||||
async renderHTML(options = {}) {
|
||||
return super.renderHTML(options)
|
||||
}
|
||||
}
|
||||
export default class CelestopolChatMessage extends ChatMessage {}
|
||||
|
||||
@@ -1,9 +1,4 @@
|
||||
export default class CelestopolItem extends Item {
|
||||
/** @override */
|
||||
prepareDerivedData() {
|
||||
super.prepareDerivedData()
|
||||
}
|
||||
|
||||
/** @override */
|
||||
getRollData() {
|
||||
return this.toObject(false).system
|
||||
|
||||
@@ -42,6 +42,7 @@ export class CelestopolRoll extends Roll {
|
||||
const fortuneValue = options.fortuneValue ?? 0
|
||||
const isResistance = options.isResistance ?? false
|
||||
const isCombat = options.isCombat ?? false
|
||||
const isRangedDefense = options.isRangedDefense ?? false
|
||||
const weaponType = options.weaponType ?? "melee"
|
||||
const weaponName = options.weaponName ?? null
|
||||
const weaponDegats = options.weaponDegats ?? "0"
|
||||
@@ -72,6 +73,7 @@ export class CelestopolRoll extends Roll {
|
||||
defaultRollMoonDie: options.rollMoonDie ?? false,
|
||||
isResistance,
|
||||
isCombat,
|
||||
isRangedDefense,
|
||||
weaponType,
|
||||
weaponName,
|
||||
weaponDegats,
|
||||
@@ -221,6 +223,7 @@ export class CelestopolRoll extends Roll {
|
||||
autoSuccess,
|
||||
isResistance,
|
||||
isCombat,
|
||||
isRangedDefense,
|
||||
weaponType,
|
||||
weaponName,
|
||||
weaponDegats,
|
||||
@@ -243,66 +246,49 @@ export class CelestopolRoll extends Roll {
|
||||
// Test de résistance échoué → cocher automatiquement la prochaine case de blessure
|
||||
const actor = game.actors.get(options.actorId)
|
||||
if (isResistance && actor && roll.options.resultType === "failure") {
|
||||
const wounds = actor.system.blessures
|
||||
const nextIdx = [1,2,3,4,5,6,7,8].find(i => !wounds[`b${i}`]?.checked)
|
||||
if (nextIdx) {
|
||||
await actor.update({ [`system.blessures.b${nextIdx}.checked`]: true })
|
||||
roll.options.woundTaken = nextIdx
|
||||
const nextLvl = (actor.system.blessures.lvl ?? 0) + 1
|
||||
if (nextLvl <= 8) {
|
||||
await actor.update({ "system.blessures.lvl": nextLvl })
|
||||
roll.options.woundTaken = nextLvl
|
||||
}
|
||||
}
|
||||
|
||||
// Combat mêlée échoué → joueur prend une blessure
|
||||
if (isCombat && weaponType === "melee" && actor && roll.options.resultType === "failure") {
|
||||
const wounds = actor.system.blessures
|
||||
const nextIdx = [1,2,3,4,5,6,7,8].find(i => !wounds[`b${i}`]?.checked)
|
||||
if (nextIdx) {
|
||||
await actor.update({ [`system.blessures.b${nextIdx}.checked`]: true })
|
||||
roll.options.woundTaken = nextIdx
|
||||
// Mêlée échouée OU défense à distance échouée → joueur prend une blessure
|
||||
if (isCombat && (weaponType === "melee" || isRangedDefense) && actor && roll.options.resultType === "failure") {
|
||||
const nextLvl = (actor.system.blessures.lvl ?? 0) + 1
|
||||
if (nextLvl <= 8) {
|
||||
await actor.update({ "system.blessures.lvl": nextLvl })
|
||||
roll.options.woundTaken = nextLvl
|
||||
}
|
||||
}
|
||||
|
||||
await roll.toMessage({}, { rollMode: rollData.rollMode })
|
||||
|
||||
// Destin utilisé → vider la jauge (reset à 0)
|
||||
if (rollData.useDestin && actor) {
|
||||
await actor.update({
|
||||
"system.destin.lvl": 0,
|
||||
"system.destin.d1.checked": false,
|
||||
"system.destin.d2.checked": false,
|
||||
"system.destin.d3.checked": false,
|
||||
"system.destin.d4.checked": false,
|
||||
"system.destin.d5.checked": false,
|
||||
"system.destin.d6.checked": false,
|
||||
"system.destin.d7.checked": false,
|
||||
"system.destin.d8.checked": false,
|
||||
})
|
||||
}
|
||||
|
||||
// Fortune utilisée → décrémenter de 1 (min 0)
|
||||
if (rollData.useFortune && actor) {
|
||||
const currentFortune = actor.system.attributs.fortune.value ?? 0
|
||||
await actor.update({ "system.attributs.fortune.value": Math.max(0, currentFortune - 1) })
|
||||
}
|
||||
|
||||
// Puiser dans ses ressources → coche une case de spleen
|
||||
if (rollData.puiserRessources && actor) {
|
||||
const currentSpleen = actor.system.spleen.lvl ?? 0
|
||||
if (currentSpleen < 8) {
|
||||
const newLvl = currentSpleen + 1
|
||||
const key = `s${newLvl}`
|
||||
await actor.update({
|
||||
"system.spleen.lvl": newLvl,
|
||||
[`system.spleen.${key}.checked`]: true,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Mémoriser les préférences sur l'acteur
|
||||
// Batching de toutes les mises à jour de l'acteur en un seul appel réseau
|
||||
if (actor) {
|
||||
await actor.update({
|
||||
"system.prefs.rollMoonDie": rollData.rollMoonDie,
|
||||
"system.prefs.difficulty": difficulty,
|
||||
})
|
||||
const updateData = {}
|
||||
|
||||
if (rollData.useDestin) {
|
||||
updateData["system.destin.lvl"] = 0
|
||||
}
|
||||
|
||||
if (rollData.useFortune) {
|
||||
const currentFortune = actor.system.attributs.fortune.value ?? 0
|
||||
updateData["system.attributs.fortune.value"] = Math.max(0, currentFortune - 1)
|
||||
}
|
||||
|
||||
if (rollData.puiserRessources) {
|
||||
const currentSpleen = actor.system.spleen.lvl ?? 0
|
||||
if (currentSpleen < 8) {
|
||||
updateData["system.spleen.lvl"] = currentSpleen + 1
|
||||
}
|
||||
}
|
||||
|
||||
// Mémoriser les préférences
|
||||
updateData["system.prefs.rollMoonDie"] = rollData.rollMoonDie
|
||||
updateData["system.prefs.difficulty"] = difficulty
|
||||
|
||||
await actor.update(updateData)
|
||||
}
|
||||
|
||||
return roll
|
||||
@@ -421,6 +407,7 @@ export class CelestopolRoll extends Roll {
|
||||
weaponName: this.options.weaponName ?? null,
|
||||
weaponDegats: this.options.weaponDegats ?? null,
|
||||
weaponType: this.options.weaponType ?? null,
|
||||
isRangedDefense: this.options.isRangedDefense ?? false,
|
||||
woundTaken: this.options.woundTaken ?? null,
|
||||
// Dé de lune
|
||||
hasMoonDie: moonDieResult !== null,
|
||||
|
||||
@@ -21,18 +21,10 @@ export default class CelestopolCharacter extends foundry.abstract.TypeDataModel
|
||||
value: new fields.NumberField({ ...reqInt, initial: 0, min: 0, max: 8 }),
|
||||
})
|
||||
|
||||
// Les 4 stats avec leurs domaines
|
||||
// Les 4 stats avec leurs domaines — niveau stocké directement comme entier
|
||||
const skillField = (label) => new fields.SchemaField({
|
||||
label: new fields.StringField({ required: true, initial: label }),
|
||||
value: new fields.NumberField({ ...reqInt, initial: 0, min: 0, max: 8 }),
|
||||
level1: new fields.BooleanField({ required: true, initial: false }),
|
||||
level2: new fields.BooleanField({ required: true, initial: false }),
|
||||
level3: new fields.BooleanField({ required: true, initial: false }),
|
||||
level4: new fields.BooleanField({ required: true, initial: false }),
|
||||
level5: new fields.BooleanField({ required: true, initial: false }),
|
||||
level6: new fields.BooleanField({ required: true, initial: false }),
|
||||
level7: new fields.BooleanField({ required: true, initial: false }),
|
||||
level8: new fields.BooleanField({ required: true, initial: false }),
|
||||
})
|
||||
|
||||
const statField = (statId) => {
|
||||
@@ -55,32 +47,19 @@ export default class CelestopolCharacter extends foundry.abstract.TypeDataModel
|
||||
esprit: statField("esprit"),
|
||||
})
|
||||
|
||||
// Blessures (8 cases)
|
||||
const woundField = (idx) => new fields.SchemaField({
|
||||
checked: new fields.BooleanField({ required: true, initial: false }),
|
||||
malus: new fields.NumberField({ ...reqInt, initial: SYSTEM.WOUND_LEVELS[idx]?.malus ?? 0 }),
|
||||
})
|
||||
// Blessures — niveau entier direct (0 = aucune, 8 = fatale)
|
||||
schema.blessures = new fields.SchemaField({
|
||||
lvl: new fields.NumberField({ ...reqInt, initial: 0, min: 0, max: 8 }),
|
||||
b1: woundField(1), b2: woundField(2), b3: woundField(3), b4: woundField(4),
|
||||
b5: woundField(5), b6: woundField(6), b7: woundField(7), b8: woundField(8),
|
||||
})
|
||||
|
||||
// Destin (8 cases)
|
||||
const destField = () => new fields.SchemaField({
|
||||
checked: new fields.BooleanField({ required: true, initial: false }),
|
||||
})
|
||||
// Destin — jauge entière directe
|
||||
schema.destin = new fields.SchemaField({
|
||||
lvl: new fields.NumberField({ ...reqInt, initial: 0, min: 0, max: 8 }),
|
||||
d1: destField(), d2: destField(), d3: destField(), d4: destField(),
|
||||
d5: destField(), d6: destField(), d7: destField(), d8: destField(),
|
||||
})
|
||||
|
||||
// Spleen (8 cases)
|
||||
// Spleen — jauge entière directe
|
||||
schema.spleen = new fields.SchemaField({
|
||||
lvl: new fields.NumberField({ ...reqInt, initial: 0, min: 0, max: 8 }),
|
||||
s1: destField(), s2: destField(), s3: destField(), s4: destField(),
|
||||
s5: destField(), s6: destField(), s7: destField(), s8: destField(),
|
||||
})
|
||||
|
||||
// Attributs de personnage (Entregent, Fortune, Rêve, Vision)
|
||||
@@ -95,18 +74,9 @@ export default class CelestopolCharacter extends foundry.abstract.TypeDataModel
|
||||
vision: persoAttrField(),
|
||||
})
|
||||
|
||||
// Factions - 9 checkboxes per faction (like wound tracks)
|
||||
// Factions — score entier direct (0-9)
|
||||
const factionField = () => new fields.SchemaField({
|
||||
value: new fields.NumberField({ ...reqInt, initial: 0 }),
|
||||
level1: new fields.BooleanField({ required: true, initial: false }),
|
||||
level2: new fields.BooleanField({ required: true, initial: false }),
|
||||
level3: new fields.BooleanField({ required: true, initial: false }),
|
||||
level4: new fields.BooleanField({ required: true, initial: false }),
|
||||
level5: new fields.BooleanField({ required: true, initial: false }),
|
||||
level6: new fields.BooleanField({ required: true, initial: false }),
|
||||
level7: new fields.BooleanField({ required: true, initial: false }),
|
||||
level8: new fields.BooleanField({ required: true, initial: false }),
|
||||
level9: new fields.BooleanField({ required: true, initial: false }),
|
||||
value: new fields.NumberField({ ...reqInt, initial: 0, min: 0, max: 9 }),
|
||||
})
|
||||
schema.factions = new fields.SchemaField({
|
||||
pinkerton: factionField(),
|
||||
@@ -133,6 +103,16 @@ export default class CelestopolCharacter extends foundry.abstract.TypeDataModel
|
||||
difficulty: new fields.StringField({ required: true, nullable: false, initial: "normal" }),
|
||||
})
|
||||
|
||||
// Expérience
|
||||
schema.xp = new fields.SchemaField({
|
||||
actuel: new fields.NumberField({ ...reqInt, initial: 0, min: 0 }),
|
||||
log: new fields.ArrayField(new fields.SchemaField({
|
||||
montant: new fields.NumberField({ ...reqInt, initial: 0, min: 0 }),
|
||||
raison: new fields.StringField({ required: true, nullable: false, initial: "" }),
|
||||
date: new fields.StringField({ required: true, nullable: false, initial: "" }),
|
||||
})),
|
||||
})
|
||||
|
||||
// Description & notes
|
||||
schema.description = new fields.HTMLField({ required: true, textSearch: true })
|
||||
schema.notes = new fields.HTMLField({ required: true, textSearch: true })
|
||||
@@ -156,15 +136,7 @@ export default class CelestopolCharacter extends foundry.abstract.TypeDataModel
|
||||
prepareDerivedData() {
|
||||
super.prepareDerivedData()
|
||||
|
||||
// Calcul automatique de la valeur de chaque domaine = nombre de cases cochées
|
||||
for (const stat of Object.values(this.stats)) {
|
||||
for (const skill of Object.values(stat)) {
|
||||
if (typeof skill !== "object" || !("level1" in skill)) continue
|
||||
skill.value = [1,2,3,4,5,6,7,8].filter(i => skill[`level${i}`]).length
|
||||
}
|
||||
}
|
||||
|
||||
// Calcul automatique de la Résistance par stat = +2 par domaine atteignant son seuil
|
||||
// Résistance par stat = +2 par domaine atteignant son seuil de spécialisation
|
||||
for (const [statId, statData] of Object.entries(this.stats)) {
|
||||
let res = 0
|
||||
for (const [skillId, skill] of Object.entries(statData)) {
|
||||
@@ -175,19 +147,11 @@ export default class CelestopolCharacter extends foundry.abstract.TypeDataModel
|
||||
statData.res = res
|
||||
}
|
||||
|
||||
// Calcul automatique de la valeur de chaque faction = nombre de cases cochées
|
||||
for (const faction of Object.values(this.factions)) {
|
||||
if (typeof faction !== "object" || !("level1" in faction)) continue
|
||||
faction.value = [1,2,3,4,5,6,7,8,9].filter(i => faction[`level${i}`]).length
|
||||
}
|
||||
|
||||
// Calcul automatique du niveau des jauges depuis les cases cochées
|
||||
this.blessures.lvl = [1,2,3,4,5,6,7,8].filter(i => this.blessures[`b${i}`]?.checked).length
|
||||
this.destin.lvl = [1,2,3,4,5,6,7,8].filter(i => this.destin[`d${i}`]?.checked).length
|
||||
this.spleen.lvl = [1,2,3,4,5,6,7,8].filter(i => this.spleen[`s${i}`]?.checked).length
|
||||
|
||||
// Initiative PJ : 4 + Mobilité (Corps) + Inspiration (Cœur) [après calcul des domaines]
|
||||
// Initiative PJ : 4 + Mobilité (Corps) + Inspiration (Cœur)
|
||||
this.initiative = 4 + (this.stats.corps.mobilite?.value ?? 0) + (this.stats.coeur.inspiration?.value ?? 0)
|
||||
|
||||
// XP dépensée = somme des montants du log
|
||||
this.xp.depense = this.xp.log.reduce((sum, entry) => sum + entry.montant, 0)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -239,10 +203,29 @@ export default class CelestopolCharacter extends foundry.abstract.TypeDataModel
|
||||
const statData = this.stats[statId]
|
||||
if (!statData) return null
|
||||
|
||||
return CelestopolRoll.prompt({
|
||||
actorId: this.parent.id,
|
||||
actorName: this.parent.name,
|
||||
actorImage: this.parent.img,
|
||||
statId,
|
||||
skillId: null,
|
||||
statLabel: SYSTEM.STATS[statId]?.label,
|
||||
skillLabel: "CELESTOPOL.Roll.resistanceTest",
|
||||
skillValue: statData.res,
|
||||
woundMalus: this.getWoundMalus(),
|
||||
woundLevel: this.blessures.lvl,
|
||||
isResistance: true,
|
||||
rollMoonDie: false,
|
||||
destGaugeFull: false,
|
||||
fortuneValue: 0,
|
||||
difficulty: "normal",
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Lance une attaque avec une arme (test Échauffourée vs Corps PNJ).
|
||||
* Mêlée : échec → blessure joueur auto-cochée.
|
||||
* Distance : échec → simple raté, pas de blessure joueur.
|
||||
* Lance une attaque avec une arme.
|
||||
* Mêlée : test Échauffourée vs Corps PNJ ; échec → blessure joueur.
|
||||
* Distance : test Échauffourée vs Corps PNJ ; échec → pas de blessure joueur.
|
||||
* Égalité (marge=0) → personne n'est blessé.
|
||||
* @param {string} itemId - Id de l'item arme
|
||||
*/
|
||||
@@ -255,23 +238,60 @@ export default class CelestopolCharacter extends foundry.abstract.TypeDataModel
|
||||
if (!echauffouree) return null
|
||||
|
||||
return CelestopolRoll.prompt({
|
||||
actorId: this.parent.id,
|
||||
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(),
|
||||
woundLevel: this.blessures.lvl,
|
||||
rollMoonDie: this.prefs.rollMoonDie ?? false,
|
||||
destGaugeFull: this.destin.lvl > 0,
|
||||
fortuneValue: this.attributs.fortune.value,
|
||||
isCombat: true,
|
||||
weaponType: item.system.type,
|
||||
weaponName: item.name,
|
||||
weaponDegats: item.system.degats,
|
||||
actorId: this.parent.id,
|
||||
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(),
|
||||
woundLevel: this.blessures.lvl,
|
||||
rollMoonDie: this.prefs.rollMoonDie ?? false,
|
||||
destGaugeFull: this.destin.lvl > 0,
|
||||
fortuneValue: this.attributs.fortune.value,
|
||||
isCombat: true,
|
||||
isRangedDefense: false,
|
||||
weaponType: item.system.type,
|
||||
weaponName: item.name,
|
||||
weaponDegats: item.system.degats,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Lance un jet de défense contre une attaque à distance (test Mobilité vs Corps PNJ).
|
||||
* Succès → esquive réussie.
|
||||
* Échec → blessure automatique (le PNJ touche).
|
||||
* @param {string} itemId - Id de l'item arme (distance uniquement)
|
||||
*/
|
||||
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
|
||||
|
||||
const mobilite = this.stats.corps.mobilite
|
||||
if (!mobilite) return null
|
||||
|
||||
return CelestopolRoll.prompt({
|
||||
actorId: this.parent.id,
|
||||
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(),
|
||||
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: item.name,
|
||||
weaponDegats: "0",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,14 +41,8 @@ export default class CelestopolNPC extends foundry.abstract.TypeDataModel {
|
||||
esprit: statField("esprit"),
|
||||
})
|
||||
|
||||
const woundField = (idx) => new fields.SchemaField({
|
||||
checked: new fields.BooleanField({ required: true, initial: false }),
|
||||
malus: new fields.NumberField({ ...reqInt, initial: SYSTEM.WOUND_LEVELS[idx]?.malus ?? 0 }),
|
||||
})
|
||||
schema.blessures = new fields.SchemaField({
|
||||
lvl: new fields.NumberField({ ...reqInt, initial: 0, min: 0, max: 8 }),
|
||||
b1: woundField(1), b2: woundField(2), b3: woundField(3), b4: woundField(4),
|
||||
b5: woundField(5), b6: woundField(6), b7: woundField(7), b8: woundField(8),
|
||||
})
|
||||
|
||||
schema.prefs = new fields.SchemaField({
|
||||
|
||||
Reference in New Issue
Block a user