import type { Ability, Alignment, ArmorConfig, Character, CharacterConfig, CharacterVariables, CompiledCharacter, DamageType, FeatureItem, ItemConfig, ItemState, Level, MainStat, Resistance, SpellConfig, SpellElement, SpellType, TrainingLevel, WeaponConfig, WeaponType } from "~/types/character"; import { keyof, z } from "zod/v4"; import characterConfig from '#shared/character-config.json'; import proses, { preview } from "#shared/proses"; import { button, buttongroup, checkbox, floater, foldable, input, loading, multiselect, numberpicker, select, tabgroup, Toaster, toggle } from "#shared/components.util"; import { div, dom, icon, span, text, type DOMList, type RedrawableHTML } from "#shared/dom.util"; import { followermenu, fullblocker, tooltip } from "#shared/floating.util"; import { clamp, deepEquals } from "#shared/general.util"; import markdown from "#shared/markdown.util"; import { getText } from "#shared/i18n"; import type { User } from "~/types/auth"; import { MarkdownEditor } from "#shared/editor.util"; import { Socket } from "#shared/websocket.util"; 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,13,14,15] as const; export const SPELL_TYPES = ["precision","knowledge","instinct","arts"] 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"] as const; export const defaultCharacter: Character = { id: -1, name: "", people: undefined, level: 1, training: MAIN_STATS.reduce((p, v) => { p[v] = { 0: 0, 1: 0, 2: 0, 3: 0, 4: 0 }; return p; }, {} as Record>>), leveling: { 1: 0 }, abilities: {}, choices: {}, variables: { health: 0, mana: 0, spells: [], items: [], exhaustion: 0, sickness: [], poisons: [], money: 0, }, owner: -1, visibility: "private", }; const defaultCompiledCharacter: (character: Character) => CompiledCharacter = (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, action: 0, reaction: 0, 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: { strength: 0, dexterity: 0, shield: 0, armor: 0, multiattack: 1, magicpower: 0, magicspeed: 0, magicelement: 0, magicinstinct: 0, }, bonus: { abilities: {}, defense: {}, }, resistance: {}, initiative: 0, capacity: 0, lists: { action: [], freeaction: [], reaction: [], passive: [], spells: [], }, aspect: "", notes: Object.assign({ public: '', private: '' }, character.notes), }); 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-2 py-px`, 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", "arts": "Oeuvres" }; export const abilityTexts: Record = { "athletics": "Athlétisme", "acrobatics": "Acrobatique", "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(), }); 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" }; type PropertySum = { list: Array, min: number, value: number, _dirty: boolean }; export class CharacterCompiler { 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: [] }, }; private _variableDirty: boolean = false; private _variableDebounce: NodeJS.Timeout = setTimeout(() => {}); constructor(character: Character) { this.character = character; } set character(value: Character) { this._character = value; this._result = 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: [] }, }; 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._result.abilities[e[0] as Ability] = e[1]); } } get character(): Character { return this._character; } get compiled(): CompiledCharacter { Object.entries(this._character.abilities).forEach(e => this._result.abilities[e[0] as Ability] = e[1]); this.compile(Object.keys(this._buffer)); return this._result; } get values(): Record { Object.entries(this._character.abilities).forEach(e => this._result.abilities[e[0] as Ability] = e[1]); const keys = Object.keys(this._buffer); this.compile(keys); return keys.reduce((p, v) => { p[v] = this._buffer[v]!.value; return p; }, {} as Record); } get armor() { const armors = this._character.variables.items.filter(e => e.equipped && config.items[e.id]?.category === 'armor'); return armors.length > 0 ? armors.map(e => ({ max: (config.items[e.id] as ArmorConfig).health, current: (config.items[e.id] as ArmorConfig).health - e.state.health })).reduce((p, v) => { p.max += v.max; p.current += v.current; return p; }, { max: 0, current: 0 }) : undefined; } parse(text: string): string { return text.replace(/\{(.*?)\}/gmi, (substring: string, group: string) => { console.log(substring, group); return substring; }) } variable(prop: T, value: CharacterVariables[T], autosave: boolean = true) { this._character.variables[prop] = value; this._result.variables[prop] = value; this._variableDirty = true; if(autosave) { clearTimeout(this._variableDebounce); this._variableDebounce = setTimeout(() => this.saveVariables(), 2000); } } saveVariables() { if(this._variableDirty) { this._variableDirty = false; useRequestFetch()(`/api/character/${this.character.id}/variables`, { method: 'POST', body: this._character.variables, }).then(() => {}).catch(() => { Toaster.add({ type: 'error', content: 'Impossible de mettre à jour les données', duration: 5000, timer: true }); }) } } 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?: string) { if(!feature) return; config.features[feature]?.effect.forEach(this.apply.bind(this)); } protected remove(feature?: string) { if(!feature) return; config.features[feature]?.effect.forEach(this.undo.bind(this)); } protected apply(feature?: FeatureItem) { if(!feature) return; switch(feature.category) { case "list": if(feature.action === 'add' && !this._result.lists[feature.list]!.includes(feature.item)) this._result.lists[feature.list]!.push(feature.item); else if(feature.action === 'remove') this._result.lists[feature.list]!.splice(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(this.apply.bind(this))); return; default: return; } } protected undo(feature?: FeatureItem) { if(!feature) return; switch(feature.category) { case "list": if(feature.action === 'remove' && !this._result.lists[feature.list]!.includes(feature.item)) this._result.lists[feature.list]!.push(feature.item); else if(feature.action === 'add') this._result.lists[feature.list]!.splice(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.splice(this._buffer[feature.property]!.list.findIndex(e => e.id === feature.id), 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(this.undo.bind(this))); return; default: return; } } protected compile(queue: string[]) { for(let i = 0; i < queue.length; i++) { if(queue[i] === undefined || queue[i] === "") continue; const property = queue[i]!; const buffer = this._buffer[property]; if(buffer && buffer._dirty === true) { let sum = 0, shortcut = 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 = this._buffer[item.value as string]!; if(modifier._dirty) { //Put it back in queue since its dependencies haven't been resolved yet queue.push(item.value as string); queue.push(property); shortcut = 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(shortcut === true) continue; const path = property.split("/"); const object = path.length === 1 ? this._result : path.slice(0, -1).reduce((p, v) => { p[v] ??= {}; return p[v]; }, this._result as any); if(object.hasOwnProperty(path.slice(-1)[0]!)) object[path.slice(-1)[0]!] = Math.max(sum, this._buffer[property]!.min); this._buffer[property]!.value = Math.max(sum, this._buffer[property]!.min); this._buffer[property]!._dirty = false; } } } } export class CharacterBuilder extends CharacterCompiler { private _container: RedrawableHTML; private _content?: RedrawableHTML; private _stepsHeader: RedrawableHTML[] = []; private _steps: Array = []; private _helperText!: Text; private id?: string; constructor(container: RedrawableHTML, 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 flex items-center", }, [ i !== 0 ? icon("radix-icons:chevron-right", { class: "w-6 h-6 flex justify-center items-center group-data-[disabled]:text-light-50 dark:group-data-[disabled]:text-dark-50 group-data-[disabled]: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]: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: [] }, 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: [] }, title: 'Notes privés', position: 'bottom-start' }) ]), div("flex w-full flex-row gap-4 items-center justify-center relative", this._stepsHeader), div(undefined, [ tooltip(icon("radix-icons:question-mark-circled", { height: 20, width: 20 }), this._helperText, "bottom-end") ]), ]), this._content, ])); } 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; } else {} } if(step !== 0 && this._steps.slice(0, step).some(e => !e.validate(this))) return; this._stepsHeader.forEach(e => e.setAttribute('data-state', 'inactive')); this._stepsHeader[step]!.setAttribute('data-state', 'active'); this._content?.replaceChildren(...(new this._steps[step]!(this)).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 { //@ts-ignore 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: RedrawableHTML, 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', 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: RedrawableHTML; private _options: RedrawableHTML[]; 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, ]), button(text('Suivant'), () => this._builder.display(1), 'h-[35px] px-[15px]'), ]), 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: RedrawableHTML[][]; 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: 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), ]), button(text('Suivant'), () => this._builder.display(2), 'h-[35px] px-[15px]'), ]), 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 RedrawableHTML).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 RedrawableHTML).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: RedrawableHTML; private _statContainer: RedrawableHTML; 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(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}}'), ]), button(text('Suivant'), () => this._builder.display(3), 'h-[35px] px-[15px]'), ]), 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 RedrawableHTML).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: RedrawableHTML[]; private _maxs: RedrawableHTML[] = []; 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, ]), button(text('Suivant'), () => this._builder.display(4), 'h-[35px] px-[15px]'), ]), 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 RedrawableHTML | 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: RedrawableHTML[]; 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, ]), button(text('Enregistrer'), () => this._builder.save(), 'h-[35px] px-[15px]'), ]), 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 RedrawableHTML).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; } } 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', } 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' }; 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 class CharacterSheet { private user: ComputedRef; private character?: CharacterCompiler; container: RedrawableHTML = div('flex flex-1 h-full w-full items-start justify-center'); private tabs?: RedrawableHTML; private tab: string = 'abilities'; 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(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 = 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?.variable(variable.key, variable.value, false); 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); } this.character?.variable(variable.key, prop, false); } }) } 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 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: RedrawableHTML }) => { const value = 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); this.character?.variable(property, clamp(isNaN(value) ? character.variables[property] : value, 0, Infinity)); this.character?.saveVariables(); obj.edit.value = (character[property] - this.character!.character.variables[property]).toString(); obj.readonly.textContent = (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: 'notes', title: [ text('Notes') ], content: () => [ div('flex flex-col gap-2', [ div('flex flex-col gap-2 border-b border-light-35 dark:border-dark-35 pb-4', [ 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') ]), div('border border-light-35 dark:border-dark-35 bg-light20 dark:bg-dark-20 p-1 h-64', [ publicNotes.dom ]) ]), div('flex flex-col gap-2', [ span('text-lg font-bold', 'Notes privés'), div('border border-light-35 dark:border-dark-35 bg-light20 dark:bg-dark-20 p-1 h-64', [ privateNotes.dom ]) ]), ]) ] }, ], { focused: this.tab, class: { container: 'flex-1 gap-4 px-4 w-[960px] h-full', content: 'overflow-auto' }, 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: "), health.readonly, text(character, `/ {{health}}`), ]), dom("span", { class: "flex flex-row items-center gap-2 text-3xl font-light" }, [ text("Mana: "), mana.readonly, 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(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(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(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(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(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(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(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, () => character.speed === false ? "Aucun déplacement" : `{{speed}} cases`) ]), 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(character, `{{defense.passive}}`) ]), 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(character, `{{defense.parry}}`) ]), 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(character, `{{defense.dodge}}`) ]), 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", [ div("flex flex-row items-center justify-center gap-2", [ dom("div", { class: 'text-xl font-semibold', text: "Compétences" }), proses('a', preview, [ icon('radix-icons:question-mark-circled', { width: 16, height: 16, class: 'text-light-70 dark:text-dark-70' }) ], { href: 'regles/l\'entrainement/competences', label: 'Compétences', class: 'h-4' }) ]), 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", [ span("text-sm text-light-70 dark:text-dark-70 max-w-20 truncate", abilityTexts[ability as Ability] || ability), span("font-bold text-base text-light-100 dark:text-dark-100", text(character.abilities, `+{{${ability}}}`)), ]) ) ), div("flex flex-row items-center justify-center gap-4", [ div("flex flex-row items-center justify-center gap-2", [ dom("div", { class: 'text-xl font-semibold', text: "Maitrises" }), proses('a', preview, [ icon('radix-icons:question-mark-circled', { width: 16, height: 16, class: 'text-light-70 dark:text-dark-70' }) ], { href: 'regles/l\'entrainement/competences', label: 'Compétences', class: 'h-4' }) ]), div("flex flex-1 border-t border-dashed border-light-50 dark:border-dark-50") ]), () => character.mastery.strength + character.mastery.dexterity > 0 ? div("grid grid-cols-2 gap-x-3 gap-y-1 text-sm", [ () => character.mastery.strength + character.mastery.dexterity > 0 ? proses('a', preview, [ text('Arme légère') ], { href: 'regles/annexes/equipement#Les armes légères', label: 'Arme légère' }) : undefined, () => character.mastery.strength + character.mastery.dexterity > 0 ? proses('a', preview, [ text('Arme de jet') ], { href: 'regles/annexes/equipement#Les armes de jet', label: 'Arme de jet' }) : undefined, () => character.mastery.strength + character.mastery.dexterity > 0 ? proses('a', preview, [ text('Arme naturelle') ], { href: 'regles/annexes/equipement#Les armes naturelles', label: 'Arme naturelle' }) : undefined, () => character.mastery.strength > 1 ? proses('a', preview, [ text('Arme standard') ], { href: 'regles/annexes/equipement#Les armes', label: 'Arme standard' }) : undefined, () => character.mastery.strength > 1 ? proses('a', preview, [ text('Arme improvisée') ], { href: 'regles/annexes/equipement#Les armes improvisées', label: 'Arme improvisée' }) : undefined, () => character.mastery.strength > 2 ? proses('a', preview, [ text('Arme lourde') ], { href: 'regles/annexes/equipement#Les armes lourdes', label: 'Arme lourde' }) : undefined, () => character.mastery.strength > 3 ? proses('a', preview, [ text('Arme à deux mains') ], { href: 'regles/annexes/equipement#Les armes à deux mains', label: 'Arme à deux mains' }) : undefined, () => character.mastery.dexterity > 0 && character.mastery.strength > 1 ? proses('a', preview, [ text('Arme maniable') ], { href: 'regles/annexes/equipement#Les armes maniables', label: 'Arme maniable' }) : undefined, () => character.mastery.dexterity > 1 && character.mastery.strength > 1 ? proses('a', preview, [ text('Arme à projectiles') ], { href: 'regles/annexes/equipement#Les armes à projectiles', label: 'Arme à projectiles' }) : undefined, () => character.mastery.dexterity > 1 && character.mastery.strength > 2 ? proses('a', preview, [ text('Arme longue') ], { href: 'regles/annexes/equipement#Les armes longues', label: 'Arme longue' }) : undefined, () => character.mastery.shield > 0 ? proses('a', preview, [ text('Bouclier') ], { href: 'regles/annexes/equipement#Les boucliers', label: 'Bouclier' }) : undefined, () => character.mastery.shield > 0 && character.mastery.strength > 3 ? proses('a', preview, [ text('Bouclier à deux mains') ], { href: 'regles/annexes/equipement#Les boucliers à deux mains', label: 'Bouclier à deux mains' }) : undefined, ]) : undefined, () => character.mastery.armor > 0 ? div("grid grid-cols-2 gap-x-3 gap-y-1 text-sm", [ () => character.mastery.armor > 0 ? proses('a', preview, [ text('Armure légère') ], { href: 'regles/annexes/equipement#Les armures légères', label: 'Armure légère' }) : undefined, () => character.mastery.armor > 1 ? proses('a', preview, [ text('Armure standard') ], { href: 'regles/annexes/equipement#Les armures', label: 'Armure standard' }) : undefined, () => character.mastery.armor > 2 ? proses('a', preview, [ text('Armure lourde') ], { href: 'regles/annexes/equipement#Les armures lourdes', label: 'Armure lourde' }) : undefined, ]) : undefined, div("grid grid-cols-2 gap-x-3 gap-y-1 text-sm", [ div('flex flex-row items-center gap-2', [ text('Précision'), span('font-bold', text(character.spellranks, "{{precision}}")) ]), div('flex flex-row items-center gap-2', [ text('Savoir'), span('font-bold', text(character.spellranks, "{{knowledge}}")) ]), div('flex flex-row items-center gap-2', [ text('Instinct'), span('font-bold', text(character.spellranks, "{{instinct}}")) ]), div('flex flex-row items-center gap-2', [ text('Oeuvres'), span('font-bold', text(character.spellranks, "{{arts}}")) ]), ]) ]) ]), div('border-l border-light-35 dark:border-dark-35'), this.tabs, ]) ])); } 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" }), proses('a', preview, [ icon('radix-icons:question-mark-circled', { width: 16, height: 16, class: 'text-light-70 dark:text-dark-70' }) ], { href: 'regles/le-combat/actions-en-combat#Actions', class: 'h-4' }) ]), div("flex flex-1 border-t border-dashed border-light-50 dark:border-dark-50"), div('flex flex-row items-center gap-2', [ ...Array(character.action).fill(undefined).map(e => div('border border-dashed border-light-50 dark:border-dark-50 w-5 h-5')), dom('span', { class: 'tracking-tight', text: '/ round' }) ]), ]), div('flex flex-col gap-2', [ div('flex flex-row flex-wrap gap-2 text-light-60 dark:text-dark-60', ["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 => dom('span', { text: e, class: 'cursor-pointer text-sm decoration-dotted underline' }))), div('flex flex-col gap-2', { render: (e) => div('flex flex-col gap-1', [ div('flex flex-row justify-between', [dom('span', { class: 'text-lg', 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" }), proses('a', preview, [ icon('radix-icons:question-mark-circled', { width: 16, height: 16, class: 'text-light-70 dark:text-dark-70' }) ], { href: 'regles/le-combat/actions-en-combat#Réaction', class: 'h-4' }) ]), div("flex flex-1 border-t border-dashed border-light-50 dark:border-dark-50"), div('flex flex-row items-center gap-2', [ ...Array(character.reaction).fill(undefined).map(e => div('border border-dashed border-light-50 dark:border-dark-50 w-5 h-5')), dom('span', { class: 'tracking-tight', text: '/ round' }) ]), ]), 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 => dom('span', { text: e, class: 'cursor-pointer text-sm decoration-dotted underline' }))), div('flex flex-col gap-2', { render: (e) => div('flex flex-col gap-1', [ div('flex flex-row justify-between', [dom('span', { class: 'text-lg', 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" }), proses('a', preview, [ icon('radix-icons:question-mark-circled', { width: 16, height: 16, class: 'text-light-70 dark:text-dark-70' }) ], { href: 'regles/le-combat/actions-en-combat#Action libre', class: 'h-4' }) ]), 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 => dom('span', { text: e, class: 'cursor-pointer text-sm decoration-dotted underline' }))), div('flex flex-col gap-2', { render: e => div('flex flex-col gap-1', [ div('flex flex-row justify-between', [dom('span', { class: 'text-lg', 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 => div('flex flex-col gap-1', [ div('flex flex-row justify-between', [dom('span', { class: 'text-lg', text: config.passive[e]?.name }) ]), markdown(getText(config.passive[e]?.description), undefined, { tags: { a: preview } }), ]), list: character.lists.passive }), ]; } spellTab(character: CompiledCharacter) { let sortPreference = (localStorage.getItem('character-sort') ?? 'rank') as 'rank' | 'type' | 'element'; const sort = () => { switch(sortPreference) { case 'rank': return container.array.sort((a, b) => (config.spells[a]?.rank ?? 0) - (config.spells[b]?.rank ?? 0) || SPELL_ELEMENTS.indexOf(config.spells[a]?.elements[0]!) - SPELL_ELEMENTS.indexOf(config.spells[b]?.elements[0]!)); case 'type': return container.array.sort((a, b) => config.spells[a]?.type.localeCompare(config.spells[b]?.type ?? '') || (config.spells[a]?.rank ?? 0) - (config.spells[b]?.rank ?? 0)); case 'element': return container.array.sort((a, b) => SPELL_ELEMENTS.indexOf(config.spells[a]?.elements[0]!) - SPELL_ELEMENTS.indexOf(config.spells[b]?.elements[0]!) || (config.spells[a]?.rank ?? 0) - (config.spells[b]?.rank ?? 0)); default: return container.array; } }; const container = div('flex flex-col gap-2', { render: e => { const spell = config.spells[e]; 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', spell.rank === 4 ? 'Sort unique' : `Sort ${spell.type === 'instinct' ? 'd\'instinct' : spell.type === 'knowledge' ? 'de savoir' : 'de précision'} 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: [...(character.lists.spells ?? []), ...character.variables.spells] }); sort().render(); return [ div('flex flex-col gap-2', [ div('flex flex-row justify-between items-center', [ div('flex flex-row gap-2 items-center', [ dom('span', { class: 'italic tracking-tight text-sm', text: 'Trier par' }), buttongroup<'rank' | 'type' | 'element'>([{ text: 'Rang', value: 'rank' }, { text: 'Type', value: 'type' }, { text: 'Element', value: 'element' }], { value: sortPreference, class: { option: 'px-2 py-1 text-sm' }, onChange: (value) => { localStorage.setItem('character-sort', value); sortPreference = value; sort().render(); } }), ]), 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.spellslots }], text: `${character.variables.spells.length}/${character.spellslots} sort${character.variables.spells.length > 1 ? 's' : ''} maitrisé${character.variables.spells.length > 1 ? 's' : ''}` }), button(text('Modifier'), () => this.spellPanel(character), 'py-1 px-4'), ]) ]), container, ]) ] } //TODO: Update to handle reactivity spellPanel(character: CompiledCharacter) { const availableSpells = Object.values(config.spells).filter(spell => { if (spell.rank === 4) return false; if (character.spellranks[spell.type] < spell.rank) return false; return true; }); const textAmount = text(character.variables.spells.length.toString()), textMax = text(character.spellslots.toString()); 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: "Ajouter un sort" }), div('flex flex-row gap-4 items-center', [ dom('span', { class: 'italic text-light-70 dark:text-dark-70 text-sm' }, [ textAmount, text(' / '), textMax, text(' sorts maitrisé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-col divide-y *:py-2 -my-2 overflow-y-auto', availableSpells.map(spell => { let state = character.lists.spells?.includes(spell.id) ? 'given' : character.variables.spells.includes(spell.id) ? 'choosen' : 'empty'; const toggleText = text(state === 'choosen' ? 'Supprimer' : state === 'given' ? 'Inné' : 'Ajouter'), toggleButton = button(toggleText, () => { if(state === 'choosen') { this.character!.variable('spells', character.variables.spells.filter(e => e !== spell.id)); state = 'empty'; } else if(state === 'empty') { this.character!.variable('spells', [...character.variables.spells, spell.id]); //TO REWORK state = 'choosen'; } toggleText.textContent = state === 'choosen' ? 'Supprimer' : state === 'given' ? 'Inné' : 'Ajouter'; textAmount.textContent = character.variables.spells.length.toString(); }, "px-2 py-1 text-sm font-normal"); toggleButton.disabled = state === 'given'; return 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` }) ]), toggleButton, ]), ]) ], { 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, onClose: () => this.character?.saveVariables() }); setTimeout(() => container.setAttribute('data-state', 'active'), 1); } itemsTab(character: CompiledCharacter) { const items = character.variables.items; const power = 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); const weight = items.reduce((p, v) => p + (config.items[v.id]?.weight ?? 0) * v.amount, 0); return [ div('flex flex-col gap-2', [ div('flex flex-row justify-end items-center gap-8', [ dom('span', { class: ['italic text-sm', () => ({ 'text-light-red dark:text-dark-red': weight > character.itempower })], text: () => `Poids total: ${weight}/${character.itempower}` }), dom('span', { class: ['italic text-sm', () => ({ 'text-light-red dark:text-dark-red': power > (character.capacity === false ? 0 : character.capacity) })], text: () => `Puissance magique: ${power}/${character.capacity}` }), button(text('Modifier'), () => this.itemsPanel(character), 'py-1 px-4'), ]), div('flex flex-col flex-1 divide-y divide-light-35 dark:divide-dark-35', { list: character.variables.items, redraw: true, render: e => { const item = config.items[e.id]; if(!item) return; 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 justify-center gap-1', [ this.character?.character.campaign ? button(text('Partager'), () => { }, 'px-2 text-sm h-5 box-content') : undefined, button(icon('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); else (items as DOMList)?.render(); this.character!.variable('items', items); }, '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({ id: item.id, amount: 1, charges: item.charge, enchantments: [], equipped: false }); else if(items.find(_e => _e === e)) { items.find(_e => _e === e)!.amount++; (items as DOMList)?.render(); } else items.push({ id: item.id, amount: 1, charges: item.charge, enchantments: [] }); this.character!.variable('items', items); }, 'p-1'), ]) ], [div('flex flex-row justify-between', [ div('flex flex-row items-center gap-4', [ item.equippable ? checkbox({ defaultValue: e.equipped, change: v => { e.equipped = v; this.character!.variable('items', items); }, 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))) ]), ]), 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('', () => item.powercost || item.capacity ? `${item.powercost ?? 0}/${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' } }) }}) ]) ]; } itemsPanel(character: CompiledCharacter) { const items = Object.values(config.items).map(item => ({ item, dom: 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({ id: item.id, amount: 1, charges: item.charge, enchantments: [], equipped: false }); else if(list.find(e => e.id === item.id)) list.find(e => e.id === item.id)!.amount++; else list.push({ id: item.id, amount: 1, charges: item.charge, enchantments: [] }); (list as DOMList)?.render(); this.character!.variable('items', list); }, '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 filters: { category: Category[], rarity: Rarity[], name: string, power: { min: number, max: number } } = { category: [], rarity: [], name: '', power: { min: 0, max: Infinity }, }; const applyFilters = () => { content.replaceChildren(...items.filter(e => (filters.category.length === 0 || filters.category.includes(e.item.category)) && (filters.rarity.length === 0 || filters.rarity.includes(e.item.rarity)) && (filters.name === '' || e.item.name.toLowerCase().includes(filters.name.toLowerCase())) ).map(e => e.dom)); } const content = div('grid grid-cols-1 -my-2 overflow-y-auto gap-1'); 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-4 items-center', [ 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; applyFilters(); }, 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; applyFilters(); }, class: { container: 'w-40' } }) ]), div('flex flex-row gap-2 items-center', [ text('Nom'), input('text', { defaultValue: filters.name, input: v => { filters.name = v; applyFilters(); }, class: 'w-64' }) ]), ]), content, ]); applyFilters(); const blocker = fullblocker([ container ], { closeWhenOutside: true, onClose: () => this.character?.saveVariables() }); setTimeout(() => container.setAttribute('data-state', 'active'), 1); } }