import type { Ability, Alignment, ArmorConfig, ArmorState, Character, CharacterConfig, CompiledCharacter, DamageType, EnchantementConfig, FeatureEquipment, FeatureID, FeatureItem, FeatureList, FeatureValue, ItemConfig, ItemState, Level, MainStat, MundaneState, Resistance, SpellConfig, SpellElement, SpellType, TrainingLevel, TreeStructure, WeaponConfig, WeaponState, WeaponType, WondrousState } from "~/types/character"; import { z } from "zod/v4"; import characterConfig from '#shared/character-config.json'; import proses, { preview } from "#shared/proses"; import { button, checkbox, floater, foldable, input, loading, multiselect, numberpicker, select, tabgroup, Toaster, toggle } from "#shared/components"; import { div, dom, icon, span, text } from "#shared/dom"; import { followermenu, fullblocker, tooltip } from "#shared/floating"; import { clamp } from "#shared/general"; import markdown from "#shared/markdown"; import { getText } from "#shared/i18n"; import type { User } from "~/types/auth"; import { MarkdownEditor } from "#shared/editor"; import { Socket } from "#shared/websocket"; import { raw, reactive, reactivity } from '#shared/reactive'; const config = characterConfig as CharacterConfig; export const MAIN_STATS = ["strength","dexterity","constitution","intelligence","curiosity","charisma","psyche"] as const; export const ABILITIES = ["athletics","acrobatics","intimidation","sleightofhand","stealth","survival","investigation","history","religion","arcana","understanding","perception","performance","medecine","persuasion","animalhandling","deception"] as const; export const LEVELS = [1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20] as const; export const TRAINING_LEVELS = [0,1,2,3,4,5,6,7,8,9,10,11,12] as const; export const SPELL_TYPES = ["precision","knowledge","instinct"] as const; export const CATEGORIES = ["action","reaction","freeaction","misc"] as const; export const SPELL_ELEMENTS = ["fire","ice","thunder","earth","arcana","air","nature","light","psyche"] as const; export const ALIGNMENTS = ['loyal_good', 'neutral_good', 'chaotic_good', 'loyal_neutral', 'neutral_neutral', 'chaotic_neutral', 'loyal_evil', 'neutral_evil', 'chaotic_evil'] as const; export const RESISTANCES = ['stun','bleed','poison','fear','influence','charm','possesion','precision','knowledge','instinct'] as const; export const DAMAGE_TYPES = ['slashing', 'piercing', 'bludgening', 'magic', 'fire', 'thunder', 'cold'] as const; export const WEAPON_TYPES = ["light", "shield", "heavy", "classic", "throw", "natural", "twohanded", "finesse", "reach", "projectile", "improvised"] as const; export const defaultCharacter: Character = { id: -1, name: "", people: undefined, level: 1, training: MAIN_STATS.reduce((p, v) => { p[v] = { 0: 0 }; return p; }, {} as Record>>), leveling: { 1: 0 }, abilities: {}, choices: {}, variables: { health: 0, mana: 0, spells: [], items: [], exhaustion: 0, sickness: [], poisons: [], money: 0, transformed: false, }, owner: -1, visibility: "private", }; const defaultCompiledCharacter = (character: Character) => ({ id: character.id, owner: character.owner, username: character.username, name: character.name, health: 0, mana: 0, race: character.people!, modifier: MAIN_STATS.reduce((p, v) => { p[v] = 0; return p; }, {} as Record), level: character.level, variables: character.variables, exhaust: 0, itempower: 0, features: { action: [], reaction: [], freeaction: [], passive: [], }, abilities: { athletics: 0, acrobatics: 0, intimidation: 0, sleightofhand: 0, stealth: 0, survival: 0, investigation: 0, history: 0, religion: 0, arcana: 0, understanding: 0, perception: 0, performance: 0, medecine: 0, persuasion: 0, animalhandling: 0, deception: 0 }, spellslots: 0, artslots: 0, spellranks: { instinct: 0 as 0 | 1 | 2 | 3, knowledge: 0 as 0 | 1 | 2 | 3, precision: 0 as 0 | 1 | 2 | 3, arts: 0 as 0 | 1 | 2 | 3, }, speed: false as number | false, defense: { hardcap: Infinity, static: 6, activeparry: 0, activedodge: 0, passiveparry: 0, passivedodge: 0, }, mastery: [], bonus: { abilities: {}, defense: {}, spells: { elements: {}, type: {}, rank: {}, }, weapon: {}, resistance: {}, }, initiative: 0, capacity: 0, lists: { action: [], freeaction: [], reaction: [], passive: [], spells: [], sickness: [], }, aspect: { id: character.aspect ?? "", duration: 0, amount: 0, shift_bonus: 0, tier: 0, }, advantages: [], craft: { bonus: 0, level: 0, prototype: false, }, notes: Object.assign({ public: '', private: '' }, character.notes), } as CompiledCharacter); export const mainStatTexts: Record = { "strength": "Force", "dexterity": "Dextérité", "constitution": "Constitution", "intelligence": "Intelligence", "curiosity": "Curiosité", "charisma": "Charisme", "psyche": "Psyché", }; export const mainStatShortTexts: Record = { "strength": "FOR", "dexterity": "DEX", "constitution": "CON", "intelligence": "INT", "curiosity": "CUR", "charisma": "CHA", "psyche": "PSY", }; export const elementTexts: Record = { fire: { class: 'text-light-red dark:text-dark-red border-light-red dark:border-dark-red bg-light-red dark:bg-dark-red', text: 'Feu' }, ice: { class: 'text-light-blue dark:text-dark-blue border-light-blue dark:border-dark-blue bg-light-blue dark:bg-dark-blue', text: 'Glace' }, thunder: { class: 'text-light-yellow dark:text-dark-yellow border-light-yellow dark:border-dark-yellow bg-light-yellow dark:bg-dark-yellow', text: 'Foudre' }, earth: { class: 'text-light-orange dark:text-dark-orange border-light-orange dark:border-dark-orange bg-light-orange dark:bg-dark-orange', text: 'Terre' }, arcana: { class: 'text-light-indigo dark:text-dark-indigo border-light-indigo dark:border-dark-indigo bg-light-indigo dark:bg-dark-indigo', text: 'Arcane' }, air: { class: 'text-light-lime dark:text-dark-lime border-light-lime dark:border-dark-lime bg-light-lime dark:bg-dark-lime', text: 'Air' }, nature: { class: 'text-light-green dark:text-dark-green border-light-green dark:border-dark-green bg-light-green dark:bg-dark-green', text: 'Nature' }, light: { class: 'text-light-yellow dark:text-dark-yellow border-light-yellow dark:border-dark-yellow bg-light-yellow dark:bg-dark-yellow', text: 'Lumière' }, psyche: { class: 'text-light-purple dark:text-dark-purple border-light-purple dark:border-dark-purple bg-light-purple dark:bg-dark-purple', text: 'Psy' }, }; export const elementDom = (element: SpellElement) => dom("span", { class: [`border !border-opacity-50 rounded-full !bg-opacity-20 px-1 py-px text-sm font-semibold`, elementTexts[element].class], text: elementTexts[element].text }); export const alignmentTexts: Record = { 'loyal_good': 'Loyal bon', 'neutral_good': 'Neutre bon', 'chaotic_good': 'Chaotique bon', 'loyal_neutral': 'Loyal neutre', 'neutral_neutral': 'Neutre', 'chaotic_neutral': 'Chaotique neutre', 'loyal_evil': 'Loyal mauvais', 'neutral_evil': 'Neutre mauvais', 'chaotic_evil': 'Chaotique mauvais', }; export const spellTypeTexts: Record = { "instinct": "Instinct", "knowledge": "Savoir", "precision": "Précision" }; export const abilityTexts: Record = { "athletics": "Athlétisme", "acrobatics": "Acrobatisme", "intimidation": "Intimidation", "sleightofhand": "Doigté", "stealth": "Discrétion", "survival": "Survie", "investigation": "Enquête", "history": "Histoire", "religion": "Religion", "arcana": "Arcanes", "understanding": "Compréhension", "perception": "Perception", "performance": "Représentation", "medecine": "Médicine", "persuasion": "Persuasion", "animalhandling": "Dressage", "deception": "Mensonge" }; export const resistanceTexts: Record = { 'stun': 'Hébètement', 'bleed': 'Saignement', 'poison': 'Empoisonement', 'fear': 'Peur', 'influence': 'Influence', 'charm': 'Charme', 'possesion': 'Possession', 'precision': 'Sorts de précision', 'knowledge': 'Sorts de savoir', 'instinct': 'Sorts d\'instinct', }; export const damageTypeTexts: Record = { 'bludgening': 'Contondant', 'cold': 'Froid', 'fire': 'Feu', 'magic': 'Magique', 'piercing': 'Perçant', 'slashing': 'Tranchant', 'thunder': 'Foudre', }; export const CharacterNotesValidation = z.object({ public: z.string().optional(), private: z.string().optional(), }); export const ItemStateValidation = z.object({ id: z.string(), amount: z.number().min(1), enchantments: z.array(z.string()).optional(), charges: z.number().optional(), equipped: z.boolean().optional(), state: z.any().optional(), }) export const CharacterVariablesValidation = z.object({ health: z.number(), mana: z.number(), exhaustion: z.number(), sickness: z.array(z.object({ id: z.string(), state: z.number().min(1).max(7).or(z.literal(true)), })), poisons: z.array(z.object({ id: z.string(), state: z.number().min(1).max(7).or(z.literal(true)), })), spells: z.array(z.string()), items: z.array(ItemStateValidation), money: z.number(), transformed: z.boolean(), }); export const CharacterValidation = z.object({ id: z.number(), name: z.string(), people: z.string().nullable(), level: z.number().min(1).max(20), aspect: z.string(), notes: CharacterNotesValidation, training: z.record(z.enum(MAIN_STATS), z.record(z.enum(TRAINING_LEVELS.map(String)), z.number().optional())), leveling: z.record(z.enum(LEVELS.map(String)), z.number().optional()), abilities: z.record(z.enum(ABILITIES), z.number().optional()), choices: z.record(z.string(), z.array(z.number())), variables: CharacterVariablesValidation, owner: z.number(), username: z.string().optional(), visibility: z.enum(["public", "private"]), thumbnail: z.any(), }); type Property = { value: number | string | false, id: string, operation: "set" | "add" | "min" }; export type PropertySum = { list: Array, min: number, value: number, _dirty: boolean }; type TreeState = { progression: Array<{ id: string, priority: number, path?: string }>, validated: string[], _dirty: boolean }; export class CharacterCompiler { private _dirty: boolean = true; protected _character!: Character; protected _result!: CompiledCharacter; protected _buffer: Record = { 'modifier/strength': { value: 0, _dirty: false, min: -Infinity, list: [] }, 'modifier/dexterity': { value: 0, _dirty: false, min: -Infinity, list: [] }, 'modifier/constitution': { value: 0, _dirty: false, min: -Infinity, list: [] }, 'modifier/intelligence': { value: 0, _dirty: false, min: -Infinity, list: [] }, 'modifier/curiosity': { value: 0, _dirty: false, min: -Infinity, list: [] }, 'modifier/charisma': { value: 0, _dirty: false, min: -Infinity, list: [] }, 'modifier/psyche': { value: 0, _dirty: false, min: -Infinity, list: [] }, }; protected _trees: Record = {}; private _variableDebounce: NodeJS.Timeout = setTimeout(() => {}); constructor(character: Character) { this.character = character; } set character(value: Character) { this._character = reactive(value); this._result = reactive(defaultCompiledCharacter(value)); this._buffer = { 'modifier/strength': { value: 0, _dirty: false, min: -Infinity, list: [] }, 'modifier/dexterity': { value: 0, _dirty: false, min: -Infinity, list: [] }, 'modifier/constitution': { value: 0, _dirty: false, min: -Infinity, list: [] }, 'modifier/intelligence': { value: 0, _dirty: false, min: -Infinity, list: [] }, 'modifier/curiosity': { value: 0, _dirty: false, min: -Infinity, list: [] }, 'modifier/charisma': { value: 0, _dirty: false, min: -Infinity, list: [] }, 'modifier/psyche': { value: 0, _dirty: false, min: -Infinity, list: [] }, }; this._dirty = true; if(value.people !== undefined) { Object.entries(value.leveling).forEach(e => this.add(config.peoples[value.people!]!.options[parseInt(e[0]) as Level][e[1]]!)); MAIN_STATS.forEach(stat => Object.entries(value.training[stat]).forEach(option => this.add(config.training[stat][parseInt(option[0]) as TrainingLevel][option[1]]))); Object.entries(value.abilities).forEach(e => this._buffer[`abilities/${e[0]}`] = { value: 0, _dirty: true, min: -Infinity, list: [{ id: '', operation: 'add', value: e[1] }] }); reactivity(() => value.variables.transformed, (v) => { if(value.aspect && config.aspects[value.aspect]) { const aspect = config.aspects[value.aspect]!; if(v) { aspect.options.forEach((e) => this.apply(e)); this._buffer[`modifier/${aspect.stat}`]!.list.push({ id: 'aspect', operation: 'add', value: 1 }); this._buffer[`modifier/${aspect.stat}`]!._dirty = true; } else { aspect.options.forEach((e) => this.undo(e)); const idx = this._buffer[`modifier/${aspect.stat}`]!.list.findIndex(e => e.id === 'aspect'); idx !== -1 && this._buffer[`modifier/${aspect.stat}`]!.list.splice(idx, 1); this._buffer[`modifier/${aspect.stat}`]!._dirty = true; } this.compile([`modifier/${aspect.stat}`]); this.saveVariables(); } }) } } get character(): Character { return this._character; } get compiled(): CompiledCharacter { if(this._dirty) { Object.keys(this._trees).forEach(tree => { if(!this._trees[tree]!._dirty || !config.trees[tree]) return; this._trees[tree]!.progression.sort((a, b) => a.priority - b.priority); const validated = validateTree(config.trees[tree]!, this._trees[tree]!.progression); this._trees[tree]!.validated.forEach(this.add, this); validated.forEach(this.add, this); this._trees[tree]!.validated = validated; }); this.compile(Object.keys(this._buffer)); this._dirty = false; } return this._result; } get values(): Record { if(this._dirty) { Object.keys(this._trees).forEach(tree => { if(!this._trees[tree]!._dirty || !config.trees[tree]) return; this._trees[tree]!.progression.sort((a, b) => a.priority - b.priority); const validated = validateTree(config.trees[tree]!, this._trees[tree]!.progression); this._trees[tree]!.validated.forEach(this.add, this); validated.forEach(this.add, this); this._trees[tree]!.validated = validated; }); this.compile(Object.keys(this._buffer)); this._dirty = false; } return Object.keys(this._buffer).reduce((p, v) => { p[v] = this._buffer[v]!.value; return p; }, {} as Record); } get armor() { const armor = this._character.variables.items.find(e => e.equipped && config.items[e.id]?.category === 'armor'); return armor ? { max: (config.items[armor.id] as ArmorConfig).health, current: (config.items[armor.id] as ArmorConfig).health - ((armor.state as ArmorState)?.loss ?? 0), flat: (config.items[armor.id] as ArmorConfig).absorb.static + ((armor.state as ArmorState)?.absorb.flat ?? 0), percent: (config.items[armor.id] as ArmorConfig).absorb.percent + ((armor.state as ArmorState)?.absorb.percent ?? 0) } : undefined; } get weight() { return this._character.variables.items.reduce((p, v) => p + (config.items[v.id]?.weight ?? 0) * v.amount, 0); } get power() { return this._character.variables.items.filter(e => config.items[e.id]?.equippable && e.equipped).reduce((p, v) => p + ((config.items[v.id]?.powercost ?? 0) + (v.enchantments?.reduce((_p, _v) => (config.enchantments[_v]?.power ?? 0) + _p, 0) ?? 0) * v.amount), 0); } enchant(item: ItemState) { if(item.equipped) item.enchantments?.forEach(e => config.enchantments[e]?.effect.filter(e => e.category !== 'value' || !e.property.startsWith('item')).forEach(_e => this.apply(_e as FeatureValue | FeatureList))); else item.enchantments?.forEach(e => config.enchantments[e]?.effect.filter(e => e.category !== 'value' || !e.property.startsWith('item')).forEach(_e => this.undo(_e as FeatureValue | FeatureList))); item.buffer ??= {} as Record; Object.keys(item.buffer).forEach(e => item.buffer![e]!.list = []); item.enchantments?.forEach(e => (config.enchantments[e]?.effect.filter(e => e.category === 'value' && e.property.startsWith('item')) as FeatureEquipment[]).forEach(feature => { const property = feature.property.substring(5); item.buffer![property] ??= { list: [], value: 0, _dirty: true, min: -Infinity }; item.buffer![property]!.list.push({ operation: feature.operation, id: feature.id, value: feature.value }); item.buffer![property]!.min = -Infinity; item.buffer![property]!._dirty = true; })); Object.keys(item.buffer).forEach(e => setProperty(item.state, e, 0, true)); this.compile(Object.keys(item.buffer), item.buffer, item.state); this.saveVariables(); } saveVariables() { clearTimeout(this._variableDebounce); this._variableDebounce = setTimeout(() => { useRequestFetch()(`/api/character/${this.character.id}/variables`, { method: 'POST', body: raw(this._character.variables), }).then(() => {}).catch(() => { Toaster.add({ type: 'error', content: 'Impossible de mettre à jour les données', duration: 5000, timer: true }); }) }, 2000); } saveNotes() { return useRequestFetch()(`/api/character/${this.character.id}/notes`, { method: 'POST', body: this._character.notes, }).then(() => {}).catch(() => { Toaster.add({ type: 'error', content: 'Impossible de mettre à jour les données', duration: 5000, timer: true }); }); } protected add(feature?: FeatureID) { if(!feature) return; config.features[feature]?.effect.forEach((effect) => this.apply(effect)); } protected remove(feature?: FeatureID) { if(!feature) return; config.features[feature]?.effect.forEach((effect) => this.undo(effect)); } protected apply(feature?: FeatureItem) { if(!feature) return; this._dirty = true; switch(feature.category) { case "list": if(feature.action === 'add' && !(feature.list === 'mastery' ? this._result.mastery : this._result.lists[feature.list]!).includes(feature.item)) (feature.list === 'mastery' ? this._result.mastery : this._result.lists[feature.list]!).push(feature.item); else if(feature.action === 'remove') (feature.list === 'mastery' ? this._result.mastery : this._result.lists[feature.list]!).splice((feature.list === 'mastery' ? this._result.mastery : this._result.lists[feature.list]!).findIndex((e: string) => e === feature.item), 1); return; case "value": this._buffer[feature.property] ??= { list: [], value: 0, _dirty: true, min: -Infinity }; this._buffer[feature.property]!.list.push({ operation: feature.operation, id: feature.id, value: feature.value }); this._buffer[feature.property]!.min = -Infinity; this._buffer[feature.property]!._dirty = true; if(feature.property.startsWith('modifier/')) Object.values(this._buffer).forEach(e => e._dirty = e.list.some(f => f.value === feature.property) ? true : e._dirty); return; case "choice": const choice = this._character.choices[feature.id]; if(choice) choice.forEach(e => feature.options[e]!.effects.forEach((effect) => this.apply(effect))); return; case "tree": if(!config.trees[feature.tree]) return; this._trees[feature.tree] ??= { progression: [], validated: [], _dirty: true }; this._trees[feature.tree]!.progression.push({ id: feature.id, priority: feature.priority ?? 1, path: feature.option }); this._trees[feature.tree]!._dirty = true; return; default: return; } } protected undo(feature?: FeatureItem) { if(!feature) return; this._dirty = true; switch(feature.category) { case "list": if(feature.action === 'remove' && !(feature.list === 'mastery' ? this._result.mastery : this._result.lists[feature.list]!).includes(feature.item)) (feature.list === 'mastery' ? this._result.mastery : this._result.lists[feature.list]!).push(feature.item); else if(feature.action === 'add') (feature.list === 'mastery' ? this._result.mastery : this._result.lists[feature.list]!).splice((feature.list === 'mastery' ? this._result.mastery : this._result.lists[feature.list]!).findIndex((e: string) => e === feature.item), 1) return; case "value": this._buffer[feature.property] ??= { list: [], value: 0, _dirty: true, min: -Infinity }; const listIdx = this._buffer[feature.property]!.list.findIndex(e => e.id === feature.id); listIdx !== -1 && this._buffer[feature.property]!.list.splice(listIdx, 1); this._buffer[feature.property]!.min = -Infinity; this._buffer[feature.property]!._dirty = true; if(feature.property.startsWith('modifier/')) Object.values(this._buffer).forEach(e => e._dirty = e.list.some(f => f.value === feature.property) ? true : e._dirty); return; case "choice": const choice = this._character.choices[feature.id]; if(choice) choice.forEach(e => feature.options[e]!.effects.forEach((effect) => this.undo(effect))); return; case "tree": if(!config.trees[feature.tree] || !this._trees[feature.tree]) return; const treeIdx = this._trees[feature.tree]!.progression.findIndex(e => e.id === feature.id); treeIdx !== -1 && this._trees[feature.tree]!.progression.splice(treeIdx, 1); this._trees[feature.tree]!._dirty = true; return; default: return; } } protected compile(queue: string[], _buffer: Record = this._buffer, _result: Record = this._result) { for(let i = 0; i < queue.length; i++) { if(queue[i] === undefined || queue[i] === "") continue; const property = queue[i]!; const buffer = _buffer[property]; if(buffer && buffer._dirty === true) { let sum = 0, stop = false; for(let j = 0; j < buffer.list.length; j++) { const item = buffer.list[j]; if(!item) continue; if(typeof item.value === 'string') // Add or set a modifier { const modifier = _buffer[item.value as string]!; if(modifier._dirty) { //Put it back in queue since its dependencies haven't been resolved yet //Also put the dependency itself in the queue to make sure it actually get resolves someday queue.push(item.value as string); queue.push(property); stop = true; break; } else { if(item.operation === 'add') sum += modifier.value; else if(item.operation === 'set') sum = modifier.value; else if(item.operation === 'min') buffer.min = modifier.value; } } else { if(item.operation === 'add') sum += item.value as number; else if(item.operation === 'set') sum = item.value as number; else if(item.operation === 'min') buffer.min = item.value as number; } } if(stop === true) continue; setProperty(_result, property, Math.max(sum, _buffer[property]!.min)); _buffer[property]!.value = Math.max(sum, _buffer[property]!.min); _buffer[property]!._dirty = false; } } } } export enum TreeFlag { REPEATING = 1 << 0, MULTIPLE = 1 << 1, HIDDEN = 1 << 2, }; function validateTree(structure: TreeStructure, progression: Array<{ path?: string }>): string[] { const validated = [] as string[]; const paths = new Map(), multiples: Record = {}; const hasFlag = (node: string, flag: number) => structure.nodes[node] && structure.nodes[node].flags && (structure.nodes[node].flags & flag) === flag; const addRequirement = (target: string, requirement: string) => { multiples[target] ??= []; multiples[target].push(requirement) }; //Precompute the requirements for the nodes with multiples inputs Object.values(structure.nodes).forEach(node => { if(Array.isArray(node.to)) node.to.forEach(to => { if(hasFlag(to, TreeFlag.MULTIPLE)) addRequirement(to, node.id) }); else if(typeof node.to === 'object') Object.values(node.to).forEach(to => { if(hasFlag(to, TreeFlag.MULTIPLE)) addRequirement(to, node.id) }); else if(node.to) if(hasFlag(node.to, TreeFlag.MULTIPLE)) addRequirement(node.to, node.id); }); const nextPath = (path: string | undefined, node: string): boolean => { if(!structure.nodes[node]) return false; else if(hasFlag(node, TreeFlag.MULTIPLE)) return false; else { paths.set(path, node); return true; } }; if(Array.isArray(structure.start)) structure.start.some(e => nextPath(undefined, e)); else if(typeof structure.start === 'object') Object.keys(structure.start).forEach(e => nextPath(e === "" ? undefined: e, (structure.start as Record)[e]!)); else if(structure.start) nextPath(undefined, structure.start); for(let i = 0; progression[i] && i < progression.length; i++) { const progress = progression[i]; let path: string | undefined, valid = false; if(paths.has(progress?.path)) path = progress?.path; else path = undefined; const next = paths.get(path); if(!next || !structure.nodes[next]) continue; const node = structure.nodes[next]; Object.keys(multiples).forEach(e => { multiples[e] = multiples[e]!.filter(node => node !== next); if(multiples[e].length === 0) { validated.push(e); delete multiples[e]; } }); validated.push(next); paths.delete(path); if(hasFlag(next, TreeFlag.REPEATING)) paths.set(path, next); else if(Array.isArray(node.to)) node.to.some(e => nextPath(path, e)); else if(typeof node.to === 'object') Object.keys(node.to).forEach(e => nextPath(e === "" ? undefined: e, (node.to as Record)[e]!)); else if(node.to) nextPath(path, node.to); } return validated; } function setProperty(root: any, path: string, value: T | ((old: T) => T), force: boolean = false) { const arr = path.split("/"); //Get the property path as an array const object = arr.length === 1 ? root : arr.slice(0, -1).reduce((p, v) => { p[v] ??= {}; return p[v]; }, root); //Get into the second to last property using the property path if(force || object.hasOwnProperty(arr.slice(-1)[0]!)) object[arr.slice(-1)[0]!] = typeof value === 'function' ? (value as (old: T) => T)(object[arr.slice(-1)[0]!]) : value; } export class CharacterBuilder extends CharacterCompiler { private _container: HTMLElement; private _content?: HTMLElement; private _stepsHeader: HTMLElement[] = []; private _steps: Array = []; private _stepContent: Array = []; private _currentStep: number = 0; private _helperText!: Text; private id?: string; constructor(container: HTMLElement, id?: string) { super(Object.assign({}, defaultCharacter)); this.id = id; this._container = container; if(id) { const load = div("flex justify-center items-center w-full h-full", [ loading('large') ]); container.replaceChildren(load); useRequestFetch()(`/api/character/${id}`).then(character => { if(character) { this.character = character; document.title = `d[any] - Edition de ${character.name ?? 'nouveau personnage'}`; load.remove(); this.render(); this.display(0); } }); } else { document.title = `d[any] - Edition de nouveau personnage`; this.id = 'new'; this._character.id = -1; this.render(); this.display(0); } } private render() { const publicNotes = new MarkdownEditor(), privateNotes = new MarkdownEditor(); this._character.notes ??= { public: '', private: '' }; publicNotes.onChange = (v) => this._character.notes!.public = this._result.notes.public = v; privateNotes.onChange = (v) => this._character.notes!.private = this._result.notes.private = v; publicNotes.content = this._character.notes.public!; privateNotes.content = this._character.notes.private!; this._steps = [ PeoplePicker, LevelPicker, TrainingPicker, AbilityPicker, AspectPicker, ]; this._stepsHeader = this._steps.map((e, i) => dom("div", { class: "group/header flex items-center", }, [ i !== 0 ? icon("radix-icons:chevron-right", { class: "w-6 h-6 flex justify-center items-center group-data-[disabled]/header:text-light-50 dark:group-data-[disabled]/header:text-dark-50 group-data-[disabled]/header:hover:border-transparent me-4" }) : undefined, dom("div", { class: "px-2 py-1 border-b border-transparent hover:border-accent-blue disabled:text-light-50 dark:disabled:text-dark-50 disabled:hover:border-transparent group-data-[state=active]/header:text-accent-blue cursor-pointer", listeners: { click: () => this.display(i) } }, [text(e.header)]), ]) ); this._helperText = text("Choisissez un peuple afin de définir la progression de votre personnage au fil des niveaux.") this._content = dom('div', { class: 'flex-1 outline-none max-w-full w-full overflow-y-auto', attributes: { id: 'characterEditorContainer' } }); this._container.appendChild(div('flex flex-1 flex-col justify-start items-center px-8 w-full h-full overflow-y-hidden', [ div("flex w-full flex-row gap-4 items-center justify-between px-4 bg-light-0 dark:bg-dark-0 z-20", [ div('flex flex-row gap-2', [ floater(tooltip(button(icon('radix-icons:pencil-2', { width: 16, height: 16 }), undefined, 'p-1'), 'Notes publics', 'left'), [ publicNotes.dom ], { pinned: true, events: { show: ['click'], hide: [] }, class: 'min-w-[200px] min-h-[150px] max-w-[600px] max-h-[600px]', title: 'Notes publics', position: 'bottom-start' }), floater(tooltip(button(icon('radix-icons:eye-none', { width: 16, height: 16 }), undefined, 'p-1'), 'Notes privés', 'right'), [ privateNotes.dom ], { pinned: true, events: { show: ['click'], hide: [] }, class: 'min-w-[200px] min-h-[150px] max-w-[600px] max-h-[600px]', title: 'Notes privés', position: 'bottom-start' }) ]), div("flex w-full flex-row gap-4 items-center justify-center relative", this._stepsHeader), div('flex flex-row gap-2', [ tooltip(button(icon("radix-icons:chevron-right", { height: 16, width: 16 }), () => this.next(), 'p-1'), 'Suivant', "bottom"), tooltip(button(icon("radix-icons:paper-plane", { height: 16, width: 16 }), () => this.save(), 'p-1'), 'Enregistrer', "bottom"), tooltip(icon("radix-icons:question-mark-circled", { height: 20, width: 20 }), this._helperText, "bottom-end") ]), ]), this._content, ])); } previous() { this.display(this._currentStep - 1); } next() { this.display(this._currentStep + 1); } private display(step: number) { if(step < 0 || step >= this._stepsHeader.length) return; for(let i = 0; i < step; i++) { if(!this._steps[i]?.validate(this)) { Toaster.add({ title: 'Erreur de validation', content: this._steps[i]!.errorMessage, type: 'error', duration: 25000, timer: true }) return; } } if(step !== 0 && this._steps.slice(0, step).some(e => !e.validate(this))) return; this._stepsHeader[this._currentStep]!.setAttribute('data-state', 'inactive'); this._stepsHeader[step]!.setAttribute('data-state', 'active'); this._currentStep = step; this._stepContent[step] ??= (new this._steps[step]!(this)); this._content?.replaceChildren(...this._stepContent[step].dom); this._helperText.textContent = this._steps[step]!.description; } async save(leave: boolean = true) { if(this.id === 'new' || this.id === '-1') { const result = await useRequestFetch()(`/api/character`, { method: 'post', body: this._character, onResponseError: (e) => { Toaster.add({ title: 'Erreur d\'enregistrement', content: e.response.status === 401 ? "Vous n'êtes pas autorisé à effectué cette opération" : e.response.statusText, type: 'error', closeable: true, duration: 25000, timer: true }); this.id = 'new'; } }); if(result !== undefined) { this._character.id = this._result.id = result as number; this.id = result.toString(); } Toaster.add({ content: 'Personnage créé', type: 'success', duration: 25000, timer: true }); useRouter().replace({ name: 'character-id-edit', params: { id: this.id } }) if(leave) useRouter().push({ name: 'character-id', params: { id: this.id } }); } else { await useRequestFetch()(`/api/character/${this._character.id}`, { method: 'post', body: this._character, onResponseError: (e) => { Toaster.add({ title: 'Erreur d\'enregistrement', content: e.response.status === 401 ? "Vous n'êtes pas autorisé à effectué cette opération" : e.response.statusText, type: 'error', closeable: true, duration: 25000, timer: true }); } }); Toaster.add({ content: 'Personnage enregistré', type: 'success', duration: 25000, timer: true }); if(leave) useRouter().push({ name: 'character-id', params: { id: this.id } }); } } updateLevel(level: Level) { this._character.level = level; if(this._character.leveling) //Invalidate higher levels { for(let _level = 20; _level > this._character.level; _level--) { const level = _level as Level; if(this._character.leveling.hasOwnProperty(level)) { const option = this._character.leveling[level]!; const feature = config.peoples[this._character.people!]!.options[level][option]!; delete this._character.leveling[level]; if(this._character.choices.hasOwnProperty(feature)) delete this._character.choices[feature]; this.remove(feature); } } } } toggleLevelOption(level: Level, choice: number) { if(level > this._character.level) //Cannot add more level options than the current level return; if(this._character.leveling === undefined) //Add level 1 if missing { this._character.leveling = { 1: 0 }; this.add(config.peoples[this._character.people!]!.options[1][0]!); } if(level == 1) //Cannot remove level 1 return; for(let i = 1; i < level; i++) //Check previous levels as a requirement { if(!this._character.leveling.hasOwnProperty(i)) return; } if(this._character.leveling.hasOwnProperty(level) && this._character.leveling[level] !== choice) //If the given level is already selected, switch to the new choice { const feature = config.peoples[this._character.people!]!.options[level][this._character.leveling[level]!]!; this.remove(feature); if(feature in this._character.choices) delete this._character.choices[feature]; this.add(config.peoples[this._character.people!]!.options[level][choice]); this._character.leveling[level] = choice; } else if(!this._character.leveling.hasOwnProperty(level)) { this._character.leveling[level] = choice; this.add(config.peoples[this._character.people!]!.options[level][choice]!); } } toggleTrainingOption(stat: MainStat, level: TrainingLevel, choice: number) { if(level == 0) //Cannot remove the initial level return; for(let i = 1; i < level; i++) //Check previous levels as a requirement { if(!this._character.training[stat].hasOwnProperty(i as TrainingLevel)) return; } if(this._character.training[stat].hasOwnProperty(level)) { if(this._character.training[stat][level] === choice) { for(let i = 15; i >= level; i --) //Invalidate higher levels { if(this._character.training[stat].hasOwnProperty(i)) { const feature = config.training[stat][i as TrainingLevel][this._character.training[stat][i as TrainingLevel]!]!; this.remove(feature); if(this._character.choices.hasOwnProperty(feature)) delete this._character.choices[feature]; delete this._character.training[stat][i as TrainingLevel]; } } } else { const feature = config.training[stat][level][this._character.training[stat][level]!]!; this.remove(feature); if(this._character.choices.hasOwnProperty(feature)) delete this._character.choices[feature]; this._character.training[stat][level] = choice; this.add(config.training[stat][level][choice]); } } else { this._character.training[stat][level] = choice; this.add(config.training[stat][level][choice]); } } handleChoice(element: HTMLElement, feature: string) { const choices = config.features[feature]!.effect.filter(e => e.category === 'choice'); if(choices.length === 0) return; const menu = followermenu(element, [ div('px-24 py-6 flex flex-col items-center text-light-100 dark:text-dark-100', choices.map(e => div('flex flex-row items-center', [ text(e.text), div('flex flex-col gap-2', Array(e.settings?.amount ?? 1).fill(0).map((_, i) => ( select(e.options.map((_e, _i) => ({ text: _e.text, value: _i })), { defaultValue: this._character.choices![e.id] !== undefined ? this._character.choices![e.id]![i] : undefined, change: (value) => { this._character.choices![e.id] ??= []; this._character.choices![e.id]![i] = value; }, class: { container: 'w-32' } }) ))) ]))) ], { arrow: true, offset: { mainAxis: 8 }, cover: 'width', placement: 'bottom', priority: false, viewport: document.getElementById('characterEditorContainer') ?? undefined, }); } } abstract class BuilderTab { protected _builder: CharacterBuilder; protected _content!: Array; static header: string; static description: string; static errorMessage: string; constructor(builder: CharacterBuilder) { this._builder = builder; } update() { } static validate(builder: CharacterBuilder): boolean { return false; } get dom() { return this._content; } }; type BuilderTabConstructor = { new (builder: CharacterBuilder): BuilderTab; header: string; description: string; errorMessage: string; validate(builder: CharacterBuilder): boolean; } class PeoplePicker extends BuilderTab { private _nameInput: HTMLInputElement; private _visibilityInput: HTMLElement; private _options: HTMLElement[]; static override header = 'Peuple'; static override description = 'Choisissez un peuple afin de définir la progression de votre personnage au fil des niveaux.'; static override errorMessage = 'Veuillez choisir un peuple pour continuer.'; constructor(builder: CharacterBuilder) { super(builder); this._nameInput = input('text', { input: (value) => { this._builder.character.name = value ?? ''; document.title = `d[any] - Edition de ${this._builder.character.name || 'nouveau personnage'}`; }, defaultValue: this._builder.character.name }); this._visibilityInput = toggle({ defaultValue: this._builder.character.visibility === "private", change: (value) => this._builder.character.visibility = value ? "private" : "public" }); this._options = Object.values(config.peoples).map( (people, i) => dom("div", { class: "flex flex-col flex-nowrap gap-2 p-2 border border-light-35 dark:border-dark-35 cursor-pointer hover:border-light-70 dark:hover:border-dark-70 w-[320px]", listeners: { click: () => { this._builder.character.people = people.id; this._builder.character = { ...this._builder.character, people: people.id }; "border-accent-blue outline-2 outline outline-accent-blue".split(" ").forEach(e => this._options.forEach(f => f?.classList.toggle(e, false))); "border-accent-blue outline-2 outline outline-accent-blue".split(" ").forEach(e => this._options[i]?.classList.toggle(e, true)); } }, attributes: { 'data-people': people.id } }, [div("h-[320px]"), div("text-xl font-bold text-center", [text(people.name)]), div("w-full border-b border-light-50 dark:border-dark-50"), div("text-wrap word-break", [text(people.description)])]), ); this._content = [ div("flex flex-1 gap-12 px-2 py-4 justify-center items-center", [ dom("label", { class: "flex justify-center items-center my-2" }, [ dom("span", { class: "pb-1 md:p-0", text: "Nom" }), this._nameInput, ]), dom("label", { class: "flex justify-center items-center my-2" }, [ dom("span", { class: "md:text-base text-sm", text: "Privé ?" }), this._visibilityInput, ]), ]), div('flex flex-1 gap-4 p-2 overflow-x-auto justify-center', this._options)]; this.update(); } override update() { this._nameInput.value = this._builder.character.name; this._visibilityInput.setAttribute('data-state', this._builder.character.visibility === "private" ? "checked" : "unchecked"); if(this._builder.character.people !== undefined) { "border-accent-blue outline-2 outline outline-accent-blue".split(" ").forEach(e => this._options.find(e => e.getAttribute('data-people') === this._builder.character.people)?.classList.toggle(e, true)); } } static override validate(builder: CharacterBuilder): boolean { return builder.character.people !== undefined; } } class LevelPicker extends BuilderTab { private _levelInput: HTMLInputElement; private _pointsInput: HTMLInputElement; private _options: HTMLElement[][]; static override header = 'Niveaux'; static override description = 'Déterminez la progression de votre personnage en choisissant une option par niveau disponible.'; static override errorMessage = 'Vous avez attribué trop de niveaux.'; constructor(builder: CharacterBuilder) { super(builder); this._levelInput = numberpicker({ defaultValue: this._builder.character.level, min: 1, max: 20, input: (value) => { this._builder.character.level = value; this.updateLevel(); } }); this._pointsInput = dom("input", { class: `w-14 mx-4 text-light-70 dark:text-dark-70 tabular-nums bg-light-10 dark:bg-dark-10 appearance-none outline-none ps-3 pe-1 py-1 focus:shadow-raw transition-[box-shadow] border bg-light-20 bg-dark-20 border-light-20 dark:border-dark-20`, attributes: { type: "number", disabled: true }}); this._options = Object.entries(config.peoples[this._builder.character.people!]!.options).map( (level) => [ div("w-full flex h-px", [div("border-t border-dashed border-light-50 dark:border-dark-50 w-full"), dom('span', { class: "relative left-4" }, [ text(level[0]) ])]), div("flex flex-row gap-4 justify-center", level[1].map((option, j) => { const choice = config.features[option]!.effect.some(e => e.category === 'choice') ? dom('div', { class: 'absolute -bottom-px -right-px border border-light-50 dark:border-dark-50 bg-light-10 dark:bg-dark-10 hover:border-light-70 dark:hover:border-dark-70 flex p-1 justify-center items-center', listeners: { click: (e) => { e.stopImmediatePropagation(); this._builder.character.leveling[level[0] as any as Level] === j && this._builder.handleChoice(choice!, config.features[option]!.id); } } }, [ icon('radix-icons:gear') ]) : undefined; return dom("div", { class: ["flex border border-light-50 dark:border-dark-50 px-4 py-2 w-[400px] relative", { 'hover:border-light-70 dark:hover:border-dark-70 cursor-pointer': (level[0] as any as Level) <= this._builder.character.level, '!border-accent-blue bg-accent-blue bg-opacity-20': this._builder.character.leveling[level[0] as any as Level] === j }], listeners: { click: e => { this._builder.toggleLevelOption(parseInt(level[0]) as Level, j); this.update(); }}}, [ dom('span', { class: "text-wrap whitespace-pre", text: getText(config.features[option]!.description) }), choice ]); })) ]); this._content = [ div("flex flex-1 gap-12 px-2 py-4 justify-center items-center", [ dom("label", { class: "flex justify-center items-center my-2" }, [ dom("span", { class: "pb-1 md:p-0", text: "Niveau" }), this._levelInput, ]), dom("label", { class: "flex justify-center items-center my-2" }, [ dom("span", { class: "md:text-base text-sm", text: "Points restantes" }), this._pointsInput, ]), div("flex justify-center items-center gap-2 my-2 md:text-base text-sm", [ dom("span", { text: "Vie" }), text(() => this._builder.compiled.health), ]), div("flex justify-center items-center gap-2 my-2 md:text-base text-sm", [ dom("span", { text: "Mana" }), text(() => this._builder.compiled.mana), ]), ]), div('flex flex-col flex-1 gap-4 mx-8 my-4', this._options.flatMap(e => [...e]))]; this.update(); } override update() { this._builder.compiled; this._levelInput.value = this._builder.character.level.toString(); this._pointsInput.value = (this._builder.character.level - Object.keys(this._builder.character.leveling).length).toString(); this.updateLevel(); } private updateLevel() { this._builder.updateLevel(this._builder.character.level as Level); this._pointsInput.value = (this._builder.character.level - Object.keys(this._builder.character.leveling).length).toString(); this._options.forEach((e, i) => { e[0]?.classList.toggle("opacity-30", ((i + 1) as Level) > this._builder.character.level); e[1]?.classList.toggle("opacity-30", ((i + 1) as Level) > this._builder.character.level); e[1]?.childNodes.forEach((option, j) => { 'hover:border-light-70 dark:hover:border-dark-70 cursor-pointer'.split(" ").forEach(_e => (option as HTMLElement).classList.toggle(_e, ((i + 1) as Level) <= this._builder.character.level)); '!border-accent-blue bg-accent-blue bg-opacity-20'.split(" ").forEach(_e => (option as HTMLElement).classList.toggle(_e, this._builder.character.leveling[((i + 1) as Level)] === j)); }); }); } static override validate(builder: CharacterBuilder): boolean { return builder.character.level - Object.keys(builder.character.leveling).length >= 0; } } class TrainingPicker extends BuilderTab { private _pointsInput: HTMLInputElement; private _options: Record; private _tab: number = 0; private _statIndicator: HTMLElement; private _statContainer: HTMLElement; static override header = 'Entrainement'; static override description = 'Spécialisez votre personnage en attribuant vos points d\'entrainement parmi les 7 branches disponibles.\nChaque paliers de 3 points augmentent votre modifieur.'; static override errorMessage = 'Vous avez dépensé trop de points d\'entrainement.'; constructor(builder: CharacterBuilder) { super(builder); const statRenderBlock = (stat: MainStat) => { return Object.entries(config.training[stat]).map( (level) => [ div("w-full flex h-px", [div("border-t border-dashed border-light-50 dark:border-dark-50 w-full"), dom('span', { class: "relative" }, [ text(level[0]) ])]), div("flex flex-row gap-4 justify-center", level[1].map((option, j) => { const choice = config.features[option]!.effect.some(e => e.category === 'choice') ? dom('div', { class: 'absolute -bottom-px -right-px border border-light-50 dark:border-dark-50 bg-light-10 dark:bg-dark-10 hover:border-light-70 dark:hover:border-dark-70 flex p-1 justify-center items-center', listeners: { click: (e) => { e.stopImmediatePropagation(); this._builder.character.training[stat as MainStat][parseInt(level[0], 10) as TrainingLevel] === j && this._builder.handleChoice(choice!, config.features[option]!.id); } } }, [ icon('radix-icons:gear') ]) : undefined; return dom("div", { class: ["border border-light-40 dark:border-dark-40 cursor-pointer px-2 py-1 w-[400px] hover:border-light-50 dark:hover:border-dark-50 relative"], listeners: { click: e => { this._builder.toggleTrainingOption(stat, parseInt(level[0]) as TrainingLevel, j); this.update(); }}}, [ markdown(getText(config.features[option]!.description), undefined, { tags: { a: preview } }), choice ]); })) ]); } this._pointsInput = dom("input", { class: `w-14 mx-4 text-light-70 dark:text-dark-70 tabular-nums bg-light-10 dark:bg-dark-10 appearance-none outline-none ps-3 pe-1 py-1 focus:shadow-raw transition-[box-shadow] border bg-light-20 bg-dark-20 border-light-20 dark:border-dark-20`, attributes: { type: "number", disabled: true }}); this._options = MAIN_STATS.reduce((p, v) => { p[v] = statRenderBlock(v); return p; }, {} as Record); this._statIndicator = dom('span', { class: 'rounded-full w-3 h-3 bg-accent-blue absolute transition-[left] after:content-[attr(data-text)] after:absolute after:-translate-x-1/2 after:top-4 after:p-px after:bg-light-0 dark:after:bg-dark-0 after:text-center' }); this._statContainer = div('relative select-none transition-[left] flex flex-1 flex-row max-w-full', Object.values(this._options).map(e => div('flex flex-shrink-0 flex-col gap-4 relative w-full overflow-y-auto px-8', e.flatMap(_e => [..._e])))); this._content = [ div("flex flex-1 gap-12 px-2 py-4 justify-center items-center sticky top-0 bg-light-0 dark:bg-dark-0 w-full z-10 min-h-20", [ div('flex flex-shrink gap-3 items-center relative w-48 ms-12', [ ...MAIN_STATS.map((e, i) => dom('span', { listeners: { click: () => this.switchTab(i) }, class: 'block w-2.5 h-2.5 m-px outline outline-1 outline-transparent hover:outline-light-70 dark:hover:outline-dark-70 rounded-full bg-light-40 dark:bg-dark-40 cursor-pointer' })), this._statIndicator, ]), div('flex flex-1 gap-12 px-2 py-4 justify-center items-center sticky top-0 bg-light-0 dark:bg-dark-0 w-full z-10', [ dom("label", { class: "flex justify-center items-center my-2" }, [ dom("span", { class: "md:text-base text-sm", text: "Points restantes" }), this._pointsInput, ]), div("flex justify-center items-center gap-2 my-2 md:text-base text-sm", [ dom("span", { text: "Vie" }), text(() => this._builder.compiled.health), ]), div("flex justify-center items-center gap-2 my-2 md:text-base text-sm", [ dom("span", { text: "Mana" }), text(() => this._builder.compiled.mana), ]), ]), dom('span') ]), div('flex flex-1 px-6 overflow-hidden max-w-full', [ this._statContainer ])]; this.switchTab(0); this.update(); } switchTab(tab: number) { this._tab = tab; this._statIndicator.setAttribute('data-text', mainStatTexts[MAIN_STATS[tab] as MainStat]); this._statIndicator.style.left = `${tab * 1.5}em`; this._statContainer.style.left = `-${tab * 100}%`; } override update() { const values = this._builder.values; const training = Object.values(this._builder.character.training).reduce((p, v) => p + Object.values(v).filter(e => e !== undefined).length, 0); this._pointsInput.value = ((values.training ?? 0) - training).toString(); Object.keys(this._options).forEach(stat => { const max = Object.keys(this._builder.character.training[stat as MainStat]).length; this._options[stat as MainStat].forEach((e, i) => { e[0]?.classList.toggle("opacity-30", (i as TrainingLevel) > max); e[1]?.classList.toggle("opacity-30", (i as TrainingLevel) > max); e[1]?.childNodes.forEach((option, j) => { '!border-accent-blue bg-accent-blue bg-opacity-20'.split(" ").forEach(_e => (option as HTMLElement).classList.toggle(_e, i == 0 || (this._builder.character.training[stat as MainStat][i as TrainingLevel] === j))); }) }) }); } static override validate(builder: CharacterBuilder): boolean { const values = builder.values; const training = Object.values(builder.character.training).reduce((p, v) => p + Object.values(v).filter(e => e !== undefined).length, 0); return (values.training ?? 0) - training >= 0; } } class AbilityPicker extends BuilderTab { private _pointsInput: HTMLInputElement; private _options: HTMLElement[]; private _maxs: HTMLElement[] = []; static override header = 'Compétences'; static override description = 'Diversifiez vos possibilités en affectant vos points dans les différentes compétences disponibles.'; static override errorMessage = 'Une compétence est incorrectement saisie ou vous avez dépassé le nombre de points à attribuer.'; constructor(builder: CharacterBuilder) { super(builder); this._pointsInput = dom("input", { class: `w-14 mx-4 text-light-70 dark:text-dark-70 tabular-nums bg-light-10 dark:bg-dark-10 appearance-none outline-none ps-3 pe-1 py-1 focus:shadow-raw transition-[box-shadow] border bg-light-20 bg-dark-20 border-light-20 dark:border-dark-20`, attributes: { type: "number", disabled: true }}); this._options = ABILITIES.map((e, i) => { const max = dom('span', { class: 'text-lg text-end font-bold' }); this._maxs.push(max); return div('flex flex-col border border-light-50 dark:border-dark-50 p-4 gap-2 w-[200px] relative', [ div('flex justify-between', [ numberpicker({ defaultValue: this._builder.character.abilities[e], input: (value) => { this._builder.character.abilities[e] = value; this.update(); }}), max ]), dom('span', { class: "text-xl text-center font-bold", text: abilityTexts[e] }), dom('span', { class: "absolute -bottom-px -left-px h-[3px] bg-accent-blue" }), ]) }); this._content = [ div("flex flex-1 gap-12 px-2 py-4 justify-center items-center sticky top-0 bg-light-0 dark:bg-dark-0 w-full z-10", [ dom("label", { class: "flex justify-center items-center my-2" }, [ dom("span", { class: "md:text-base text-sm", text: "Points restantes" }), this._pointsInput, ]), ]), div('flex flex-row flex-wrap justify-center items-center flex-1 gap-12 mx-8 my-4 px-48', this._options)]; this.update(); } override update() { const values = this._builder.values, compiled = this._builder.compiled; const abilities = Object.values(this._builder.character.abilities).reduce((p, v) => p + v, 0); this._pointsInput.value = ((values.ability ?? 0) - abilities).toString(); ABILITIES.forEach((e, i) => { const max = (values[`bonus/abilities/${e}`] ?? 0); const load = this._options[i]?.lastElementChild as HTMLElement | undefined; const valid = (compiled.abilities[e] ?? 0) <= max; if(load) { Object.assign(load.style ?? {}, { width: `${clamp((max === 0 ? 0 : (this._builder.character.abilities[e] ?? 0) / max) * 100, 0, 100)}%` }); 'bg-accent-blue'.split(' ').forEach(_e => load.classList.toggle(_e, valid)); 'bg-light-red dark:bg-dark-red'.split(' ').forEach(_e => load.classList.toggle(_e, !valid)); } this._maxs[i]!.textContent = `/ ${max ?? 0}`; }) } static override validate(builder: CharacterBuilder): boolean { const values = builder.values, compiled = builder.compiled; const abilities = Object.values(builder.character.abilities).reduce((p, v) => p + v, 0); return ABILITIES.map(e => (values[`bonus/abilities/${e}`] ?? 0) >= (compiled.abilities[e] ?? 0)).every(e => e) && (values.ability ?? 0) - abilities >= 0; } } class AspectPicker extends BuilderTab { private _physicInput: HTMLInputElement; private _mentalInput: HTMLInputElement; private _personalityInput: HTMLInputElement; private _filter: boolean = true; private _options: HTMLElement[]; static override header = 'Aspect'; static override description = 'Déterminez l\'Aspect qui vous corresponds et benéficiez de puissants bonus.'; static override errorMessage = 'Veuillez choisir un Aspect.'; constructor(builder: CharacterBuilder) { super(builder); this._physicInput = dom("input", { class: `w-14 mx-4 text-light-70 dark:text-dark-70 tabular-nums bg-light-10 dark:bg-dark-10 appearance-none outline-none ps-3 pe-1 py-1 focus:shadow-raw transition-[box-shadow] border bg-light-20 bg-dark-20 border-light-20 dark:border-dark-20`, attributes: { type: "number", disabled: true }}); this._mentalInput = dom("input", { class: `w-14 mx-4 text-light-70 dark:text-dark-70 tabular-nums bg-light-10 dark:bg-dark-10 appearance-none outline-none ps-3 pe-1 py-1 focus:shadow-raw transition-[box-shadow] border bg-light-20 bg-dark-20 border-light-20 dark:border-dark-20`, attributes: { type: "number", disabled: true }}); this._personalityInput = dom("input", { class: `w-14 mx-4 text-light-70 dark:text-dark-70 tabular-nums bg-light-10 dark:bg-dark-10 appearance-none outline-none ps-3 pe-1 py-1 focus:shadow-raw transition-[box-shadow] border bg-light-20 bg-dark-20 border-light-20 dark:border-dark-20`, attributes: { type: "number", disabled: true }}); this._options = Object.values(config.aspects).map((e, i) => dom('div', { attributes: { "data-aspect": e.id }, listeners: { click: () => { this._builder.character.aspect = e.id; this._options.forEach(_e => _e.setAttribute('data-state', 'inactive')); this._options[i]?.setAttribute('data-state', 'active'); }}, class: 'group flex flex-col w-[360px] border border-light-35 dark:border-dark-35 hover:border-light-50 dark:hover:border-dark-50 cursor-pointer' }, [ div('bg-light-10 dark:bg-dark-10 border-b border-light-35 dark:border-dark-35 p-2 flex flex-col gap-2 group-data-[state=active]:bg-accent-blue group-data-[state=active]:bg-opacity-10', [ div('flex flex-row gap-8 ps-4 items-center', [ div("flex flex-1 flex-col gap-2 justify-center", [ div('text-lg font-bold', [ text(e.name) ]), dom('span', { class: 'border-b w-full border-light-50 dark:border-dark-50 group-data-[state=active]:border-b-[4px] group-data-[state=active]:border-accent-blue' }) ]), div('rounded-full w-[96px] h-[96px] border border-light-50 dark:border-dark-50 bg-light-100 dark:bg-dark-100 !bg-opacity-10') ]) ]), div('flex justify-stretch items-stretch py-2 px-4 gap-4', [ div('flex flex-col flex-1 items-stretch gap-4', [ div('flex flex-1 justify-between', [ text('Difficulté'), div('text-sm font-bold', [ text(e.difficulty.toString()) ]) ]), div('flex flex-1 justify-between', [ text('Bonus'), div('text-sm font-bold', [ text(e.stat === 'special' ? 'Special' : mainStatTexts[e.stat]) ]) ]) ]), div('w-px h-full bg-light-50 dark:bg-dark-50'), div('flex flex-col items-center justify-between py-2', [ div('text-sm italic', [ text(alignmentTexts[e.alignment]) ]), div(['text-sm font-bold', { "text-light-purple dark:text-dark-purple italic": e.magic, "text-light-orange dark:text-dark-orange": !e.magic }], [ text(e.magic ? 'Magie autorisée' : 'Magie interdite') ]), ]), ]) ])); const filterSwitch = dom("div", { class: `group mx-3 w-12 h-6 select-none transition-all border border-light-35 dark:border-dark-35 bg-light-20 dark:bg-dark-20 outline-none data-[state=checked]:bg-light-35 dark:data-[state=checked]:bg-dark-35 hover:border-light-50 dark:hover:border-dark-50 focus:shadow-raw focus:shadow-light-40 dark:focus:shadow-dark-40 data-[disabled]:bg-light-20 dark:data-[disabled]:bg-dark-20 data-[disabled]:border-light-20 dark:data-[disabled]:border-dark-20 relative py-[2px]`, attributes: { "data-state": this._filter ? "checked" : "unchecked" }, listeners: { click: (e: Event) => { this._filter = !this._filter; filterSwitch.setAttribute('data-state', this._filter ? "checked" : "unchecked"); this.update(); } }}, [ div('block w-[18px] h-[18px] translate-x-[2px] will-change-transform transition-transform bg-light-50 dark:bg-dark-50 group-data-[state=checked]:translate-x-[26px] group-data-[disabled]:bg-light-30 dark:group-data-[disabled]:bg-dark-30 group-data-[disabled]:border-light-30 dark:group-data-[disabled]:border-dark-30') ]); this._content = [ div("flex flex-1 gap-12 px-2 py-4 justify-center items-center sticky top-0 bg-light-0 dark:bg-dark-0 w-full z-10", [ dom("label", { class: "flex justify-center items-center my-2" }, [ dom("span", { class: "md:text-base text-sm", text: "Physique" }), this._physicInput, ]), dom("label", { class: "flex justify-center items-center my-2" }, [ dom("span", { class: "md:text-base text-sm", text: "Mental" }), this._mentalInput, ]), dom("label", { class: "flex justify-center items-center my-2" }, [ dom("span", { class: "md:text-base text-sm", text: "Caractère" }), this._personalityInput, ]), dom("label", { class: "flex justify-center items-center my-2" }, [ dom("span", { class: "md:text-base text-sm", text: "Filtrer ?" }), filterSwitch, ]), ]), div('flex flex-row flex-wrap justify-center items-center flex-1 gap-8 mx-8 my-4 px-8', this._options)]; this.update(); } override update() { const physic = Object.values(this._builder.character.training['strength']).length + Object.values(this._builder.character.training['dexterity']).length + Object.values(this._builder.character.training['constitution']).length; const mental = Object.values(this._builder.character.training['intelligence']).length + Object.values(this._builder.character.training['curiosity']).length; const personality = Object.values(this._builder.character.training['charisma']).length + Object.values(this._builder.character.training['psyche']).length; this._physicInput.value = physic.toString(); this._mentalInput.value = mental.toString(); this._personalityInput.value = personality.toString(); (this._content[1] as HTMLElement).replaceChildren(...this._options.filter(e => { const id = e.getAttribute('data-aspect')!; const aspect = config.aspects[id]!; e.setAttribute('data-state', this._builder.character.aspect === id ? 'active' : 'inactive'); if(!this._filter) return true; if(physic > aspect.physic.max || physic < aspect.physic.min) return false; if(mental > aspect.mental.max || mental < aspect.mental.min) return false; if(personality > aspect.personality.max || personality < aspect.personality.min) return false; return true; })); } static override validate(builder: CharacterBuilder): boolean { if(builder.character.aspect === undefined) return false; return true; } } export const masteryTexts: Record = { "armor/light": { text: "Armure légère", href: "regles/annexes/equipement#Les armures légères" }, "armor/medium": { text: "Armure moyenne", href: "regles/annexes/equipement#Les armures" }, "armor/heavy": { text: "Armure lourde", href: "regles/annexes/equipement#Les armures lourdes" }, "weapon/light": { text: "Arme légère", href: "regles/annexes/equipement#Les armes légères" }, "weapon/throw": { text: "Arme de jet", href: "regles/annexes/equipement#Les armes de jet" }, "weapon/natural": { text: "Arme naturelle", href: "regles/annexes/equipement#Les armes naturelles" }, "weapon/classic": { text: "Arme standard", href: "regles/annexes/equipement#Les armes" }, "weapon/improvised": { text: "Arme improvisée", href: "regles/annexes/equipement#Les armes improvisées" }, "weapon/heavy": { text: "Arme lourde", href: "regles/annexes/equipement#Les armes lourdes" }, "weapon/twohanded": { text: "Arme à deux mains", href: "regles/annexes/equipement#Les armes à deux mains" }, "weapon/finesse": { text: "Arme maniable", href: "regles/annexes/equipement#Les armes maniables" }, "weapon/projectile": { text: "Arme à projectiles", href: "regles/annexes/equipement#Les armes à projectiles" }, "weapon/reach": { text: "Arme longue", href: "regles/annexes/equipement#Les armes longues" }, "weapon/shield": { text: "Bouclier", href: "regles/annexes/equipement#Les boucliers" }, } type Category = ItemConfig['category']; type Rarity = ItemConfig['rarity']; export const colorByRarity: Record = { 'common': 'text-light-100 dark:text-dark-100', 'uncommon': 'text-light-cyan dark:text-dark-cyan', 'rare': 'text-light-purple dark:text-dark-purple', 'legendary': 'text-light-orange dark:text-dark-orange' } export const weaponTypeTexts: Record = { "light": 'légère', "shield": 'bouclier', "heavy": 'lourde', "classic": 'arme', "throw": 'de jet', "natural": 'naturelle', "twohanded": 'à deux mains', "finesse": 'maniable', "reach": 'longue', "projectile": 'à projectile', "improvised": "improvisée" } export const armorTypeTexts: Record = { 'heavy': 'Armure lourde', 'light': 'Armure légère', 'medium': 'Armure', } export const categoryText: Record = { 'mundane': 'Objet', 'armor': 'Armure', 'weapon': 'Arme', 'wondrous': 'Objet magique' }; export const rarityText: Record = { 'common': 'Commun', 'uncommon': 'Atypique', 'rare': 'Rare', 'legendary': 'Légendaire' }; export const subnameFactory = (item: ItemConfig, state?: ItemState): string[] => { let result = []; switch(item.category) { case 'armor': result = [armorTypeTexts[(item as ArmorConfig).type]]; break; case 'weapon': result = ['Arme', ...(item as WeaponConfig).type.filter(e => e !== 'classic').map(e => weaponTypeTexts[e])]; break; case 'mundane': result = ['Objet']; break; case 'wondrous': result = ['Objet magique']; break; } if(state && state.enchantments !== undefined && state.enchantments.length > 0) result.push('Enchanté'); if(item.consummable) result.push('Consommable'); return result; } export const stateFactory = (item: ItemConfig) => { const state = { id: item.id, amount: 1, charges: item.charge, enchantments: [], equipped: item.equippable ? false : undefined } as ItemState; switch(item.category) { case 'armor': state.state = { loss: 0, health: 0, absorb: { flat: 0, percent: 0 } } as ArmorState; break; case 'mundane': state.state = { } as MundaneState; break; case 'weapon': state.state = { attack: 0, hit: 0 } as WeaponState; break; case 'wondrous': state.state = { } as WondrousState; break; default: break; } return state; } export class CharacterSheet { private user: ComputedRef; private character?: CharacterCompiler; container: HTMLElement = div('flex flex-1 h-full w-full items-start justify-center'); private tabs?: HTMLElement; private tab: string = 'actions'; ws?: Socket; constructor(id: string, user: ComputedRef) { this.user = user; const load = div("flex justify-center items-center w-full h-full", [ loading('large') ]); this.container.replaceChildren(load); useRequestFetch()(`/api/character/${id}`).then(character => { if(character) { this.character = new CharacterCompiler(reactive(character)); if(character.campaign) { /* this.ws = new Socket(`/ws/campaign/${character.campaign}`, true); this.ws.handleMessage('SYNC', () => { useRequestFetch()(`/api/character/${id}`).then(character => { if(character) { this.character!.character = reactive(character); this.character!.values; 'update' in this.container! && this.container!.update!(true); } }); }) this.ws.handleMessage<{ action: 'set' | 'add' | 'remove', key: keyof CharacterVariables, value: any }>('VARIABLE', (variable) => { const prop = this.character!.character.variables[variable.key]; if(variable.action === 'set') this.character!.character.variables[variable.key] = variable.value; else if(Array.isArray(prop)) { if(variable.action === 'add') prop.push(variable.value); else if(variable.action === 'remove') { const idx = prop.findIndex(e => deepEquals(e, variable.value)); if(idx !== -1) prop.splice(idx, 1); } } }) */ } document.title = `d[any] - ${character.name}`; load.remove(); this.render(); } else throw new Error(); }).catch((e) => { console.error(e); this.container.replaceChildren(div('flex flex-col items-center justify-center flex-1 h-full gap-4', [ span('text-2xl font-bold tracking-wider', 'Personnage introuvable'), span(undefined, 'Ce personnage n\'existe pas ou est privé.'), div('flex flex-row gap-4 justify-center items-center', [ button(text('Personnages publics'), () => useRouter().push({ name: 'character-list' }), 'px-2 py-1'), button(text('Créer un personange'), () => useRouter().push({ name: 'character-id-edit', params: { id: 'new' } }), 'px-2 py-1') ]) ])) }); } render() { if(!this.character) return; const character = this.character.compiled; const publicNotes = new MarkdownEditor(); const privateNotes = new MarkdownEditor(); const healthPanel = this.healthPanel(character); const loadableIcon = icon('radix-icons:paper-plane', { width: 16, height: 16 }); const saveLoading = loading('small'); const saveNotes = () => { loadableIcon.replaceWith(saveLoading); this.character?.saveNotes().finally(() => { saveLoading.replaceWith(loadableIcon) }); } publicNotes.onChange = (v) => this.character!.character.notes!.public = v; privateNotes.onChange = (v) => this.character!.character.notes!.private = v; publicNotes.content = this.character!.character.notes!.public!; privateNotes.content = this.character!.character.notes!.private!; /* const validateProperty = (v: string, property: 'health' | 'mana', obj: { edit: HTMLInputElement, readonly: HTMLElement }) => { character.variables[property] = v.startsWith('-') ? character.variables[property] + parseInt(v.substring(1), 10) : v.startsWith('+') ? character.variables[property] - parseInt(v.substring(1), 10) : character[property] - parseInt(v, 10); obj.edit.value = (character[property] - character.variables[property]).toString(); obj.edit.replaceWith(obj.readonly); }; const health = { readonly: dom("span", { class: "font-bold px-2 border-transparent border cursor-pointer hover:border-light-35 dark:hover:border-dark-35", text: () => `${character.health - character.variables.health}`, listeners: { click: () => { health.readonly.replaceWith(health.edit); health.edit.select(); health.edit.focus(); } }, }), edit: input('text', { defaultValue: (character.health - character.variables.health).toString(), input: (v) => { return v.startsWith('-') || v.startsWith('+') ? v.length === 1 || !isNaN(parseInt(v.substring(1), 10)) : v.length === 0 || !isNaN(parseInt(v, 10)); }, change: (v) => validateProperty(v, 'health', health), blur: () => validateProperty(health.edit.value, 'health', health), class: 'font-bold px-2 w-20 text-center' }), }; const mana = { readonly: dom("span", { class: "font-bold px-2 border-transparent border cursor-pointer hover:border-light-35 dark:hover:border-dark-35", text: () => `${character.mana - character.variables.mana}`, listeners: { click: () => { mana.readonly.replaceWith(mana.edit); mana.edit.select(); mana.edit.focus(); } }, }), edit: input('text', { defaultValue: (character.mana - character.variables.mana).toString(), input: (v) => { return v.startsWith('-') || v.startsWith('+') ? v.length === 1 || !isNaN(parseInt(v.substring(1), 10)) : v.length === 0 || !isNaN(parseInt(v, 10)); }, change: (v) => validateProperty(v, 'mana', mana), blur: () => validateProperty(mana.edit.value, 'mana', mana), class: 'font-bold px-2 w-20 text-center' }), }; */ this.tabs = tabgroup([ { id: 'actions', title: [ text('Actions') ], content: this.actionsTab(character) }, { id: 'abilities', title: [ text('Aptitudes') ], content: this.abilitiesTab(character) }, { id: 'spells', title: [ text('Sorts') ], content: this.spellTab(character) }, { id: 'inventory', title: [ text('Inventaire') ], content: this.itemsTab(character) }, { id: 'aspect', title: [ span(() => ({ 'relative before:absolute before:top-0 before:-right-2 before:w-2 before:h-2 before:rounded-full before:bg-accent-blue': character.variables.transformed }), 'Aspect') ], content: () => this.aspectTab(character) }, { id: 'effects', title: [ text('Afflictions') ], content: () => this.effectsTab(character) }, { id: 'notes', title: [ text('Notes') ], content: () => [ div('flex flex-col h-full divide-y divide-light-30 dark:divide-dark-30', [ foldable([ div('border border-light-35 dark:border-dark-35 bg-light20 dark:bg-dark-20 p-1 flex-1', [ publicNotes.dom ]) ], [ div('flex flex-row w-full items-center justify-between', [ span('text-lg font-bold', 'Notes publics'), tooltip(button(loadableIcon, saveNotes, 'p-1 items-center justify-center'), 'Enregistrer', 'right') ]), ], { class: { container: 'flex flex-col gap-2 data-[active]:flex-1 py-2', content: 'h-full' }, open: true }), foldable([ div('border border-light-35 dark:border-dark-35 bg-light20 dark:bg-dark-20 p-1 flex-1', [ privateNotes.dom ]) ], [ span('text-lg font-bold', 'Notes privés'), ], { class: { container: 'flex flex-col gap-2 data-[active]:flex-1 py-2', content: 'h-full' }, open: false }), ]) ] }, ], { focused: this.tab, class: { container: 'flex-1 gap-4 px-4 max-w-[960px] h-full', content: 'overflow-auto h-full' }, switch: v => { this.tab = v; } }); this.container.replaceChildren(div('flex flex-col justify-start gap-1 h-full', [ div("flex flex-row gap-4 justify-between", [ div(), div("flex lg:flex-row flex-col gap-6 items-center justify-center", [ div("flex gap-6 items-center", [ div('inline-flex select-none items-center justify-center overflow-hidden align-middle h-16', [ div('text-light-100 dark:text-dark-100 leading-1 flex p-4 items-center justify-center bg-light-25 dark:bg-dark-25 font-medium', [ icon("radix-icons:person", { width: 16, height: 16 }), ]) ]), div("flex flex-col", [ dom("span", { class: "text-xl font-bold", text: character.name }), dom("span", { class: "text-sm", text: `De ${character.username}` }) ]), div("flex flex-col", [ dom("span", { class: "font-bold", text: `Niveau ${character.level}` }), dom("span", { text: config.peoples[character.race]?.name ?? 'Peuple inconnu' }) ]) ]), div("flex flex-row lg:border-l border-light-35 dark:border-dark-35 py-4 ps-4 gap-8", [ dom("span", { class: "flex flex-row items-center gap-2 text-3xl font-light" }, [ text("PV: "), dom("span", { class: "font-bold px-2 border-transparent border cursor-pointer hover:border-light-35 dark:hover:border-dark-35", text: () => `${character.health - character.variables.health}`, listeners: { click: healthPanel.show }, }), text('/'), text(() => character.health), ]), dom("span", { class: "flex flex-row items-center gap-2 text-3xl font-light" }, [ text("Mana: "), dom("span", { class: "font-bold px-2 border-transparent border cursor-pointer hover:border-light-35 dark:hover:border-dark-35", text: () => `${character.mana - character.variables.mana}`, listeners: { click: healthPanel.show }, }), text('/'), text(() => character.mana), ]), ]), ]), div("self-center", [ this.user.value && this.user.value.id === character.owner ? button(icon("radix-icons:pencil-2"), () => useRouter().push({ name: 'character-id-edit', params: { id: this.character?.character.id } }), "p-1") : div(), ]), ]), div("flex flex-row justify-center 2xl:gap-4 gap-2 p-4 border-b border-light-35 dark:border-dark-35", [ div("flex 2xl:gap-4 gap-2 flex-row items-center justify-between", [ div("flex flex-col items-center px-2", [ dom("span", { class: () => ["2xl:text-2xl text-xl font-bold", { 'text-accent-blue': character.variables.transformed && config.aspects[character.aspect.id]?.stat === 'strength' }] }, [ text(() => `+${character.modifier.strength}`) ]), dom("span", { class: "text-sm 2xl:text-base", text: "Force" }) ]), div("flex flex-col items-center px-2", [ dom("span", { class: () => ["2xl:text-2xl text-xl font-bold", { 'text-accent-blue': character.variables.transformed && config.aspects[character.aspect.id]?.stat === 'dexterity' }] }, [ text(() => `+${character.modifier.dexterity}`) ]), dom("span", { class: "text-sm 2xl:text-base", text: "Dextérité" }) ]), div("flex flex-col items-center px-2", [ dom("span", { class: () => ["2xl:text-2xl text-xl font-bold", { 'text-accent-blue': character.variables.transformed && config.aspects[character.aspect.id]?.stat === 'constitution' }] }, [ text(() => `+${character.modifier.constitution}`) ]), dom("span", { class: "text-sm 2xl:text-base", text: "Constitution" }) ]), div("flex flex-col items-center px-2", [ dom("span", { class: () => ["2xl:text-2xl text-xl font-bold", { 'text-accent-blue': character.variables.transformed && config.aspects[character.aspect.id]?.stat === 'intelligence' }] }, [ text(() => `+${character.modifier.intelligence}`) ]), dom("span", { class: "text-sm 2xl:text-base", text: "Intelligence" }) ]), div("flex flex-col items-center px-2", [ dom("span", { class: () => ["2xl:text-2xl text-xl font-bold", { 'text-accent-blue': character.variables.transformed && config.aspects[character.aspect.id]?.stat === 'curiosity' }] }, [ text(() => `+${character.modifier.curiosity}`) ]), dom("span", { class: "text-sm 2xl:text-base", text: "Curiosité" }) ]), div("flex flex-col items-center px-2", [ dom("span", { class: () => ["2xl:text-2xl text-xl font-bold", { 'text-accent-blue': character.variables.transformed && config.aspects[character.aspect.id]?.stat === 'charisma' }] }, [ text(() => `+${character.modifier.charisma}`) ]), dom("span", { class: "text-sm 2xl:text-base", text: "Charisme" }) ]), div("flex flex-col items-center px-2", [ dom("span", { class: () => ["2xl:text-2xl text-xl font-bold", { 'text-accent-blue': character.variables.transformed && config.aspects[character.aspect.id]?.stat === 'psyche' }] }, [ text(() => `+${character.modifier.psyche}`) ]), dom("span", { class: "text-sm 2xl:text-base", text: "Psyché" }) ]) ]), div('border-l border-light-35 dark:border-dark-35'), div("flex 2xl:gap-4 gap-2 flex-row items-center justify-between", [ div("flex flex-col px-2 items-center", [ dom("span", { class: "2xl:text-2xl text-xl font-bold" }, [ text(() => `+${character.initiative}`) ]), dom("span", { class: "text-sm 2xl:text-base", text: "Initiative" }) ]), div("flex flex-col px-2 items-center", [ dom("span", { class: "2xl:text-2xl text-xl font-bold" }, [ text(() => character.speed === false ? "N/A" : `${character.speed}`) ]), dom("span", { class: "text-sm 2xl:text-base", text: "Course" }) ]) ]), div('border-l border-light-35 dark:border-dark-35'), div("flex 2xl:gap-4 gap-2 flex-row items-center justify-between", [ icon("game-icons:checked-shield", { width: 32, height: 32 }), div("flex flex-col px-2 items-center", [ dom("span", { class: "2xl:text-2xl text-xl font-bold" }, [ text(() => clamp(character.defense.static + character.defense.passiveparry + character.defense.passivedodge, 0, character.defense.hardcap)) ]), dom("span", { class: "text-sm 2xl:text-base", text: "Passive" }) ]), div("flex flex-col px-2 items-center", [ dom("span", { class: "2xl:text-2xl text-xl font-bold" }, [ text(() => clamp(character.defense.static + character.defense.activeparry + character.defense.passivedodge, 0, character.defense.hardcap)) ]), dom("span", { class: "text-sm 2xl:text-base", text: "Blocage" }) ]), div("flex flex-col px-2 items-center", [ dom("span", { class: "2xl:text-2xl text-xl font-bold" }, [ text(() => clamp(character.defense.static + character.defense.passiveparry + character.defense.activedodge, 0, character.defense.hardcap)) ]), dom("span", { class: "text-sm 2xl:text-base", text: "Esquive" }) ]) ]), ]), div("flex flex-1 flex-row items-stretch justify-center py-2 gap-4 h-0", [ div("flex flex-col gap-4 py-1 w-60", [ div("flex flex-col py-1 gap-4", [ div("flex flex-row items-center justify-center gap-4", [ dom("div", { class: 'text-xl font-semibold', text: "Compétences" }), div("flex flex-1 border-t border-dashed border-light-50 dark:border-dark-50") ]), div("grid grid-cols-2 gap-2", Object.keys(character.abilities).map((ability) => div("flex flex-row px-1 justify-between items-center", [ proses('a', preview, [ span("text-sm text-light-70 dark:text-dark-70 max-w-20 truncate cursor-help decoration-dotted underline", abilityTexts[ability as Ability] || ability) ], { href: `regles/l'entrainement/competences#${abilityTexts[ability as Ability]}`, label: abilityTexts[ability as Ability], navigate: false }), span("font-bold text-base text-light-100 dark:text-dark-100", text(() => `+${character.abilities[ability as Ability] ?? 0}`)), ]) ) ), div("flex flex-row items-center justify-center gap-4", [ dom("div", { class: 'text-xl font-semibold', text: "Maitrises" }), div("flex flex-1 border-t border-dashed border-light-50 dark:border-dark-50") ]), div("grid grid-cols-2 gap-x-3 gap-y-1 text-sm", { list: character.mastery, render: (e, _c) => proses('a', preview, [ text(masteryTexts[e].text) ], { href: masteryTexts[e].href, label: masteryTexts[e].text, class: 'text-sm text-light-70 dark:text-dark-70 cursor-help decoration-dotted underline', }) }), div("grid grid-cols-2 gap-x-3 gap-y-1 text-sm", [ () => character.spellranks.precision > 0 ? div('flex flex-row items-center gap-2', [ proses('a', preview, [ text('Précision') ], { href: 'regles/la-magie/magie#Les sorts de précision', label: 'Précision', class: 'text-sm text-light-70 dark:text-dark-70 cursor-help decoration-dotted underline' }), span('font-bold', text(() => character.spellranks.precision)) ]) : undefined, () => character.spellranks.knowledge > 0 ? div('flex flex-row items-center gap-2', [ proses('a', preview, [ text('Savoir') ], { href: 'regles/la-magie/magie#Les sorts de savoir', label: 'Savoir', class: 'text-sm text-light-70 dark:text-dark-70 cursor-help decoration-dotted underline' }), span('font-bold', text(() => character.spellranks.knowledge)) ]) : undefined, () => character.spellranks.instinct > 0 ? div('flex flex-row items-center gap-2', [ proses('a', preview, [ text('Instinct') ], { href: 'regles/la-magie/magie#Les sorts instinctif', label: 'Instinct', class: 'text-sm text-light-70 dark:text-dark-70 cursor-help decoration-dotted underline' }), span('font-bold', text(() => character.spellranks.instinct)) ]) : undefined, () => character.spellranks.arts > 0 ? div('flex flex-row items-center gap-2', [ proses('a', preview, [ text('Oeuvres') ], { href: 'regles/annexes/œuvres', label: 'Oeuvres', class: 'text-sm text-light-70 dark:text-dark-70 cursor-help decoration-dotted underline' }), span('font-bold', text(() => character.spellranks.arts)) ]) : undefined, ]) ]) ]), div('border-l border-light-35 dark:border-dark-35'), this.tabs, ]) ])); } healthPanel(character: CompiledCharacter) { const inputs = reactive({ health: { sum: 0, slashing: 0, piercing: 0, bludgening: 0, magic: 0, fire: 0, thunder: 0, cold: 0, open: false, }, mana: 0, }); const armor = this.character?.armor; const container = div("border-light-35 dark:border-dark-35 bg-light-10 dark:bg-dark-10 border-l absolute top-0 bottom-0 right-0 w-[10%] data-[state=active]:w-[480px] flex flex-col gap-4 text-light-100 dark:text-dark-100 p-8 transition-[width] transition-delay-[150ms]", [ div('flex flex-row justify-between items-center', [ div('flex flex-row gap-8 items-center', [ span('text-xl font-bold', 'Edititon de vie'), div('flex flex-row items-center gap-1', [ span('text-xl font-bold', () => (character.health - character.variables.health)), text('/'), text(() => character.health) ]) ]), tooltip(button(icon("radix-icons:cross-1", { width: 24, height: 24 }), () => { setTimeout(blocker.close, 150); container.setAttribute('data-state', 'inactive'); }, "p-1"), "Fermer", "left") ]), foldable([ div('flex flex-col w-full gap-2 ms-2 ps-4 border-l border-light-35 dark:border-dark-35', DAMAGE_TYPES.map(e => div('flex flex-row justify-between items-center', [ span('text-lg', damageTypeTexts[e]), div('flex flew-row gap-4 justify-end', [ () => /* Res/Vul/Immun */ div('w-8'), numberpicker({ defaultValue: () => inputs.health[e], input: v => { inputs.health[e] = v; inputs.health.sum = DAMAGE_TYPES.reduce((p, v) => p + inputs.health[v], 0) }, min: 0, class: 'h-8 !m-0' }), div('w-8') ]), ]))) ], [ div('flex flex-row justify-between items-center', [ span('text-lg', 'Total'), div('flex flew-row gap-4 justify-end', [ () => armor ? tooltip(button(div('flex flex-row gap-2 items-center text-sm', [ icon('game-icons:shoulder-armor', { width: 16, height: 16, class: 'text-light-70 dark:text-dark-70' }), span('italic', () => `${armor.current}/${armor.max} (${[armor.flat > 0 ? '-' + armor.flat : undefined, armor.percent > 0 ? armor.percent + '%' : undefined].filter(e => !!e).join('/')})`) ]), () => { //TODO }, 'px-2 h-8 border-light-red dark:border-dark-red hover:border-light-red dark:hover:border-dark-red focus:border-light-red dark:focus:border-dark-red focus:shadow-light-red dark:focus:shadow-dark-red'), 'Dégats', 'left') : undefined, tooltip(button(icon('radix-icons:minus', { width: 16, height: 16 }), () => { character.variables.health += inputs.health.sum; inputs.health.sum = 0; DAMAGE_TYPES.forEach(e => inputs.health[e] = 0); this.character?.saveVariables(); }, 'w-8 h-8 border-light-red dark:border-dark-red hover:border-light-red dark:hover:border-dark-red focus:border-light-red dark:focus:border-dark-red focus:shadow-light-red dark:focus:shadow-dark-red'), 'Dégats', 'left'), numberpicker({ defaultValue: () => inputs.health.sum, input: v => { inputs.health.sum = v }, min: 0, disabled: () => inputs.health.open, class: 'h-8 !m-0' }), tooltip(button(icon('radix-icons:plus', { width: 16, height: 16 }), () => { character.variables.health = Math.max(character.variables.health - inputs.health.sum, 0); inputs.health.sum = 0; DAMAGE_TYPES.forEach(e => inputs.health[e] = 0); this.character?.saveVariables(); }, 'w-8 h-8 border-light-green dark:border-dark-green hover:border-light-green dark:hover:border-dark-green focus:border-light-green dark:focus:border-dark-green focus:shadow-light-green dark:focus:shadow-dark-green'), 'Soin', 'left'), ]) ]) ], { class: { container: 'gap-2', title: 'ps-2' }, open: false, onFold: v => { inputs.health.open = v; if(v) { inputs.health.sum = 0; }} }), div('flex flex-row justify-between items-center', [ div('flex flex-row gap-8 items-center', [ span('text-xl font-bold', 'Mana'), div('flex flex-row items-center gap-1', [ span('text-xl font-bold', () => (character.mana - character.variables.mana)), text('/'), text(() => character.mana) ]) ]), div('flex flex-row gap-4 justify-end', [ tooltip(button(icon('radix-icons:minus', { width: 16, height: 16 }), () => { character.variables.mana += inputs.mana; inputs.mana = 0; this.character?.saveVariables(); }, 'w-8 h-8 border-light-red dark:border-dark-red hover:border-light-red dark:hover:border-dark-red focus:border-light-red dark:focus:border-dark-red focus:shadow-light-red dark:focus:shadow-dark-red'), 'Dégats', 'left'), numberpicker({ defaultValue: () => inputs.mana, input: v => { inputs.mana = v }, min: 0, class: 'h-8 !m-0' }), tooltip(button(icon('radix-icons:plus', { width: 16, height: 16 }), () => { character.variables.mana = Math.max(character.variables.mana - inputs.mana, 0); inputs.mana = 0; this.character?.saveVariables(); }, 'w-8 h-8 border-light-green dark:border-dark-green hover:border-light-green dark:hover:border-dark-green focus:border-light-green dark:focus:border-dark-green focus:shadow-light-green dark:focus:shadow-dark-green'), 'Soin', 'left'), ]) ]), ]); const blocker = fullblocker([ container ], { closeWhenOutside: true, open: false }) return { show: () => { setTimeout(() => container.setAttribute('data-state', 'active'), 1); blocker.open(); }, hide: () => { setTimeout(blocker.close, 150); container.setAttribute('data-state', 'inactive'); }}; } actionsTab(character: CompiledCharacter) { return [ div('flex flex-col gap-8', [ div('flex flex-col gap-2', [ div("flex flex-row items-center justify-center gap-4", [ div("flex flex-row items-center justify-center gap-2", [ dom("div", { class: 'text-lg font-semibold', text: "Actions" }) ]), div("flex flex-1 border-t border-dashed border-light-50 dark:border-dark-50"), div('flex flex-row items-center gap-2', [ div('border border-dashed border-light-50 dark:border-dark-50 w-5 h-5'), div('border border-dashed border-light-50 dark:border-dark-50 w-5 h-5'), div('border border-dashed border-light-50 dark:border-dark-50 w-5 h-5'), dom('span', { class: 'tracking-tight', text: '/ tour' }) ]), ]), div('flex flex-col gap-2', [ div('flex flex-row flex-wrap gap-2', ["Attaquer", "Désarmer", "Saisir", "Faire chuter", "Déplacer", "Courir", "Pas de coté", "Charger", "Lancer un sort", "S'interposer", "Se transformer", "Utiliser un objet", "Anticiper une action", "Improviser"].map(e => proses('a', preview, [ span('cursor-pointer text-sm decoration-dotted underline', e) ], { href: 'regles/le-combat/actions-en-combat#' + e, label: e, trigger: 'hover', navigate: false, class: 'text-light-60 dark:text-dark-60', lowers: false }))), div('flex flex-col gap-2', { render: (e, _c) => _c ?? div('flex flex-col gap-1', [ div('flex flex-row justify-between', [dom('span', { class: 'text-lg font-semibold', text: config.action[e]?.name }), config.action[e]?.cost ? div('flex flex-row gap-1', [dom('span', { class: 'font-bold', text: config.action[e]?.cost?.toString() }), text(`point${config.action[e]?.cost > 1 ? 's' : ''} d'action`)]) : undefined]), markdown(getText(config.action[e]?.description), undefined, { tags: { a: preview } }), ]), list: character.lists.action }), ]), ]), div('flex flex-col gap-2', [ div("flex flex-row items-center justify-center gap-4", [ div("flex flex-row items-center justify-center gap-2", [ dom("div", { class: 'text-lg font-semibold', text: "Réactions" }) ]), div("flex flex-1 border-t border-dashed border-light-50 dark:border-dark-50"), div('flex flex-row items-center gap-2', [ div('border border-dashed border-light-50 dark:border-dark-50 w-5 h-5'), div('border border-dashed border-light-50 dark:border-dark-50 w-5 h-5'), dom('span', { class: 'tracking-tight', text: '/ tour' }) ]), ]), div('flex flex-col gap-2', [ div('flex flex-row flex-wrap gap-2 text-light-60 dark:text-dark-60', ["Parer", "Esquiver", "Saisir une opportunité", "Prendre en tenaille", "Intercepter"].map(e => proses('a', preview, [ span('cursor-pointer text-sm decoration-dotted underline', e) ], { href: 'regles/le-combat/actions-en-combat#' + e, label: e, trigger: 'hover', navigate: false, class: 'text-light-60 dark:text-dark-60', lowers: false }))), div('flex flex-col gap-2', { render: (e, _c) => _c ?? div('flex flex-col gap-1', [ div('flex flex-row justify-between', [dom('span', { class: 'text-lg font-semibold', text: config.reaction[e]?.name }), config.reaction[e]?.cost ? div('flex flex-row gap-1', [dom('span', { class: 'font-bold', text: config.reaction[e]?.cost?.toString() }), text(`point${config.reaction[e]?.cost > 1 ? 's' : ''} d'action`)]) : undefined]), markdown(getText(config.reaction[e]?.description), undefined, { tags: { a: preview } }), ]), list: character.lists.reaction }), ]), ]), div('flex flex-col gap-2', [ div("flex flex-row items-center justify-center gap-4", [ div("flex flex-row items-center justify-center gap-2", [ dom("div", { class: 'text-lg font-semibold', text: "Actions libres" }) ]), div("flex flex-1 border-t border-dashed border-light-50 dark:border-dark-50"), ]), div('flex flex-col gap-2', [ div('flex flex-row flex-wrap gap-2 text-light-60 dark:text-dark-60', ["Analyser une situation", "Communiquer", "Dégainer", "Attraper un objet"].map(e => proses('a', preview, [ span('cursor-pointer text-sm decoration-dotted underline', e) ], { href: 'regles/le-combat/actions-en-combat#' + e, label: e, trigger: 'hover', navigate: false, class: 'text-light-60 dark:text-dark-60', lowers: false }))), div('flex flex-col gap-2', { render: (e, _c) => _c ?? div('flex flex-col gap-1', [ div('flex flex-row justify-between', [dom('span', { class: 'text-lg font-semibold', text: config.freeaction[e]?.name }) ]), markdown(getText(config.freeaction[e]?.description), undefined, { tags: { a: preview } }), ]), list: character.lists.reaction }) ]), ]), ]), ] } abilitiesTab(character: CompiledCharacter) { return [ div('flex flex-col gap-2', { render: (e, _c) => _c ?? div('flex flex-col gap-1', [ div('flex flex-row justify-between', [dom('span', { class: 'text-lg font-semibold', text: config.passive[e]?.name }) ]), markdown(getText(config.passive[e]?.description), undefined, { tags: { a: preview } }), ]), list: character.lists.passive }), ]; } spellTab(character: CompiledCharacter) { const preference = reactive({ sort: localStorage.getItem('character-sort') ?? 'rank-asc', } as { sort: `${'rank'|'type'|'element'|'cost'|'range'|'speed'}-${'asc'|'desc'}` | '' }); const sort = (spells: string[]) => { localStorage.setItem('character-sort', preference.sort); const _spells = Object.keys(config.spells); spells.sort((a, b) => _spells.indexOf(a) - _spells.indexOf(b)); switch(preference.sort) { case 'rank-asc': return spells.sort((a, b) => (config.spells[a]?.rank ?? 0) - (config.spells[b]?.rank ?? 0)); case 'type-asc': return spells.sort((a, b) => config.spells[a]?.type.localeCompare(config.spells[b]?.type ?? '') ?? 0); case 'element-asc': return spells.sort((a, b) => SPELL_ELEMENTS.indexOf(config.spells[a]?.elements[0]!) - SPELL_ELEMENTS.indexOf(config.spells[b]?.elements[0]!)); case 'cost-asc': return spells.sort((a, b) => (config.spells[a]?.cost ?? 0) - (config.spells[b]?.cost ?? 0)); case 'range-asc': return spells.sort((a, b) => (config.spells[a]?.range === 'personnal' ? -1 : config.spells[a]?.range ?? 0) - (config.spells[b]?.range === 'personnal' ? -1 : config.spells[b]?.range ?? 0)); case 'speed-asc': return spells.sort((a, b) => (config.spells[a]?.speed === 'action' ? -2 : config.spells[a]?.speed === 'reaction' ? -1 : config.spells[a]?.speed ?? 0) - (config.spells[a]?.speed === 'action' ? -2 : config.spells[a]?.speed === 'reaction' ? -1 : config.spells[a]?.speed ?? 0)); case 'rank-desc': return spells.sort((b, a) => (config.spells[a]?.rank ?? 0) - (config.spells[b]?.rank ?? 0)); case 'type-desc': return spells.sort((b, a) => config.spells[a]?.type.localeCompare(config.spells[b]?.type ?? '') ?? 0); case 'element-desc': return spells.sort((b, a) => SPELL_ELEMENTS.indexOf(config.spells[a]?.elements[0]!) - SPELL_ELEMENTS.indexOf(config.spells[b]?.elements[0]!)); case 'cost-desc': return spells.sort((b, a) => (config.spells[a]?.cost ?? 0) - (config.spells[b]?.cost ?? 0)); case 'range-desc': return spells.sort((b, a) => (config.spells[a]?.range === 'personnal' ? -1 : config.spells[a]?.range ?? 0) - (config.spells[b]?.range === 'personnal' ? -1 : config.spells[b]?.range ?? 0)); case 'speed-desc': return spells.sort((b, a) => (config.spells[a]?.speed === 'action' ? -2 : config.spells[a]?.speed === 'reaction' ? -1 : config.spells[a]?.speed ?? 0) - (config.spells[a]?.speed === 'action' ? -2 : config.spells[a]?.speed === 'reaction' ? -1 : config.spells[a]?.speed ?? 0)); default: return spells; } }; const panel = this.spellPanel(character); const sorter = function(this: HTMLElement) { return followermenu(this, [ () => dom('div', { class: 'flex flex-row justify-between items-center gap-4 py-1 px-2 hover:bg-light-40 dark:hover:bg-dark-40 cursor-pointer', listeners: { click: () => preference.sort = (preference.sort === 'rank-asc' ? 'rank-desc' : preference.sort === 'rank-desc' ? '' : 'rank-asc') } }, [text('Rang'), () => preference.sort.startsWith('rank') ? icon(preference.sort.endsWith('asc') ? 'ph:sort-ascending' : 'ph:sort-descending', { width: 16, height: 16 }) : undefined ]), () => dom('div', { class: 'flex flex-row justify-between items-center gap-4 py-1 px-2 hover:bg-light-40 dark:hover:bg-dark-40 cursor-pointer', listeners: { click: () => preference.sort = (preference.sort === 'type-asc' ? 'type-desc' : preference.sort === 'type-desc' ? '' : 'type-asc') } }, [text('Type'), () => preference.sort.startsWith('type') ? icon(preference.sort.endsWith('asc') ? 'ph:sort-ascending' : 'ph:sort-descending', { width: 16, height: 16 }) : undefined ]), () => dom('div', { class: 'flex flex-row justify-between items-center gap-4 py-1 px-2 hover:bg-light-40 dark:hover:bg-dark-40 cursor-pointer', listeners: { click: () => preference.sort = (preference.sort === 'element-asc' ? 'element-desc' : preference.sort === 'element-desc' ? '' : 'element-asc') } }, [text('Element'), () => preference.sort.startsWith('element') ? icon(preference.sort.endsWith('asc') ? 'ph:sort-ascending' : 'ph:sort-descending', { width: 16, height: 16 }) : undefined ]), () => dom('div', { class: 'flex flex-row justify-between items-center gap-4 py-1 px-2 hover:bg-light-40 dark:hover:bg-dark-40 cursor-pointer', listeners: { click: () => preference.sort = (preference.sort === 'cost-asc' ? 'cost-desc' : preference.sort === 'cost-desc' ? '' : 'cost-asc') } }, [text('Coût'), () => preference.sort.startsWith('cost') ? icon(preference.sort.endsWith('asc') ? 'ph:sort-ascending' : 'ph:sort-descending', { width: 16, height: 16 }) : undefined ]), () => dom('div', { class: 'flex flex-row justify-between items-center gap-4 py-1 px-2 hover:bg-light-40 dark:hover:bg-dark-40 cursor-pointer', listeners: { click: () => preference.sort = (preference.sort === 'range-asc' ? 'range-desc' : preference.sort === 'range-desc' ? '' : 'range-asc') } }, [text('Portée'), () => preference.sort.startsWith('range') ? icon(preference.sort.endsWith('asc') ? 'ph:sort-ascending' : 'ph:sort-descending', { width: 16, height: 16 }) : undefined ]), () => dom('div', { class: 'flex flex-row justify-between items-center gap-4 py-1 px-2 hover:bg-light-40 dark:hover:bg-dark-40 cursor-pointer', listeners: { click: () => preference.sort = (preference.sort === 'speed-asc' ? 'speed-desc' : preference.sort === 'speed-desc' ? '' : 'speed-asc') } }, [text('Incantation'), () => preference.sort.startsWith('speed') ? icon(preference.sort.endsWith('asc') ? 'ph:sort-ascending' : 'ph:sort-descending', { width: 16, height: 16 }) : undefined ]), ], { class: 'text-light-100 dark:text-dark-100 w-32', offset: 8, placement: 'bottom-end', arrow: true }); } return [ div('flex flex-col gap-2 h-full', [ div('flex flex-row justify-end items-center', [ div('flex flex-row gap-2 items-center', [ dom('span', { class: () => ['italic text-sm', { 'text-light-red dark:text-dark-red': character.variables.spells.length + (character.lists.spells?.length ?? 0) !== character.spellslots }], text: () => `${character.variables.spells.length + (character.lists.spells?.length ?? 0)}/${character.spellslots} sort(s) maitrisé(s)`.replaceAll('(s)', character.variables.spells.length + (character.lists.spells?.length ?? 0) > 1 ? 's' : '') }), button(text('Modifier'), () => panel.show(), 'py-1 px-4'), tooltip(button(icon('ph:arrows-down-up', { width: 16, height: 16 }), sorter, 'p-1'), 'Trier par', 'right') ]) ]), div('flex flex-col gap-2 overflow-auto', { render: (e, _c) => { if(_c) return _c; const spell = config.spells[e] as SpellConfig | undefined; if(!spell) return; return div('flex flex-col gap-2', [ div('flex flex-row items-center gap-4', [ dom('span', { class: 'font-semibold text-lg', text: spell.name ?? 'Inconnu' }), div('flex-1 border-b border-dashed border-light-50 dark:border-dark-50'), dom('span', { class: 'text-light-70 dark:text-dark-70', text: `${spell.cost ?? 0} mana` }) ]), div('flex flex-row justify-between items-center gap-2 text-light-70 dark:text-dark-70', [ div('flex flex-row gap-2', [ span('flex flex-row', `Sort ${spell.type === 'instinct' ? 'd\'instinct' : spell.type === 'knowledge' ? 'de savoir' : 'de précision'} ${spell.rank === 4 ? 'unique' :`de rang ${spell.rank}`}`), ...(spell.elements ?? []).map(elementDom) ]), div('flex flex-row gap-4 items-center', [ spell.concentration ? proses('a', preview, [span('italic text-sm', 'concentration')], { href: '' }) : undefined, span(undefined, typeof spell.range === 'number' && spell.range > 0 ? `${spell.range} case${spell.range > 1 ? 's' : ''}` : spell.range === 0 ? 'toucher' : 'personnel'), span(undefined, typeof spell.speed === 'number' ? `${spell.speed} minute${spell.speed > 1 ? 's' : ''}` : spell.speed) ]) ]), div('flex flex-row ps-4 p-1 border-l-4 border-light-35 dark:border-dark-35', [ markdown(spell.description) ]), ]) }, list: () => sort([...(character.lists.spells ?? []), ...character.variables.spells]) }), ]) ] } spellPanel(character: CompiledCharacter) { const spells = character.variables.spells; const filters = reactive<{ tag: Array, rank: Array, type: Array, element: Array, cost: { min: number, max: number }, range: Array, speed: Array }>({ tag: [], type: [], rank: [], element: [], cost: { min: 0, max: Infinity }, range: [], speed: [], }); const container = div("border-light-35 dark:border-dark-35 bg-light-10 dark:bg-dark-10 border-l absolute top-0 bottom-0 right-0 w-[10%] data-[state=active]:w-1/2 flex flex-col gap-4 text-light-100 dark:text-dark-100 p-8 transition-[width] transition-delay-[150ms]", [ div("flex flex-row justify-between items-center", [ dom("h2", { class: "text-xl font-bold", text: "Ajouter un sort" }), div('flex flex-row gap-4 items-center', [ dom('span', { class: 'italic text-light-70 dark:text-dark-70 text-sm' }, [ text(() => `${spells.length + (character.lists.spells?.length ?? 0)}/${character.spellslots} sort(s) maitrisé(s)`.replaceAll('(s)', spells.length + (character.lists.spells?.length ?? 0) > 1 ? 's' : '')) ]), tooltip(button(icon("radix-icons:cross-1", { width: 20, height: 20 }), () => { setTimeout(blocker.close, 150); container.setAttribute('data-state', 'inactive'); }, "p-1"), "Fermer", "left") ]) ]), div('flex flex-row gap-2', [ div('flex flex-col gap-1 items-center', [ text('Tags'), multiselect([{ text: 'Dégâts', value: 'damage' }, { text: 'Buff', value: 'buff' }, { text: 'Debuff', value: 'debuff' }, { text: 'Support', value: 'support' }, { text: 'Tank', value: 'tank' }, { text: 'Mouvement', value: 'movement' }, { text: 'Utilitaire', value: 'utilitary' }], { defaultValue: filters.tag, change: v => filters.tag = v, class: { container: 'w-32 !mx-0 text-xs', option: 'text-sm p-1' } }) ]), div('flex flex-col gap-1 items-center', [ text('Types'), multiselect(SPELL_TYPES.map(f => ({ text: spellTypeTexts[f], value: f })), { defaultValue: filters.type, change: v => filters.type = v, class: { container: 'w-28 !mx-0 text-xs', option: 'text-sm p-1' } }) ]), div('flex flex-col gap-1 items-center', [ text('Rangs'), multiselect([{ text: 'Rang 1', value: 1 }, { text: 'Rang 2', value: 2 }, { text: 'Rang 3', value: 3 }], { defaultValue: filters.rank, change: v => filters.rank = v, class: { container: 'w-24 !mx-0 text-xs', option: 'text-sm p-1' } }) ]), div('flex flex-col gap-1 items-center', [ text('Elements'), multiselect(SPELL_ELEMENTS.map(f => ({ text: elementTexts[f].text, value: f })), { defaultValue: filters.element, change: v => filters.element = v, class: { container: 'w-28 !mx-0 text-xs', option: 'text-sm p-1' } }) ]), div('flex flex-col gap-1 items-center', [ text('Portée'), multiselect([{ text: 'Toucher', value: 0 }, { text: 'Personnel', value: 'personnal' }, { text: '3 cases', value: 3 }, { text: '6 cases', value: 6 }, { text: '9 cases', value: 9 }, { text: '12 cases', value: 12 }, { text: '18 cases', value: 18 }], { defaultValue: filters.range, change: v => filters.range = v, class: { container: 'w-28 !mx-0 text-xs', option: 'text-sm p-1' } }) ]), div('flex flex-col gap-1 items-center', [ text('Incantation'), multiselect([{ text: 'Action', value: 'action' }, { text: 'Reaction', value: 'reaction' }, { text: '1 minute', value: 1 }, { text: '10 minutes', value: 10 }], { defaultValue: filters.speed, change: v => filters.speed = v, class: { container: 'w-32 !mx-0 text-xs', option: 'text-sm p-1' } }) ]), ]), div('flex flex-col divide-y *:py-2 -my-2 overflow-y-auto', { list: () => Object.values(config.spells).filter(spell => { //if(spells.includes(spell.id)) return true; if(character.spellranks[spell.type] < spell.rank) return false; if(filters.cost.min > spell.cost || spell.cost > filters.cost.max) return false; if(filters.element.length > 0 && !filters.element.some(e => spell.elements.includes(e))) return false; if(filters.range.length > 0 && !filters.range.includes(spell.range)) return false; if(filters.rank.length > 0 && !filters.rank.includes(spell.rank)) return false; if(filters.type.length > 0 && !filters.type.includes(spell.type)) return false; if(filters.speed.length > 0 && !filters.speed.includes(spell.speed)) return false; if(filters.tag.length > 0 && !filters.tag.some(e => spell.tags?.includes(e))) return false; return true; }) as SpellConfig[], render: (spell, _c) => _c ?? foldable(() => [ markdown(spell.description), ], [ div("flex flex-row justify-between gap-2", [ dom("span", { class: "text-lg font-bold", text: spell.name }), div("flex flex-row items-center gap-6", [ div("flex flex-row text-sm gap-2", spell.elements.map(el => dom("span", { class: [`border !border-opacity-50 rounded-full !bg-opacity-20 px-2 py-px`, elementTexts[el].class], text: elementTexts[el].text }) ) ), div("flex flex-row text-sm gap-1", [ ...(spell.rank !== 4 ? [ dom("span", { text: `Rang ${spell.rank}` }), text("/"), dom("span", { text: spellTypeTexts[spell.type] }), text("/") ] : []), dom("span", { text: `${spell.cost} mana` }), text("/"), dom("span", { text: typeof spell.speed === "string" ? spell.speed : `${spell.speed} minutes` }) ]), button(text(() => spells.includes(spell.id) ? 'Supprimer' : character.lists.spells?.includes(spell.id) ? 'Inné' : 'Ajouter'), () => { const idx = spells.findIndex(e => e === spell.id); if(idx !== -1) spells.splice(idx, 1); else spells.push(spell.id); this.character?.saveVariables(); }, "px-2 py-1 text-sm font-normal"), ]), ]) ], { open: false, class: { container: "px-2 flex flex-col border-light-35 dark:border-dark-35", content: 'py-2' } }) }) ]); const blocker = fullblocker([ container ], { closeWhenOutside: true, open: false }); return { show: () => { setTimeout(() => container.setAttribute('data-state', 'active'), 1); blocker.open(); }, hide: () => { setTimeout(blocker.close, 150); container.setAttribute('data-state', 'inactive'); }}; } itemsTab(character: CompiledCharacter) { const items = character.variables.items; const panel = this.itemsPanel(character); const enchant = this.enchantPanel(character); const money = { readonly: dom('div', { listeners: { click: () => { money.readonly.replaceWith(money.edit); money.edit.focus(); } }, class: 'cursor-pointer border border-transparent hover:border-light-40 dark:hover:border-dark-40 px-2 py-px flex flex-row gap-1 items-center' }, [ span('text-lg font-bold', () => character.variables.money.toLocaleString(undefined, { useGrouping: true })), icon('ph:coin', { width: 16, height: 16 }) ]), edit: numberpicker({ defaultValue: character.variables.money, change: v => { character.variables.money = v; this.character?.saveVariables(); money.edit.replaceWith(money.readonly); }, blur: v => { character.variables.money = v; this.character?.saveVariables(); money.edit.replaceWith(money.readonly); }, min: 0, class: 'w-24' }), }; return [ div('flex flex-col gap-2', [ div('flex flex-row justify-between items-center', [ div('flex flex-row justify-end items-center gap-8', [ div('flex flex-row gap-1 items-center', [ span('italic text-sm', 'Argent'), money.readonly ]), ]), div('flex flex-row justify-end items-center gap-8', [ dom('span', { class: () => ['italic text-sm', { 'text-light-red dark:text-dark-red': this.character!.power > character.itempower }], text: () => `Puissance magique: ${this.character!.power}/${character.itempower}` }), dom('span', { class: () => ['italic text-sm', { 'text-light-red dark:text-dark-red': this.character!.weight > (character.capacity === false ? 0 : character.capacity) }], text: () => `Poids total: ${this.character!.weight}/${character.capacity}` }), button(text('Modifier'), () => panel.show(), 'py-1 px-4'), ]), ]), div('flex flex-col flex-1 divide-y divide-light-35 dark:divide-dark-35', { list: character.variables.items, render: (e, _c) => { if(_c) return _c; const item = config.items[e.id]; if(!item) return; const itempower = () => (item.powercost ?? 0) + (e.enchantments?.reduce((_p, _v) => (config.enchantments[_v]?.power ?? 0) + _p, 0) ?? 0); const price = div(() => ['flex flex-row min-w-16 gap-2 justify-between items-center px-2', { 'cursor-help': e.amount > 1 && !!item.price }], [ icon('ph:coin', { width: 16, height: 16, class: 'text-light-70 dark:text-dark-70' }), span(() => ({ 'underline decoration-1 decoration-dotted underline-offset-2': e.amount > 1 && !!item.price }), () => item.price ? `${item.price * e.amount}` : '-') ]); const weight = div(() => ['flex flex-row min-w-16 gap-2 justify-between items-center px-2', { 'cursor-help': e.amount > 1 && !!item.weight }], [ icon('mdi:weight', { width: 16, height: 16, class: 'text-light-70 dark:text-dark-70' }), span(() => ({ 'underline decoration-1 decoration-dotted underline-offset-2': e.amount > 1 && !!item.weight }), () => item.weight ? `${item.weight * e.amount}` : '-') ]); return foldable(() => [ markdown(getText(item.description)), div('flex flex-row gap-1', { list: () => e.enchantments!.map(e => config.enchantments[e]).filter(e => !!e), render: (e, _c) => _c ?? floater(div('flex flex-row gap-2 border border-accent-blue px-2 rounded-full py-px bg-accent-blue bg-opacity-20', [ span('text-sm font-semibold tracking-tight', e.name), div('flex flex-row gap-1 items-center', [icon('game-icons:bolt-drop', { width: 12, height: 12 }), span('text-sm font-light', e.power)]) ]), () => [markdown(getText(e.description), undefined, { tags: { a: preview } })], { class: 'max-w-96 max-h-48 p-2', position: "right" }) }), div('flex flex-row justify-center gap-1', [ this.character?.character.campaign ? button(text('Partager'), () => { }, 'px-2 text-sm h-5 box-content') : undefined, button(icon(() => e.amount === 1 ? 'radix-icons:trash' : 'radix-icons:minus', { width: 12, height: 12 }), () => { const idx = items.findIndex(_e => _e === e); if(idx === -1) return; items[idx]!.amount--; if(items[idx]!.amount <= 0) items.splice(idx, 1); this.character?.saveVariables(); }, 'p-1'), button(icon('radix-icons:plus', { width: 12, height: 12 }), () => { const idx = items.findIndex(_e => _e === e); if(idx === -1) return; if(item.equippable) items.push(stateFactory(item)); else if(items.find(_e => _e === e)) items.find(_e => _e === e)!.amount++; else items.push(stateFactory(item)); this.character?.saveVariables(); }, 'p-1'), () => !item.capacity ? undefined : button(text("Enchanter"), () => { enchant.show(e); }, 'px-2 text-sm h-5 box-content'), ]) ], [ div('flex flex-row justify-between', [ div('flex flex-row items-center gap-y-1 gap-x-4 flex-wrap', [ item.equippable ? checkbox({ defaultValue: e.equipped, change: v => { e.equipped = v; this.character?.enchant(e); }, class: { container: '!w-5 !h-5' } }) : undefined, div('flex flex-row items-center gap-4', [ span([colorByRarity[item.rarity], 'text-lg'], item.name), div('flex flex-row gap-2 text-light-60 dark:text-dark-60 text-sm italic', subnameFactory(item).map(e => span('', e))) ]), item.category === 'armor' ? div('flex flex-row gap-2 items-center text-sm', [ icon('game-icons:shoulder-armor', { width: 16, height: 16, class: 'text-light-70 dark:text-dark-70' }), span('italic', () => `${item.health + ((e.state as ArmorState)?.health ?? 0) - ((e.state as ArmorState)?.loss ?? 0)}/${item.health + ((e.state as ArmorState)?.health ?? 0)} (${[item.absorb.static + ((e.state as ArmorState).absorb?.flat ?? 0) > 0 ? '-' + (item.absorb.static + ((e.state as ArmorState).absorb?.flat ?? 0)) : undefined, item.absorb.percent + ((e.state as ArmorState).absorb?.percent ?? 0) > 0 ? '-' + (item.absorb.percent + ((e.state as ArmorState).absorb?.percent ?? 0)) + '%' : undefined].filter(e => !!e).join('/')})`) ]) : item.category === 'weapon' ? div('flex flex-row gap-2 items-center text-sm', [ icon('game-icons:broadsword', { width: 16, height: 16, class: 'text-light-70 dark:text-dark-70' }), span('italic', () => `${item.damage.value} ${(e.state as WeaponState)?.attack ? '+' + (e.state as WeaponState).attack : ''}`), proses('a', preview, [ text(damageTypeTexts[item.damage.type].toLowerCase()) ], { href: `regles/le-combat/les-types-de-degats#${damageTypeTexts[item.damage.type]}`, label: damageTypeTexts[item.damage.type], navigate: false }) ]) : undefined ]), div('flex flex-row items-center divide-x divide-light-50 dark:divide-dark-50 divide-dashed px-2', [ e.amount > 1 && !!item.price ? tooltip(price, `Prix unitaire: ${item.price}`, 'bottom') : price, div('flex flex-row min-w-16 gap-2 justify-between items-center px-2', [ icon('radix-icons:cross-2', { width: 16, height: 16, class: 'text-light-70 dark:text-dark-70' }), span('', () => e.amount ?? '-') ]), div('flex flex-row min-w-16 gap-2 justify-between items-center px-2', [ icon('game-icons:bolt-drop', { width: 16, height: 16, class: 'text-light-70 dark:text-dark-70' }), span(() => ({ 'text-red': !!item.capacity && itempower() > item.capacity }), () => item.capacity ? `${itempower()}/${item.capacity ?? 0}` : '-') ]), e.amount > 1 && !!item.weight ? tooltip(weight, `Poids unitaire: ${item.weight}`, 'bottom') : weight, div('flex flex-row min-w-16 gap-2 justify-between items-center px-2', [ icon('game-icons:battery-pack', { width: 16, height: 16, class: 'text-light-70 dark:text-dark-70' }), span('', () => item.charge ? `${item.charge}` : '-') ]), ]), ])], { open: false, class: { icon: 'px-2', container: 'p-1 gap-2', content: 'px-4 pb-1 flex flex-col gap-1' } }) }}) ]) ]; } itemsPanel(character: CompiledCharacter) { const filters: { category: Category[], rarity: Rarity[], name: string, power: { min: number, max: number } } = reactive({ category: [], rarity: [], name: '', power: { min: 0, max: Infinity }, }); const container = div("border-light-35 dark:border-dark-35 bg-light-10 dark:bg-dark-10 border-l absolute top-0 bottom-0 right-0 w-[10%] data-[state=active]:w-1/2 flex flex-col gap-4 text-light-100 dark:text-dark-100 p-8 transition-[width] transition-delay-[150ms]", [ div("flex flex-row justify-between items-center mb-4", [ dom("h2", { class: "text-xl font-bold", text: "Gestion de l'inventaire" }), div('flex flex-row gap-8 items-center justify-end', [ dom('span', { class: () => ['italic text-sm', { 'text-light-red dark:text-dark-red': this.character!.weight > (character.capacity === false ? 0 : character.capacity) }], text: () => `Poids total: ${this.character!.weight}/${character.capacity}` }), tooltip(button(icon("radix-icons:cross-1", { width: 20, height: 20 }), () => { setTimeout(blocker.close, 150); container.setAttribute('data-state', 'inactive'); }, "p-1"), "Fermer", "left") ]) ]), div('flex flex-row items-center gap-4', [ div('flex flex-row gap-2 items-center', [ text('Catégorie'), multiselect(Object.keys(categoryText).map(e => ({ text: categoryText[e as Category], value: e as Category })), { defaultValue: filters.category, change: v => filters.category = v, class: { container: 'w-40' } }) ]), div('flex flex-row gap-2 items-center', [ text('Rareté'), multiselect(Object.keys(rarityText).map(e => ({ text: rarityText[e as Rarity], value: e as Rarity })), { defaultValue: filters.rarity, change: v => filters.rarity = v, class: { container: 'w-40' } }) ]), div('flex flex-row gap-2 items-center', [ text('Nom'), input('text', { defaultValue: filters.name, input: v => { filters.name = v; }, class: 'w-64' }) ]), ]), div('grid grid-cols-1 -my-2 overflow-y-auto gap-1', { list: () => Object.values(config.items).filter(item => (filters.category.length === 0 || filters.category.includes(item.category)) && (filters.rarity.length === 0 || filters.rarity.includes(item.rarity)) && (filters.name === '' || item.name.toLowerCase().includes(filters.name.toLowerCase())) ), render: (e, _c) => { if(_c) return _c; const item = config.items[e.id]; if(!item) return; return foldable(() => [ markdown(getText(item.description)) ], [div('flex flex-row justify-between', [ div('flex flex-row items-center gap-4', [ div('flex flex-row items-center gap-4', [ span([colorByRarity[item.rarity], 'text-lg'], item.name), div('flex flex-row gap-2 text-light-60 dark:text-dark-60 text-sm italic', subnameFactory(item).map(e => span('', e))) ]), ]), div('flex flex-row items-center divide-x divide-light-50 dark:divide-dark-50 divide-dashed px-2', [ div('flex flex-row w-16 gap-2 justify-between items-center px-2', [ icon('game-icons:bolt-drop', { width: 16, height: 16, class: 'text-light-70 dark:text-dark-70' }), span('', item.powercost || item.capacity ? `${item.powercost ?? 0}/${item.capacity ?? 0}` : '-') ]), div('flex flex-row w-16 gap-2 justify-between items-center px-2', [ icon('mdi:weight', { width: 16, height: 16, class: 'text-light-70 dark:text-dark-70' }), span('', item.weight?.toString() ?? '-') ]), div('flex flex-row w-16 gap-2 justify-between items-center px-2', [ icon('game-icons:battery-pack', { width: 16, height: 16, class: 'text-light-70 dark:text-dark-70' }), span('', item.charge ? `${item.charge}` : '-') ]), div('flex flex-row w-16 gap-2 justify-between items-center px-2', [ icon('ph:coin', { width: 16, height: 16, class: 'text-light-70 dark:text-dark-70' }), span('', item.price ? `${item.price}` : '-') ]), button(icon('radix-icons:plus', { width: 16, height: 16 }), () => { const list = this.character!.character.variables.items; if(item.equippable) list.push(stateFactory(item)); else if(list.find(e => e.id === item.id)) list.find(e => e.id === item.id)!.amount++; else list.push(stateFactory(item)); this.character?.saveVariables(); }, 'p-1 !border-solid !border-r'), ]), ])], { open: false, class: { icon: 'px-2', container: 'border border-light-35 dark:border-dark-35 p-1 gap-2', content: 'px-2 pb-1' } }) } }), ]); const blocker = fullblocker([ container ], { closeWhenOutside: true, open: false }); return { show: () => { setTimeout(() => container.setAttribute('data-state', 'active'), 1); blocker.open(); }, hide: () => { setTimeout(blocker.close, 150); container.setAttribute('data-state', 'inactive'); }}; } enchantPanel(character: CompiledCharacter) { const current = reactive({ item: undefined as ItemState | undefined, }); const restrict = (enchant: EnchantementConfig, id?: string) => { if(!id) return true; const item = config.items[id]!; if(!enchant.restrictions) return true; if(enchant.restrictions.includes(item.category)) return true; else if(item.category === 'armor' && enchant.restrictions.includes(`armor/${item.type}`)) return true; else if(item.category === 'weapon' && item.type.some(e => enchant.restrictions!.includes(`weapon/${e}`))) return true; return false; } const itempower = () => current.item && config.items[current.item.id] !== undefined ? ((config.items[current.item.id]!.powercost ?? 0) + (current.item.enchantments?.reduce((_p, _v) => (config.enchantments[_v]?.power ?? 0) + _p, 0) ?? 0)) : 0; const container = div("border-light-35 dark:border-dark-35 bg-light-10 dark:bg-dark-10 border-l absolute top-0 bottom-0 right-0 w-[10%] data-[state=active]:w-1/2 flex flex-col gap-4 text-light-100 dark:text-dark-100 p-8 transition-[width] transition-delay-[150ms]", [ div("flex flex-row justify-between items-center mb-4", [ dom("h2", { class: "text-xl font-bold", text: "Enchantements" }), div('flex flex-row gap-8 items-center justify-end', [ dom('span', { class: () => ['italic text-sm', { 'text-light-red dark:text-dark-red': current.item && config.items[current.item.id] !== undefined ? itempower() > (config.items[current.item.id]!.capacity ?? 0) : false }], text: () => `Puissance de l'objet: ${current.item && config.items[current.item.id] !== undefined ? itempower() : false}/${current.item ? (config.items[current.item.id]!.capacity ?? 0) : 0}` }), dom('span', { class: () => ['italic text-sm', { 'text-light-red dark:text-dark-red': this.character!.power > character!.itempower }], text: () => `Puissance du personnage: ${this.character!.power}/${character.itempower}` }), tooltip(button(icon("radix-icons:cross-1", { width: 20, height: 20 }), () => { setTimeout(blocker.close, 150); container.setAttribute('data-state', 'inactive'); }, "p-1"), "Fermer", "left") ]) ]), div('grid grid-cols-1 -my-2 overflow-y-auto gap-1', { list: () => Object.values(config.enchantments).filter(e => restrict(e, current.item?.id)), render: (enchant, _c) => _c ?? foldable(() => [ markdown(getText(enchant.description)) ], [div('flex flex-row justify-between', [ div('flex flex-row items-center gap-4', [ span('text-lg', enchant.name) ]), div('flex flex-row items-center divide-x divide-light-50 dark:divide-dark-50 divide-dashed px-2 gap-4', [ span('italic text-sm', `Puissance magique: ${enchant.power}`), button(icon(() => current.item?.enchantments?.includes(enchant.id) ? 'radix-icons:minus' : 'radix-icons:plus', { width: 16, height: 16 }), () => { const idx = current.item!.enchantments?.findIndex(e => e === enchant.id) ?? -1; if(idx === -1) current.item!.enchantments?.push(enchant.id); else current.item!.enchantments?.splice(idx, 1); this.character?.enchant(current.item!); }, 'p-1 !border-solid !border-r'), ]), ])], { open: false, class: { icon: 'px-2', container: 'border border-light-35 dark:border-dark-35 p-1 gap-2', content: 'px-2 pb-1' } }) }), ]); const blocker = fullblocker([ container ], { closeWhenOutside: true, open: false }); return { show: (item: ItemState) => { setTimeout(() => container.setAttribute('data-state', 'active'), 1); current.item = item; blocker.open(); }, hide: () => { setTimeout(blocker.close, 150); container.setAttribute('data-state', 'inactive'); }}; } aspectTab(character: CompiledCharacter) { return [ div('flex flex-col gap-2', [ div('flex flex-row justify-between items-center', [ div('flex flex-row gap-12 items-center', [ span('text-lg font-semibold', config.aspects[character.aspect.id]?.name), div('flex flex-row items-center gap-2', [ text('Transformé'), checkbox({ defaultValue: character.variables.transformed, change: v => character.variables.transformed = v, }) ]), ]), div('flex flex-row gap-8 items-center', [ text('Difficulté: '), span('text-lg font-semibold', config.aspects[character.aspect.id]?.difficulty), ]), ]), div(() => ({ 'opacity-20': !character.variables.transformed }), [ markdown(getText(config.aspects[character.aspect.id]?.description), undefined, { tags: { a: preview } }), ]), ]) ] } effectsTab(character: CompiledCharacter) { return [ ] } }