feat: implémentation complète du système Célestopol 1922 pour FoundryVTT v13

- DataModels (character, npc, anomaly, aspect, attribute, equipment)
- ApplicationV2 sheets (character 5 tabs, npc 3 tabs, 4 item sheets)
- DialogV2 pour les jets de dés avec phase de lune
- Templates Handlebars complets (fiches PJ/PNJ, items, jet, chat)
- Styles LESS → CSS compilé (thème vert foncé / orange CopaseticNF)
- i18n fr.json complet (clés CELESTOPOL.*)
- Point d'entrée fvtt-celestopol.mjs avec hooks init/ready
- Assets : polices CopaseticNF, images UI, icônes items
- Mise à jour copilot-instructions.md avec l'architecture réelle

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
2026-03-28 09:28:34 +01:00
parent 446f9b7500
commit 64e23271df
54 changed files with 3433 additions and 4 deletions

87
.github/copilot-instructions.md vendored Normal file
View File

@@ -0,0 +1,87 @@
# Copilot Instructions — fvtt-celestopol
## Project Overview
This is a **Foundry VTT system** for **Célestopol 1922**, a French tabletop RPG set in an art-deco 1922 universe. The project targets FoundryVTT v13+ and is developed in French.
The reference rulebooks are in `__regles/` (gitignored):
- *Célestopol 1922 Livre de base* — core rulebook
- *Célestopol 1922 Fiches de prêts à jouer* — pre-generated character sheets
## Architecture
This system uses **FoundryVTT v13 DataModels + ApplicationV2** — NOT the legacy template.json / AppV1 approach.
```
fvtt-celestopol.mjs # Main entry point (Hooks.once("init"))
module/
config/system.mjs # All game constants (SYSTEM export)
models/ # TypeDataModel subclasses (character, npc, items)
documents/ # Actor, Item, ChatMessage, Roll wrappers
applications/sheets/ # AppV2 sheets (HandlebarsApplicationMixin)
lang/fr.json # French i18n (key prefix: CELESTOPOL.*)
styles/ # LESS source files
css/ # Compiled CSS (via gulp)
templates/ # Handlebars (.hbs) templates
assets/fonts/ # CopaseticNF art-deco font
assets/ui/ # Background images
assets/icons/ # Item icons
packs-system/ # Source files for compendium packs
```
## DataModels (no template.json)
- Extend `foundry.abstract.TypeDataModel`
- Schema in `static defineSchema()` using `foundry.data.fields.*`
- `prepareDerivedData()` for computed values
- Files: `module/models/character.mjs`, `npc.mjs`, `items.mjs`
## ApplicationV2 / Sheets
- Actor sheets: `HandlebarsApplicationMixin(foundry.applications.sheets.ActorSheetV2)`
- Item sheets: `HandlebarsApplicationMixin(foundry.applications.sheets.ItemSheetV2)`
- `static DEFAULT_OPTIONS` for config; `static PARTS` for templates
- `_prepareContext()` for base context; `_preparePartContext(partId, context)` for per-tab
- Edit/Play mode toggle via `_sheetMode` + `isPlayMode`/`isEditMode` getters
- Actions: `static #onXxx(event, target)` private static methods in `DEFAULT_OPTIONS.actions`
- `form: { submitOnChange: true }` enables live saving
## Roll Mechanics
- Pool of d6 dice: `nbDice = max(1, skillValue + woundMalus)`
- Formula: `{n}d6 [+ moonBonus + modifier]`
- Moon phase bonus: Nouvelle Lune=0, Croissants=+1, Gibbeuse=+2, Pleine Lune=+3
- Compare total vs difficulty threshold (normal=7)
- Wound malus: levels 1-2=0, 3-4=-1, 5-6=-2, 7=-3, 8=-999 (out)
- DialogV2 for roll configuration: `foundry.applications.api.DialogV2.wait(...)`
## Game Data (4 stats × 4 skills)
- **Âme**: Artifice, Attraction, Coercition, Faveur
- **Corps**: Échauffourée, Effacement, Mobilité, Prouesse
- **Cœur**: Appréciation, Arts, Inspiration, Traque
- **Esprit**: Instruction, Merveilleux technologique, Raisonnement, Traitement
**Tracks**: Blessures (8 niveaux), Destin (8), Spleen (8)
**Anomalies**: 9 types (none + 8)
**Factions**: 8 standard + 2 custom
## Build
```bash
npm install # Install dev deps
npx gulp css # Compile LESS → css/fvtt-celestopol.css (once)
npx gulp # Compile + watch
```
## Visual Style
- Font: **CopaseticNF** (Regular + Bold, in `assets/fonts/`) — art-deco style
- Header bg color: `rgb(12, 76, 12)` (dark green) with orange text (`#e07b00`)
- Sheet header texture: `assets/ui/fond_cadrille.jpg`
- CSS variables: `--cel-green`, `--cel-orange`, `--cel-font-title`, etc.
## Language
All in-game text, labels, and code comments should be in **French**. Code identifiers may be English. All i18n keys use the `CELESTOPOL.*` prefix (see `lang/fr.json`).

9
.gitignore vendored
View File

@@ -7,8 +7,9 @@
node_modules/
package-lock.json
chroniquesdeletrange.lock
*.pdf
.github/
__regles/
# CSS compilé (généré par gulp depuis styles/)
css/*.css
# Règles (PDFs privés)
__regles/
*.pdf

Binary file not shown.

Binary file not shown.

BIN
assets/icons/anomaly.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

BIN
assets/icons/aspect.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

BIN
assets/icons/attribute.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

BIN
assets/icons/item.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 183 KiB

BIN
assets/ui/fond_cadrille.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 MiB

212
fvtt-celestopol.mjs Normal file
View File

@@ -0,0 +1,212 @@
/**
* fvtt-celestopol.mjs — Point d'entrée principal du système Célestopol 1922
* FoundryVTT v13+ / DataModels / ApplicationV2
*/
import { SYSTEM, SYSTEM_ID, ASCII } from "./module/config/system.mjs"
import {
CelestopolCharacter,
CelestopolNPC,
CelestopolAnomaly,
CelestopolAspect,
CelestopolAttribute,
CelestopolEquipment,
} from "./module/models/_module.mjs"
import {
CelestopolActor,
CelestopolItem,
CelestopolChatMessage,
CelestopolRoll,
} from "./module/documents/_module.mjs"
import {
CelestopolCharacterSheet,
CelestopolNPCSheet,
CelestopolAnomalySheet,
CelestopolAspectSheet,
CelestopolAttributeSheet,
CelestopolEquipmentSheet,
} from "./module/applications/_module.mjs"
/* ─── Init hook ──────────────────────────────────────────────────────────── */
Hooks.once("init", () => {
console.log(ASCII)
console.log(`${SYSTEM_ID} | Initializing Célestopol 1922 system`)
// Expose SYSTEM constants in game.system namespace
game.celestopol = { SYSTEM }
// ── DataModels ──────────────────────────────────────────────────────────
CONFIG.Actor.dataModels.character = CelestopolCharacter
CONFIG.Actor.dataModels.npc = CelestopolNPC
CONFIG.Item.dataModels.anomaly = CelestopolAnomaly
CONFIG.Item.dataModels.aspect = CelestopolAspect
CONFIG.Item.dataModels.attribute = CelestopolAttribute
CONFIG.Item.dataModels.equipment = CelestopolEquipment
// ── Document classes ────────────────────────────────────────────────────
CONFIG.Actor.documentClass = CelestopolActor
CONFIG.Item.documentClass = CelestopolItem
CONFIG.ChatMessage.documentClass = CelestopolChatMessage
CONFIG.Dice.rolls.push(CelestopolRoll)
// ── Token display defaults ───────────────────────────────────────────────
CONFIG.Actor.trackableAttributes = {
character: {
bar: ["blessures.lvl"],
value: ["initiative", "anomaly.value"],
},
npc: {
bar: ["blessures.lvl"],
value: ["initiative"],
},
}
// ── Sheets: unregister core, register system sheets ─────────────────────
foundry.applications.sheets.ActorSheetV2.unregisterSheet?.("core", "Actor", { types: ["character", "npc"] })
Actors.unregisterSheet("core", ActorSheet)
Actors.registerSheet(SYSTEM_ID, CelestopolCharacterSheet, {
types: ["character"],
makeDefault: true,
label: "CELESTOPOL.Sheet.character",
})
Actors.registerSheet(SYSTEM_ID, CelestopolNPCSheet, {
types: ["npc"],
makeDefault: true,
label: "CELESTOPOL.Sheet.npc",
})
Items.unregisterSheet("core", ItemSheet)
Items.registerSheet(SYSTEM_ID, CelestopolAnomalySheet, {
types: ["anomaly"],
makeDefault: true,
label: "CELESTOPOL.Sheet.anomaly",
})
Items.registerSheet(SYSTEM_ID, CelestopolAspectSheet, {
types: ["aspect"],
makeDefault: true,
label: "CELESTOPOL.Sheet.aspect",
})
Items.registerSheet(SYSTEM_ID, CelestopolAttributeSheet, {
types: ["attribute"],
makeDefault: true,
label: "CELESTOPOL.Sheet.attribute",
})
Items.registerSheet(SYSTEM_ID, CelestopolEquipmentSheet, {
types: ["equipment"],
makeDefault: true,
label: "CELESTOPOL.Sheet.equipment",
})
// ── Handlebars helpers ───────────────────────────────────────────────────
_registerHandlebarsHelpers()
// ── Game settings ────────────────────────────────────────────────────────
_registerSettings()
// ── Pre-load templates ───────────────────────────────────────────────────
_preloadTemplates()
})
/* ─── Ready hook ─────────────────────────────────────────────────────────── */
Hooks.once("ready", () => {
console.log(`${SYSTEM_ID} | System ready`)
// Socket handler for GM-only operations (e.g. wound application)
if (game.socket) {
game.socket.on(`system.${SYSTEM_ID}`, _onSocketMessage)
}
})
/* ─── Handlebars helpers ─────────────────────────────────────────────────── */
function _registerHandlebarsHelpers() {
// Helper : concat strings
Handlebars.registerHelper("concat", (...args) => args.slice(0, -1).join(""))
// Helper : strict equality
Handlebars.registerHelper("eq", (a, b) => a === b)
// Helper : greater than
Handlebars.registerHelper("gt", (a, b) => a > b)
// Helper : logical OR
Handlebars.registerHelper("or", (...args) => args.slice(0, -1).some(Boolean))
// Helper : build array from args (Handlebars doesn't have arrays natively)
Handlebars.registerHelper("array", (...args) => args.slice(0, -1))
// Helper : nested object lookup with dot path or multiple keys
Handlebars.registerHelper("lookup", (obj, ...args) => {
const options = args.pop() // last arg is Handlebars options hash
return args.reduce((acc, key) => (acc && acc[key] !== undefined ? acc[key] : undefined), obj)
})
// Helper : let (scope variable assignment inside template)
Handlebars.registerHelper("let", function(value, options) {
return options.fn({ value })
})
}
/* ─── Settings ───────────────────────────────────────────────────────────── */
function _registerSettings() {
game.settings.register(SYSTEM_ID, "defaultMoonPhase", {
name: "CELESTOPOL.Setting.defaultMoonPhase.name",
hint: "CELESTOPOL.Setting.defaultMoonPhase.hint",
scope: "world",
config: true,
type: String,
default: "nouvelleLune",
choices: Object.fromEntries(
Object.entries(SYSTEM.MOON_DICE_PHASES).map(([k, v]) => [k, v.label])
),
})
game.settings.register(SYSTEM_ID, "autoWounds", {
name: "CELESTOPOL.Setting.autoWounds.name",
hint: "CELESTOPOL.Setting.autoWounds.hint",
scope: "world",
config: true,
type: Boolean,
default: false,
})
}
/* ─── Template preload ───────────────────────────────────────────────────── */
function _preloadTemplates() {
const base = `systems/${SYSTEM_ID}/templates`
loadTemplates([
`${base}/character-main.hbs`,
`${base}/character-competences.hbs`,
`${base}/character-blessures.hbs`,
`${base}/character-factions.hbs`,
`${base}/character-biography.hbs`,
`${base}/npc-main.hbs`,
`${base}/npc-competences.hbs`,
`${base}/npc-blessures.hbs`,
`${base}/anomaly.hbs`,
`${base}/aspect.hbs`,
`${base}/attribute.hbs`,
`${base}/equipment.hbs`,
`${base}/roll-dialog.hbs`,
`${base}/chat-message.hbs`,
`${base}/partials/item-scores.hbs`,
])
}
/* ─── Socket handler ─────────────────────────────────────────────────────── */
function _onSocketMessage(data) {
if (!game.user.isGM) return
switch (data.type) {
case "applyWound": {
const actor = game.actors.get(data.actorId)
if (actor) actor.update({ "system.blessures.lvl": data.level })
break
}
}
}

31
gulpfile.js Normal file
View File

@@ -0,0 +1,31 @@
const gulp = require('gulp');
const less = require('gulp-less');
/* ----------------------------------------- */
/* Compile LESS
/* ----------------------------------------- */
function compileLESS() {
return gulp.src("styles/fvtt-celestopol.less")
.pipe(less()).on('error', console.log.bind(console))
.pipe(gulp.dest("./css"))
}
const css = gulp.series(compileLESS);
/* ----------------------------------------- */
/* Watch Updates
/* ----------------------------------------- */
const SIMPLE_LESS = ["styles/*.less"];
function watchUpdates() {
gulp.watch(SIMPLE_LESS, css);
}
/* ----------------------------------------- */
/* Export Tasks
/* ----------------------------------------- */
exports.default = gulp.series(
gulp.parallel(css),
watchUpdates
);
exports.css = css;
exports.watchUpdates = watchUpdates;

163
lang/fr.json Normal file
View File

@@ -0,0 +1,163 @@
{
"CELESTOPOL": {
"Actor": {
"name": "Nom",
"concept": "Concept / Profession",
"initiative": "Initiative",
"anomaly": "Anomalie",
"description": "Biographie",
"notes": "Notes"
},
"Stat": {
"res": "Résistance",
"ame": "Âme",
"corps": "Corps",
"coeur": "Cœur",
"esprit": "Esprit"
},
"Skill": {
"artifice": "Artifice",
"attraction": "Attraction",
"coercition": "Coercition",
"faveur": "Faveur",
"echauffouree": "Échauffourée",
"effacement": "Effacement",
"mobilite": "Mobilité",
"prouesse": "Prouesse",
"appreciation": "Appréciation",
"arts": "Arts",
"inspiration": "Inspiration",
"traque": "Traque",
"instruction": "Instruction",
"mtechnologique": "Merveilleux technologique",
"raisonnement": "Raisonnement",
"traitement": "Traitement"
},
"Anomaly": {
"type": "Type d'anomalie",
"none": "Aucune",
"charnel": "Charnel",
"mecanique": "Mécanique",
"spectral": "Spectral",
"onirique": "Onirique",
"telepath": "Télépathique",
"alchimique": "Alchimique",
"cosmique": "Cosmique",
"temporel": "Temporel"
},
"Attribut": {
"entregent": "Entregent",
"fortune": "Fortune",
"reve": "Rêve",
"vision": "Vision"
},
"Faction": {
"label": "Faction",
"score": "Score",
"custom": "Faction personnalisée…",
"pinkerton": "Pinkerton",
"police": "Police",
"okhrana": "Okhrana",
"lunanovatek": "LunaNovaTek",
"oto": "OTO",
"syndicats": "Syndicats",
"vorovskoymir": "Vorovskoymir",
"cour": "Cour"
},
"Track": {
"blessures": "Blessures",
"destin": "Destin",
"spleen": "Spleen",
"level": "Niveau",
"currentMalus": "Malus actuel"
},
"Tab": {
"main": "Principal",
"competences": "Compétences",
"blessures": "Blessures",
"factions": "Factions",
"biography": "Biographie",
"description": "Description",
"technique": "Technique"
},
"Roll": {
"clickToRoll": "Cliquer pour lancer",
"moonPhase": "Phase de lune",
"difficulty": "Difficulté",
"modifier": "Modificateur",
"nbDice": "Nombre de dés",
"total": "Total",
"success": "SUCCÈS",
"failure": "ÉCHEC",
"criticalSuccess": "Succès critique !",
"criticalFailure": "Échec critique !",
"moonBonus": "Bonus de lune",
"rollTitle": "Lancer les dés"
},
"Moon": {
"nouvelleLune": "Nouvelle Lune",
"croissantDebutant": "Croissant débutant",
"croissantMontant": "Croissant montant",
"gibbeuseMontante": "Gibbeuse montante",
"pleineLune": "Pleine Lune",
"gibbeuseDecroissante": "Gibbeuse décroissante",
"croissantDecroissant": "Croissant décroissant",
"croissantFinissant": "Croissant finissant"
},
"Difficulty": {
"facile": "Facile",
"normal": "Normal",
"difficile": "Difficile",
"extreme": "Extrême",
"impossible": "Impossible"
},
"Item": {
"anomalies": "Anomalies",
"aspects": "Aspects",
"attributes": "Attributs",
"equipments": "Équipements",
"newAnomaly": "Nouvelle anomalie",
"newAspect": "Nouvel aspect",
"newAttribute": "Nouvel attribut",
"newEquipment": "Nouvel équipement",
"value": "Valeur",
"scores": "Scores bonus / malus",
"reference": "Référence (page)",
"technique": "Description technique",
"narratif": "Description narrative",
"quantity": "Quantité",
"damage": "Dégâts",
"range": "Portée",
"protection": "Protection",
"speed": "Vitesse",
"crew": "Équipage",
"weight": "Poids"
},
"Equipment": {
"type": {
"general": "Général",
"arme": "Arme",
"armure": "Armure",
"vehicule": "Véhicule",
"gadget": "Gadget"
}
},
"Sheet": {
"editMode": "Mode édition",
"playMode": "Mode jeu"
},
"Setting": {
"autoWounds": {
"name": "Blessures automatiques",
"hint": "Appliquer automatiquement les malus de blessures lors des jets"
},
"defaultMoonPhase": {
"name": "Phase de lune par défaut",
"hint": "Phase de lune utilisée par défaut dans les jets de dés"
}
},
"ChatCard": {
"rollFor": "Jet de {skill} ({stat})"
}
}
}

View File

@@ -0,0 +1,3 @@
export { default as CelestopolCharacterSheet } from "./sheets/character-sheet.mjs"
export { default as CelestopolNPCSheet } from "./sheets/npc-sheet.mjs"
export { CelestopolAnomalySheet, CelestopolAspectSheet, CelestopolAttributeSheet, CelestopolEquipmentSheet } from "./sheets/item-sheets.mjs"

View File

@@ -0,0 +1,126 @@
const { HandlebarsApplicationMixin } = foundry.applications.api
export default class CelestopolActorSheet extends HandlebarsApplicationMixin(foundry.applications.sheets.ActorSheetV2) {
static SHEET_MODES = { EDIT: 0, PLAY: 1 }
constructor(options = {}) {
super(options)
this.#dragDrop = this.#createDragDropHandlers()
}
#dragDrop
/** @override */
static DEFAULT_OPTIONS = {
classes: ["fvtt-celestopol", "actor"],
position: { width: 900, height: "auto" },
form: { submitOnChange: true },
window: { resizable: true },
dragDrop: [{ dragSelector: '[data-drag="true"], .rollable', dropSelector: null }],
actions: {
editImage: CelestopolActorSheet.#onEditImage,
toggleSheet: CelestopolActorSheet.#onToggleSheet,
edit: CelestopolActorSheet.#onItemEdit,
delete: CelestopolActorSheet.#onItemDelete,
},
}
_sheetMode = this.constructor.SHEET_MODES.PLAY
get isPlayMode() { return this._sheetMode === this.constructor.SHEET_MODES.PLAY }
get isEditMode() { return this._sheetMode === this.constructor.SHEET_MODES.EDIT }
/** @override */
async _prepareContext() {
return {
fields: this.document.schema.fields,
systemFields: this.document.system.schema.fields,
actor: this.document,
system: this.document.system,
source: this.document.toObject(),
isEditMode: this.isEditMode,
isPlayMode: this.isPlayMode,
isEditable: this.isEditable,
}
}
/** @override */
_onRender(context, options) {
this.#dragDrop.forEach(d => d.bind(this.element))
this.element.querySelectorAll(".rollable").forEach(el => {
el.addEventListener("click", this._onRoll.bind(this))
})
}
async _onRoll(event) {
if (!this.isPlayMode) return
const el = event.currentTarget
const statId = el.dataset.statId
const skillId = el.dataset.skillId
if (!statId || !skillId) return
await this.document.system.roll(statId, skillId)
}
#createDragDropHandlers() {
return this.options.dragDrop.map(d => {
d.permissions = {
dragstart: this._canDragStart.bind(this),
drop: this._canDragDrop.bind(this),
}
d.callbacks = {
dragover: this._onDragOver.bind(this),
drop: this._onDrop.bind(this),
}
return new foundry.applications.ux.DragDrop.implementation(d)
})
}
_canDragStart() { return this.isEditable }
_canDragDrop() { return true }
_onDragOver() {}
async _onDrop(event) {
if (!this.isEditable) return
const data = foundry.applications.ux.TextEditor.implementation.getDragEventData(event)
if (data.type === "Item") {
const item = await fromUuid(data.uuid)
if (item) return this._onDropItem(item)
}
}
async _onDropItem(item) {
await this.document.createEmbeddedDocuments("Item", [item.toObject()], { renderSheet: false })
}
static async #onEditImage(event, target) {
const attr = target.dataset.edit
const current = foundry.utils.getProperty(this.document, attr)
const fp = new FilePicker({
current,
type: "image",
callback: (path) => this.document.update({ [attr]: path }),
top: this.position.top + 40,
left: this.position.left + 10,
})
return fp.browse()
}
static #onToggleSheet() {
const modes = this.constructor.SHEET_MODES
this._sheetMode = this.isEditMode ? modes.PLAY : modes.EDIT
this.render()
}
static async #onItemEdit(event, target) {
const uuid = target.getAttribute("data-item-uuid")
const id = target.getAttribute("data-item-id")
const item = uuid ? await fromUuid(uuid) : this.document.items.get(id)
item?.sheet.render(true)
}
static async #onItemDelete(event, target) {
const uuid = target.getAttribute("data-item-uuid")
const item = await fromUuid(uuid)
await item?.deleteDialog()
}
}

View File

@@ -0,0 +1,39 @@
const { HandlebarsApplicationMixin } = foundry.applications.api
export default class CelestopolItemSheet extends HandlebarsApplicationMixin(foundry.applications.sheets.ItemSheetV2) {
/** @override */
static DEFAULT_OPTIONS = {
classes: ["fvtt-celestopol", "item"],
position: { width: 580, height: "auto" },
form: { submitOnChange: true },
window: { resizable: true },
actions: {
editImage: CelestopolItemSheet.#onEditImage,
},
}
/** @override */
async _prepareContext() {
return {
fields: this.document.schema.fields,
systemFields: this.document.system.schema.fields,
item: this.document,
system: this.document.system,
source: this.document.toObject(),
isEditable: this.isEditable,
}
}
static async #onEditImage(event, target) {
const attr = target.dataset.edit
const current = foundry.utils.getProperty(this.document, attr)
const fp = new FilePicker({
current,
type: "image",
callback: (path) => this.document.update({ [attr]: path }),
top: this.position.top + 40,
left: this.position.left + 10,
})
return fp.browse()
}
}

View File

@@ -0,0 +1,116 @@
import CelestopolActorSheet from "./base-actor-sheet.mjs"
import { SYSTEM } from "../../config/system.mjs"
export default class CelestopolCharacterSheet extends CelestopolActorSheet {
/** @override */
static DEFAULT_OPTIONS = {
classes: ["character"],
position: { width: 920, height: 660 },
window: { contentClasses: ["character-content"] },
actions: {
createAnomaly: CelestopolCharacterSheet.#onCreateAnomaly,
createAspect: CelestopolCharacterSheet.#onCreateAspect,
createAttribute: CelestopolCharacterSheet.#onCreateAttribute,
createEquipment: CelestopolCharacterSheet.#onCreateEquipment,
},
}
/** @override */
static PARTS = {
main: { template: "systems/fvtt-celestopol/templates/character-main.hbs" },
tabs: { template: "templates/generic/tab-navigation.hbs" },
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" },
biography: { template: "systems/fvtt-celestopol/templates/character-biography.hbs" },
}
tabGroups = { sheet: "competences" }
#getTabs() {
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" },
factions: { id: "factions", group: "sheet", icon: "fa-solid fa-flag", label: "CELESTOPOL.Tab.factions" },
biography: { id: "biography", group: "sheet", icon: "fa-solid fa-book", label: "CELESTOPOL.Tab.biography" },
}
for (const v of Object.values(tabs)) {
v.active = this.tabGroups[v.group] === v.id
v.cssClass = v.active ? "active" : ""
}
return tabs
}
/** @override */
async _prepareContext() {
const context = await super._prepareContext()
context.tabs = this.#getTabs()
context.stats = SYSTEM.STATS
context.skills = SYSTEM.SKILLS
context.anomalyTypes = SYSTEM.ANOMALY_TYPES
context.factions = SYSTEM.FACTIONS
context.woundLevels = SYSTEM.WOUND_LEVELS
return context
}
/** @override */
async _preparePartContext(partId, context) {
context.systemFields = this.document.system.schema.fields
const doc = this.document
switch (partId) {
case "main":
break
case "competences":
context.tab = context.tabs.competences
context.anomalies = doc.itemTypes.anomaly
context.aspects = doc.itemTypes.aspect
context.attributes = doc.itemTypes.attribute
break
case "blessures":
context.tab = context.tabs.blessures
break
case "factions":
context.tab = context.tabs.factions
break
case "biography":
context.tab = context.tabs.biography
context.equipments = doc.itemTypes.equipment
context.equipments.sort((a, b) => a.name.localeCompare(b.name))
context.enrichedDescription = await foundry.applications.ux.TextEditor.implementation.enrichHTML(
doc.system.description, { async: true })
context.enrichedNotes = await foundry.applications.ux.TextEditor.implementation.enrichHTML(
doc.system.notes, { async: true })
break
}
return context
}
static #onCreateAnomaly() {
this.document.createEmbeddedDocuments("Item", [{
name: game.i18n.localize("CELESTOPOL.Item.newAnomaly"), type: "anomaly",
}])
}
static #onCreateAspect() {
this.document.createEmbeddedDocuments("Item", [{
name: game.i18n.localize("CELESTOPOL.Item.newAspect"), type: "aspect",
}])
}
static #onCreateAttribute() {
this.document.createEmbeddedDocuments("Item", [{
name: game.i18n.localize("CELESTOPOL.Item.newAttribute"), type: "attribute",
}])
}
static #onCreateEquipment() {
this.document.createEmbeddedDocuments("Item", [{
name: game.i18n.localize("CELESTOPOL.Item.newEquipment"), type: "equipment",
}])
}
}

View File

@@ -0,0 +1,83 @@
import CelestopolItemSheet from "./base-item-sheet.mjs"
import { SYSTEM } from "../../config/system.mjs"
export class CelestopolAnomalySheet extends CelestopolItemSheet {
static DEFAULT_OPTIONS = {
classes: ["anomaly"],
position: { width: 620, height: 560 },
}
static PARTS = {
main: { template: "systems/fvtt-celestopol/templates/anomaly.hbs" },
}
async _prepareContext() {
const ctx = await super._prepareContext()
ctx.anomalyTypes = SYSTEM.ANOMALY_TYPES
ctx.skills = SYSTEM.SKILLS
ctx.enrichedDescription = await foundry.applications.ux.TextEditor.implementation.enrichHTML(
this.document.system.description, { async: true })
ctx.enrichedTechnique = await foundry.applications.ux.TextEditor.implementation.enrichHTML(
this.document.system.technique, { async: true })
ctx.enrichedNarratif = await foundry.applications.ux.TextEditor.implementation.enrichHTML(
this.document.system.narratif, { async: true })
return ctx
}
}
export class CelestopolAspectSheet extends CelestopolItemSheet {
static DEFAULT_OPTIONS = {
classes: ["aspect"],
position: { width: 620, height: 520 },
}
static PARTS = {
main: { template: "systems/fvtt-celestopol/templates/aspect.hbs" },
}
async _prepareContext() {
const ctx = await super._prepareContext()
ctx.skills = SYSTEM.SKILLS
ctx.enrichedDescription = await foundry.applications.ux.TextEditor.implementation.enrichHTML(
this.document.system.description, { async: true })
ctx.enrichedTechnique = await foundry.applications.ux.TextEditor.implementation.enrichHTML(
this.document.system.technique, { async: true })
ctx.enrichedNarratif = await foundry.applications.ux.TextEditor.implementation.enrichHTML(
this.document.system.narratif, { async: true })
return ctx
}
}
export class CelestopolAttributeSheet extends CelestopolItemSheet {
static DEFAULT_OPTIONS = {
classes: ["attribute"],
position: { width: 620, height: 520 },
}
static PARTS = {
main: { template: "systems/fvtt-celestopol/templates/attribute.hbs" },
}
async _prepareContext() {
const ctx = await super._prepareContext()
ctx.skills = SYSTEM.SKILLS
ctx.enrichedDescription = await foundry.applications.ux.TextEditor.implementation.enrichHTML(
this.document.system.description, { async: true })
ctx.enrichedTechnique = await foundry.applications.ux.TextEditor.implementation.enrichHTML(
this.document.system.technique, { async: true })
ctx.enrichedNarratif = await foundry.applications.ux.TextEditor.implementation.enrichHTML(
this.document.system.narratif, { async: true })
return ctx
}
}
export class CelestopolEquipmentSheet extends CelestopolItemSheet {
static DEFAULT_OPTIONS = {
classes: ["equipment"],
position: { width: 540, height: 420 },
}
static PARTS = {
main: { template: "systems/fvtt-celestopol/templates/equipment.hbs" },
}
async _prepareContext() {
const ctx = await super._prepareContext()
ctx.equipmentTypes = SYSTEM.EQUIPMENT_TYPES
ctx.enrichedDescription = await foundry.applications.ux.TextEditor.implementation.enrichHTML(
this.document.system.description, { async: true })
return ctx
}
}

View File

@@ -0,0 +1,57 @@
import CelestopolActorSheet from "./base-actor-sheet.mjs"
import { SYSTEM } from "../../config/system.mjs"
export default class CelestopolNPCSheet extends CelestopolActorSheet {
/** @override */
static DEFAULT_OPTIONS = {
classes: ["npc"],
position: { width: 760, height: 600 },
window: { contentClasses: ["npc-content"] },
}
/** @override */
static PARTS = {
main: { template: "systems/fvtt-celestopol/templates/npc-main.hbs" },
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" },
}
tabGroups = { sheet: "competences" }
#getTabs() {
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" },
}
for (const v of Object.values(tabs)) {
v.active = this.tabGroups[v.group] === v.id
v.cssClass = v.active ? "active" : ""
}
return tabs
}
/** @override */
async _prepareContext() {
const context = await super._prepareContext()
context.tabs = this.#getTabs()
context.stats = SYSTEM.STATS
context.skills = SYSTEM.SKILLS
context.woundLevels = SYSTEM.WOUND_LEVELS
return context
}
/** @override */
async _preparePartContext(partId, context) {
context.systemFields = this.document.system.schema.fields
switch (partId) {
case "competences":
context.tab = context.tabs.competences
break
case "blessures":
context.tab = context.tabs.blessures
break
}
return context
}
}

130
module/config/system.mjs Normal file
View File

@@ -0,0 +1,130 @@
export const SYSTEM_ID = "fvtt-celestopol"
export const ASCII = `
░█▀▀░█▀▀░█░░░█▀▀░█▀▀░▀█▀░█▀█░█▀█░█▀█░█░░
░█░░░█▀▀░█░░░█▀▀░▀▀█░░█░░█░█░█▀▀░█░█░█░░
░▀▀▀░▀▀▀░▀▀▀░▀▀▀░▀▀▀░░▀░░▀▀▀░▀░░░▀▀▀░▀▀▀
░░░░░░░░░░░░░░░░░░1922░░░░░░░░░░░░░░░░░░░
`
/** Les 4 attributs principaux (stats). Chacun a une résistance (res) et 4 compétences. */
export const STATS = {
ame: { id: "ame", label: "CELESTOPOL.Stat.ame" },
corps: { id: "corps", label: "CELESTOPOL.Stat.corps" },
coeur: { id: "coeur", label: "CELESTOPOL.Stat.coeur" },
esprit: { id: "esprit", label: "CELESTOPOL.Stat.esprit" },
}
/** Compétences groupées par attribut. */
export const SKILLS = {
ame: {
artifice: { id: "artifice", label: "CELESTOPOL.Skill.artifice", stat: "ame" },
attraction: { id: "attraction", label: "CELESTOPOL.Skill.attraction", stat: "ame" },
coercition: { id: "coercition", label: "CELESTOPOL.Skill.coercition", stat: "ame" },
faveur: { id: "faveur", label: "CELESTOPOL.Skill.faveur", stat: "ame" },
},
corps: {
echauffouree: { id: "echauffouree", label: "CELESTOPOL.Skill.echauffouree", stat: "corps" },
effacement: { id: "effacement", label: "CELESTOPOL.Skill.effacement", stat: "corps" },
mobilite: { id: "mobilite", label: "CELESTOPOL.Skill.mobilite", stat: "corps" },
prouesse: { id: "prouesse", label: "CELESTOPOL.Skill.prouesse", stat: "corps" },
},
coeur: {
appreciation: { id: "appreciation", label: "CELESTOPOL.Skill.appreciation", stat: "coeur" },
arts: { id: "arts", label: "CELESTOPOL.Skill.arts", stat: "coeur" },
inspiration: { id: "inspiration", label: "CELESTOPOL.Skill.inspiration", stat: "coeur" },
traque: { id: "traque", label: "CELESTOPOL.Skill.traque", stat: "coeur" },
},
esprit: {
instruction: { id: "instruction", label: "CELESTOPOL.Skill.instruction", stat: "esprit" },
mtechnologique: { id: "mtechnologique", label: "CELESTOPOL.Skill.mtechnologique", stat: "esprit" },
raisonnement: { id: "raisonnement", label: "CELESTOPOL.Skill.raisonnement", stat: "esprit" },
traitement: { id: "traitement", label: "CELESTOPOL.Skill.traitement", stat: "esprit" },
},
}
/** Liste plate de toutes les compétences (utile pour les DataModels d'items). */
export const ALL_SKILLS = Object.values(SKILLS).flatMap(group => Object.values(group))
/** Types d'anomalies (pouvoirs paranormaux). */
export const ANOMALY_TYPES = {
none: { id: "none", label: "CELESTOPOL.Anomaly.none" },
entropie: { id: "entropie", label: "CELESTOPOL.Anomaly.entropie" },
communicationaveclesmorts:{ id: "communicationaveclesmorts",label: "CELESTOPOL.Anomaly.communicationaveclesmorts" },
illusion: { id: "illusion", label: "CELESTOPOL.Anomaly.illusion" },
suggestion: { id: "suggestion", label: "CELESTOPOL.Anomaly.suggestion" },
tarotdivinatoire: { id: "tarotdivinatoire", label: "CELESTOPOL.Anomaly.tarotdivinatoire" },
telekinesie: { id: "telekinesie", label: "CELESTOPOL.Anomaly.telekinesie" },
telepathie: { id: "telepathie", label: "CELESTOPOL.Anomaly.telepathie" },
voyageastral: { id: "voyageastral", label: "CELESTOPOL.Anomaly.voyageastral" },
}
/** Factions du monde de Célestopol. */
export const FACTIONS = {
pinkerton: { id: "pinkerton", label: "CELESTOPOL.Faction.pinkerton" },
police: { id: "police", label: "CELESTOPOL.Faction.police" },
okhrana: { id: "okhrana", label: "CELESTOPOL.Faction.okhrana" },
lunanovatek: { id: "lunanovatek", label: "CELESTOPOL.Faction.lunanovatek" },
oto: { id: "oto", label: "CELESTOPOL.Faction.oto" },
syndicats: { id: "syndicats", label: "CELESTOPOL.Faction.syndicats" },
vorovskoymir:{ id: "vorovskoymir",label: "CELESTOPOL.Faction.vorovskoymir" },
cour: { id: "cour", label: "CELESTOPOL.Faction.cour" },
}
/** 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 },
]
/** Seuils de difficulté pour les jets de dés. */
export const DIFFICULTY_CHOICES = {
unknown: { id: "unknown", label: "CELESTOPOL.Difficulty.unknown", value: 0 },
facile: { id: "facile", label: "CELESTOPOL.Difficulty.facile", value: 5 },
normal: { id: "normal", label: "CELESTOPOL.Difficulty.normal", value: 7 },
difficile:{ id: "difficile", label: "CELESTOPOL.Difficulty.difficile", value: 9 },
ardu: { id: "ardu", label: "CELESTOPOL.Difficulty.ardu", value: 11 },
extreme: { id: "extreme", label: "CELESTOPOL.Difficulty.extreme", value: 13 },
}
/** Phases de la lune (dé de lune). */
export const MOON_DICE_PHASES = {
none: { id: "none", label: "CELESTOPOL.Moon.none", bonus: 0 },
nouvellelune: { id: "nouvellelune", label: "CELESTOPOL.Moon.nouvellelune", bonus: 0 },
premiercroissant: { id: "premiercroissant", label: "CELESTOPOL.Moon.premiercroissant", bonus: 1 },
premierquartier: { id: "premierquartier", label: "CELESTOPOL.Moon.premierquartier", bonus: 1 },
lunegibbeuse: { id: "lunegibbeuse", label: "CELESTOPOL.Moon.lunegibbeuse", bonus: 2 },
lunevoutee: { id: "lunevoutee", label: "CELESTOPOL.Moon.lunevoutee", bonus: 2 },
derniercroissant: { id: "derniercroissant", label: "CELESTOPOL.Moon.derniercroissant", bonus: 1 },
dernierquartier: { id: "dernierquartier", label: "CELESTOPOL.Moon.dernierquartier", bonus: 1 },
pleinelune: { id: "pleinelune", label: "CELESTOPOL.Moon.pleinelune", bonus: 3 },
}
/** Types d'équipements. */
export const EQUIPMENT_TYPES = {
autre: { id: "autre", label: "CELESTOPOL.Equipment.autre" },
arme: { id: "arme", label: "CELESTOPOL.Equipment.arme" },
protection:{ id: "protection",label: "CELESTOPOL.Equipment.protection" },
vehicule: { id: "vehicule", label: "CELESTOPOL.Equipment.vehicule" },
}
export const SYSTEM = {
id: SYSTEM_ID,
ASCII,
STATS,
SKILLS,
ALL_SKILLS,
ANOMALY_TYPES,
FACTIONS,
WOUND_LEVELS,
DIFFICULTY_CHOICES,
MOON_DICE_PHASES,
EQUIPMENT_TYPES,
}

View File

@@ -0,0 +1,4 @@
export { default as CelestopolActor } from "./actor.mjs"
export { default as CelestopolItem } from "./item.mjs"
export { default as CelestopolChatMessage } from "./chat-message.mjs"
export { CelestopolRoll } from "./roll.mjs"

View File

@@ -0,0 +1,12 @@
export default class CelestopolActor extends Actor {
/** @override */
prepareDerivedData() {
super.prepareDerivedData()
this.system.prepareDerivedData?.()
}
/** @override */
getRollData() {
return this.toObject(false).system
}
}

View File

@@ -0,0 +1,7 @@
export default class CelestopolChatMessage extends ChatMessage {
/** @override */
async getHTML() {
const html = await super.getHTML()
return html
}
}

11
module/documents/item.mjs Normal file
View File

@@ -0,0 +1,11 @@
export default class CelestopolItem extends Item {
/** @override */
prepareDerivedData() {
super.prepareDerivedData()
}
/** @override */
getRollData() {
return this.toObject(false).system
}
}

170
module/documents/roll.mjs Normal file
View File

@@ -0,0 +1,170 @@
import { SYSTEM } from "../config/system.mjs"
/**
* Système de dés de Célestopol 1922.
*
* Le jet de base est : (valeur compétence)d6 comparé à un seuil de difficulté.
* Le dé de lune ajoute un bonus selon la phase actuelle.
* Destin et Spleen modifient le nombre de dés.
*/
export class CelestopolRoll extends Roll {
static CHAT_TEMPLATE = "systems/fvtt-celestopol/templates/chat-message.hbs"
get resultType() { return this.options.resultType }
get isSuccess() { return this.resultType === "success" }
get isFailure() { return this.resultType === "failure" }
get actorId() { return this.options.actorId }
get actorName() { return this.options.actorName }
get actorImage() { return this.options.actorImage }
get skillLabel() { return this.options.skillLabel }
get difficulty() { return this.options.difficulty }
get moonBonus() { return this.options.moonBonus ?? 0 }
/**
* Ouvre le dialogue de configuration du jet via DialogV2 et exécute le jet.
* @param {object} options
* @returns {Promise<CelestopolRoll|null>}
*/
static async prompt(options = {}) {
const rollModes = foundry.utils.duplicate(CONFIG.Dice.rollModes)
const fieldRollMode = new foundry.data.fields.StringField({
choices: rollModes,
blank: false,
default: "publicroll",
})
const dialogContext = {
actorName: options.actorName,
skillLabel: options.skillLabel,
skillValue: options.skillValue,
woundMalus: options.woundMalus ?? 0,
difficultyChoices:SYSTEM.DIFFICULTY_CHOICES,
moonPhaseChoices: SYSTEM.MOON_DICE_PHASES,
defaultDifficulty:options.difficulty ?? "normal",
defaultMoonPhase: options.moonPhase ?? "none",
rollModes,
fieldRollMode,
}
const content = await foundry.applications.handlebars.renderTemplate(
"systems/fvtt-celestopol/templates/roll-dialog.hbs",
dialogContext
)
const title = `${game.i18n.localize("CELESTOPOL.Roll.title")}${game.i18n.localize(options.skillLabel ?? "")}`
const rollContext = await foundry.applications.api.DialogV2.wait({
window: { title },
classes: ["fvtt-celestopol", "roll-dialog"],
content,
buttons: [
{
label: game.i18n.localize("CELESTOPOL.Roll.roll"),
callback: (event, button) => {
return Array.from(button.form.elements).reduce((obj, input) => {
if (input.name) obj[input.name] = input.value
return obj
}, {})
},
},
],
rejectClose: false,
})
if (!rollContext) return null
const difficulty = rollContext.difficulty ?? "normal"
const diffConfig = SYSTEM.DIFFICULTY_CHOICES[difficulty] ?? SYSTEM.DIFFICULTY_CHOICES.normal
const moonPhase = rollContext.moonPhase ?? "none"
const moonConfig = SYSTEM.MOON_DICE_PHASES[moonPhase] ?? SYSTEM.MOON_DICE_PHASES.none
const modifier = parseInt(rollContext.modifier ?? 0) || 0
const woundMalus = options.woundMalus ?? 0
const skillValue = Math.max(0, (options.skillValue ?? 0) + woundMalus)
const nbDice = Math.max(1, skillValue)
const moonBonus = moonConfig.bonus ?? 0
const totalModifier = moonBonus + modifier
const formula = totalModifier !== 0
? `${nbDice}d6 + ${totalModifier}`
: `${nbDice}d6`
const rollData = {
...options,
difficulty,
difficultyValue: diffConfig.value,
moonPhase,
moonBonus,
modifier,
formula,
rollMode: rollContext.visibility ?? "publicroll",
}
const roll = new this(formula, {}, rollData)
await roll.evaluate()
roll.computeResult()
await roll.toMessage({}, { rollMode: rollData.rollMode })
// Mémoriser les préférences sur l'acteur
const actor = game.actors.get(options.actorId)
if (actor) {
await actor.update({
"system.prefs.moonPhase": moonPhase,
"system.prefs.difficulty": difficulty,
})
}
return roll
}
/** Détermine succès/échec selon le total vs le seuil. */
computeResult() {
const threshold = SYSTEM.DIFFICULTY_CHOICES[this.options.difficulty]?.value ?? 0
if (threshold === 0) {
this.options.resultType = "unknown"
} else if (this.total >= threshold) {
this.options.resultType = "success"
} else {
this.options.resultType = "failure"
}
}
/** @override */
async render(chatOptions = {}) {
const data = await this._getChatCardData(chatOptions.isPrivate)
return foundry.applications.handlebars.renderTemplate(this.constructor.CHAT_TEMPLATE, data)
}
async _getChatCardData(isPrivate) {
return {
css: [SYSTEM.id, "dice-roll"],
cssClass: [SYSTEM.id, "dice-roll"].join(" "),
actorId: this.actorId,
actingCharName: this.actorName,
actingCharImg: this.actorImage,
skillLabel: this.skillLabel,
formula: this.formula,
total: this.total,
resultType: this.resultType,
isSuccess: this.isSuccess,
isFailure: this.isFailure,
difficulty: this.options.difficulty,
difficultyValue:this.options.difficultyValue,
moonPhase: this.options.moonPhase,
moonBonus: this.moonBonus,
isPrivate,
tooltip: isPrivate ? "" : await this.getTooltip(),
results: this.dice[0]?.results ?? [],
}
}
/** @override */
async toMessage(messageData = {}, { rollMode, create = true } = {}) {
return super.toMessage(
{
flavor: `<strong>${game.i18n.localize(this.skillLabel ?? "")}</strong>`,
...messageData,
},
{ rollMode }
)
}
}

View File

@@ -0,0 +1,3 @@
export { default as CelestopolCharacter } from "./character.mjs"
export { default as CelestopolNPC } from "./npc.mjs"
export { CelestopolAnomaly, CelestopolAspect, CelestopolAttribute, CelestopolEquipment } from "./items.mjs"

175
module/models/character.mjs Normal file
View File

@@ -0,0 +1,175 @@
import { SYSTEM } from "../config/system.mjs"
export default class CelestopolCharacter extends foundry.abstract.TypeDataModel {
static defineSchema() {
const fields = foundry.data.fields
const reqInt = { required: true, nullable: false, integer: true }
const schema = {}
// Concept du personnage
schema.concept = new fields.StringField({ required: true, nullable: false, initial: "" })
// Initiative (calculée mais stockée pour affichage)
schema.initiative = new fields.NumberField({ ...reqInt, initial: 0, min: 0 })
// Anomalie du personnage
schema.anomaly = new fields.SchemaField({
type: new fields.StringField({ required: true, nullable: false, initial: "none",
choices: Object.keys(SYSTEM.ANOMALY_TYPES) }),
value: new fields.NumberField({ ...reqInt, initial: 0, min: 0, max: 8 }),
})
// Les 4 stats avec leurs compétences
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 }),
})
const statField = (statId) => {
const skills = SYSTEM.SKILLS[statId]
const skillSchema = {}
for (const [key, skill] of Object.entries(skills)) {
skillSchema[key] = skillField(skill.label)
}
return new fields.SchemaField({
label: new fields.StringField({ required: true, initial: SYSTEM.STATS[statId].label }),
res: new fields.NumberField({ ...reqInt, initial: 0, min: 0, max: 8 }),
...skillSchema,
})
}
schema.stats = new fields.SchemaField({
ame: statField("ame"),
corps: statField("corps"),
coeur: statField("coeur"),
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 }),
})
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 }),
})
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)
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)
const persoAttrField = () => new fields.SchemaField({
value: new fields.NumberField({ ...reqInt, initial: 0, min: 0 }),
max: new fields.NumberField({ ...reqInt, initial: 0, min: 0 }),
})
schema.attributs = new fields.SchemaField({
entregent: persoAttrField(),
fortune: persoAttrField(),
reve: persoAttrField(),
vision: persoAttrField(),
})
// Factions
const factionField = () => new fields.SchemaField({
value: new fields.NumberField({ ...reqInt, initial: 0 }),
})
schema.factions = new fields.SchemaField({
pinkerton: factionField(),
police: factionField(),
okhrana: factionField(),
lunanovatek: factionField(),
oto: factionField(),
syndicats: factionField(),
vorovskoymir: factionField(),
cour: factionField(),
perso1: new fields.SchemaField({
label: new fields.StringField({ required: true, nullable: false, initial: "" }),
value: new fields.NumberField({ ...reqInt, initial: 0 }),
}),
perso2: new fields.SchemaField({
label: new fields.StringField({ required: true, nullable: false, initial: "" }),
value: new fields.NumberField({ ...reqInt, initial: 0 }),
}),
})
// Préférences de jet (mémorisé entre sessions)
schema.prefs = new fields.SchemaField({
moonPhase: new fields.StringField({ required: true, nullable: false, initial: "none" }),
difficulty: new fields.StringField({ required: true, nullable: false, initial: "normal" }),
})
// Description & notes
schema.description = new fields.HTMLField({ required: true, textSearch: true })
schema.notes = new fields.HTMLField({ required: true, textSearch: true })
// Données biographiques
schema.biodata = new fields.SchemaField({
age: new fields.StringField({ required: true, nullable: false, initial: "" }),
genre: new fields.StringField({ required: true, nullable: false, initial: "" }),
taille: new fields.StringField({ required: true, nullable: false, initial: "" }),
yeux: new fields.StringField({ required: true, nullable: false, initial: "" }),
naissance: new fields.StringField({ required: true, nullable: false, initial: "" }),
cheveux: new fields.StringField({ required: true, nullable: false, initial: "" }),
origine: new fields.StringField({ required: true, nullable: false, initial: "" }),
})
return schema
}
static LOCALIZATION_PREFIXES = ["CELESTOPOL.Character"]
prepareDerivedData() {
super.prepareDerivedData()
// L'initiative est basée sur la résistance Corps
this.initiative = this.stats.corps.res
}
/**
* Calcule le malus de blessures actif.
* @returns {number}
*/
getWoundMalus() {
const lvl = Math.max(0, Math.min(8, this.blessures.lvl))
return SYSTEM.WOUND_LEVELS[lvl]?.malus ?? 0
}
/**
* Lance les dés pour une compétence donnée.
* @param {string} statId - Id de la stat (ame, corps, coeur, esprit)
* @param {string} skillId - Id de la compétence
*/
async roll(statId, skillId) {
const { CelestopolRoll } = await import("../documents/roll.mjs")
const skill = this.stats[statId][skillId]
if (!skill) return null
return CelestopolRoll.prompt({
actorId: this.parent.id,
actorName: this.parent.name,
actorImage: this.parent.img,
statId,
skillId,
skillLabel: skill.label,
skillValue: skill.value,
woundMalus: this.getWoundMalus(),
moonPhase: this.prefs.moonPhase,
difficulty: this.prefs.difficulty,
})
}
}

98
module/models/items.mjs Normal file
View File

@@ -0,0 +1,98 @@
import { SYSTEM } from "../config/system.mjs"
/** Schéma partagé pour les bonus/malus par compétence (utilisé dans anomaly/aspect/attribute). */
function skillScoresSchema() {
const fields = foundry.data.fields
const reqInt = { required: true, nullable: false, integer: true }
const scoreField = () => new fields.SchemaField({
bonus: new fields.NumberField({ ...reqInt, initial: 0 }),
malus: new fields.NumberField({ ...reqInt, initial: 0 }),
})
const statGroup = (statId) => {
const skills = SYSTEM.SKILLS[statId]
const schema = {}
for (const key of Object.keys(skills)) {
schema[key] = scoreField()
}
return new fields.SchemaField(schema)
}
return new fields.SchemaField({
ame: statGroup("ame"),
corps: statGroup("corps"),
coeur: statGroup("coeur"),
esprit: statGroup("esprit"),
})
}
export class CelestopolAnomaly extends foundry.abstract.TypeDataModel {
static defineSchema() {
const fields = foundry.data.fields
const reqInt = { required: true, nullable: false, integer: true }
return {
subtype: new fields.StringField({ required: true, nullable: false, initial: "none",
choices: Object.keys(SYSTEM.ANOMALY_TYPES) }),
value: new fields.NumberField({ ...reqInt, initial: 0, min: 0, max: 8 }),
reference: new fields.StringField({ required: true, nullable: false, initial: "" }),
scores: skillScoresSchema(),
description: new fields.HTMLField({ required: true, textSearch: true }),
technique: new fields.HTMLField({ required: true, textSearch: true }),
narratif: new fields.HTMLField({ required: true, textSearch: true }),
notes: new fields.HTMLField({ required: true, textSearch: true }),
}
}
}
export class CelestopolAspect extends foundry.abstract.TypeDataModel {
static defineSchema() {
const fields = foundry.data.fields
const reqInt = { required: true, nullable: false, integer: true }
return {
value: new fields.NumberField({ ...reqInt, initial: 0, min: 0, max: 8 }),
reference: new fields.StringField({ required: true, nullable: false, initial: "" }),
scores: skillScoresSchema(),
description: new fields.HTMLField({ required: true, textSearch: true }),
technique: new fields.HTMLField({ required: true, textSearch: true }),
narratif: new fields.HTMLField({ required: true, textSearch: true }),
notes: new fields.HTMLField({ required: true, textSearch: true }),
}
}
}
export class CelestopolAttribute extends foundry.abstract.TypeDataModel {
static defineSchema() {
const fields = foundry.data.fields
const reqInt = { required: true, nullable: false, integer: true }
return {
value: new fields.NumberField({ ...reqInt, initial: 0, min: 0, max: 8 }),
reference: new fields.StringField({ required: true, nullable: false, initial: "" }),
scores: skillScoresSchema(),
description: new fields.HTMLField({ required: true, textSearch: true }),
technique: new fields.HTMLField({ required: true, textSearch: true }),
narratif: new fields.HTMLField({ required: true, textSearch: true }),
notes: new fields.HTMLField({ required: true, textSearch: true }),
}
}
}
export class CelestopolEquipment extends foundry.abstract.TypeDataModel {
static defineSchema() {
const fields = foundry.data.fields
const reqInt = { required: true, nullable: false, integer: true }
return {
subtype: new fields.StringField({ required: true, nullable: false, initial: "autre",
choices: Object.keys(SYSTEM.EQUIPMENT_TYPES) }),
reference: new fields.StringField({ required: true, nullable: false, initial: "" }),
quantity: new fields.NumberField({ ...reqInt, initial: 1, min: 0 }),
weight: new fields.NumberField({ required: true, nullable: false, initial: 0, min: 0 }),
damage: new fields.StringField({ required: true, nullable: false, initial: "" }),
range: new fields.StringField({ required: true, nullable: false, initial: "" }),
speed: new fields.StringField({ required: true, nullable: false, initial: "" }),
protection: new fields.StringField({ required: true, nullable: false, initial: "" }),
crew: new fields.StringField({ required: true, nullable: false, initial: "" }),
description:new fields.HTMLField({ required: true, textSearch: true }),
notes: new fields.HTMLField({ required: true, textSearch: true }),
}
}
}

99
module/models/npc.mjs Normal file
View File

@@ -0,0 +1,99 @@
import { SYSTEM } from "../config/system.mjs"
export default class CelestopolNPC extends foundry.abstract.TypeDataModel {
static defineSchema() {
const fields = foundry.data.fields
const reqInt = { required: true, nullable: false, integer: true }
const schema = {}
schema.concept = new fields.StringField({ required: true, nullable: false, initial: "" })
schema.initiative = new fields.NumberField({ ...reqInt, initial: 0, min: 0 })
schema.anomaly = new fields.SchemaField({
type: new fields.StringField({ required: true, nullable: false, initial: "none",
choices: Object.keys(SYSTEM.ANOMALY_TYPES) }),
value: new fields.NumberField({ ...reqInt, initial: 0, min: 0, max: 8 }),
})
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 }),
})
const statField = (statId) => {
const skills = SYSTEM.SKILLS[statId]
const skillSchema = {}
for (const [key, skill] of Object.entries(skills)) {
skillSchema[key] = skillField(skill.label)
}
return new fields.SchemaField({
label: new fields.StringField({ required: true, initial: SYSTEM.STATS[statId].label }),
res: new fields.NumberField({ ...reqInt, initial: 0, min: 0, max: 8 }),
actuel: new fields.NumberField({ ...reqInt, initial: 0, min: 0 }), // res + wound malus
...skillSchema,
})
}
schema.stats = new fields.SchemaField({
ame: statField("ame"),
corps: statField("corps"),
coeur: statField("coeur"),
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({
moonPhase: new fields.StringField({ required: true, nullable: false, initial: "none" }),
difficulty: new fields.StringField({ required: true, nullable: false, initial: "normal" }),
})
schema.description = new fields.HTMLField({ required: true, textSearch: true })
schema.notes = new fields.HTMLField({ required: true, textSearch: true })
return schema
}
static LOCALIZATION_PREFIXES = ["CELESTOPOL.NPC"]
prepareDerivedData() {
super.prepareDerivedData()
const malus = this.getWoundMalus()
this.initiative = Math.max(0, this.stats.corps.res + malus)
for (const stat of Object.values(this.stats)) {
stat.actuel = Math.max(0, stat.res + malus)
}
}
getWoundMalus() {
const lvl = Math.max(0, Math.min(8, this.blessures.lvl))
return SYSTEM.WOUND_LEVELS[lvl]?.malus ?? 0
}
async roll(statId, skillId) {
const { CelestopolRoll } = await import("../documents/roll.mjs")
const skill = this.stats[statId][skillId]
if (!skill) return null
return CelestopolRoll.prompt({
actorId: this.parent.id,
actorName: this.parent.name,
actorImage: this.parent.img,
statId,
skillId,
skillLabel: skill.label,
skillValue: skill.value,
woundMalus: this.getWoundMalus(),
moonPhase: this.prefs.moonPhase,
difficulty: this.prefs.difficulty,
})
}
}

33
package.json Normal file
View File

@@ -0,0 +1,33 @@
{
"name": "fvtt-celestopol",
"private": true,
"version": "1.0.0",
"description": "Système FoundryVTT pour Célestopol 1922",
"author": "LeRatierBretonnien / Uberwald",
"license": "UNLICENSED",
"main": "gulpfile.js",
"devDependencies": {
"@eslint/js": "^9.8.0",
"@foundryvtt/foundryvtt-cli": "^1.0.2",
"commander": "^11.1.0",
"eslint": "^9.9.0",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-jsdoc": "^48.11.0",
"eslint-plugin-prettier": "^5.2.1",
"globals": "^15.9.0",
"less": "^4.1.3",
"prettier": "^3.3.3"
},
"dependencies": {
"gulp": "^5.0.0",
"gulp-less": "^5.0.0"
},
"scripts": {
"build": "gulp css",
"watch": "gulp"
},
"repository": {
"type": "git",
"url": "https://www.uberwald.me/gitea/uberwald/fvtt-celestopol.git"
}
}

241
styles/character.less Normal file
View File

@@ -0,0 +1,241 @@
@import "mixins";
// ─── Character sheet specifics ───────────────────────────────────────────────
.celestopol.character-sheet {
// Attributs perso (Entregent, Fortune, Rêve, Vision)
.perso-attributs {
display: flex;
gap: 8px;
flex-wrap: wrap;
margin-top: 4px;
.perso-attr {
display: flex;
flex-direction: column;
align-items: center;
background: rgba(0,0,0,0.25);
border: 1px solid var(--cel-orange);
border-radius: 4px;
padding: 4px 8px;
min-width: 60px;
label {
font-size: 0.6em;
text-transform: uppercase;
color: var(--cel-orange-light);
}
.attr-display, .attr-val, .attr-max {
color: var(--cel-orange);
font-family: var(--cel-font-title);
font-size: 1em;
font-weight: bold;
}
input.attr-val, input.attr-max {
width: 28px;
text-align: center;
background: transparent;
border: none;
border-bottom: 1px solid var(--cel-orange-light);
color: var(--cel-orange);
}
}
}
// Stats × Compétences grid
.stats-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
padding: 8px 0;
.stat-block {
border: 1px solid var(--cel-border);
border-radius: 4px;
overflow: hidden;
.stat-header {
background: var(--cel-green);
color: var(--cel-orange);
display: flex;
justify-content: space-between;
align-items: center;
padding: 4px 8px;
.stat-name {
font-family: var(--cel-font-title);
font-weight: bold;
font-size: 1em;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.stat-res {
display: flex;
align-items: center;
gap: 4px;
font-size: 0.8em;
label { color: var(--cel-orange-light); }
input[type="number"] { width: 30px; .cel-input-std(); }
.stat-res-value {
font-size: 1.3em;
font-weight: bold;
min-width: 24px;
text-align: center;
}
}
}
.skills-list {
background: var(--cel-cream);
.skill-row {
display: flex;
align-items: center;
justify-content: space-between;
padding: 3px 8px;
border-bottom: 1px solid rgba(139,115,85,0.2);
font-size: 0.85em;
&.rollable { .cel-rollable(); }
.skill-name { flex: 1; }
.skill-value {
font-weight: bold;
min-width: 24px;
text-align: center;
color: var(--cel-green);
}
.skill-value-input {
width: 36px;
.cel-input-std();
text-align: center;
}
}
}
}
}
// Items (anomalies, aspects, attributs)
.items-section {
display: flex;
flex-direction: column;
gap: 8px;
padding: 8px 0;
.items-group {
.items-header {
.cel-section-header();
display: flex;
justify-content: space-between;
align-items: center;
a { color: var(--cel-orange); cursor: pointer; }
}
.item-row { .cel-item-row(); }
}
}
// Tracks (Blessures, Destin, Spleen)
.track-section {
border: 1px solid var(--cel-border);
border-radius: 4px;
margin-bottom: 12px;
overflow: hidden;
.track-header {
background: var(--cel-green);
color: var(--cel-orange);
display: flex;
justify-content: space-between;
align-items: center;
padding: 4px 8px;
.track-title {
font-family: var(--cel-font-title);
font-weight: bold;
text-transform: uppercase;
font-size: 0.9em;
}
}
.track-boxes {
display: flex;
padding: 8px;
gap: 8px;
flex-wrap: wrap;
background: var(--cel-cream);
.track-box {
display: flex;
flex-direction: column;
align-items: center;
gap: 2px;
input[type="checkbox"] { .cel-box(); }
.box-label {
font-size: 0.65em;
color: var(--cel-border);
}
&.checked input[type="checkbox"] {
accent-color: var(--cel-orange);
}
}
}
.track-level {
display: flex;
align-items: center;
gap: 6px;
padding: 4px 8px;
background: rgba(139,115,85,0.1);
font-size: 0.85em;
label { color: var(--cel-border); }
input[type="number"] { width: 40px; .cel-input-std(); }
}
}
// Factions table
.factions-table {
width: 100%;
border-collapse: collapse;
font-size: 0.9em;
thead tr {
background: var(--cel-green);
color: var(--cel-orange);
th { padding: 4px 8px; font-family: var(--cel-font-title); }
}
.faction-row {
&:nth-child(odd) td { background: rgba(139,115,85,0.08); }
td { padding: 4px 8px; border-bottom: 1px solid rgba(139,115,85,0.2); }
&.custom td { font-style: italic; color: #666; }
.faction-value input[type="number"] {
width: 50px;
.cel-input-std();
text-align: center;
}
}
}
// Biography / Equipment
.equipments-section {
margin-bottom: 12px;
.section-header { .cel-section-header(); display: flex; justify-content: space-between; }
.item-row { .cel-item-row(); }
.item-qty { font-size: 0.8em; color: var(--cel-border); }
}
.biography-section, .notes-section {
margin-bottom: 12px;
.section-header { .cel-section-header(); }
.enriched-html { font-size: 0.9em; line-height: 1.6; }
}
}

13
styles/fonts.less Normal file
View File

@@ -0,0 +1,13 @@
@font-face {
font-family: "CopaseticNF";
src: url("../assets/fonts/CopaseticNF.otf") format("opentype");
font-weight: normal;
font-style: normal;
}
@font-face {
font-family: "CopaseticNF";
src: url("../assets/fonts/CopaseticNF-Bold.otf") format("opentype");
font-weight: bold;
font-style: normal;
}

View File

@@ -0,0 +1,10 @@
// ─── Master LESS file for fvtt-celestopol ────────────────────────────────────
// Compilation : gulp css → css/fvtt-celestopol.css
@import "fonts";
@import "mixins";
@import "global";
@import "character";
@import "npc";
@import "items";
@import "roll";

158
styles/global.less Normal file
View File

@@ -0,0 +1,158 @@
// ─── Variables CSS (couleurs + typo) ────────────────────────────────────────
:root {
--cel-green: rgb(12, 76, 12);
--cel-green-light: rgb(20, 110, 20);
--cel-green-dark: rgb(6, 40, 6);
--cel-orange: #e07b00;
--cel-orange-light: #f0a040;
--cel-cream: #f5f0e8;
--cel-border: #8b7355;
--cel-shadow: rgba(0,0,0,0.4);
--cel-font-title: "CopaseticNF", "Palatino Linotype", serif;
--cel-font-body: "Palatino Linotype", "Book Antiqua", Palatino, serif;
--cel-font-ui: "Signika", "Palatino Linotype", sans-serif;
}
// ─── Sheet base layout ───────────────────────────────────────────────────────
.celestopol {
&.sheet {
background: var(--cel-cream);
font-family: var(--cel-font-body);
color: #1a1209;
.window-content {
padding: 0;
overflow: hidden;
display: flex;
flex-direction: column;
background: var(--cel-cream);
}
}
// ─── Header ──────────────────────────────────────────────────────────────
.sheet-header {
display: flex;
align-items: stretch;
background: var(--cel-green);
background-image: url("../assets/ui/fond_cadrille.jpg");
background-blend-mode: overlay;
padding: 8px;
gap: 8px;
border-bottom: 3px solid var(--cel-orange);
.actor-portrait {
width: 80px;
height: 80px;
object-fit: cover;
border: 2px solid var(--cel-orange);
cursor: pointer;
}
.header-fields {
flex: 1;
display: flex;
flex-direction: column;
gap: 4px;
color: var(--cel-cream);
input[type="text"] {
background: transparent;
border: none;
border-bottom: 1px solid var(--cel-orange-light);
color: var(--cel-orange);
font-family: var(--cel-font-title);
font-size: 1.4em;
font-weight: bold;
padding: 2px 4px;
&::placeholder { color: var(--cel-orange-light); }
}
.actor-name {
font-family: var(--cel-font-title);
font-size: 1.4em;
color: var(--cel-orange);
margin: 0;
}
.concept-display,
input[name="system.concept"] {
font-style: italic;
font-size: 0.9em;
color: var(--cel-cream);
background: transparent;
border-bottom: 1px solid rgba(255,255,255,0.3);
}
}
.header-stats-row {
display: flex;
gap: 12px;
align-items: center;
.header-stat {
display: flex;
flex-direction: column;
align-items: center;
background: rgba(0,0,0,0.3);
border: 1px solid var(--cel-orange);
border-radius: 4px;
padding: 4px 8px;
label {
font-size: 0.65em;
text-transform: uppercase;
color: var(--cel-orange-light);
}
.stat-value {
font-size: 1.4em;
font-family: var(--cel-font-title);
color: var(--cel-orange);
font-weight: bold;
}
}
}
}
// ─── Tabs ────────────────────────────────────────────────────────────────
.sheet-tabs {
display: flex;
background: var(--cel-green-dark);
padding: 0;
border-bottom: 2px solid var(--cel-orange);
.item {
padding: 6px 12px;
color: var(--cel-cream);
font-family: var(--cel-font-title);
font-size: 0.85em;
text-transform: uppercase;
letter-spacing: 0.05em;
cursor: pointer;
border-right: 1px solid var(--cel-green-light);
transition: background 0.2s;
&:hover { background: var(--cel-green-light); }
&.active {
background: var(--cel-orange);
color: #1a0a00;
font-weight: bold;
}
}
}
// ─── Sheet body / tabs content ───────────────────────────────────────────
.sheet-body {
flex: 1;
overflow-y: auto;
padding: 8px;
}
.tab { display: none; &.active { display: block; } }
}

158
styles/items.less Normal file
View File

@@ -0,0 +1,158 @@
@import "mixins";
// ─── Item sheets shared ───────────────────────────────────────────────────────
.celestopol.item-sheet {
.item-header {
display: flex;
align-items: center;
gap: 8px;
padding: 8px;
background: var(--cel-green);
background-image: url("../assets/ui/fond_cadrille.jpg");
background-blend-mode: overlay;
border-bottom: 2px solid var(--cel-orange);
.item-portrait img {
width: 56px;
height: 56px;
object-fit: cover;
border: 2px solid var(--cel-orange);
cursor: pointer;
}
.item-header-fields {
flex: 1;
display: flex;
flex-direction: column;
gap: 4px;
input[type="text"] {
background: transparent;
border: none;
border-bottom: 1px solid var(--cel-orange-light);
color: var(--cel-orange);
font-family: var(--cel-font-title);
font-size: 1.2em;
font-weight: bold;
padding: 2px 4px;
}
.item-meta {
display: flex;
gap: 8px;
align-items: center;
select {
background: transparent;
border: 1px solid var(--cel-orange-light);
color: var(--cel-orange);
font-size: 0.85em;
option { background: var(--cel-green-dark); color: var(--cel-cream); }
}
}
.item-value-field {
display: flex;
align-items: center;
gap: 4px;
label { color: var(--cel-orange-light); font-size: 0.75em; text-transform: uppercase; }
input[type="number"] {
width: 40px;
background: transparent;
border: 1px solid var(--cel-orange-light);
color: var(--cel-orange);
text-align: center;
font-size: 1.1em;
font-weight: bold;
}
}
}
}
.item-tabs {
display: flex;
background: var(--cel-green-dark);
border-bottom: 2px solid var(--cel-orange);
.item {
padding: 5px 10px;
color: var(--cel-cream);
font-family: var(--cel-font-title);
font-size: 0.8em;
text-transform: uppercase;
cursor: pointer;
&:hover { background: var(--cel-green-light); }
&.active { background: var(--cel-orange); color: #1a0a00; font-weight: bold; }
}
}
section.tab {
padding: 8px;
display: none;
&.active { display: block; }
}
.form-group {
margin-bottom: 8px;
label {
display: block;
font-size: 0.75em;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--cel-green);
margin-bottom: 2px;
}
input[type="text"], input[type="number"] { .cel-input-std(); width: 100%; box-sizing: border-box; }
}
// Scores grid
.scores-section {
.scores-header { .cel-section-header(); }
.scores-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 8px;
.scores-stat-col {
.scores-stat-name {
font-family: var(--cel-font-title);
font-size: 0.75em;
color: var(--cel-green);
text-transform: uppercase;
border-bottom: 1px solid var(--cel-border);
margin-bottom: 4px;
}
.score-row {
display: flex;
align-items: center;
gap: 4px;
padding: 2px 0;
font-size: 0.8em;
.score-skill-name { flex: 1; }
.score-bonus, .score-malus {
width: 36px;
.cel-input-std();
text-align: center;
}
.score-bonus { border-color: #4a8a4a; }
.score-malus { border-color: #8a4a4a; }
}
}
}
}
// Equipment-specific
&.equipment-sheet {
.equipment-stats {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 6px;
margin-bottom: 8px;
}
}
}

72
styles/mixins.less Normal file
View File

@@ -0,0 +1,72 @@
// ─── Mixins ──────────────────────────────────────────────────────────────────
.cel-section-header() {
font-family: var(--cel-font-title);
font-size: 0.8em;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--cel-green);
border-bottom: 1px solid var(--cel-border);
padding-bottom: 2px;
margin-bottom: 6px;
}
.cel-input-std() {
background: rgba(255,255,255,0.7);
border: 1px solid var(--cel-border);
border-radius: 2px;
padding: 2px 4px;
color: #1a1209;
font-family: var(--cel-font-body);
}
.cel-box() {
display: inline-block;
width: 22px;
height: 22px;
border: 2px solid var(--cel-border);
border-radius: 3px;
background: rgba(255,255,255,0.5);
cursor: pointer;
&.checked { background: var(--cel-orange); border-color: var(--cel-orange); }
}
.cel-rollable() {
cursor: pointer;
transition: background 0.15s, color 0.15s;
&:hover {
background: var(--cel-orange-light);
color: #1a0a00;
border-radius: 3px;
}
}
.cel-item-row() {
display: flex;
align-items: center;
gap: 6px;
padding: 3px 6px;
border-bottom: 1px solid rgba(139,115,85,0.3);
transition: background 0.1s;
&:hover { background: rgba(224,123,0,0.08); }
.item-icon {
width: 24px;
height: 24px;
object-fit: cover;
border: 1px solid var(--cel-border);
border-radius: 2px;
}
.item-name { flex: 1; font-style: italic; }
.item-controls {
display: flex;
gap: 4px;
opacity: 0;
transition: opacity 0.15s;
a { color: var(--cel-orange); cursor: pointer; }
}
&:hover .item-controls { opacity: 1; }
}

117
styles/npc.less Normal file
View File

@@ -0,0 +1,117 @@
@import "mixins";
// ─── NPC sheet specifics ─────────────────────────────────────────────────────
.celestopol.npc-sheet {
.stats-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
padding: 8px 0;
.stat-block {
border: 1px solid var(--cel-border);
border-radius: 4px;
overflow: hidden;
.stat-header {
background: var(--cel-green);
color: var(--cel-orange);
display: flex;
justify-content: space-between;
align-items: center;
padding: 4px 8px;
.stat-name {
font-family: var(--cel-font-title);
font-weight: bold;
text-transform: uppercase;
font-size: 0.9em;
}
.stat-res {
display: flex;
align-items: center;
gap: 4px;
font-size: 0.8em;
label { color: var(--cel-orange-light); }
.stat-res-value { font-weight: bold; color: var(--cel-orange); }
.stat-actuel {
font-size: 0.9em;
color: rgba(255,200,0,0.7);
font-style: italic;
}
input[type="number"] { width: 30px; .cel-input-std(); }
}
}
.skills-list {
background: var(--cel-cream);
.skill-row {
display: flex;
align-items: center;
justify-content: space-between;
padding: 3px 8px;
border-bottom: 1px solid rgba(139,115,85,0.2);
font-size: 0.85em;
&.rollable { .cel-rollable(); }
.skill-name { flex: 1; }
.skill-value { font-weight: bold; color: var(--cel-green); min-width: 24px; text-align: center; }
.skill-value-input { width: 36px; .cel-input-std(); text-align: center; }
}
}
}
}
.track-section {
border: 1px solid var(--cel-border);
border-radius: 4px;
margin-bottom: 8px;
overflow: hidden;
.track-header {
background: var(--cel-green);
color: var(--cel-orange);
padding: 4px 8px;
font-family: var(--cel-font-title);
font-weight: bold;
text-transform: uppercase;
font-size: 0.9em;
}
.track-boxes {
display: flex;
padding: 8px;
gap: 6px;
background: var(--cel-cream);
.track-box {
display: flex;
flex-direction: column;
align-items: center;
gap: 2px;
.box-label { font-size: 0.65em; color: var(--cel-border); }
}
}
.track-level {
display: flex;
align-items: center;
gap: 6px;
padding: 4px 8px;
background: rgba(139,115,85,0.1);
font-size: 0.85em;
input[type="number"] { width: 40px; .cel-input-std(); }
}
}
.description-section {
margin-top: 8px;
.enriched-html { font-size: 0.9em; line-height: 1.6; }
}
}

177
styles/roll.less Normal file
View File

@@ -0,0 +1,177 @@
// ─── Roll dialog ─────────────────────────────────────────────────────────────
.roll-dialog.celestopol {
padding: 8px 12px;
font-family: var(--cel-font-body, "Palatino Linotype", serif);
.roll-title {
text-align: center;
font-family: var(--cel-font-title, "CopaseticNF", serif);
font-size: 1.1em;
color: var(--cel-green, rgb(12,76,12));
margin-bottom: 10px;
.separator {
margin: 0 6px;
color: var(--cel-orange, #e07b00);
}
}
.form-group {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
label {
flex: 0 0 140px;
font-size: 0.85em;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--cel-green, rgb(12,76,12));
}
select, input[type="number"] {
flex: 1;
border: 1px solid var(--cel-border, #8b7355);
border-radius: 2px;
padding: 2px 6px;
background: rgba(255,255,255,0.7);
}
}
.dice-preview {
text-align: center;
font-size: 1em;
margin-top: 10px;
padding: 6px;
background: rgba(12,76,12,0.08);
border-radius: 4px;
border: 1px solid var(--cel-green, rgb(12,76,12));
.nb-dice {
font-family: var(--cel-font-title, "CopaseticNF", serif);
font-size: 1.5em;
color: var(--cel-orange, #e07b00);
font-weight: bold;
}
}
}
// ─── Chat message ─────────────────────────────────────────────────────────────
.celestopol.chat-roll {
border: 1px solid var(--cel-border, #8b7355);
border-radius: 4px;
overflow: hidden;
font-family: var(--cel-font-body, "Palatino Linotype", serif);
.roll-header {
display: flex;
align-items: center;
gap: 8px;
background: var(--cel-green, rgb(12,76,12));
padding: 6px 8px;
.actor-img {
width: 36px;
height: 36px;
object-fit: cover;
border: 1px solid var(--cel-orange, #e07b00);
border-radius: 2px;
}
.roll-info {
display: flex;
flex-direction: column;
.actor-name {
font-family: var(--cel-font-title, "CopaseticNF", serif);
color: var(--cel-orange, #e07b00);
font-weight: bold;
}
.skill-info {
color: var(--cel-cream, #f5f0e8);
font-size: 0.8em;
font-style: italic;
}
}
}
.roll-details {
padding: 8px;
background: var(--cel-cream, #f5f0e8);
.dice-results {
display: flex;
gap: 4px;
flex-wrap: wrap;
margin-bottom: 6px;
.die.d6 {
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border: 2px solid var(--cel-border, #8b7355);
border-radius: 4px;
background: white;
font-weight: bold;
font-size: 1em;
}
}
.bonus-line {
display: flex;
justify-content: space-between;
font-size: 0.85em;
color: #666;
padding: 1px 0;
}
.roll-total-line {
display: flex;
align-items: center;
gap: 8px;
margin-top: 6px;
padding-top: 4px;
border-top: 1px solid var(--cel-border, #8b7355);
.total-label {
text-transform: uppercase;
font-size: 0.75em;
color: var(--cel-green, rgb(12,76,12));
}
.total-value {
font-family: var(--cel-font-title, "CopaseticNF", serif);
font-size: 1.6em;
font-weight: bold;
color: var(--cel-orange, #e07b00);
}
.vs-difficulty {
font-size: 0.8em;
color: #999;
}
}
}
.roll-result-banner {
text-align: center;
padding: 4px 8px;
font-family: var(--cel-font-title, "CopaseticNF", serif);
font-size: 1.1em;
text-transform: uppercase;
letter-spacing: 0.08em;
&.success { background: var(--cel-green, rgb(12,76,12)); color: var(--cel-orange, #e07b00); }
&.failure { background: #4a1a1a; color: #e0a0a0; }
.critical {
display: block;
font-size: 0.75em;
letter-spacing: 0.12em;
}
}
}

74
system.json Normal file
View File

@@ -0,0 +1,74 @@
{
"id": "fvtt-celestopol",
"title": "Célestopol 1922",
"description": "Système FoundryVTT pour Célestopol 1922, le jeu de rôle d'Antre Monde Éditions.",
"manifest": "https://www.uberwald.me/gitea/uberwald/fvtt-celestopol/releases/download/latest/system.json",
"download": "#{DOWNLOAD}#",
"url": "https://www.uberwald.me/gitea/public/fvtt-celestopol",
"license": "LICENSE",
"version": "13.0.0",
"authors": [
{
"name": "Uberwald",
"discord": "LeRatierBretonnien"
}
],
"flags": {
"hotReload": {
"extensions": ["css", "html", "hbs", "json"],
"paths": ["css/", "templates/", "lang/fr.json"]
}
},
"compatibility": {
"minimum": "13",
"verified": "13"
},
"esmodules": ["fvtt-celestopol.mjs"],
"styles": ["css/fvtt-celestopol.css"],
"languages": [
{
"lang": "fr",
"name": "Français",
"path": "lang/fr.json"
}
],
"documentTypes": {
"Actor": {
"character": {
"htmlFields": ["description", "notes"]
},
"npc": {
"htmlFields": ["description", "notes"]
}
},
"Item": {
"anomaly": { "htmlFields": ["description", "technique", "narratif", "notes"] },
"aspect": { "htmlFields": ["description", "technique", "narratif", "notes"] },
"attribute": { "htmlFields": ["description", "technique", "narratif", "notes"] },
"equipment": { "htmlFields": ["description", "notes"] }
}
},
"packs": [
{
"name": "aspects",
"label": "Célestopol 1922 — Aspects",
"system": "fvtt-celestopol",
"path": "packs-system/aspects",
"type": "Item"
},
{
"name": "anomalies",
"label": "Célestopol 1922 — Anomalies",
"system": "fvtt-celestopol",
"path": "packs-system/anomalies",
"type": "Item"
}
],
"grid": {
"distance": 5,
"units": "m"
},
"primaryTokenAttribute": "resource",
"socket": true,
"background": "systems/fvtt-celestopol/assets/ui/celestopol_background.webp"
}

52
templates/anomaly.hbs Normal file
View File

@@ -0,0 +1,52 @@
<div class="item-sheet anomaly">
<header class="item-header">
<div class="item-portrait" data-action="editImage" data-edit="img">
<img src="{{item.img}}" alt="{{item.name}}">
</div>
<div class="item-header-fields">
<input type="text" name="name" value="{{item.name}}" {{#unless isEditable}}disabled{{/unless}}>
<div class="item-meta">
<select name="system.subtype" {{#unless isEditable}}disabled{{/unless}}>
{{selectOptions systemFields.subtype.choices selected=system.subtype localize=true}}
</select>
<div class="item-value-field">
<label>{{localize "CELESTOPOL.Item.value"}}</label>
<input type="number" name="system.value" value="{{system.value}}" min="0" max="8"
{{#unless isEditable}}disabled{{/unless}}>
</div>
</div>
</div>
</header>
<nav class="item-tabs sheet-tabs tabs" data-group="item-tabs">
<a class="item active" data-tab="description">{{localize "CELESTOPOL.Tab.description"}}</a>
<a class="item" data-tab="technique">{{localize "CELESTOPOL.Tab.technique"}}</a>
<a class="item" data-tab="scores">{{localize "CELESTOPOL.Item.scores"}}</a>
</nav>
<section class="tab active" data-tab="description">
<div class="form-group">
{{editor system.description target="system.description" button=true editable=isEditable}}
</div>
<div class="form-group">
<label>{{localize "CELESTOPOL.Item.reference"}}</label>
<input type="text" name="system.reference" value="{{system.reference}}"
{{#unless isEditable}}disabled{{/unless}}>
</div>
</section>
<section class="tab" data-tab="technique">
<div class="form-group">
<label>{{localize "CELESTOPOL.Item.technique"}}</label>
{{editor system.technique target="system.technique" button=true editable=isEditable}}
</div>
<div class="form-group">
<label>{{localize "CELESTOPOL.Item.narratif"}}</label>
{{editor system.narratif target="system.narratif" button=true editable=isEditable}}
</div>
</section>
<section class="tab" data-tab="scores">
{{> "systems/fvtt-celestopol/templates/partials/item-scores.hbs" skills=skills stats=stats}}
</section>
</div>

32
templates/aspect.hbs Normal file
View File

@@ -0,0 +1,32 @@
<div class="item-sheet aspect">
<header class="item-header">
<div class="item-portrait" data-action="editImage" data-edit="img">
<img src="{{item.img}}" alt="{{item.name}}">
</div>
<div class="item-header-fields">
<input type="text" name="name" value="{{item.name}}" {{#unless isEditable}}disabled{{/unless}}>
<div class="item-value-field">
<label>{{localize "CELESTOPOL.Item.value"}}</label>
<input type="number" name="system.value" value="{{system.value}}" min="0" max="8"
{{#unless isEditable}}disabled{{/unless}}>
</div>
</div>
</header>
<nav class="item-tabs sheet-tabs tabs" data-group="item-tabs">
<a class="item active" data-tab="description">{{localize "CELESTOPOL.Tab.description"}}</a>
<a class="item" data-tab="technique">{{localize "CELESTOPOL.Tab.technique"}}</a>
<a class="item" data-tab="scores">{{localize "CELESTOPOL.Item.scores"}}</a>
</nav>
<section class="tab active" data-tab="description">
{{editor system.description target="system.description" button=true editable=isEditable}}
<label>{{localize "CELESTOPOL.Item.reference"}}</label>
<input type="text" name="system.reference" value="{{system.reference}}" {{#unless isEditable}}disabled{{/unless}}>
</section>
<section class="tab" data-tab="technique">
{{editor system.technique target="system.technique" button=true editable=isEditable}}
{{editor system.narratif target="system.narratif" button=true editable=isEditable}}
</section>
<section class="tab" data-tab="scores">
{{> "systems/fvtt-celestopol/templates/partials/item-scores.hbs" skills=skills}}
</section>
</div>

32
templates/attribute.hbs Normal file
View File

@@ -0,0 +1,32 @@
<div class="item-sheet attribute">
<header class="item-header">
<div class="item-portrait" data-action="editImage" data-edit="img">
<img src="{{item.img}}" alt="{{item.name}}">
</div>
<div class="item-header-fields">
<input type="text" name="name" value="{{item.name}}" {{#unless isEditable}}disabled{{/unless}}>
<div class="item-value-field">
<label>{{localize "CELESTOPOL.Item.value"}}</label>
<input type="number" name="system.value" value="{{system.value}}" min="0" max="8"
{{#unless isEditable}}disabled{{/unless}}>
</div>
</div>
</header>
<nav class="item-tabs sheet-tabs tabs" data-group="item-tabs">
<a class="item active" data-tab="description">{{localize "CELESTOPOL.Tab.description"}}</a>
<a class="item" data-tab="technique">{{localize "CELESTOPOL.Tab.technique"}}</a>
<a class="item" data-tab="scores">{{localize "CELESTOPOL.Item.scores"}}</a>
</nav>
<section class="tab active" data-tab="description">
{{editor system.description target="system.description" button=true editable=isEditable}}
<label>{{localize "CELESTOPOL.Item.reference"}}</label>
<input type="text" name="system.reference" value="{{system.reference}}" {{#unless isEditable}}disabled{{/unless}}>
</section>
<section class="tab" data-tab="technique">
{{editor system.technique target="system.technique" button=true editable=isEditable}}
{{editor system.narratif target="system.narratif" button=true editable=isEditable}}
</section>
<section class="tab" data-tab="scores">
{{> "systems/fvtt-celestopol/templates/partials/item-scores.hbs" skills=skills}}
</section>
</div>

View File

@@ -0,0 +1,42 @@
<div class="tab biography" data-group="sheet" data-tab="biography">
{{!-- Équipements --}}
<div class="equipments-section">
<div class="section-header">
<span>{{localize "CELESTOPOL.Item.equipments"}}</span>
{{#if isEditMode}}
<a data-action="createEquipment" title="{{localize 'CELESTOPOL.Item.newEquipment'}}"><i class="fas fa-plus"></i></a>
{{/if}}
</div>
{{#each equipments as |item|}}
<div class="item-row equipment" data-item-id="{{item.id}}" data-item-uuid="{{item.uuid}}" data-drag="true">
<img src="{{item.img}}" class="item-icon">
<span class="item-name">{{item.name}}</span>
<span class="item-qty">×{{item.system.quantity}}</span>
<div class="item-controls">
<a data-action="edit" data-item-uuid="{{item.uuid}}"><i class="fas fa-edit"></i></a>
{{#if ../isEditMode}}<a data-action="delete" data-item-uuid="{{item.uuid}}"><i class="fas fa-trash"></i></a>{{/if}}
</div>
</div>
{{/each}}
</div>
{{!-- Description / Biographie --}}
<div class="biography-section">
<div class="section-header">{{localize "CELESTOPOL.Actor.description"}}</div>
{{#if isEditMode}}
{{editor system.description target="system.description" button=true editable=isEditable}}
{{else}}
<div class="enriched-html">{{{enrichedDescription}}}</div>
{{/if}}
</div>
{{!-- Notes --}}
<div class="notes-section">
<div class="section-header">{{localize "CELESTOPOL.Actor.notes"}}</div>
{{#if isEditMode}}
{{editor system.notes target="system.notes" button=true editable=isEditable}}
{{else}}
<div class="enriched-html">{{{enrichedNotes}}}</div>
{{/if}}
</div>
</div>

View File

@@ -0,0 +1,77 @@
<div class="tab blessures" data-group="sheet" data-tab="blessures">
{{!-- Blessures --}}
<section class="track-section">
<div class="track-header">
<span class="track-title">{{localize "CELESTOPOL.Track.blessures"}}</span>
<span class="wound-malus">{{localize "CELESTOPOL.Track.currentMalus"}} :
<strong>{{system.blessures.lvl}}</strong>
</span>
</div>
<div class="track-boxes">
{{#each (array "b1" "b2" "b3" "b4" "b5" "b6" "b7" "b8") as |key idx|}}
<div class="track-box {{#if (lookup ../system.blessures key 'checked')}}checked{{/if}}">
<input type="checkbox" name="system.blessures.{{key}}.checked"
{{#if (lookup ../system.blessures key 'checked')}}checked{{/if}}
{{#unless ../isEditable}}disabled{{/unless}}>
<label class="box-label">{{lookup ../system.blessures key 'malus'}}</label>
</div>
{{/each}}
</div>
<div class="track-level">
<label>{{localize "CELESTOPOL.Track.level"}}</label>
{{#if isEditMode}}
<input type="number" name="system.blessures.lvl" value="{{system.blessures.lvl}}" min="0" max="8">
{{else}}
<span>{{system.blessures.lvl}}</span>
{{/if}}
</div>
</section>
{{!-- Destin --}}
<section class="track-section">
<div class="track-header">
<span class="track-title">{{localize "CELESTOPOL.Track.destin"}}</span>
</div>
<div class="track-boxes destin-boxes">
{{#each (array "d1" "d2" "d3" "d4" "d5" "d6" "d7" "d8") as |key|}}
<div class="track-box destiny {{#if (lookup ../system.destin key 'checked')}}checked{{/if}}">
<input type="checkbox" name="system.destin.{{key}}.checked"
{{#if (lookup ../system.destin key 'checked')}}checked{{/if}}
{{#unless ../isEditable}}disabled{{/unless}}>
</div>
{{/each}}
</div>
<div class="track-level">
<label>{{localize "CELESTOPOL.Track.level"}}</label>
{{#if isEditMode}}
<input type="number" name="system.destin.lvl" value="{{system.destin.lvl}}" min="0" max="8">
{{else}}
<span>{{system.destin.lvl}}</span>
{{/if}}
</div>
</section>
{{!-- Spleen --}}
<section class="track-section">
<div class="track-header">
<span class="track-title">{{localize "CELESTOPOL.Track.spleen"}}</span>
</div>
<div class="track-boxes spleen-boxes">
{{#each (array "s1" "s2" "s3" "s4" "s5" "s6" "s7" "s8") as |key|}}
<div class="track-box spleen {{#if (lookup ../system.spleen key 'checked')}}checked{{/if}}">
<input type="checkbox" name="system.spleen.{{key}}.checked"
{{#if (lookup ../system.spleen key 'checked')}}checked{{/if}}
{{#unless ../isEditable}}disabled{{/unless}}>
</div>
{{/each}}
</div>
<div class="track-level">
<label>{{localize "CELESTOPOL.Track.level"}}</label>
{{#if isEditMode}}
<input type="number" name="system.spleen.lvl" value="{{system.spleen.lvl}}" min="0" max="8">
{{else}}
<span>{{system.spleen.lvl}}</span>
{{/if}}
</div>
</section>
</div>

View File

@@ -0,0 +1,102 @@
<div class="tab competences" data-group="sheet" data-tab="competences">
{{!-- Grille des 4 stats × 4 compétences --}}
<div class="stats-grid">
{{#each stats as |stat statId|}}
<div class="stat-block">
<div class="stat-header">
<span class="stat-name">{{localize stat.label}}</span>
<div class="stat-res">
<label>{{localize "CELESTOPOL.Stat.res"}}</label>
{{#if ../isEditMode}}
<input type="number" name="system.stats.{{statId}}.res" value="{{lookup ../system.stats statId 'res'}}" min="0" max="8" class="stat-res-input">
{{else}}
<span class="stat-res-value">{{lookup ../system.stats statId 'res'}}</span>
{{/if}}
</div>
</div>
<div class="skills-list">
{{#each (lookup ../skills statId) as |skill skillId|}}
<div class="skill-row {{#unless ../isEditMode}}rollable{{/unless}}"
data-stat-id="{{statId}}" data-skill-id="{{skillId}}"
title="{{#unless ../isEditMode}}{{localize 'CELESTOPOL.Roll.clickToRoll'}}{{/unless}}">
<span class="skill-name">{{localize skill.label}}</span>
{{#if ../isEditMode}}
<input type="number" name="system.stats.{{statId}}.{{skillId}}.value"
value="{{lookup (lookup ../system.stats statId) skillId 'value'}}"
min="0" max="8" class="skill-value-input">
{{else}}
<span class="skill-value">{{lookup (lookup ../system.stats statId) skillId 'value'}}</span>
{{/if}}
</div>
{{/each}}
</div>
</div>
{{/each}}
</div>
{{!-- Items : Anomalies, Aspects, Attributs --}}
<div class="items-section">
{{!-- Anomalies --}}
<div class="items-group">
<div class="items-header">
<span>{{localize "CELESTOPOL.Item.anomalies"}}</span>
{{#if isEditMode}}
<a data-action="createAnomaly" title="{{localize 'CELESTOPOL.Item.newAnomaly'}}"><i class="fas fa-plus"></i></a>
{{/if}}
</div>
{{#each anomalies as |item|}}
<div class="item-row" data-item-id="{{item.id}}" data-item-uuid="{{item.uuid}}" data-drag="true">
<img src="{{item.img}}" class="item-icon" alt="{{item.name}}">
<span class="item-name">{{item.name}}</span>
<span class="item-value">{{item.system.value}}</span>
<div class="item-controls">
<a data-action="edit" data-item-uuid="{{item.uuid}}"><i class="fas fa-edit"></i></a>
{{#if ../isEditMode}}<a data-action="delete" data-item-uuid="{{item.uuid}}"><i class="fas fa-trash"></i></a>{{/if}}
</div>
</div>
{{/each}}
</div>
{{!-- Aspects --}}
<div class="items-group">
<div class="items-header">
<span>{{localize "CELESTOPOL.Item.aspects"}}</span>
{{#if isEditMode}}
<a data-action="createAspect" title="{{localize 'CELESTOPOL.Item.newAspect'}}"><i class="fas fa-plus"></i></a>
{{/if}}
</div>
{{#each aspects as |item|}}
<div class="item-row" data-item-id="{{item.id}}" data-item-uuid="{{item.uuid}}" data-drag="true">
<img src="{{item.img}}" class="item-icon" alt="{{item.name}}">
<span class="item-name">{{item.name}}</span>
<span class="item-value">{{item.system.value}}</span>
<div class="item-controls">
<a data-action="edit" data-item-uuid="{{item.uuid}}"><i class="fas fa-edit"></i></a>
{{#if ../isEditMode}}<a data-action="delete" data-item-uuid="{{item.uuid}}"><i class="fas fa-trash"></i></a>{{/if}}
</div>
</div>
{{/each}}
</div>
{{!-- Attributs --}}
<div class="items-group">
<div class="items-header">
<span>{{localize "CELESTOPOL.Item.attributes"}}</span>
{{#if isEditMode}}
<a data-action="createAttribute" title="{{localize 'CELESTOPOL.Item.newAttribute'}}"><i class="fas fa-plus"></i></a>
{{/if}}
</div>
{{#each attributes as |item|}}
<div class="item-row" data-item-id="{{item.id}}" data-item-uuid="{{item.uuid}}" data-drag="true">
<img src="{{item.img}}" class="item-icon" alt="{{item.name}}">
<span class="item-name">{{item.name}}</span>
<span class="item-value">{{item.system.value}}</span>
<div class="item-controls">
<a data-action="edit" data-item-uuid="{{item.uuid}}"><i class="fas fa-edit"></i></a>
{{#if ../isEditMode}}<a data-action="delete" data-item-uuid="{{item.uuid}}"><i class="fas fa-trash"></i></a>{{/if}}
</div>
</div>
{{/each}}
</div>
</div>
</div>

View File

@@ -0,0 +1,63 @@
<div class="tab factions" data-group="sheet" data-tab="factions">
<table class="factions-table">
<thead>
<tr>
<th>{{localize "CELESTOPOL.Faction.label"}}</th>
<th>{{localize "CELESTOPOL.Faction.score"}}</th>
</tr>
</thead>
<tbody>
{{#each factions as |faction factionId|}}
<tr class="faction-row">
<td class="faction-name">{{localize faction.label}}</td>
<td class="faction-value">
{{#if ../isEditMode}}
<input type="number" name="system.factions.{{factionId}}.value"
value="{{lookup ../system.factions factionId 'value'}}" min="0">
{{else}}
<span>{{lookup ../system.factions factionId 'value'}}</span>
{{/if}}
</td>
</tr>
{{/each}}
{{!-- Factions personnalisées --}}
<tr class="faction-row custom">
<td>
{{#if isEditMode}}
<input type="text" name="system.factions.perso1.label"
value="{{system.factions.perso1.label}}"
placeholder="{{localize 'CELESTOPOL.Faction.custom'}}">
{{else}}
<span>{{#if system.factions.perso1.label}}{{system.factions.perso1.label}}{{else}}{{/if}}</span>
{{/if}}
</td>
<td>
{{#if isEditMode}}
<input type="number" name="system.factions.perso1.value" value="{{system.factions.perso1.value}}" min="0">
{{else}}
<span>{{system.factions.perso1.value}}</span>
{{/if}}
</td>
</tr>
<tr class="faction-row custom">
<td>
{{#if isEditMode}}
<input type="text" name="system.factions.perso2.label"
value="{{system.factions.perso2.label}}"
placeholder="{{localize 'CELESTOPOL.Faction.custom'}}">
{{else}}
<span>{{#if system.factions.perso2.label}}{{system.factions.perso2.label}}{{else}}{{/if}}</span>
{{/if}}
</td>
<td>
{{#if isEditMode}}
<input type="number" name="system.factions.perso2.value" value="{{system.factions.perso2.value}}" min="0">
{{else}}
<span>{{system.factions.perso2.value}}</span>
{{/if}}
</td>
</tr>
</tbody>
</table>
</div>

View File

@@ -0,0 +1,62 @@
<div class="character-main sheet-part">
<header class="sheet-header">
<div class="portrait" data-action="editImage" data-edit="img">
<img src="{{actor.img}}" alt="{{actor.name}}" class="actor-portrait {{#unless isEditMode}}click-to-edit{{/unless}}">
</div>
<div class="header-fields">
<div class="charname">
{{#if isEditMode}}
<input type="text" name="name" value="{{actor.name}}" placeholder="{{localize 'CELESTOPOL.Actor.name'}}">
{{else}}
<h1 class="actor-name">{{actor.name}}</h1>
{{/if}}
</div>
<div class="concept-row">
{{#if isEditMode}}
<input type="text" name="system.concept" value="{{system.concept}}" placeholder="{{localize 'CELESTOPOL.Actor.concept'}}">
{{else}}
<span class="concept-display">{{system.concept}}</span>
{{/if}}
</div>
<div class="header-stats-row">
<div class="header-stat initiative">
<label>{{localize "CELESTOPOL.Actor.initiative"}}</label>
<span class="stat-value">{{system.initiative}}</span>
</div>
<div class="header-stat anomaly">
<label>{{localize "CELESTOPOL.Actor.anomaly"}}</label>
{{#if isEditMode}}
<select name="system.anomaly.type">
{{selectOptions systemFields.anomaly.fields.type.choices selected=system.anomaly.type localize=true}}
</select>
<input type="number" name="system.anomaly.value" value="{{system.anomaly.value}}" min="0" max="8" class="anomaly-value">
{{else}}
<span>{{localize system.anomaly.type}} {{#if system.anomaly.value}}({{system.anomaly.value}}){{/if}}</span>
{{/if}}
</div>
</div>
{{!-- Attributs personnage (Entregent, Fortune, Rêve, Vision) --}}
<div class="perso-attributs">
{{#each system.attributs as |attr key|}}
<div class="perso-attr">
<label>{{localize (concat "CELESTOPOL.Attribut." key)}}</label>
{{#if ../isEditMode}}
<input type="number" name="system.attributs.{{key}}.value" value="{{attr.value}}" min="0" class="attr-val">
<span>/</span>
<input type="number" name="system.attributs.{{key}}.max" value="{{attr.max}}" min="0" class="attr-max">
{{else}}
<span class="attr-display">{{attr.value}} / {{attr.max}}</span>
{{/if}}
</div>
{{/each}}
</div>
</div>
<div class="header-buttons">
<a class="toggle-sheet" data-action="toggleSheet" title="{{#if isEditMode}}{{localize 'CELESTOPOL.Sheet.playMode'}}{{else}}{{localize 'CELESTOPOL.Sheet.editMode'}}{{/if}}">
<i class="fas {{#if isEditMode}}fa-eye{{else}}fa-edit{{/if}}"></i>
</a>
</div>
</header>
</div>

View File

@@ -0,0 +1,49 @@
<div class="celestopol chat-roll {{resultClass}}">
<div class="roll-header">
{{#if actorImg}}
<img src="{{actorImg}}" class="actor-img" alt="{{actorName}}">
{{/if}}
<div class="roll-info">
<span class="actor-name">{{actorName}}</span>
<span class="skill-info">{{statLabel}} {{skillLabel}}</span>
</div>
</div>
<div class="roll-details">
<div class="dice-results">
{{#each diceResults as |die|}}
<span class="die d6">{{die}}</span>
{{/each}}
</div>
{{#if moonBonus}}
<div class="bonus-line">
<span class="bonus-label">{{localize "CELESTOPOL.Roll.moonBonus"}} ({{moonPhaseLabel}}) :</span>
<span class="bonus-value">+{{moonBonus}}</span>
</div>
{{/if}}
{{#if modifier}}
<div class="bonus-line">
<span class="bonus-label">{{localize "CELESTOPOL.Roll.modifier"}} :</span>
<span class="bonus-value">{{#if (gt modifier 0)}}+{{/if}}{{modifier}}</span>
</div>
{{/if}}
<div class="roll-total-line">
<span class="total-label">{{localize "CELESTOPOL.Roll.total"}}</span>
<span class="total-value">{{total}}</span>
<span class="vs-difficulty">vs {{difficulty}}</span>
</div>
</div>
<div class="roll-result-banner {{resultClass}}">
{{#if success}}
<span class="result-label success">{{localize "CELESTOPOL.Roll.success"}}</span>
{{#if criticalSuccess}}<span class="critical">{{localize "CELESTOPOL.Roll.criticalSuccess"}}</span>{{/if}}
{{else}}
<span class="result-label failure">{{localize "CELESTOPOL.Roll.failure"}}</span>
{{#if criticalFailure}}<span class="critical">{{localize "CELESTOPOL.Roll.criticalFailure"}}</span>{{/if}}
{{/if}}
</div>
</div>

56
templates/equipment.hbs Normal file
View File

@@ -0,0 +1,56 @@
<div class="item-sheet equipment">
<header class="item-header">
<div class="item-portrait" data-action="editImage" data-edit="img">
<img src="{{item.img}}" alt="{{item.name}}">
</div>
<div class="item-header-fields">
<input type="text" name="name" value="{{item.name}}" {{#unless isEditable}}disabled{{/unless}}>
<div class="item-meta">
<select name="system.subtype" {{#unless isEditable}}disabled{{/unless}}>
{{selectOptions systemFields.subtype.choices selected=system.subtype localize=true}}
</select>
<div class="item-qty">
<label>{{localize "CELESTOPOL.Item.quantity"}}</label>
<input type="number" name="system.quantity" value="{{system.quantity}}" min="0"
{{#unless isEditable}}disabled{{/unless}}>
</div>
</div>
</div>
</header>
<div class="form-grid equipment-stats">
<div class="form-group">
<label>{{localize "CELESTOPOL.Item.damage"}}</label>
<input type="text" name="system.damage" value="{{system.damage}}" {{#unless isEditable}}disabled{{/unless}}>
</div>
<div class="form-group">
<label>{{localize "CELESTOPOL.Item.range"}}</label>
<input type="text" name="system.range" value="{{system.range}}" {{#unless isEditable}}disabled{{/unless}}>
</div>
<div class="form-group">
<label>{{localize "CELESTOPOL.Item.protection"}}</label>
<input type="text" name="system.protection" value="{{system.protection}}" {{#unless isEditable}}disabled{{/unless}}>
</div>
<div class="form-group">
<label>{{localize "CELESTOPOL.Item.speed"}}</label>
<input type="text" name="system.speed" value="{{system.speed}}" {{#unless isEditable}}disabled{{/unless}}>
</div>
<div class="form-group">
<label>{{localize "CELESTOPOL.Item.crew"}}</label>
<input type="text" name="system.crew" value="{{system.crew}}" {{#unless isEditable}}disabled{{/unless}}>
</div>
<div class="form-group">
<label>{{localize "CELESTOPOL.Item.weight"}}</label>
<input type="number" name="system.weight" value="{{system.weight}}" min="0"
{{#unless isEditable}}disabled{{/unless}}>
</div>
</div>
<div class="form-group description-group">
{{editor system.description target="system.description" button=true editable=isEditable}}
</div>
<div class="form-group">
<label>{{localize "CELESTOPOL.Item.reference"}}</label>
<input type="text" name="system.reference" value="{{system.reference}}" {{#unless isEditable}}disabled{{/unless}}>
</div>
</div>

View File

@@ -0,0 +1,34 @@
<div class="tab blessures" data-group="sheet" data-tab="blessures">
<section class="track-section">
<div class="track-header">
<span class="track-title">{{localize "CELESTOPOL.Track.blessures"}}</span>
</div>
<div class="track-boxes">
{{#each (array "b1" "b2" "b3" "b4" "b5" "b6" "b7" "b8") as |key|}}
<div class="track-box {{#if (lookup ../system.blessures key 'checked')}}checked{{/if}}">
<input type="checkbox" name="system.blessures.{{key}}.checked"
{{#if (lookup ../system.blessures key 'checked')}}checked{{/if}}
{{#unless ../isEditable}}disabled{{/unless}}>
<label class="box-label">{{lookup ../system.blessures key 'malus'}}</label>
</div>
{{/each}}
</div>
<div class="track-level">
<label>{{localize "CELESTOPOL.Track.level"}}</label>
{{#if isEditMode}}
<input type="number" name="system.blessures.lvl" value="{{system.blessures.lvl}}" min="0" max="8">
{{else}}
<span>{{system.blessures.lvl}}</span>
{{/if}}
</div>
</section>
{{!-- Description --}}
<div class="description-section">
{{#if isEditMode}}
{{editor system.description target="system.description" button=true editable=isEditable}}
{{else}}
<div class="enriched-html">{{{enrichedDescription}}}</div>
{{/if}}
</div>
</div>

View File

@@ -0,0 +1,36 @@
<div class="tab competences" data-group="sheet" data-tab="competences">
<div class="stats-grid">
{{#each stats as |stat statId|}}
<div class="stat-block">
<div class="stat-header">
<span class="stat-name">{{localize stat.label}}</span>
<div class="stat-res">
<label>{{localize "CELESTOPOL.Stat.res"}}</label>
{{#if ../isEditMode}}
<input type="number" name="system.stats.{{statId}}.res"
value="{{lookup ../system.stats statId 'res'}}" min="0" max="8">
{{else}}
<span class="stat-res-value">{{lookup ../system.stats statId 'res'}}</span>
<span class="stat-actuel">({{lookup ../system.stats statId 'actuel'}})</span>
{{/if}}
</div>
</div>
<div class="skills-list">
{{#each (lookup ../skills statId) as |skill skillId|}}
<div class="skill-row {{#unless ../isEditMode}}rollable{{/unless}}"
data-stat-id="{{statId}}" data-skill-id="{{skillId}}">
<span class="skill-name">{{localize skill.label}}</span>
{{#if ../isEditMode}}
<input type="number" name="system.stats.{{statId}}.{{skillId}}.value"
value="{{lookup (lookup ../system.stats statId) skillId 'value'}}"
min="0" max="8" class="skill-value-input">
{{else}}
<span class="skill-value">{{lookup (lookup ../system.stats statId) skillId 'value'}}</span>
{{/if}}
</div>
{{/each}}
</div>
</div>
{{/each}}
</div>
</div>

46
templates/npc-main.hbs Normal file
View File

@@ -0,0 +1,46 @@
<div class="npc-main sheet-part">
<header class="sheet-header">
<div class="portrait" data-action="editImage" data-edit="img">
<img src="{{actor.img}}" alt="{{actor.name}}" class="actor-portrait">
</div>
<div class="header-fields">
<div class="charname">
{{#if isEditMode}}
<input type="text" name="name" value="{{actor.name}}">
{{else}}
<h1 class="actor-name">{{actor.name}}</h1>
{{/if}}
</div>
<div class="concept-row">
{{#if isEditMode}}
<input type="text" name="system.concept" value="{{system.concept}}"
placeholder="{{localize 'CELESTOPOL.Actor.concept'}}">
{{else}}
<span class="concept-display">{{system.concept}}</span>
{{/if}}
</div>
<div class="header-stats-row">
<div class="header-stat">
<label>{{localize "CELESTOPOL.Actor.initiative"}}</label>
<span>{{system.initiative}}</span>
</div>
<div class="header-stat">
<label>{{localize "CELESTOPOL.Actor.anomaly"}}</label>
{{#if isEditMode}}
<select name="system.anomaly.type">
{{selectOptions systemFields.anomaly.fields.type.choices selected=system.anomaly.type localize=true}}
</select>
<input type="number" name="system.anomaly.value" value="{{system.anomaly.value}}" min="0" max="8" class="small-input">
{{else}}
<span>{{localize system.anomaly.type}} {{#if system.anomaly.value}}({{system.anomaly.value}}){{/if}}</span>
{{/if}}
</div>
</div>
</div>
<div class="header-buttons">
<a data-action="toggleSheet">
<i class="fas {{#if isEditMode}}fa-eye{{else}}fa-edit{{/if}}"></i>
</a>
</div>
</header>
</div>

View File

@@ -0,0 +1,26 @@
{{!-- Template partagé pour les scores bonus/malus d'un item par compétence --}}
<div class="scores-section">
<div class="scores-header">
<span>{{localize "CELESTOPOL.Item.scores"}}</span>
</div>
<div class="scores-grid">
{{#each skills as |statSkills statId|}}
<div class="scores-stat-col">
<div class="scores-stat-name">{{localize (concat "CELESTOPOL.Stat." statId)}}</div>
{{#each statSkills as |skill skillId|}}
<div class="score-row">
<span class="score-skill-name">{{localize skill.label}}</span>
{{#if ../isEditable}}
<input type="number" name="system.scores.{{statId}}.{{skillId}}.bonus"
value="{{lookup (lookup (lookup ../system.scores statId) skillId) 'bonus'}}"
min="0" class="score-bonus" title="+bonus">
<input type="number" name="system.scores.{{statId}}.{{skillId}}.malus"
value="{{lookup (lookup (lookup ../system.scores statId) skillId) 'malus'}}"
min="0" class="score-malus" title="-malus">
{{/if}}
</div>
{{/each}}
</div>
{{/each}}
</div>
</div>

40
templates/roll-dialog.hbs Normal file
View File

@@ -0,0 +1,40 @@
<form class="roll-dialog celestopol">
<div class="roll-title">
<span class="stat-label">{{statLabel}}</span>
<span class="separator"></span>
<span class="skill-label">{{skillLabel}}</span>
</div>
<div class="form-group">
<label for="moonPhase">{{localize "CELESTOPOL.Roll.moonPhase"}}</label>
<select id="moonPhase" name="moonPhase">
{{#each moonPhases as |phase key|}}
<option value="{{key}}" {{#if (eq key ../currentMoonPhase)}}selected{{/if}}>
{{localize phase.label}} {{#if phase.bonus}}(+{{phase.bonus}}){{else}}(+0){{/if}}
</option>
{{/each}}
</select>
</div>
<div class="form-group">
<label for="difficulty">{{localize "CELESTOPOL.Roll.difficulty"}}</label>
<select id="difficulty" name="difficulty">
{{#each difficulties as |diff key|}}
<option value="{{diff.threshold}}" {{#if (eq diff.threshold ../currentDifficulty)}}selected{{/if}}>
{{localize diff.label}} ({{diff.threshold}})
</option>
{{/each}}
</select>
</div>
<div class="form-group">
<label for="modifier">{{localize "CELESTOPOL.Roll.modifier"}}</label>
<input type="number" id="modifier" name="modifier" value="0">
</div>
<div class="dice-preview">
<span>{{localize "CELESTOPOL.Roll.nbDice"}}</span>
<span class="nb-dice">{{nbDice}}</span>
<span>d6</span>
</div>
</form>