Files
obsidian-visualiser/shared/character.ts
2026-07-01 09:37:37 +02:00

2476 lines
153 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type { Ability, Alignment, ArmorConfig, ArmorState, Character, CharacterConfig, CompiledCharacter, DamageType, ImprovementConfig, FeatureEquipment, FeatureID, FeatureItem, FeatureList, FeatureState, FeatureValue, ItemConfig, ItemState, Level, MainStat, MundaneState, Resistance, SpellConfig, SpellElement, SpellType, TrainingLevel, TreeStructure, WeaponConfig, WeaponState, WeaponType, WondrousState, CraftingType, CharacterVariables } 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, deepEquals } 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';
import { breakpoint } from '#shared/breakpoint';
import { parseDice, stringifyRoll } from "./dice";
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 CRAFTING_TYPES = ["crafter", "armorer", "enchanter", "brewerer"] 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<MainStat, Partial<Record<TrainingLevel, number>>>),
leveling: { 1: 0 },
abilities: {},
choices: {},
variables: {
health: 0,
mana: 0,
spells: [],
items: [],
exhaustion: 0,
sickness: [],
poisons: [],
components: { money: 0, natural: 0, mineral: 0, processed: 0, magical: 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<MainStat, number>),
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,
},
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: {},
damage: {}
},
initiative: 0,
capacity: 0,
lists: {
action: [],
freeaction: [],
reaction: [],
passive: [],
spells: [],
sickness: [],
dedication: [],
poison: [],
},
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<MainStat, string> = {
"strength": "Force",
"dexterity": "Dextérité",
"constitution": "Constitution",
"intelligence": "Intelligence",
"curiosity": "Curiosité",
"charisma": "Charisme",
"psyche": "Psyché",
};
export const mainStatShortTexts: Record<MainStat, string> = {
"strength": "FOR",
"dexterity": "DEX",
"constitution": "CON",
"intelligence": "INT",
"curiosity": "CUR",
"charisma": "CHA",
"psyche": "PSY",
};
export const elementTexts: Record<SpellElement, { class: string, text: string }> = {
fire: { class: 'text-light-red dark:text-dark-red border-light-red/50 dark:border-dark-red/50 bg-light-red/20 dark:bg-dark-red/20', text: 'Feu' },
ice: { class: 'text-light-blue dark:text-dark-blue border-light-blue/50 dark:border-dark-blue/50 bg-light-blue/20 dark:bg-dark-blue/20', text: 'Glace' },
thunder: { class: 'text-light-yellow dark:text-dark-yellow border-light-yellow/50 dark:border-dark-yellow/50 bg-light-yellow/20 dark:bg-dark-yellow/20', text: 'Foudre' },
earth: { class: 'text-light-orange dark:text-dark-orange border-light-orange/50 dark:border-dark-orange/50 bg-light-orange/20 dark:bg-dark-orange/20', text: 'Terre' },
arcana: { class: 'text-light-indigo dark:text-dark-indigo border-light-indigo/50 dark:border-dark-indigo/50 bg-light-indigo/20 dark:bg-dark-indigo/20', text: 'Arcane' },
air: { class: 'text-light-lime dark:text-dark-lime border-light-lime/50 dark:border-dark-lime/50 bg-light-lime/20 dark:bg-dark-lime/20', text: 'Air' },
nature: { class: 'text-light-green dark:text-dark-green border-light-green/50 dark:border-dark-green/50 bg-light-green/20 dark:bg-dark-green/20', text: 'Nature' },
light: { class: 'text-light-yellow dark:text-dark-yellow border-light-yellow/50 dark:border-dark-yellow/50 bg-light-yellow/20 dark:bg-dark-yellow/20', text: 'Lumière' },
psyche: { class: 'text-light-purple dark:text-dark-purple border-light-purple/50 dark:border-dark-purple/50 bg-light-purple/20 dark:bg-dark-purple/20', text: 'Psy' },
};
export const elementDom = (element: SpellElement) => dom("span", {
class: [`border rounded-full px-1 py-px text-sm font-semibold`, elementTexts[element].class],
text: elementTexts[element].text
});
export const alignmentTexts: Record<Alignment, string> = {
'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<SpellType, string> = { "instinct": "Instinct", "knowledge": "Savoir", "precision": "Précision" };
export const abilityTexts: Record<Ability, string> = {
"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<Resistance, string> = {
'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<DamageType, string> = {
'bludgening': 'Contondant',
'cold': 'Froid',
'fire': 'Feu',
'magic': 'Magique',
'piercing': 'Perçant',
'slashing': 'Tranchant',
'thunder': 'Foudre',
};
export const protectionTexts: Record<'resistance' | 'immunity' | 'vulnerability', { summary: string, detail: string }> = {
immunity: { summary: 'x0', detail: 'Immunité' },
resistance: { summary: '1/2', detail: 'Résistance' },
vulnerability: { summary: 'x2', detail: 'Vulnérabilité' }
}
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),
improvements: 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),
components: z.object({ natural: z.number(), mineral: z.number(), processed: z.number(), magical: z.number(), 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().optional(),
});
type Property = { value: number | string | false, id: string, operation: "set" | "add" | "min" };
export type PropertySum = { list: Array<Property>, 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 = reactive(defaultCharacter);
private _result: CompiledCharacter = reactive(defaultCompiledCharacter(defaultCharacter));
private _buffer: Record<string, PropertySum> = {
'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 _trees: Record<string, TreeState> = {};
constructor(character?: Character)
{
if(character) this.character = character;
}
set character(value: Character)
{
Object.assign(this._character, {}, value);
Object.assign(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: [] },
};
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] }] });
value.variables.items.forEach(e => this.improve(e));
reactivity(() => value.variables.transformed, (v) => {
if(this._character && this._result && 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._buffer, this._result);
}
})
}
}
get character(): Character
{
return this._character;
}
get compiled(): CompiledCharacter
{
if(this._character && this._result && 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._buffer, this._result);
this._dirty = false;
}
return this._result;
}
get values(): Record<string, number>
{
if(this._character && this._result && 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._buffer, this._result);
this._dirty = false;
}
return Object.keys(this._buffer).reduce((p, v) => {
p[v] = this._buffer[v]!.value;
return p;
}, {} as Record<string, number>);
}
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), state: armor } : { max: 0, current: 0, flat: 0, percent: 0, state: armor };
}
get weight()
{
return this._character.variables.items.reduce((p, v) => p + (config.items[v.id]?.weight ?? 0) * v.amount, 0) ?? 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.improvements?.reduce((_p, _v) => (config.improvements[_v]?.power ?? 0) + _p, 0) ?? 0) * v.amount), 0) ?? 0;
}
improve(item: ItemState)
{
if(item.equipped)
{
config.items[item.id]?.effects?.filter(e => e.category !== 'value' || !e.property.startsWith('item/'))?.forEach(f => this.apply(f as FeatureValue | FeatureState | FeatureList))
item.improvements?.forEach(e => config.improvements[e]?.effect.filter(e => e.category !== 'value' || !e.property.startsWith('item')).forEach(f => this.apply(f as FeatureValue | FeatureState | FeatureList)));
}
else
{
config.items[item.id]?.effects?.filter(e => e.category !== 'value' || !e.property.startsWith('item/'))?.forEach(f => this.undo(f as FeatureValue | FeatureState | FeatureList))
item.improvements?.forEach(e => config.improvements[e]?.effect.filter(e => e.category !== 'value' || !e.property.startsWith('item')).forEach(f => this.undo(f as FeatureValue | FeatureState | FeatureList)));
}
item.buffer ??= {} as Record<string, PropertySum>;
Object.keys(item.buffer).forEach(e => item.buffer![e]!.list = []);
item.improvements?.forEach(e => (config.improvements[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!);
}
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 };
if(!this._buffer[feature.property]!.list.find(e => e.id === feature.id))
{
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 "state":
setProperty(this._result, feature.property, feature.value, true);
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 "state":
setProperty(this._result, feature.property, undefined);
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<string, PropertySum> = this._buffer, _result: Record<string, any> = 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<string | undefined, string>(), multiples: Record<string, string[]> = {};
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<string, string>)[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<string, string>)[e]!));
else if(node.to)
nextPath(path, node.to);
}
return validated;
}
function setProperty<T>(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<BuilderTabConstructor> = [];
private _stepContent: Array<BuilderTab> = [];
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-hidden 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<Node | string>;
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 hover:dark: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 = 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-hidden 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._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-hidden 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 hover:dark: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 hover:dark:border-dark-70 cursor-pointer': (level[0] as any as Level) <= this._builder.character.level, '!border-accent-blue bg-accent-blue/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 hover:dark: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/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<MainStat, HTMLElement[][]>;
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 hover:dark: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 hover:dark: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-hidden 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<MainStat, HTMLElement[][]>);
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 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 hover:dark: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/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-hidden 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-hidden 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-hidden 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-hidden 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 hover:dark: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/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/10 dark:bg-dark-100/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-hidden data-[state=checked]:bg-light-35 dark:data-[state=checked]:bg-dark-35 hover:border-light-50 hover:dark:border-dark-50 focus:shadow-raw focus:shadow-light-40 focus:dark: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<CompiledCharacter['mastery'][number], { text: string, href: string }> = {
"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<Rarity, string> = {
'common': 'text-light-100 dark:text-dark-100',
'uncommon': 'text-light-green dark:text-dark-green',
'rare': 'text-light-cyan dark:text-dark-cyan',
'veryrare': 'text-light-purple dark:text-dark-purple',
'legendary': 'text-light-orange dark:text-dark-orange'
}
export const weaponTypeTexts: Record<WeaponType, string> = {
"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<ArmorConfig["type"], string> = {
'heavy': 'Armure lourde',
'light': 'Armure légère',
'medium': 'Armure',
}
export const categoryText: Record<Category, string> = {
'mundane': 'Objet',
'armor': 'Armure',
'weapon': 'Arme',
'wondrous': 'Objet magique'
};
export const rarityText: Record<Rarity, string> = {
'common': 'Commun',
'uncommon': 'Peu commun',
'rare': 'Rare',
'veryrare': 'Très rare',
'legendary': 'Légendaire'
};
export const craftingText: Record<CraftingType, string> = {
'crafter': 'Fabrication',
'armorer': 'Armurerie',
'enchanter': 'Enchantement',
'brewerer': 'Alchimie',
};
export const materialText: Record<keyof CharacterVariables['components'], string> = {
'money': 'Argent',
'natural': 'Naturel',
'mineral': 'Minéral',
'processed': 'Transformé',
'magical': 'Magique',
};
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 = item.consummable ? ['Objet'] : ['Consommable'];
break;
case 'wondrous':
result = item.consummable ? ['Objet magique'] : ['Consommable magique'];
break;
}
if(state && state.improvements !== undefined && state.improvements.length > 0) result.push('amélioré');
return result;
}
export const stateFactory = (item: ItemConfig) => {
const state = { id: item.id, amount: 1, charges: item.charge, improvements: [], 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<User | null>;
private character: CharacterCompiler = reactive(new CharacterCompiler(defaultCharacter));
container: HTMLElement = div('flex flex-1 h-full w-full items-start justify-center');
private tabs?: HTMLElement;
private tab: string = localStorage.getItem('character-tab') ?? 'actions';
private _variableDebounce: NodeJS.Timeout = setTimeout(() => {});
ws?: Socket;
constructor(id: string, user: ComputedRef<User | null>)
{
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.character = 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.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('Cannot find the requested character from its ID');
}).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')
]),
span('text-sm italic text-light-70 dark:text-dark-70', e.toString())
]))
});
}
render()
{
const publicNotes = new MarkdownEditor();
const privateNotes = new MarkdownEditor();
const healthPanel = this.healthPanel();
const loadableIcon = icon('radix-icons:paper-plane', { width: 16, height: 16 });
const saveLoading = loading('small');
const saveNotes = () => { loadableIcon.replaceWith(saveLoading); this.saveNotes().finally(() => { saveLoading.replaceWith(loadableIcon) }); }
this.tabs = tabgroup([
() => (breakpoint.viewport === 'sm' || breakpoint.viewport === 'md') ? { id: 'stats', title: [ text('Stats') ], content: () => this.sidebarTab() } : undefined,
{ id: 'actions', title: [ text('Actions') ], content: () => this.actionsTab() },
{ id: 'abilities', title: [ text('Aptitudes') ], content: () => this.abilitiesTab() },
{ id: 'spells', title: [ text('Sorts') ], content: () => this.spellTab() },
{ id: 'inventory', title: [ text('Inventaire') ], content: () => this.itemsTab() },
{ 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': this.character.compiled?.variables?.transformed ?? false }), 'Aspect') ], content: () => this.aspectTab() },
{ id: 'effects', title: [ text('Afflictions') ], content: () => this.effectsTab() },
{ 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 lg:px-4 px-2 lg:max-w-[960px] max-w-full h-full', content: 'overflow-auto h-full', title: 'text-sm md:text-base sm: px-1 md:px-2', tabbar: 'overflow-x-auto overflow-y-hidden' }, switch: v => { this.tab = v; localStorage.setItem('this.character.compiled-tab', v); } });
this.container.replaceChildren(div('flex flex-col justify-start gap-1 h-full w-full min-w-half', [
div("flex lg:flex-row gap-4 lg:gap-6 items-center justify-center", [
div("flex gap-4 lg:gap-6 items-center", [
div('inline-flex select-none items-center justify-center overflow-hidden align-middle h-12 lg:h-16', [
div('text-light-100 dark:text-dark-100 leading-1 flex p-3 lg: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", [
span("text-lg lg:text-xl font-bold", () => this.character.compiled.name === '' ? "Inconnu" : this.character.compiled.name),
span("text-xs lg:text-sm", () => this.character.compiled.username ? `De ${this.character.compiled.username}` : `De ${this.user.value?.username}`)
]),
div("flex flex-col", [
span("font-bold text-sm lg:text-base", () =>`Niveau ${this.character.compiled?.level ?? 0}`),
span('text-xs lg:text-sm', () => this.character.compiled && this.character.compiled.race ? config.peoples[this.character.compiled.race]?.name ?? 'Peuple inconnu' : '')
])
]),
div("flex flex-row lg:border-l border-light-35 dark:border-dark-35 py-2 lg:py-4 ps-2 lg:ps-4 gap-4 lg:gap-8", [
div("flex flex-row items-center gap-1 lg:gap-2 text-xl sm:text-2xl lg:text-3xl font-light", [
text("PV: "),
() => this.character.compiled ? dom("span", {
class: "font-bold px-1 lg:px-2 border-transparent border cursor-pointer hover:border-light-35 hover:dark:border-dark-35",
text: () => `${this.character.compiled.health - this.character.compiled.variables.health}`,
listeners: { click: healthPanel.show },
}) : undefined,
() => this.character.compiled ? text('/') : text('-'),
() => this.character.compiled ? text(() => this.character.compiled.health) : undefined,
]),
div("flex flex-row items-center gap-1 lg:gap-2 text-xl sm:text-2xl lg:text-3xl font-light", [
text("Mana: "),
() => this.character.compiled ? dom("span", {
class: "font-bold px-1 lg:px-2 border-transparent border cursor-pointer hover:border-light-35 hover:dark:border-dark-35",
text: () => `${this.character.compiled.mana - this.character.compiled.variables.mana}`,
listeners: { click: healthPanel.show },
}) : undefined,
() => this.character.compiled ? text('/') : text('-'),
() => this.character.compiled ? text(() => this.character.compiled.mana) : undefined,
]),
]),
]),
div("hidden lg:flex flex-row flex-wrap justify-center gap-2 p-4 border-b border-light-35 dark:border-dark-35", [
div("flex gap-2 flex-row items-center justify-between", [
div("flex flex-col items-center px-2", [
() => !this.character.compiled ? span('text-xl font-bold', '-') : span(() => ["text-xl font-bold", { 'text-accent-blue': this.character.compiled.variables.transformed && config.aspects[this.character.compiled.aspect.id]?.stat === 'strength' }], () => `+${this.character.compiled.modifier.strength}`),
span("text-sm", mainStatShortTexts.strength)
]),
div("flex flex-col items-center px-2", [
() => !this.character.compiled ? span('text-xl font-bold', '-') : span(() => ["text-xl font-bold", { 'text-accent-blue': this.character.compiled.variables.transformed && config.aspects[this.character.compiled.aspect.id]?.stat === 'dexterity' }], () => `+${this.character.compiled.modifier.dexterity}`),
span("text-sm", mainStatShortTexts.dexterity)
]),
div("flex flex-col items-center px-2", [
() => !this.character.compiled ? span('text-xl font-bold', '-') : span(() => ["text-xl font-bold", { 'text-accent-blue': this.character.compiled.variables.transformed && config.aspects[this.character.compiled.aspect.id]?.stat === 'constitution' }], () => `+${this.character.compiled.modifier.constitution}`),
span("text-sm", mainStatShortTexts.constitution)
]),
div("flex flex-col items-center px-2", [
() => !this.character.compiled ? span('text-xl font-bold', '-') : span(() => ["text-xl font-bold", { 'text-accent-blue': this.character.compiled.variables.transformed && config.aspects[this.character.compiled.aspect.id]?.stat === 'intelligence' }], () => `+${this.character.compiled.modifier.intelligence}`),
span("text-sm", mainStatShortTexts.intelligence)
]),
div("flex flex-col items-center px-2", [
() => !this.character.compiled ? span('text-xl font-bold', '-') : span(() => ["text-xl font-bold", { 'text-accent-blue': this.character.compiled.variables.transformed && config.aspects[this.character.compiled.aspect.id]?.stat === 'curiosity' }], () => `+${this.character.compiled.modifier.curiosity}`),
span("text-sm", mainStatShortTexts.curiosity)
]),
div("flex flex-col items-center px-2", [
() => !this.character.compiled ? span('text-xl font-bold', '-') : span(() => ["text-xl font-bold", { 'text-accent-blue': this.character.compiled.variables.transformed && config.aspects[this.character.compiled.aspect.id]?.stat === 'charisma' }], () => `+${this.character.compiled.modifier.charisma}`),
span("text-sm", mainStatShortTexts.charisma)
]),
div("flex flex-col items-center px-2", [
() => !this.character.compiled ? span('text-xl font-bold', '-') : span(() => ["text-xl font-bold", { 'text-accent-blue': this.character.compiled.variables.transformed && config.aspects[this.character.compiled.aspect.id]?.stat === 'psyche' }], () => `+${this.character.compiled.modifier.psyche}`),
span("text-sm ", mainStatShortTexts.psyche)
])
]),
div('border-l border-light-35 dark:border-dark-35'),
div("flex gap-2 flex-row items-center justify-between", [
div("flex flex-col px-2 items-center", [
span("text-xl font-bold", () => !this.character.compiled ? '-' : `+${this.character.compiled.initiative}`),
span("text-sm ", "Init.")
]),
div("flex flex-col px-2 items-center", [
span("text-xl font-bold", () => !this.character.compiled ? '-' : this.character.compiled.speed === false ? "N/A" : `${this.character.compiled.speed}`),
span("text-sm ", "Course")
])
]),
div('border-l border-light-35 dark:border-dark-35'),
div("flex gap-2 flex-row items-center justify-between", [
icon("game-icons:checked-shield", { width: 24, height: 24 }),
div("flex flex-col px-2 items-center", [
span("text-xl font-bold", () => !this.character.compiled ? '-' : clamp(this.character.compiled.defense.static + this.character.compiled.defense.passiveparry + this.character.compiled.defense.passivedodge, 0, this.character.compiled.defense.hardcap)),
span("text-sm ", "Passive")
]),
div("flex flex-col px-2 items-center", [
span("text-xl font-bold", () => !this.character.compiled ? '-' : clamp(this.character.compiled.defense.static + this.character.compiled.defense.activeparry + this.character.compiled.defense.passivedodge, 0, this.character.compiled.defense.hardcap)),
span("text-sm ", "Blocage")
]),
div("flex flex-col px-2 items-center", [
span("text-xl font-bold", () => !this.character.compiled ? '-' : clamp(this.character.compiled.defense.static + this.character.compiled.defense.passiveparry + this.character.compiled.defense.activedodge, 0, this.character.compiled.defense.hardcap)),
span("text-sm ", "Esquive")
])
]),
]),
div("flex flex-1 flex-row items-stretch justify-center py-2 gap-4 h-0", [
div("hidden lg:flex flex-col gap-4 py-1 w-48", [
foldable([
div("flex flex-1 flex-col gap-1",
MAIN_STATS.map((stat) =>
div("flex flex-row px-1 justify-between items-center", [
span("text-base text-light-100 dark:text-dark-100 font-semibold", mainStatTexts[stat as MainStat] || stat),
span("font-bold text-lg text-light-100 dark:text-dark-100", () => !this.character.compiled ? '-' : `+${(this.character.compiled.modifier[stat as MainStat] ?? 0) + ((this.character.compiled.bonus?.defense !== undefined ? this.character.compiled.bonus?.defense[stat as MainStat] : 0) ?? 0)}`),
])
)
),
], [
div("flex flex-row items-center justify-center gap-4", [
dom("div", { class: 'text-xl font-semibold', text: "Résistances" }),
div("flex flex-1 border-t border-dashed border-light-50 dark:border-dark-50")
]),
], { class: { container: 'flex flex-col px-1 gap-2', title: 'ps-2' }, open: true, }),
foldable([
div("flex flex-1 flex-col gap-1",
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 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-lg text-light-100 dark:text-dark-100", () => !this.character.compiled ? '-' : `+${this.character.compiled.abilities[ability as Ability] ?? 0}`),
])
)
),
], [
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")
]),
], { class: { container: 'flex flex-col px-1 gap-2', title: 'ps-2' }, open: true, })
]),
div('hidden lg:block border-l border-light-35 dark:border-dark-35'),
this.tabs,
])
]));
}
saveVariables()
{
if(!this.character.character) return;
clearTimeout(this._variableDebounce);
this._variableDebounce = setTimeout(() => {
if(!this.character.character) return;
useRequestFetch()(`/api/character/${this.character.character.id}/variables`, {
method: 'POST',
body: raw(this.character.character.variables),
}).then(() => {}).catch(() => {
Toaster.add({ type: 'error', content: 'Impossible de mettre à jour les données', duration: 5000, timer: true });
})
}, 2000);
}
saveNotes()
{
if(!this.character.character) return Promise.resolve();
return useRequestFetch()(`/api/character/${this.character.character.id}/notes`, {
method: 'POST',
body: this.character.character.notes,
}).then(() => {}).catch(() => {
Toaster.add({ type: 'error', content: 'Impossible de mettre à jour les données', duration: 5000, timer: true });
});
}
healthPanel()
{
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 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] max-lg:data-[state=active]:w-full 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'), () => !this.character.compiled ? span('text-xl font-bold', '-') : div('flex flex-row items-center gap-1', [ span('text-xl font-bold', () => (this.character.compiled.health - this.character.compiled.variables.health)), text('/'), text(() => this.character.compiled.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 items-center justify-end', [ () => this.character.compiled.bonus.damage[e] ? tooltip(span('w-8 font-bold', protectionTexts[this.character.compiled.bonus.damage[e] as 'resistance' | 'immunity' | 'vulnerability'].summary), protectionTexts[this.character.compiled.bonus.damage[e] as 'resistance' | 'immunity' | 'vulnerability'].detail, 'left') : 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', [
() => !this.character.compiled ? undefined : span('text-lg', 'Total'), div('flex flew-row gap-4 justify-end', [
() => this.character.armor.state ? 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', () => `${this.character.armor.current}/${this.character.armor.max} (${[this.character.armor.flat > 0 ? '-' + this.character.armor.flat : undefined, this.character.armor.percent > 0 ? this.character.armor.percent + '%' : undefined].filter(e => !!e).join('/')})`) ]), () => {
const value = clamp(this.character.armor.percent > 0 ? Math.floor(inputs.health.sum * clamp(this.character.armor.percent / 100, 0, 1)) : clamp(inputs.health.sum, 0, this.character.armor.flat), 0, this.character.armor.current);
(this.character.armor.state!.state as ArmorState).loss += value;
inputs.health.sum -= value;
}, 'px-2 h-8 border-light-red dark:border-dark-red hover:border-light-red hover:dark:border-dark-red focus:border-light-red focus:dark:border-dark-red focus:shadow-light-red focus:dark:shadow-dark-red', () => this.character.armor.current === 0), 'Dégats', 'left') : undefined,
tooltip(button(icon('radix-icons:minus', { width: 16, height: 16 }), () => {
this.character.compiled.variables.health += inputs.health.sum;
inputs.health.sum = 0;
DAMAGE_TYPES.forEach(e => inputs.health[e] = 0);
this.saveVariables();
}, 'w-8 h-8 border-light-red dark:border-dark-red hover:border-light-red hover:dark:border-dark-red focus:border-light-red focus:dark:border-dark-red focus:shadow-light-red focus:dark: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 }), () => {
this.character.compiled.variables.health = Math.max(this.character.compiled.variables.health - inputs.health.sum, 0);
inputs.health.sum = 0;
DAMAGE_TYPES.forEach(e => inputs.health[e] = 0);
this.saveVariables();
}, 'w-8 h-8 border-light-green dark:border-dark-green hover:border-light-green hover:dark:border-dark-green focus:border-light-green focus:dark:border-dark-green focus:shadow-light-green focus:dark: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', () => (this.character.compiled.mana - this.character.compiled.variables.mana)), text('/'), text(() => this.character.compiled.mana) ]) ]),
div('flex flex-row gap-4 justify-end', [
tooltip(button(icon('radix-icons:minus', { width: 16, height: 16 }), () => {
this.character.compiled.variables.mana += inputs.mana;
inputs.mana = 0;
this.saveVariables();
}, 'w-8 h-8 border-light-red dark:border-dark-red hover:border-light-red hover:dark:border-dark-red focus:border-light-red focus:dark:border-dark-red focus:shadow-light-red focus:dark: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 }), () => {
this.character.compiled.variables.mana = Math.max(this.character.compiled.variables.mana - inputs.mana, 0);
inputs.mana = 0;
this.saveVariables();
}, 'w-8 h-8 border-light-green dark:border-dark-green hover:border-light-green hover:dark:border-dark-green focus:border-light-green focus:dark:border-dark-green focus:shadow-light-green focus:dark: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()
{
return [
div('flex flex-col gap-8', [
div('flex flex-col', [
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-4', { render: (e, _c) => _c ?? div('flex flex-col', [
div('flex flex-row justify-between', [dom('span', { class: 'text-base font-bold tracking-tight', 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: () => this.character.compiled ? this.character.compiled.lists.action ?? [] : [] }),
]),
]),
div('flex flex-col', [
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', [
div('flex flex-row justify-between', [dom('span', { class: 'text-base font-bold tracking-tight', 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: () => this.character.compiled ? this.character.compiled.lists.reaction ?? [] : [] }),
]),
]),
div('flex flex-col', [
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", "Communiquer", "Dégainer", "Attraper"].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', [
div('flex flex-row justify-between', [dom('span', { class: 'text-base font-bold tracking-tight', text: config.freeaction[e]?.name }) ]),
markdown(getText(config.freeaction[e]?.description), undefined, { tags: { a: preview } }),
]), list: () => this.character.compiled ? this.character.compiled.lists.freeaction ?? [] : [] })
]),
]),
]),
]
}
abilitiesTab()
{
return [ div('flex flex-col gap-4', [
foldable(() => [div('flex flex-col gap-4 pt-2', { render: (e, _c) => _c ?? div('flex flex-col', [
div('flex flex-row justify-between', [dom('span', { class: 'text-base font-bold tracking-tight', text: config.passive[e]?.name }) ]),
markdown(getText(config.passive[e]?.description), undefined, { tags: { a: preview } }),
]), list: this.character.compiled.lists.passive })], [
div("flex flex-row items-center justify-center gap-4 ps-2", [
div("flex flex-row items-center justify-center gap-2", [ dom("div", { class: 'text-lg font-semibold', text: "Aptitudes" }) ]),
div("flex flex-1 border-t border-dashed border-light-50 dark:border-dark-50"),
]),
], { open: true }),
foldable(() => [div('flex flex-col gap-4 pt-2', { render: (e, _c) => _c ?? div('flex flex-col', [
div('flex flex-row justify-between', [dom('span', { class: 'text-base font-bold tracking-tight', text: config.dedication[e]?.name }) ]),
markdown(getText(config.features[e]?.description), undefined, { tags: { a: preview } }),
]), list: this.character.compiled.lists.dedication })], [
div("flex flex-row items-center justify-center gap-4 ps-2", [
div("flex flex-row items-center justify-center gap-2", [ dom("div", { class: 'text-lg font-semibold', text: "Spécialisations" }) ]),
div("flex flex-1 border-t border-dashed border-light-50 dark:border-dark-50"),
]),
], { open: false }),
foldable(() => [
div("flex flex-row gap-x-4 gap-y-1 flex-wrap text-sm", { list: () => this.character.compiled.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("flex flex-row gap-x-4 gap-y-1 flex-wrap text-sm", [
() => (this.character.compiled.spellranks.precision ?? 0) > 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', () => this.character.compiled?.spellranks?.precision ?? 0) ]) : undefined,
() => (this.character.compiled.spellranks.knowledge ?? 0) > 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', () => this.character.compiled?.spellranks?.knowledge ?? 0) ]) : undefined,
() => (this.character.compiled.spellranks.instinct ?? 0) > 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', () => this.character.compiled?.spellranks?.instinct ?? 0) ]) : undefined,
])
], [
div("flex flex-row items-center justify-center gap-4 ps-2", [
div("flex flex-row items-center justify-center gap-2", [ dom("div", { class: 'text-lg font-semibold', text: "Maitrises" }) ]),
div("flex flex-1 border-t border-dashed border-light-50 dark:border-dark-50"),
]),
], { open: false, class: { content: 'flex flex-col gap-2 py-2' } }),
]) ];
}
spellTab()
{
const preference = reactive({
sort: localStorage.getItem('this.character.compiled-sort') ?? 'rank-asc',
} as { sort: `${'rank'|'type'|'element'|'cost'|'range'|'speed'}-${'asc'|'desc'}` | '' });
const sort = (spells: string[]) => {
localStorage.setItem('this.character.compiled-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' ? -3 : config.spells[a]?.speed === 'reaction' ? -2 : config.spells[a]?.speed === 'channeling' ? -1 : config.spells[a]?.speed ?? 0) - (config.spells[b]?.speed === 'action' ? -3 : config.spells[b]?.speed === 'reaction' ? -2 : config.spells[b]?.speed === 'channeling' ? -1 : config.spells[b]?.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' ? -3 : config.spells[a]?.speed === 'reaction' ? -2 : config.spells[a]?.speed === 'channeling' ? -1 : config.spells[a]?.speed ?? 0) - (config.spells[b]?.speed === 'action' ? -3 : config.spells[b]?.speed === 'reaction' ? -2 : config.spells[b]?.speed === 'channeling' ? -1 : config.spells[b]?.speed ?? 0));
default: return spells;
}
};
const panel = this.spellPanel();
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 hover:dark: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 hover:dark: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 hover:dark: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 hover:dark: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 hover:dark: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 hover:dark: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': this.character.compiled.variables.spells.length !== this.character.compiled.spellslots }], text: () => `${this.character.compiled.variables.spells.length}/${this.character.compiled.spellslots} sort(s) maitrisé(s)`.replaceAll('(s)', this.character.compiled.variables.spells.length > 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 foldable(() => [
div('flex flex-row ps-4 p-1 border-l-4 border-light-35 dark:border-dark-35', [ markdown(spell.description) ]),
], [
div('flex flex-row items-center gap-4', [
div('flex flex-row gap-8 items-baseline', [
dom('span', { class: 'font-semibold text-lg', text: spell.name ?? 'Inconnu' }),
span('text-light-100 dark:text-dark-100 text-sm italic', spell.speed === 'action' ? 'Action' : spell.speed === 'reaction' ? 'Réaction' : spell.speed === 'channeling' ? 'Canalisation' : `${spell.speed} minutes`),
]),
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 flex-wrap items-center gap-x-3 gap-y-1 text-light-70 dark:text-dark-70', [
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}`}`),
div('flex flex-row flex-wrap gap-1', (spell.elements ?? []).map(elementDom)),
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'),
]),
], { open: false, class: { container: 'flex flex-col gap-1', title: 'ps-2' } })
}, list: () => sort([...(this.character.compiled.lists.spells ?? []), ...this.character.compiled.variables.spells]) }),
])
]
}
spellPanel()
{
const spells = this.character.compiled.variables.spells;
const filters = reactive<{ tag: Array<string>, rank: Array<SpellConfig['rank']>, type: Array<SpellConfig['type']>, element: Array<SpellConfig['elements'][number]>, cost: { min: number, max: number }, range: Array<SpellConfig['range']>, speed: Array<SpellConfig['speed']> }>({
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 max-lg:data-[state=active]:w-full 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}/${this.character.compiled.spellslots} sort(s) maitrisé(s)`.replaceAll('(s)', spells.length > 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<typeof filters.tag[number]>([{ 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<typeof filters.type[number]>(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<typeof filters.rank[number]>([{ 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<typeof filters.element[number]>(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<typeof filters.range[number]>([{ 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<typeof filters.speed[number]>([{ text: 'Action', value: 'action' }, { text: 'Reaction', value: 'reaction' }, { text: 'Canalisaton', value: 'channeling' }, { 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(this.character.compiled.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 rounded-full 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' : this.character.compiled.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.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()
{
const items = this.character.compiled.variables.items;
const panel = this.itemsPanel();
const improve = this.improvePanel();
return [
div('flex flex-col gap-2', [
div('flex flex-row justify-between items-center', [
div('flex flex-row items-center gap-4', [
//TODO: money and components as special foldable with the content replacing the title on fold open.
]),
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 > this.character.compiled.itempower }], text: () => `Puissance magique: ${this.character.power}/${this.character.compiled.itempower}` }),
dom('span', { class: () => ['italic text-sm', { 'text-light-red dark:text-dark-red': this.character.weight > (this.character.compiled.capacity === false ? 0 : this.character.compiled.capacity) }], text: () => `Poids total: ${this.character.weight}/${this.character.compiled.capacity}` }),
button(text('Modifier'), () => panel.show(), 'py-1 px-4'),
]),
]),
div('flex flex-col flex-1', [
div('flex flex-row items-center text-xs text-light-70 dark:text-dark-70 border-b border-light-35 dark:border-dark-35 pb-1 px-1 gap-2', [
div('w-6 shrink-0 pl-5'),
div('flex-1 min-w-0', [text('Nom')]),
div('w-32 shrink-0', [text('Stats')]),
div('w-10 shrink-0 text-center', [text('Qté')]),
div('w-16 shrink-0 text-center hidden md:block', [text('Puis.')]),
div('w-12 shrink-0 text-center hidden md:block', [text('Poids')]),
div('w-12 shrink-0 text-center hidden md:block', [text('Charg.')]),
]),
div('flex flex-col', { list: this.character.compiled.variables.items, render: (e, _c) => {
if(_c) return _c;
const item = config.items[e.id];
if(!item) return;
const itempower = () => (item.powercost ?? 0) + (e.improvements?.reduce((_p, _v) => (config.improvements[_v]?.power ?? 0) + _p, 0) ?? 0);
return foldable(() => [
markdown(getText(item.description)),
div('flex flex-row gap-1', { list: () => e.improvements!.map(e => config.improvements[e]).filter(e => !!e), render: (e, _c) => _c ?? floater(div(() => ['flex flex-row gap-2 border px-2 rounded-full py-px', { 'border-accent-blue bg-accent-blue/20': !e.cursed, 'border-light-purple bg-light-purple/20 dark:border-dark-purple dark:bg-dark-purple/20': e.cursed }], [ 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: "bottom-start" }) }),
div('flex flex-row flex-wrap gap-x-3 gap-y-1 text-xs text-light-70 dark:text-dark-70 md:hidden', [
item.category === 'armor' ? span('', () => `Armure: ${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('/')})`) : undefined,
item.category === 'weapon' ? span('', () => `${stringifyRoll(parseDice(`${item.damage.value}${(e.state as WeaponState)?.attack ? '+' + (e.state as WeaponState).attack : ''}`), this.character.compiled.modifier, true)} ${damageTypeTexts[item.damage.type].toLowerCase()}`) : undefined,
item.capacity ? span('', () => `Puissance: ${itempower()}/${item.capacity}`) : undefined,
item.weight ? span('', () => `${e.amount > 1 ? `Poids: ${item.weight} (×${e.amount} = ${item.weight ?? 0 * e.amount})` : `Poids: ${item.weight}`}`) : undefined,
item.charge ? span('', `Charges: ${item.charge}`) : undefined,
]),
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.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.saveVariables();
}, 'p-1'),
() => !item.capacity ? undefined : button(text("Améliorer"), () => {
improve.show(e);
}, 'px-2 text-sm h-5 box-content'),
])
], [
div('flex flex-row items-center gap-2 px-1', [
item.equippable ? checkbox({ defaultValue: e.equipped, change: v => {
if(v && config.items[e.id]?.category === 'armor' && this.character.compiled.variables.items.find(e => config.items[e.id]?.category === 'armor' && e.equipped))
return Toaster.add({ content: "Vous ne pouvez equipper qu'une seule armure à la fois.", duration: 5000, timer: true, type: 'info' }), false;
e.equipped = v;
this.character.improve(e);
}, class: { container: '!w-5 !h-5' } }) : div('w-5 h-5'),
div('flex-1 min-w-0 flex flex-row items-center justify-between gap-1 flex-wrap', [
span([colorByRarity[item.rarity], 'text-md lg:text-lg'], item.name),
span('text-xs text-light-60 dark:text-dark-60 italic', subnameFactory(item).join(' ')),
]),
div('w-32 shrink-0 flex items-center text-xs', [
item.category === 'armor' ? 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-1 items-center', [ span('italic', () => stringifyRoll(parseDice(`${item.damage.value}${(e.state as WeaponState)?.attack ? '+' + (e.state as WeaponState).attack : ''}`), this.character.compiled.modifier, true)), 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('w-10 shrink-0 text-center text-sm', [text(() => e.amount.toString())]),
div('w-16 shrink-0 text-center text-xs hidden md:block', [
span(() => ({ 'text-light-red dark:text-dark-red': !!item.capacity && itempower() > item.capacity }), () => item.capacity ? `${itempower()}/${item.capacity}` : '-')
]),
div('w-12 shrink-0 text-center text-xs hidden md:block', [
e.amount > 1 && !!item.weight ? tooltip(span(() => ({ 'cursor-help underline decoration-1 decoration-dotted underline-offset-2': e.amount > 1 && !!item.weight }), () => item.weight ? `${item.weight * e.amount}` : '-'), `Poids unitaire: ${item.weight}`, 'bottom') : span('', () => item.weight ? `${item.weight * e.amount}` : '-')
]),
div('w-12 shrink-0 text-center text-xs hidden md:block', [span('', () => item.charge ? `${item.charge}` : '-')]),
])
], { open: false, class: { container: 'border-b border-dashed border-light-35 dark:border-dark-35 py-1', icon: 'px-1', content: 'px-4 pb-2 flex flex-col gap-1' } })
}})
])
])
];
}
itemsPanel()
{
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 max-lg:data-[state=active]:w-full 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 > (this.character.compiled.capacity === false ? 0 : this.character.compiled.capacity) }], text: () => `Poids total: ${this.character.weight}/${this.character.compiled.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.compiled.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.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');
}};
}
improvePanel()
{
const current = reactive({
item: undefined as ItemState | undefined,
});
const restrict = (improvement: ImprovementConfig, id?: string) => {
if(!id) return true;
const item = config.items[id]!;
if(!improvement.restrictions)
return true;
if(improvement.restrictions[item.category])
return true;
else if(item.category === 'armor' && improvement.restrictions[`armor/${item.type}`])
return true;
else if(item.category === 'weapon' && item.type.some(e => improvement.restrictions![`weapon/${e}`]))
return true;
else if(improvement.restrictions[item.id])
return true;
return false;
}
const itempower = () => current.item && config.items[current.item.id] !== undefined ? ((config.items[current.item.id]!.powercost ?? 0) + (current.item.improvements?.reduce((_p, _v) => (config.improvements[_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 max-lg:data-[state=active]:w-full 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: "Améliorations" }),
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 > this.character.compiled!.itempower }], text: () => `Puissance du personnage: ${this.character.power}/${this.character.compiled.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.improvements).filter(e => restrict(e, current.item?.id)), render: (improve, _c) => _c ?? foldable(() => [ markdown(getText(improve.description)) ], [
div('flex flex-row justify-between', [
div('flex flex-row items-center gap-4', [ span('text-lg', improve.name) ]),
div('flex flex-row items-center divide-x divide-light-50 dark:divide-dark-50 divide-dashed px-2 gap-4', [
improve.cursed ? span('italic text-sm text-light-purple dark:text-dark-purple', `Malédiction`) : undefined,
span('italic text-sm', `Puissance magique: ${improve.power}`),
button(icon(() => current.item?.improvements?.includes(improve.id) ? 'radix-icons:minus' : 'radix-icons:plus', { width: 16, height: 16 }), () => {
const idx = current.item!.improvements?.findIndex(e => e === improve.id) ?? -1;
if(idx === -1)
current.item!.improvements?.push(improve.id);
else
current.item!.improvements?.splice(idx, 1);
this.character.improve(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()
{
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[this.character.compiled.aspect.id]?.name), div('flex flex-row items-center gap-2', [ text('Transformé'), checkbox({ defaultValue: this.character.compiled.variables.transformed, change: v => this.character.compiled.variables.transformed = v, }) ]),
]),
div('flex flex-row gap-8 items-center', [
text('Difficulté: '), span('text-lg font-semibold', config.aspects[this.character.compiled.aspect.id]?.difficulty),
]),
]),
div(() => ({ 'opacity-20': !this.character.compiled.variables.transformed }), [ markdown(getText(config.aspects[this.character.compiled.aspect.id]?.description), undefined, { tags: { a: preview } }), ]),
])
]
}
effectsTab()
{
return [
]
}
sidebarTab()
{
return [
div('flex flex-1 flex-col gap-2 p-2', [
div('flex flex-row justify-between gap-1 md:gap-2',
MAIN_STATS.map(stat =>
div('flex flex-col items-center', [
span(() => ['text-xl font-bold', { 'text-accent-blue': this.character.compiled.variables.transformed && config.aspects[this.character.compiled.aspect.id]?.stat === stat }], () => !this.character.compiled ? '-' : `+${this.character.compiled.modifier[stat]}`),
span('text-sm', mainStatShortTexts[stat]),
])
)
),
div('border-t border-dashed border-light-35 dark:border-dark-35'),
div('flex flex-row justify-between gap-2', [
div('flex flex-col items-center', [
span('text-lg font-bold', () => !this.character.compiled ? '-' : `+${this.character.compiled.initiative}`),
span('text-xs text-light-70 dark:text-dark-70', 'Init.'),
]),
div('flex flex-col items-center', [
span('text-lg font-bold', () => !this.character.compiled ? '-' : this.character.compiled.speed === false ? 'N/A' : `${this.character.compiled.speed}`),
span('text-xs text-light-70 dark:text-dark-70', 'Course'),
]),
...(['Passive', 'Blocage', 'Esquive'] as const).map((label, i) => {
const defs = [
() => this.character.compiled.defense.static + this.character.compiled.defense.passiveparry + this.character.compiled.defense.passivedodge,
() => this.character.compiled.defense.static + this.character.compiled.defense.activeparry + this.character.compiled.defense.passivedodge,
() => this.character.compiled.defense.static + this.character.compiled.defense.passiveparry + this.character.compiled.defense.activedodge,
];
return div('flex flex-col items-center', [
span('text-lg font-bold', () => !this.character.compiled ? '-' : clamp(defs[i]!(), 0, this.character.compiled.defense.hardcap)),
span('text-xs text-light-70 dark:text-dark-70', label),
]);
})
]),
]),
div("flex flex-col md:flex-row gap-4 py-1", [
div("flex flex-col flex-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",
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", () => !this.character.compiled ? '-' : `+${this.character.compiled.abilities[ability as Ability] ?? 0}`),
])
)
),
]),
div('hidden md:block border-l border-light-35 dark:border-dark-35'),
div('flex flex-col flex-1 gap-4', [
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-3 text-sm", { list: () => this.character.compiled?.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-2 text-sm", [
() => (this.character.compiled?.spellranks?.precision ?? 0) > 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', () => this.character.compiled?.spellranks?.precision ?? 0) ]) : undefined,
() => (this.character.compiled?.spellranks?.knowledge ?? 0) > 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', () => this.character.compiled?.spellranks?.knowledge ?? 0) ]) : undefined,
() => (this.character.compiled?.spellranks?.instinct ?? 0) > 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', () => this.character.compiled?.spellranks?.instinct ?? 0) ]) : undefined,
])
])
]),
];
}
}