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:
2026-03-31 00:33:59 +02:00
parent 79a68ee9ab
commit 9dbd614c5a
40 changed files with 849 additions and 529 deletions

View File

@@ -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) })
}
}

View File

@@ -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,
})
}
}