Correction compendiums

This commit is contained in:
2026-04-27 21:30:33 +02:00
parent 1e252ff6f2
commit bc49286f91
76 changed files with 1645 additions and 73 deletions
+1
View File
@@ -161,4 +161,5 @@ export const TEMPLATE_PARTIALS = [
"systems/fvtt-chroniques-de-l-etrange/templates/actor/parts/cde-npc-items.html",
"systems/fvtt-chroniques-de-l-etrange/templates/apps/cde-loksyu-app.html",
"systems/fvtt-chroniques-de-l-etrange/templates/apps/cde-tinji-app.html",
"systems/fvtt-chroniques-de-l-etrange/templates/apps/cde-wheel-app.html",
]
+87
View File
@@ -0,0 +1,87 @@
/**
* Chroniques de l'Étrange — Système FoundryVTT
*
* Chroniques de l'Étrange est un jeu de rôle édité par Antre-Monde Éditions.
* Ce système FoundryVTT est une implémentation indépendante et n'est pas
* affilié à Antre-Monde Éditions,
* mais a été réalisé avec l'autorisation d'Antre-Monde Éditions.
*
* @author LeRatierBretonnien
* @copyright 20242026 LeRatierBretonnien
* @license CC BY-NC-SA 4.0 https://creativecommons.org/licenses/by-nc-sa/4.0/
*/
import { rollInitiativePC, rollInitiativeNPC } from "../ui/initiative.js"
import { ACTOR_TYPES } from "../config/constants.js"
/**
* Custom Combat document for Chroniques de l'Étrange.
*
* The initiative wheel has 24 crans (positions). The character with the
* highest initiative acts first (furthest counter-clockwise from reference).
* After each action, their position advances clockwise by the action's cran cost
* (initiative decreases, wrapping from 1 → 24).
*
* Sort order: descending by initiative (highest acts first).
*/
export class CDECombat extends Combat {
/**
* Override rollInitiative to open the PC or NPC initiative dialog
* for each selected combatant, then sync the result to the Combatant document.
*/
async rollInitiative(ids, options = {}) {
const combatantIds = typeof ids === "string" ? [ids] : ids
for (const id of combatantIds) {
const combatant = this.combatants.get(id)
if (!combatant) continue
const actor = combatant.actor
if (!actor) continue
if (actor.type === ACTOR_TYPES.character) {
await rollInitiativePC(actor)
} else {
await rollInitiativeNPC(actor)
}
// combatant.initiative is synced by the updateActor hook in system.js
// (triggered by actor.update inside rollInitiativePC/NPC)
}
return this
}
/**
* Sort combatants: highest initiative first (furthest counter-clockwise = acts first).
* Ties: PCs before NPCs; among PCs, by name; among NPCs, by name.
* Calls super.setupTurns() first to ensure this.current is properly initialized.
*/
setupTurns() {
super.setupTurns()
this.turns = this.turns.slice().sort((a, b) => {
const ia = a.initiative ?? 0
const ib = b.initiative ?? 0
if (ia !== ib) return ib - ia // descending — highest acts first
// Tie-break: PCs before NPCs
const aIsPC = a.actor?.type === ACTOR_TYPES.character ? 1 : 0
const bIsPC = b.actor?.type === ACTOR_TYPES.character ? 1 : 0
if (aIsPC !== bIsPC) return bIsPC - aIsPC
return (a.name ?? "").localeCompare(b.name ?? "")
})
return this.turns
}
}
/**
* Advance a combatant's wheel position by the given action cran cost.
* Position wraps: after reaching 1, it continues from 24.
*
* @param {Combatant} combatant
* @param {number} cranCost
*/
export async function advanceCombatantPosition(combatant, cranCost) {
const current = combatant.initiative ?? combatant.actor?.system?.initiative ?? 1
const newValue = ((current - cranCost - 1 + 48) % 24) + 1
// Update combatant only; the updateCombatant hook in system.js syncs actor.initiative.
await combatant.update({ initiative: newValue })
}
+42 -1
View File
@@ -27,7 +27,9 @@ import { CDECharacterSheet, CDENpcSheet } from "./ui/sheets/actors/index.js"
import { CDEItemSheet, CDEKungfuSheet, CDESpellSheet, CDESupernaturalSheet, CDEWeaponSheet, CDEArmorSheet, CDESanheiSheet, CDEIngredientSheet } from "./ui/sheets/items/index.js"
import { CDELoksyuApp } from "./ui/apps/loksyu-app.js"
import { CDETinjiApp } from "./ui/apps/tinji-app.js"
import { CDEWheelApp } from "./ui/apps/wheel-app.js"
import { injectRollActions, refreshAllRollActions } from "./ui/roll-actions.js"
import { CDECombat } from "./documents/combat.js"
Hooks.once("i18nInit", preLocalizeConfig)
@@ -39,7 +41,9 @@ Hooks.once("init", async () => {
game.system.CONST = { MAGICS, SUBTYPES }
// Expose standalone apps globally for macros
game.cde = { CDELoksyuApp, CDETinjiApp }
game.cde = { CDELoksyuApp, CDETinjiApp, CDEWheelApp }
CONFIG.Combat.documentClass = CDECombat
CONFIG.Actor.dataModels = {
[ACTOR_TYPES.character]: CharacterDataModel,
@@ -126,6 +130,7 @@ Hooks.once("init", async () => {
Hooks.once("ready", async () => {
await migrateIfNeeded()
CDEWheelApp.registerHooks()
})
/** Add Loksyu + Tin Ji quick-access buttons to the chat panel (FoundryVTT v13) */
@@ -145,12 +150,16 @@ Hooks.on("renderChatLog", (_app, html) => {
<button type="button" class="cde-chat-btn cde-chat-btn--tinji">
<i class="fas fa-star"></i> ${game.i18n.localize("CDE.TinJi2")}
</button>
<button type="button" class="cde-chat-btn cde-chat-btn--wheel">
<i class="fas fa-circle-notch"></i> ${game.i18n.localize("CDE.InitiativeWheel")}
</button>
`
// Use event delegation to avoid being swallowed by Foundry's own handlers
wrapper.addEventListener("click", (ev) => {
if (ev.target.closest(".cde-chat-btn--loksyu")) CDELoksyuApp.open()
if (ev.target.closest(".cde-chat-btn--tinji")) CDETinjiApp.open()
if (ev.target.closest(".cde-chat-btn--wheel")) CDEWheelApp.open()
})
// Insert before the chat form — works on v12 and v13
@@ -173,3 +182,35 @@ Hooks.on("updateSetting", setting => {
refreshAllRollActions()
}
})
/**
* When an actor's initiative changes (via +/- buttons on the sheet),
* sync the corresponding combatant in the active combat.
*/
Hooks.on("updateActor", (actor, diff) => {
if (!foundry.utils.hasProperty(diff, "system.initiative")) return
if (!game.combat) return
const initiative = actor.system.initiative
const combatant = game.combat.combatants.find(c => c.actor?.id === actor.id)
if (combatant && combatant.initiative !== initiative) {
combatant.update({ initiative }).catch(() => {})
}
})
/**
* When a combatant's initiative changes (via wheel action buttons),
* sync the actor's system.initiative to match.
* Uses setTimeout to defer until after Foundry's update chain resolves,
* avoiding concurrent #recordPreviousState errors on the combat document.
*/
Hooks.on("updateCombatant", (combatant, diff) => {
if (!("initiative" in diff)) return
const initiative = combatant.initiative
if (initiative == null) return
setTimeout(() => {
const actor = combatant.actor
if (actor && actor.system?.initiative !== initiative) {
actor.update({ "system.initiative": initiative }).catch(() => {})
}
}, 0)
})
+1
View File
@@ -14,3 +14,4 @@
export { CDELoksyuApp } from "./loksyu-app.js"
export { CDETinjiApp } from "./tinji-app.js"
export { updateLoksyuFromRoll, updateTinjiFromRoll } from "./singletons.js"
export { CDEWheelApp } from "./wheel-app.js"
+204
View File
@@ -0,0 +1,204 @@
/**
* Chroniques de l'Étrange — Système FoundryVTT
*
* Chroniques de l'Étrange est un jeu de rôle édité par Antre-Monde Éditions.
* Ce système FoundryVTT est une implémentation indépendante et n'est pas
* affilié à Antre-Monde Éditions,
* mais a été réalisé avec l'autorisation d'Antre-Monde Éditions.
*
* @author LeRatierBretonnien
* @copyright 20242026 LeRatierBretonnien
* @license CC BY-NC-SA 4.0 https://creativecommons.org/licenses/by-nc-sa/4.0/
*/
import { advanceCombatantPosition } from "../../documents/combat.js"
const WHEEL_TEMPLATE = "systems/fvtt-chroniques-de-l-etrange/templates/apps/cde-wheel-app.html"
/**
* Action costs for common combat actions (in crans).
* Listed in order from cheapest to most expensive.
*/
export const ACTION_COSTS = [
{ key: "draw", label: "CDE.ActionCostDraw", cost: 1 },
{ key: "changestyle", label: "CDE.ActionCostChangeStyle", cost: 1 },
{ key: "defense", label: "CDE.ActionCostDefense", cost: 1 },
{ key: "aim", label: "CDE.ActionCostAim", cost: 2 },
{ key: "help", label: "CDE.ActionCostHelp", cost: 2 },
{ key: "defally", label: "CDE.ActionCostDefendAlly", cost: 2 },
{ key: "move", label: "CDE.ActionCostMove", cost: 2 },
{ key: "attack", label: "CDE.ActionCostAttack", cost: 3 },
{ key: "delay", label: "CDE.ActionCostDelay", cost: 6 },
]
/**
* Wu Xing color segments for the 24-cran wheel.
* 6 colors × 4 crans = 24. Each colour covers crans [start..start+3].
* An effect lasting 6 crans returns to the same colour.
*/
const WHEEL_SEGMENTS = [
{ label: "Métal", color: "#b8c4cc", textColor: "#1a1a1a", crans: [1, 2, 3, 4] },
{ label: "Eau", color: "#3a7bd5", textColor: "#ffffff", crans: [5, 6, 7, 8] },
{ label: "Terre", color: "#c8a84b", textColor: "#1a1a1a", crans: [9, 10, 11, 12] },
{ label: "Feu", color: "#d94f3d", textColor: "#ffffff", crans: [13, 14, 15, 16] },
{ label: "Bois", color: "#4a9b5a", textColor: "#ffffff", crans: [17, 18, 19, 20] },
{ label: "Repère", color: "#1a1a2e", textColor: "#aaaaaa", crans: [21, 22, 23, 24] },
]
/** Return the segment data for a given cran (124). */
function segmentForCran(cran) {
return WHEEL_SEGMENTS.find(s => s.crans.includes(cran)) ?? WHEEL_SEGMENTS[0]
}
/**
* Roue d'Initiative — visual initiative wheel for CDE combat.
*
* Shows all combatants in the current combat scene on a 24-cran wheel.
* Provides action-cost buttons to advance a combatant's position.
*
* Singleton: open via CDEWheelApp.open().
*/
export class CDEWheelApp extends foundry.applications.api.ApplicationV2 {
static DEFAULT_OPTIONS = {
id: "cde-wheel-app",
classes: ["cde-wheel-app"],
tag: "div",
window: {
title: "CDE.InitiativeWheel",
icon: "fas fa-circle-notch",
resizable: true,
},
position: { width: 820, height: 620 },
actions: {
advanceCran: CDEWheelApp.#advanceCran,
setSurprised: CDEWheelApp.#setSurprised,
rollInitiative: CDEWheelApp.#rollInitiative,
},
}
/** @type {CDEWheelApp|null} */
static #instance = null
/** Open (or bring to front) the singleton instance. */
static open() {
if (!CDEWheelApp.#instance || CDEWheelApp.#instance.rendered === false) {
CDEWheelApp.#instance = new CDEWheelApp()
CDEWheelApp.#instance.render(true)
} else {
CDEWheelApp.#instance.bringToFront()
}
return CDEWheelApp.#instance
}
/** Currently selected combatant id (for action panel). */
#selectedId = null
async _prepareContext(options) {
const combat = game.combat
const combatants = combat ? [...combat.combatants.values()] : []
const sorted = [...combatants].sort((a, b) => (b.initiative ?? 0) - (a.initiative ?? 0))
const cranData = this.#buildCranData(combatants)
const selected = this.#selectedId
? combatants.find(c => c.id === this.#selectedId)
: null
const actionCosts = ACTION_COSTS.map(a => ({
...a,
label: game.i18n.localize(a.label),
}))
return {
hasCombat: !!combat,
combatants: sorted.map(c => ({
id: c.id,
name: c.name,
img: c.token?.texture?.src ?? c.actor?.img ?? "icons/svg/mystery-man.svg",
initiative: c.initiative ?? "—",
segment: segmentForCran(c.initiative ?? 1),
isActive: combat?.current?.combatantId === c.id,
isSelected: c.id === this.#selectedId,
hasInitiative: c.initiative != null,
})),
cranData,
selected,
selectedName: selected?.name ?? null,
actionCosts,
}
}
async _renderHTML(context, options) {
return foundry.applications.handlebars.renderTemplate(WHEEL_TEMPLATE, context)
}
_replaceHTML(result, content, options) {
content.innerHTML = result
this.#bindEvents(content)
}
/** Build per-cran data for the SVG wheel. */
#buildCranData(combatants) {
const data = []
for (let cran = 1; cran <= 24; cran++) {
const segment = segmentForCran(cran)
const fighters = combatants.filter(c => Math.round(c.initiative) === cran)
data.push({ cran, segment, fighters })
}
return data
}
/** Bind click events for combatant selection. */
#bindEvents(content) {
content.querySelectorAll("[data-select-combatant]").forEach(el => {
el.addEventListener("click", () => {
this.#selectedId = el.dataset.selectCombatant
this.render()
})
})
}
/** Action: advance selected combatant by given cran cost. */
static async #advanceCran(event, element) {
const app = CDEWheelApp.#instance
if (!app?.#selectedId) return
const cost = parseInt(element.dataset.cost, 10)
if (!cost || isNaN(cost)) return
const combatant = game.combat?.combatants.get(app.#selectedId)
if (!combatant) return
await advanceCombatantPosition(combatant, cost)
}
/** Action: set selected combatant to surprised (position 1 = reference). */
static async #setSurprised(event, element) {
const app = CDEWheelApp.#instance
if (!app?.#selectedId) return
const combatant = game.combat?.combatants.get(app.#selectedId)
if (!combatant) return
// Update combatant only — updateCombatant hook in system.js syncs actor
await combatant.update({ initiative: 1 })
}
/** Action: open the initiative dialog for the selected combatant. */
static async #rollInitiative(event, element) {
const app = CDEWheelApp.#instance
if (!app?.#selectedId) return
const combatant = game.combat?.combatants.get(app.#selectedId)
if (!combatant) return
await game.combat.rollInitiative([app.#selectedId])
}
/** Re-render when combat state changes. */
static registerHooks() {
const refresh = () => {
if (CDEWheelApp.#instance?.rendered) CDEWheelApp.#instance.render()
}
Hooks.on("updateCombat", refresh)
Hooks.on("updateCombatant", refresh)
Hooks.on("createCombatant", refresh)
Hooks.on("deleteCombatant", refresh)
Hooks.on("updateActor", (_actor, diff) => {
if (foundry.utils.hasProperty(diff, "system.initiative")) refresh()
})
Hooks.on("deleteCombat", () => {
if (CDEWheelApp.#instance?.rendered) CDEWheelApp.#instance.render()
})
}
}
+25
View File
@@ -118,4 +118,29 @@ export function registerHandlebarsHelpers() {
}
return game.i18n.localize(keys[activation] ?? "CDE.Activation")
})
/**
* Compute the SVG x,y coordinates for a cran on the initiative wheel.
* Cran 124 are arranged counter-clockwise from the bottom (reference at 6 o'clock).
* angle = 90° + cran * 15° (counter-clockwise = positive in standard math, negative in SVG).
* In SVG coords: x = cx + r*cos(a), y = cy - r*sin(a) [y-axis is flipped in SVG].
*/
Handlebars.registerHelper("cranPosition", function (cran, cx, cy, r) {
const angleDeg = 90 + cran * 15 // counter-clockwise from bottom
const angleRad = (angleDeg * Math.PI) / 180
const x = Math.round(cx + r * Math.cos(angleRad))
const y = Math.round(cy - r * Math.sin(angleRad))
return { x, y }
})
/** X offset for overlapping fighters on the same cran. Centres a 30px image on the cran cx. */
Handlebars.registerHelper("fighterX", function (cx, index, total) {
const offset = total > 1 ? (index - (total - 1) / 2) * 34 : 0
return Math.round(cx - 15 + offset)
})
/** Y offset for fighters — positions image just above the cran circle. */
Handlebars.registerHelper("fighterY", function (cy, index, total) {
return Math.round(cy - 50)
})
}