You've already forked obsidian-visualiser
2446 lines
151 KiB
TypeScript
2446 lines
151 KiB
TypeScript
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 dark:border-dark-red bg-light-red dark:bg-dark-red', text: 'Feu' },
|
||
ice: { class: 'text-light-blue dark:text-dark-blue border-light-blue dark:border-dark-blue bg-light-blue dark:bg-dark-blue', text: 'Glace' },
|
||
thunder: { class: 'text-light-yellow dark:text-dark-yellow border-light-yellow dark:border-dark-yellow bg-light-yellow dark:bg-dark-yellow', text: 'Foudre' },
|
||
earth: { class: 'text-light-orange dark:text-dark-orange border-light-orange dark:border-dark-orange bg-light-orange dark:bg-dark-orange', text: 'Terre' },
|
||
arcana: { class: 'text-light-indigo dark:text-dark-indigo border-light-indigo dark:border-dark-indigo bg-light-indigo dark:bg-dark-indigo', text: 'Arcane' },
|
||
air: { class: 'text-light-lime dark:text-dark-lime border-light-lime dark:border-dark-lime bg-light-lime dark:bg-dark-lime', text: 'Air' },
|
||
nature: { class: 'text-light-green dark:text-dark-green border-light-green dark:border-dark-green bg-light-green dark:bg-dark-green', text: 'Nature' },
|
||
light: { class: 'text-light-yellow dark:text-dark-yellow border-light-yellow dark:border-dark-yellow bg-light-yellow dark:bg-dark-yellow', text: 'Lumière' },
|
||
psyche: { class: 'text-light-purple dark:text-dark-purple border-light-purple dark:border-dark-purple bg-light-purple dark:bg-dark-purple', text: 'Psy' },
|
||
};
|
||
export const elementDom = (element: SpellElement) => dom("span", {
|
||
class: [`border !border-opacity-50 rounded-full !bg-opacity-20 px-1 py-px text-sm font-semibold`, elementTexts[element].class],
|
||
text: elementTexts[element].text
|
||
});
|
||
export const alignmentTexts: Record<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-none max-w-full w-full overflow-y-auto', attributes: { id: 'characterEditorContainer' } });
|
||
this._container.appendChild(div('flex flex-1 flex-col justify-start items-center px-8 w-full h-full overflow-y-hidden', [
|
||
div("flex w-full flex-row gap-4 items-center justify-between px-4 bg-light-0 dark:bg-dark-0 z-20", [
|
||
div('flex flex-row gap-2', [ floater(tooltip(button(icon('radix-icons:pencil-2', { width: 16, height: 16 }), undefined, 'p-1'), 'Notes publics', 'left'), [ publicNotes.dom ], { pinned: true, events: { show: ['click'], hide: [] }, class: 'min-w-[200px] min-h-[150px] max-w-[600px] max-h-[600px]', title: 'Notes publics', position: 'bottom-start' }), floater(tooltip(button(icon('radix-icons:eye-none', { width: 16, height: 16 }), undefined, 'p-1'), 'Notes privés', 'right'), [ privateNotes.dom ], { pinned: true, events: { show: ['click'], hide: [] }, class: 'min-w-[200px] min-h-[150px] max-w-[600px] max-h-[600px]', title: 'Notes privés', position: 'bottom-start' }) ]), div("flex w-full flex-row gap-4 items-center justify-center relative", this._stepsHeader), div('flex flex-row gap-2', [ tooltip(button(icon("radix-icons:chevron-right", { height: 16, width: 16 }), () => this.next(), 'p-1'), 'Suivant', "bottom"), tooltip(button(icon("radix-icons:paper-plane", { height: 16, width: 16 }), () => this.save(), 'p-1'), 'Enregistrer', "bottom"), tooltip(icon("radix-icons:question-mark-circled", { height: 20, width: 20 }), this._helperText, "bottom-end") ]),
|
||
]),
|
||
this._content,
|
||
]));
|
||
}
|
||
previous()
|
||
{
|
||
this.display(this._currentStep - 1);
|
||
}
|
||
next()
|
||
{
|
||
this.display(this._currentStep + 1);
|
||
}
|
||
private display(step: number)
|
||
{
|
||
if(step < 0 || step >= this._stepsHeader.length)
|
||
return;
|
||
|
||
for(let i = 0; i < step; i++)
|
||
{
|
||
if(!this._steps[i]?.validate(this))
|
||
{
|
||
Toaster.add({ title: 'Erreur de validation', content: this._steps[i]!.errorMessage, type: 'error', duration: 25000, timer: true })
|
||
return;
|
||
}
|
||
}
|
||
if(step !== 0 && this._steps.slice(0, step).some(e => !e.validate(this)))
|
||
return;
|
||
|
||
this._stepsHeader[this._currentStep]!.setAttribute('data-state', 'inactive');
|
||
this._stepsHeader[step]!.setAttribute('data-state', 'active');
|
||
|
||
this._currentStep = step;
|
||
|
||
this._stepContent[step] ??= (new this._steps[step]!(this));
|
||
this._content?.replaceChildren(...this._stepContent[step].dom);
|
||
|
||
this._helperText.textContent = this._steps[step]!.description;
|
||
}
|
||
async save(leave: boolean = true)
|
||
{
|
||
if(this.id === 'new' || this.id === '-1')
|
||
{
|
||
const result = await useRequestFetch()(`/api/character`, {
|
||
method: 'post',
|
||
body: this._character,
|
||
onResponseError: (e) => {
|
||
Toaster.add({ title: 'Erreur d\'enregistrement', content: e.response.status === 401 ? "Vous n'êtes pas autorisé à effectué cette opération" : e.response.statusText, type: 'error', closeable: true, duration: 25000, timer: true });
|
||
this.id = 'new';
|
||
}
|
||
});
|
||
|
||
if(result !== undefined)
|
||
{
|
||
this._character.id = this._result.id = result as number;
|
||
this.id = result.toString();
|
||
}
|
||
|
||
Toaster.add({ content: 'Personnage créé', type: 'success', duration: 25000, timer: true });
|
||
useRouter().replace({ name: 'character-id-edit', params: { id: this.id } })
|
||
if(leave) useRouter().push({ name: 'character-id', params: { id: this.id } });
|
||
}
|
||
else
|
||
{
|
||
await useRequestFetch()(`/api/character/${this._character.id}`, {
|
||
method: 'post',
|
||
body: this._character,
|
||
onResponseError: (e) => {
|
||
Toaster.add({ title: 'Erreur d\'enregistrement', content: e.response.status === 401 ? "Vous n'êtes pas autorisé à effectué cette opération" : e.response.statusText, type: 'error', closeable: true, duration: 25000, timer: true });
|
||
}
|
||
});
|
||
Toaster.add({ content: 'Personnage enregistré', type: 'success', duration: 25000, timer: true });
|
||
if(leave) useRouter().push({ name: 'character-id', params: { id: this.id } });
|
||
}
|
||
}
|
||
|
||
updateLevel(level: Level)
|
||
{
|
||
this._character.level = level;
|
||
|
||
if(this._character.leveling) //Invalidate higher levels
|
||
{
|
||
for(let _level = 20; _level > this._character.level; _level--)
|
||
{
|
||
const level = _level as Level;
|
||
if(this._character.leveling.hasOwnProperty(level))
|
||
{
|
||
const option = this._character.leveling[level]!;
|
||
const feature = config.peoples[this._character.people!]!.options[level][option]!;
|
||
delete this._character.leveling[level];
|
||
if(this._character.choices.hasOwnProperty(feature)) delete this._character.choices[feature];
|
||
|
||
this.remove(feature);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
toggleLevelOption(level: Level, choice: number)
|
||
{
|
||
if(level > this._character.level) //Cannot add more level options than the current level
|
||
return;
|
||
|
||
if(this._character.leveling === undefined) //Add level 1 if missing
|
||
{
|
||
this._character.leveling = { 1: 0 };
|
||
this.add(config.peoples[this._character.people!]!.options[1][0]!);
|
||
}
|
||
|
||
if(level == 1) //Cannot remove level 1
|
||
return;
|
||
|
||
for(let i = 1; i < level; i++) //Check previous levels as a requirement
|
||
{
|
||
if(!this._character.leveling.hasOwnProperty(i))
|
||
return;
|
||
}
|
||
|
||
if(this._character.leveling.hasOwnProperty(level) && this._character.leveling[level] !== choice) //If the given level is already selected, switch to the new choice
|
||
{
|
||
const feature = config.peoples[this._character.people!]!.options[level][this._character.leveling[level]!]!;
|
||
this.remove(feature);
|
||
if(feature in this._character.choices) delete this._character.choices[feature];
|
||
|
||
this.add(config.peoples[this._character.people!]!.options[level][choice]);
|
||
this._character.leveling[level] = choice;
|
||
}
|
||
else if(!this._character.leveling.hasOwnProperty(level))
|
||
{
|
||
this._character.leveling[level] = choice;
|
||
|
||
this.add(config.peoples[this._character.people!]!.options[level][choice]!);
|
||
}
|
||
}
|
||
toggleTrainingOption(stat: MainStat, level: TrainingLevel, choice: number)
|
||
{
|
||
if(level == 0) //Cannot remove the initial level
|
||
return;
|
||
|
||
for(let i = 1; i < level; i++) //Check previous levels as a requirement
|
||
{
|
||
if(!this._character.training[stat].hasOwnProperty(i as TrainingLevel))
|
||
return;
|
||
}
|
||
|
||
if(this._character.training[stat].hasOwnProperty(level))
|
||
{
|
||
if(this._character.training[stat][level] === choice)
|
||
{
|
||
for(let i = 15; i >= level; i --) //Invalidate higher levels
|
||
{
|
||
if(this._character.training[stat].hasOwnProperty(i))
|
||
{
|
||
const feature = config.training[stat][i as TrainingLevel][this._character.training[stat][i as TrainingLevel]!]!;
|
||
this.remove(feature);
|
||
if(this._character.choices.hasOwnProperty(feature)) delete this._character.choices[feature];
|
||
delete this._character.training[stat][i as TrainingLevel];
|
||
}
|
||
}
|
||
}
|
||
else
|
||
{
|
||
const feature = config.training[stat][level][this._character.training[stat][level]!]!;
|
||
this.remove(feature);
|
||
if(this._character.choices.hasOwnProperty(feature)) delete this._character.choices[feature];
|
||
this._character.training[stat][level] = choice;
|
||
this.add(config.training[stat][level][choice]);
|
||
}
|
||
}
|
||
else
|
||
{
|
||
this._character.training[stat][level] = choice;
|
||
this.add(config.training[stat][level][choice]);
|
||
}
|
||
}
|
||
handleChoice(element: HTMLElement, feature: string)
|
||
{
|
||
const choices = config.features[feature]!.effect.filter(e => e.category === 'choice');
|
||
if(choices.length === 0)
|
||
return;
|
||
|
||
const menu = followermenu(element, [ div('px-24 py-6 flex flex-col items-center text-light-100 dark:text-dark-100', choices.map(e => div('flex flex-row items-center', [ text(e.text), div('flex flex-col gap-2', Array(e.settings?.amount ?? 1).fill(0).map((_, i) => (
|
||
select(e.options.map((_e, _i) => ({ text: _e.text, value: _i })), { defaultValue: this._character.choices![e.id] !== undefined ? this._character.choices![e.id]![i] : undefined, change: (value) => {
|
||
this._character.choices![e.id] ??= [];
|
||
this._character.choices![e.id]![i] = value;
|
||
}, class: { container: 'w-32' } })
|
||
))) ]))) ], { arrow: true, offset: { mainAxis: 8 }, cover: 'width', placement: 'bottom', priority: false, viewport: document.getElementById('characterEditorContainer') ?? undefined, });
|
||
}
|
||
}
|
||
|
||
abstract class BuilderTab {
|
||
protected _builder: CharacterBuilder;
|
||
protected _content!: Array<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 dark:hover:border-dark-70 w-[320px]", listeners: { click: () => {
|
||
this._builder.character.people = people.id;
|
||
this._builder.character = { ...this._builder.character, people: people.id };
|
||
"border-accent-blue outline-2 outline outline-accent-blue".split(" ").forEach(e => this._options.forEach(f => f?.classList.toggle(e, false)));
|
||
"border-accent-blue outline-2 outline outline-accent-blue".split(" ").forEach(e => this._options[i]?.classList.toggle(e, true));
|
||
}
|
||
}, attributes: { 'data-people': people.id } }, [div("h-[320px]"), div("text-xl font-bold text-center", [text(people.name)]), div("w-full border-b border-light-50 dark:border-dark-50"), div("text-wrap word-break", [text(people.description)])]),
|
||
);
|
||
|
||
this._content = [ div("flex flex-1 gap-12 px-2 py-4 justify-center items-center", [
|
||
dom("label", { class: "flex justify-center items-center my-2" }, [
|
||
dom("span", { class: "pb-1 md:p-0", text: "Nom" }),
|
||
this._nameInput,
|
||
]),
|
||
dom("label", { class: "flex justify-center items-center my-2" }, [
|
||
dom("span", { class: "md:text-base text-sm", text: "Privé ?" }),
|
||
this._visibilityInput,
|
||
]),
|
||
]), div('flex flex-1 gap-4 p-2 overflow-x-auto justify-center', this._options)];
|
||
|
||
this.update();
|
||
}
|
||
override update()
|
||
{
|
||
this._nameInput.value = this._builder.character.name;
|
||
this._visibilityInput.setAttribute('data-state', this._builder.character.visibility === "private" ? "checked" : "unchecked");
|
||
|
||
if(this._builder.character.people !== undefined)
|
||
{
|
||
"border-accent-blue outline-2 outline outline-accent-blue".split(" ").forEach(e => this._options.find(e => e.getAttribute('data-people') === this._builder.character.people)?.classList.toggle(e, true));
|
||
}
|
||
}
|
||
static override validate(builder: CharacterBuilder): boolean
|
||
{
|
||
return builder.character.people !== undefined;
|
||
}
|
||
}
|
||
class LevelPicker extends BuilderTab
|
||
{
|
||
private _levelInput: HTMLInputElement;
|
||
private _pointsInput: HTMLInputElement;
|
||
|
||
private _options: HTMLElement[][];
|
||
|
||
static override header = 'Niveaux';
|
||
static override description = 'Déterminez la progression de votre personnage en choisissant une option par niveau disponible.';
|
||
static override errorMessage = 'Vous avez attribué trop de niveaux.';
|
||
|
||
constructor(builder: CharacterBuilder)
|
||
{
|
||
super(builder);
|
||
|
||
this._levelInput = dom("input", { class: `w-14 mx-4 text-light-70 dark:text-dark-70 tabular-nums bg-light-10 dark:bg-dark-10 appearance-none outline-none ps-3 pe-1 py-1 focus:shadow-raw transition-[box-shadow] border bg-light-20 bg-dark-20 border-light-20 dark:border-dark-20`, attributes: { type: "number", disabled: true }});
|
||
this._pointsInput = dom("input", { class: `w-14 mx-4 text-light-70 dark:text-dark-70 tabular-nums bg-light-10 dark:bg-dark-10 appearance-none outline-none ps-3 pe-1 py-1 focus:shadow-raw transition-[box-shadow] border bg-light-20 bg-dark-20 border-light-20 dark:border-dark-20`, attributes: { type: "number", disabled: true }});
|
||
|
||
this._options = Object.entries(config.peoples[this._builder.character.people!]!.options).map(
|
||
(level) => [ div("w-full flex h-px", [div("border-t border-dashed border-light-50 dark:border-dark-50 w-full"), dom('span', { class: "relative left-4" }, [ text(level[0]) ])]),
|
||
div("flex flex-row gap-4 justify-center", level[1].map((option, j) => {
|
||
const choice = config.features[option]!.effect.some(e => e.category === 'choice') ? dom('div', { class: 'absolute -bottom-px -right-px border border-light-50 dark:border-dark-50 bg-light-10 dark:bg-dark-10 hover:border-light-70 dark:hover:border-dark-70 flex p-1 justify-center items-center', listeners: { click: (e) => {
|
||
e.stopImmediatePropagation();
|
||
this._builder.character.leveling[level[0] as any as Level] === j && this._builder.handleChoice(choice!, config.features[option]!.id);
|
||
} } }, [ icon('radix-icons:gear') ]) : undefined;
|
||
return dom("div", { class: ["flex border border-light-50 dark:border-dark-50 px-4 py-2 w-[400px] relative", { 'hover:border-light-70 dark:hover:border-dark-70 cursor-pointer': (level[0] as any as Level) <= this._builder.character.level, '!border-accent-blue bg-accent-blue bg-opacity-20': this._builder.character.leveling[level[0] as any as Level] === j }], listeners: { click: e => {
|
||
this._builder.toggleLevelOption(parseInt(level[0]) as Level, j);
|
||
this.update();
|
||
}}}, [ dom('span', { class: "text-wrap whitespace-pre", text: getText(config.features[option]!.description) }), choice ]);
|
||
}))
|
||
]);
|
||
|
||
this._content = [ div("flex flex-1 gap-12 px-2 py-4 justify-center items-center", [
|
||
dom("label", { class: "flex justify-center items-center my-2" }, [
|
||
dom("span", { class: "pb-1 md:p-0", text: "Niveau" }),
|
||
this._levelInput,
|
||
]),
|
||
dom("label", { class: "flex justify-center items-center my-2" }, [
|
||
dom("span", { class: "md:text-base text-sm", text: "Points restantes" }),
|
||
this._pointsInput,
|
||
]),
|
||
div("flex justify-center items-center gap-2 my-2 md:text-base text-sm", [
|
||
dom("span", { text: "Vie" }),
|
||
text(() => this._builder.compiled.health),
|
||
]),
|
||
div("flex justify-center items-center gap-2 my-2 md:text-base text-sm", [
|
||
dom("span", { text: "Mana" }),
|
||
text(() => this._builder.compiled.mana),
|
||
]),
|
||
]), div('flex flex-col flex-1 gap-4 mx-8 my-4', this._options.flatMap(e => [...e]))];
|
||
|
||
this.update();
|
||
}
|
||
override update()
|
||
{
|
||
this._builder.compiled;
|
||
|
||
this._levelInput.value = this._builder.character.level.toString();
|
||
this._pointsInput.value = (this._builder.character.level - Object.keys(this._builder.character.leveling).length).toString();
|
||
|
||
this.updateLevel();
|
||
}
|
||
private updateLevel()
|
||
{
|
||
this._builder.updateLevel(this._builder.character.level as Level);
|
||
|
||
this._pointsInput.value = (this._builder.character.level - Object.keys(this._builder.character.leveling).length).toString();
|
||
this._options.forEach((e, i) => {
|
||
e[0]?.classList.toggle("opacity-30", ((i + 1) as Level) > this._builder.character.level);
|
||
e[1]?.classList.toggle("opacity-30", ((i + 1) as Level) > this._builder.character.level);
|
||
e[1]?.childNodes.forEach((option, j) => {
|
||
'hover:border-light-70 dark:hover:border-dark-70 cursor-pointer'.split(" ").forEach(_e => (option as HTMLElement).classList.toggle(_e, ((i + 1) as Level) <= this._builder.character.level));
|
||
'!border-accent-blue bg-accent-blue bg-opacity-20'.split(" ").forEach(_e => (option as HTMLElement).classList.toggle(_e, this._builder.character.leveling[((i + 1) as Level)] === j));
|
||
});
|
||
});
|
||
}
|
||
static override validate(builder: CharacterBuilder): boolean
|
||
{
|
||
return builder.character.level - Object.keys(builder.character.leveling).length >= 0;
|
||
}
|
||
}
|
||
class TrainingPicker extends BuilderTab
|
||
{
|
||
private _pointsInput: HTMLInputElement;
|
||
private _options: Record<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 dark:hover:border-dark-70 flex p-1 justify-center items-center', listeners: { click: (e) => {
|
||
e.stopImmediatePropagation();
|
||
this._builder.character.training[stat as MainStat][parseInt(level[0], 10) as TrainingLevel] === j && this._builder.handleChoice(choice!, config.features[option]!.id);
|
||
} } }, [ icon('radix-icons:gear') ]) : undefined;
|
||
return dom("div", { class: ["border border-light-40 dark:border-dark-40 cursor-pointer px-2 py-1 w-[400px] hover:border-light-50 dark:hover:border-dark-50 relative"], listeners: { click: e => {
|
||
this._builder.toggleTrainingOption(stat, parseInt(level[0]) as TrainingLevel, j);
|
||
this.update();
|
||
}}}, [ markdown(getText(config.features[option]!.description), undefined, { tags: { a: preview } }), choice ]);
|
||
}))
|
||
]);
|
||
}
|
||
|
||
this._pointsInput = dom("input", { class: `w-14 mx-4 text-light-70 dark:text-dark-70 tabular-nums bg-light-10 dark:bg-dark-10 appearance-none outline-none ps-3 pe-1 py-1 focus:shadow-raw transition-[box-shadow] border bg-light-20 bg-dark-20 border-light-20 dark:border-dark-20`, attributes: { type: "number", disabled: true }});
|
||
|
||
this._options = MAIN_STATS.reduce((p, v) => { p[v] = statRenderBlock(v); return p; }, {} as Record<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 flex-shrink-0 flex-col gap-4 relative w-full overflow-y-auto px-8', e.flatMap(_e => [..._e]))));
|
||
this._content = [ div("flex flex-1 gap-12 px-2 py-4 justify-center items-center sticky top-0 bg-light-0 dark:bg-dark-0 w-full z-10 min-h-20", [
|
||
div('flex flex-shrink gap-3 items-center relative w-48 ms-12', [
|
||
...MAIN_STATS.map((e, i) => dom('span', { listeners: { click: () => this.switchTab(i) }, class: 'block w-2.5 h-2.5 m-px outline outline-1 outline-transparent hover:outline-light-70 dark:hover:outline-dark-70 rounded-full bg-light-40 dark:bg-dark-40 cursor-pointer' })),
|
||
this._statIndicator,
|
||
]),
|
||
div('flex flex-1 gap-12 px-2 py-4 justify-center items-center sticky top-0 bg-light-0 dark:bg-dark-0 w-full z-10', [
|
||
dom("label", { class: "flex justify-center items-center my-2" }, [
|
||
dom("span", { class: "md:text-base text-sm", text: "Points restantes" }),
|
||
this._pointsInput,
|
||
]),
|
||
div("flex justify-center items-center gap-2 my-2 md:text-base text-sm", [
|
||
dom("span", { text: "Vie" }),
|
||
text(() => this._builder.compiled.health),
|
||
]),
|
||
div("flex justify-center items-center gap-2 my-2 md:text-base text-sm", [
|
||
dom("span", { text: "Mana" }),
|
||
text(() => this._builder.compiled.mana),
|
||
]),
|
||
]), dom('span')
|
||
]), div('flex flex-1 px-6 overflow-hidden max-w-full', [ this._statContainer ])];
|
||
|
||
this.switchTab(0);
|
||
|
||
this.update();
|
||
}
|
||
switchTab(tab: number)
|
||
{
|
||
this._tab = tab;
|
||
|
||
this._statIndicator.setAttribute('data-text', mainStatTexts[MAIN_STATS[tab] as MainStat]);
|
||
this._statIndicator.style.left = `${tab * 1.5}em`;
|
||
|
||
this._statContainer.style.left = `-${tab * 100}%`;
|
||
}
|
||
override update()
|
||
{
|
||
const values = this._builder.values;
|
||
const training = Object.values(this._builder.character.training).reduce((p, v) => p + Object.values(v).filter(e => e !== undefined).length, 0);
|
||
|
||
this._pointsInput.value = ((values.training ?? 0) - training).toString();
|
||
|
||
Object.keys(this._options).forEach(stat => {
|
||
const max = Object.keys(this._builder.character.training[stat as MainStat]).length;
|
||
this._options[stat as MainStat].forEach((e, i) => {
|
||
e[0]?.classList.toggle("opacity-30", (i as TrainingLevel) > max);
|
||
e[1]?.classList.toggle("opacity-30", (i as TrainingLevel) > max);
|
||
e[1]?.childNodes.forEach((option, j) => {
|
||
'!border-accent-blue bg-accent-blue bg-opacity-20'.split(" ").forEach(_e => (option as HTMLElement).classList.toggle(_e, i == 0 || (this._builder.character.training[stat as MainStat][i as TrainingLevel] === j)));
|
||
})
|
||
})
|
||
});
|
||
}
|
||
static override validate(builder: CharacterBuilder): boolean
|
||
{
|
||
const values = builder.values;
|
||
const training = Object.values(builder.character.training).reduce((p, v) => p + Object.values(v).filter(e => e !== undefined).length, 0);
|
||
|
||
return (values.training ?? 0) - training >= 0;
|
||
}
|
||
}
|
||
class AbilityPicker extends BuilderTab
|
||
{
|
||
private _pointsInput: HTMLInputElement;
|
||
private _options: HTMLElement[];
|
||
|
||
private _maxs: HTMLElement[] = [];
|
||
|
||
static override header = 'Compétences';
|
||
static override description = 'Diversifiez vos possibilités en affectant vos points dans les différentes compétences disponibles.';
|
||
static override errorMessage = 'Une compétence est incorrectement saisie ou vous avez dépassé le nombre de points à attribuer.';
|
||
|
||
constructor(builder: CharacterBuilder)
|
||
{
|
||
super(builder);
|
||
|
||
this._pointsInput = dom("input", { class: `w-14 mx-4 text-light-70 dark:text-dark-70 tabular-nums bg-light-10 dark:bg-dark-10 appearance-none outline-none ps-3 pe-1 py-1 focus:shadow-raw transition-[box-shadow] border bg-light-20 bg-dark-20 border-light-20 dark:border-dark-20`, attributes: { type: "number", disabled: true }});
|
||
|
||
this._options = ABILITIES.map((e, i) => {
|
||
const max = dom('span', { class: 'text-lg text-end font-bold' });
|
||
this._maxs.push(max);
|
||
return div('flex flex-col border border-light-50 dark:border-dark-50 p-4 gap-2 w-[200px] relative', [
|
||
div('flex justify-between', [ numberpicker({ defaultValue: this._builder.character.abilities[e], input: (value) => { this._builder.character.abilities[e] = value; this.update(); }}), max ]),
|
||
dom('span', { class: "text-xl text-center font-bold", text: abilityTexts[e] }),
|
||
dom('span', { class: "absolute -bottom-px -left-px h-[3px] bg-accent-blue" }),
|
||
])
|
||
});
|
||
|
||
this._content = [ div("flex flex-1 gap-12 px-2 py-4 justify-center items-center sticky top-0 bg-light-0 dark:bg-dark-0 w-full z-10", [
|
||
dom("label", { class: "flex justify-center items-center my-2" }, [
|
||
dom("span", { class: "md:text-base text-sm", text: "Points restantes" }),
|
||
this._pointsInput,
|
||
]),
|
||
]), div('flex flex-row flex-wrap justify-center items-center flex-1 gap-12 mx-8 my-4 px-48', this._options)];
|
||
|
||
this.update();
|
||
}
|
||
override update()
|
||
{
|
||
const values = this._builder.values, compiled = this._builder.compiled;
|
||
const abilities = Object.values(this._builder.character.abilities).reduce((p, v) => p + v, 0);
|
||
|
||
this._pointsInput.value = ((values.ability ?? 0) - abilities).toString();
|
||
|
||
ABILITIES.forEach((e, i) => {
|
||
const max = (values[`bonus/abilities/${e}`] ?? 0);
|
||
|
||
const load = this._options[i]?.lastElementChild as HTMLElement | undefined;
|
||
const valid = (compiled.abilities[e] ?? 0) <= max;
|
||
if(load)
|
||
{
|
||
Object.assign(load.style ?? {}, { width: `${clamp((max === 0 ? 0 : (this._builder.character.abilities[e] ?? 0) / max) * 100, 0, 100)}%` });
|
||
'bg-accent-blue'.split(' ').forEach(_e => load.classList.toggle(_e, valid));
|
||
'bg-light-red dark:bg-dark-red'.split(' ').forEach(_e => load.classList.toggle(_e, !valid));
|
||
}
|
||
this._maxs[i]!.textContent = `/ ${max ?? 0}`;
|
||
})
|
||
}
|
||
static override validate(builder: CharacterBuilder): boolean
|
||
{
|
||
const values = builder.values, compiled = builder.compiled;
|
||
const abilities = Object.values(builder.character.abilities).reduce((p, v) => p + v, 0);
|
||
|
||
return ABILITIES.map(e => (values[`bonus/abilities/${e}`] ?? 0) >= (compiled.abilities[e] ?? 0)).every(e => e) && (values.ability ?? 0) - abilities >= 0;
|
||
}
|
||
}
|
||
class AspectPicker extends BuilderTab
|
||
{
|
||
private _physicInput: HTMLInputElement;
|
||
private _mentalInput: HTMLInputElement;
|
||
private _personalityInput: HTMLInputElement;
|
||
|
||
private _filter: boolean = true;
|
||
|
||
private _options: HTMLElement[];
|
||
|
||
static override header = 'Aspect';
|
||
static override description = 'Déterminez l\'Aspect qui vous corresponds et benéficiez de puissants bonus.';
|
||
static override errorMessage = 'Veuillez choisir un Aspect.';
|
||
|
||
constructor(builder: CharacterBuilder)
|
||
{
|
||
super(builder);
|
||
|
||
this._physicInput = dom("input", { class: `w-14 mx-4 text-light-70 dark:text-dark-70 tabular-nums bg-light-10 dark:bg-dark-10 appearance-none outline-none ps-3 pe-1 py-1 focus:shadow-raw transition-[box-shadow] border bg-light-20 bg-dark-20 border-light-20 dark:border-dark-20`, attributes: { type: "number", disabled: true }});
|
||
this._mentalInput = dom("input", { class: `w-14 mx-4 text-light-70 dark:text-dark-70 tabular-nums bg-light-10 dark:bg-dark-10 appearance-none outline-none ps-3 pe-1 py-1 focus:shadow-raw transition-[box-shadow] border bg-light-20 bg-dark-20 border-light-20 dark:border-dark-20`, attributes: { type: "number", disabled: true }});
|
||
this._personalityInput = dom("input", { class: `w-14 mx-4 text-light-70 dark:text-dark-70 tabular-nums bg-light-10 dark:bg-dark-10 appearance-none outline-none ps-3 pe-1 py-1 focus:shadow-raw transition-[box-shadow] border bg-light-20 bg-dark-20 border-light-20 dark:border-dark-20`, attributes: { type: "number", disabled: true }});
|
||
|
||
this._options = Object.values(config.aspects).map((e, i) => dom('div', { attributes: { "data-aspect": e.id }, listeners: { click: () => {
|
||
this._builder.character.aspect = e.id;
|
||
this._options.forEach(_e => _e.setAttribute('data-state', 'inactive'));
|
||
this._options[i]?.setAttribute('data-state', 'active');
|
||
}}, class: 'group flex flex-col w-[360px] border border-light-35 dark:border-dark-35 hover:border-light-50 dark:hover:border-dark-50 cursor-pointer' }, [
|
||
div('bg-light-10 dark:bg-dark-10 border-b border-light-35 dark:border-dark-35 p-2 flex flex-col gap-2 group-data-[state=active]:bg-accent-blue group-data-[state=active]:bg-opacity-10', [
|
||
div('flex flex-row gap-8 ps-4 items-center', [
|
||
div("flex flex-1 flex-col gap-2 justify-center", [ div('text-lg font-bold', [ text(e.name) ]), dom('span', { class: 'border-b w-full border-light-50 dark:border-dark-50 group-data-[state=active]:border-b-[4px] group-data-[state=active]:border-accent-blue' }) ]),
|
||
div('rounded-full w-[96px] h-[96px] border border-light-50 dark:border-dark-50 bg-light-100 dark:bg-dark-100 !bg-opacity-10')
|
||
])
|
||
]),
|
||
div('flex justify-stretch items-stretch py-2 px-4 gap-4', [
|
||
div('flex flex-col flex-1 items-stretch gap-4', [
|
||
div('flex flex-1 justify-between', [ text('Difficulté'), div('text-sm font-bold', [ text(e.difficulty.toString()) ]) ]),
|
||
div('flex flex-1 justify-between', [ text('Bonus'), div('text-sm font-bold', [ text(e.stat === 'special' ? 'Special' : mainStatTexts[e.stat]) ]) ])
|
||
]),
|
||
div('w-px h-full bg-light-50 dark:bg-dark-50'),
|
||
div('flex flex-col items-center justify-between py-2', [
|
||
div('text-sm italic', [ text(alignmentTexts[e.alignment]) ]),
|
||
div(['text-sm font-bold', { "text-light-purple dark:text-dark-purple italic": e.magic, "text-light-orange dark:text-dark-orange": !e.magic }], [ text(e.magic ? 'Magie autorisée' : 'Magie interdite') ]),
|
||
]),
|
||
])
|
||
]));
|
||
|
||
const filterSwitch = dom("div", { class: `group mx-3 w-12 h-6 select-none transition-all border border-light-35 dark:border-dark-35 bg-light-20 dark:bg-dark-20 outline-none data-[state=checked]:bg-light-35 dark:data-[state=checked]:bg-dark-35 hover:border-light-50 dark:hover:border-dark-50 focus:shadow-raw focus:shadow-light-40 dark:focus:shadow-dark-40 data-[disabled]:bg-light-20 dark:data-[disabled]:bg-dark-20 data-[disabled]:border-light-20 dark:data-[disabled]:border-dark-20 relative py-[2px]`, attributes: { "data-state": this._filter ? "checked" : "unchecked" }, listeners: {
|
||
click: (e: Event) => {
|
||
this._filter = !this._filter;
|
||
filterSwitch.setAttribute('data-state', this._filter ? "checked" : "unchecked");
|
||
this.update();
|
||
}
|
||
}}, [ div('block w-[18px] h-[18px] translate-x-[2px] will-change-transform transition-transform bg-light-50 dark:bg-dark-50 group-data-[state=checked]:translate-x-[26px] group-data-[disabled]:bg-light-30 dark:group-data-[disabled]:bg-dark-30 group-data-[disabled]:border-light-30 dark:group-data-[disabled]:border-dark-30') ]);
|
||
|
||
this._content = [ div("flex flex-1 gap-12 px-2 py-4 justify-center items-center sticky top-0 bg-light-0 dark:bg-dark-0 w-full z-10", [
|
||
dom("label", { class: "flex justify-center items-center my-2" }, [
|
||
dom("span", { class: "md:text-base text-sm", text: "Physique" }),
|
||
this._physicInput,
|
||
]),
|
||
dom("label", { class: "flex justify-center items-center my-2" }, [
|
||
dom("span", { class: "md:text-base text-sm", text: "Mental" }),
|
||
this._mentalInput,
|
||
]),
|
||
dom("label", { class: "flex justify-center items-center my-2" }, [
|
||
dom("span", { class: "md:text-base text-sm", text: "Caractère" }),
|
||
this._personalityInput,
|
||
]),
|
||
dom("label", { class: "flex justify-center items-center my-2" }, [
|
||
dom("span", { class: "md:text-base text-sm", text: "Filtrer ?" }),
|
||
filterSwitch,
|
||
]),
|
||
]), div('flex flex-row flex-wrap justify-center items-center flex-1 gap-8 mx-8 my-4 px-8', this._options)];
|
||
|
||
this.update();
|
||
}
|
||
override update()
|
||
{
|
||
const physic = Object.values(this._builder.character.training['strength']).length + Object.values(this._builder.character.training['dexterity']).length + Object.values(this._builder.character.training['constitution']).length;
|
||
const mental = Object.values(this._builder.character.training['intelligence']).length + Object.values(this._builder.character.training['curiosity']).length;
|
||
const personality = Object.values(this._builder.character.training['charisma']).length + Object.values(this._builder.character.training['psyche']).length;
|
||
|
||
this._physicInput.value = physic.toString();
|
||
this._mentalInput.value = mental.toString();
|
||
this._personalityInput.value = personality.toString();
|
||
|
||
(this._content[1] as HTMLElement).replaceChildren(...this._options.filter(e => {
|
||
const id = e.getAttribute('data-aspect')!;
|
||
const aspect = config.aspects[id]!;
|
||
|
||
e.setAttribute('data-state', this._builder.character.aspect === id ? 'active' : 'inactive');
|
||
|
||
if(!this._filter)
|
||
return true;
|
||
|
||
if(physic > aspect.physic.max || physic < aspect.physic.min)
|
||
return false;
|
||
if(mental > aspect.mental.max || mental < aspect.mental.min)
|
||
return false;
|
||
if(personality > aspect.personality.max || personality < aspect.personality.min)
|
||
return false;
|
||
|
||
return true;
|
||
}));
|
||
}
|
||
static override validate(builder: CharacterBuilder): boolean
|
||
{
|
||
if(builder.character.aspect === undefined)
|
||
return false;
|
||
|
||
return true;
|
||
}
|
||
}
|
||
|
||
export const masteryTexts: Record<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 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.current === 'sm' || breakpoint.current === '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 px-4 lg:max-w-[960px] h-full', content: 'overflow-auto h-full' }, 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 dark:hover: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 dark:hover: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-60", [
|
||
div("flex flex-col py-1 gap-4", [
|
||
div("flex flex-row items-center justify-center gap-4", [
|
||
dom("div", { class: 'text-xl font-semibold', text: "Compétences" }),
|
||
div("flex flex-1 border-t border-dashed border-light-50 dark:border-dark-50")
|
||
]),
|
||
|
||
div("grid grid-cols-2 gap-2",
|
||
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("flex flex-row items-center justify-center gap-4", [
|
||
dom("div", { class: 'text-xl font-semibold', text: "Maitrises" }),
|
||
div("flex flex-1 border-t border-dashed border-light-50 dark:border-dark-50")
|
||
]),
|
||
|
||
div("grid grid-cols-2 gap-x-3 gap-y-1 text-sm", { list: () => 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-1 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('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] 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 dark:hover:border-dark-red focus:border-light-red dark:focus:border-dark-red focus:shadow-light-red dark:focus: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 dark:hover:border-dark-red focus:border-light-red dark:focus:border-dark-red focus:shadow-light-red dark:focus:shadow-dark-red'), 'Dégats', 'left'),
|
||
numberpicker({ defaultValue: () => inputs.health.sum, input: v => { inputs.health.sum = v }, min: 0, disabled: () => inputs.health.open, class: 'h-8 !m-0' }),
|
||
tooltip(button(icon('radix-icons:plus', { width: 16, height: 16 }), () => {
|
||
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 dark:hover:border-dark-green focus:border-light-green dark:focus:border-dark-green focus:shadow-light-green dark:focus:shadow-dark-green'), 'Soin', 'left'),
|
||
])
|
||
])
|
||
], { class: { container: 'gap-2', title: 'ps-2' }, open: false, onFold: v => { inputs.health.open = v; if(v) { inputs.health.sum = 0; }} }),
|
||
() => div('flex flex-row justify-between items-center', [
|
||
div('flex flex-row gap-8 items-center', [ span('text-xl font-bold', 'Mana'), div('flex flex-row items-center gap-1', [ span('text-xl font-bold', () => (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 dark:hover:border-dark-red focus:border-light-red dark:focus:border-dark-red focus:shadow-light-red dark:focus:shadow-dark-red'), 'Dégats', 'left'),
|
||
numberpicker({ defaultValue: () => inputs.mana, input: v => { inputs.mana = v }, min: 0, class: 'h-8 !m-0' }),
|
||
tooltip(button(icon('radix-icons:plus', { width: 16, height: 16 }), () => {
|
||
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 dark:hover:border-dark-green focus:border-light-green dark:focus:border-dark-green focus:shadow-light-green dark:focus:shadow-dark-green'), 'Soin', 'left'),
|
||
])
|
||
]),
|
||
]);
|
||
const blocker = fullblocker([ container ], { closeWhenOutside: true, open: false })
|
||
|
||
return { show: () => {
|
||
setTimeout(() => container.setAttribute('data-state', 'active'), 1);
|
||
blocker.open();
|
||
}, hide: () => {
|
||
setTimeout(blocker.close, 150);
|
||
container.setAttribute('data-state', 'inactive');
|
||
}};
|
||
}
|
||
actionsTab()
|
||
{
|
||
return [
|
||
div('flex flex-col gap-8', [
|
||
div('flex flex-col gap-2', [
|
||
div("flex flex-row items-center justify-center gap-4", [
|
||
div("flex flex-row items-center justify-center gap-2", [ dom("div", { class: 'text-lg font-semibold', text: "Actions" }) ]),
|
||
div("flex flex-1 border-t border-dashed border-light-50 dark:border-dark-50"),
|
||
div('flex flex-row items-center gap-2', [ div('border border-dashed border-light-50 dark:border-dark-50 w-5 h-5'), div('border border-dashed border-light-50 dark:border-dark-50 w-5 h-5'), div('border border-dashed border-light-50 dark:border-dark-50 w-5 h-5'), dom('span', { class: 'tracking-tight', text: '/ tour' }) ]),
|
||
]),
|
||
|
||
div('flex flex-col gap-2', [
|
||
div('flex flex-row flex-wrap gap-2', ["Attaquer", "Désarmer", "Saisir", "Faire chuter", "Déplacer", "Courir", "Pas de coté", "Charger", "Lancer un sort", "S'interposer", "Se transformer", "Utiliser un objet", "Anticiper une action", "Improviser"].map(e => proses('a', preview, [ span('cursor-pointer text-sm decoration-dotted underline', e) ], { href: 'regles/le-combat/actions-en-combat#' + e, label: e, trigger: 'hover', navigate: false, class: 'text-light-60 dark:text-dark-60', lowers: false }))),
|
||
div('flex flex-col gap-2', { render: (e, _c) => _c ?? div('flex flex-col gap-1', [
|
||
div('flex flex-row justify-between', [dom('span', { class: 'text-lg font-semibold', text: config.action[e]?.name }), config.action[e]?.cost ? div('flex flex-row gap-1', [dom('span', { class: 'font-bold', text: config.action[e]?.cost?.toString() }), text(`point${config.action[e]?.cost > 1 ? 's' : ''} d'action`)]) : undefined]),
|
||
markdown(getText(config.action[e]?.description), undefined, { tags: { a: preview } }),
|
||
]), list: () => this.character.compiled ? this.character.compiled.lists.action ?? [] : [] }),
|
||
]),
|
||
]),
|
||
div('flex flex-col gap-2', [
|
||
div("flex flex-row items-center justify-center gap-4", [
|
||
div("flex flex-row items-center justify-center gap-2", [ dom("div", { class: 'text-lg font-semibold', text: "Réactions" }) ]),
|
||
div("flex flex-1 border-t border-dashed border-light-50 dark:border-dark-50"),
|
||
div('flex flex-row items-center gap-2', [ div('border border-dashed border-light-50 dark:border-dark-50 w-5 h-5'), div('border border-dashed border-light-50 dark:border-dark-50 w-5 h-5'), dom('span', { class: 'tracking-tight', text: '/ tour' }) ]),
|
||
]),
|
||
|
||
div('flex flex-col gap-2', [
|
||
div('flex flex-row flex-wrap gap-2 text-light-60 dark:text-dark-60', ["Parer", "Esquiver", "Saisir une opportunité", "Prendre en tenaille", "Intercepter"].map(e => proses('a', preview, [ span('cursor-pointer text-sm decoration-dotted underline', e) ], { href: 'regles/le-combat/actions-en-combat#' + e, label: e, trigger: 'hover', navigate: false, class: 'text-light-60 dark:text-dark-60', lowers: false }))),
|
||
div('flex flex-col gap-2', { render: (e, _c) => _c ?? div('flex flex-col gap-1', [
|
||
div('flex flex-row justify-between', [dom('span', { class: 'text-lg font-semibold', text: config.reaction[e]?.name }), config.reaction[e]?.cost ? div('flex flex-row gap-1', [dom('span', { class: 'font-bold', text: config.reaction[e]?.cost?.toString() }), text(`point${config.reaction[e]?.cost > 1 ? 's' : ''} d'action`)]) : undefined]),
|
||
markdown(getText(config.reaction[e]?.description), undefined, { tags: { a: preview } }),
|
||
]), list: () => this.character.compiled ? this.character.compiled.lists.reaction ?? [] : [] }),
|
||
]),
|
||
]),
|
||
div('flex flex-col gap-2', [
|
||
div("flex flex-row items-center justify-center gap-4", [
|
||
div("flex flex-row items-center justify-center gap-2", [ dom("div", { class: 'text-lg font-semibold', text: "Actions libres" }) ]),
|
||
div("flex flex-1 border-t border-dashed border-light-50 dark:border-dark-50"),
|
||
]),
|
||
|
||
div('flex flex-col gap-2', [
|
||
div('flex flex-row flex-wrap gap-2 text-light-60 dark:text-dark-60', ["Analyser une situation", "Communiquer", "Dégainer", "Attraper un objet"].map(e => proses('a', preview, [ span('cursor-pointer text-sm decoration-dotted underline', e) ], { href: 'regles/le-combat/actions-en-combat#' + e, label: e, trigger: 'hover', navigate: false, class: 'text-light-60 dark:text-dark-60', lowers: false }))),
|
||
div('flex flex-col gap-2', { render: (e, _c) => _c ?? div('flex flex-col gap-1', [
|
||
div('flex flex-row justify-between', [dom('span', { class: 'text-lg font-semibold', text: config.freeaction[e]?.name }) ]),
|
||
markdown(getText(config.freeaction[e]?.description), undefined, { tags: { a: preview } }),
|
||
]), list: () => this.character.compiled ? this.character.compiled.lists.freeaction ?? [] : [] })
|
||
]),
|
||
]),
|
||
]),
|
||
]
|
||
}
|
||
abilitiesTab()
|
||
{
|
||
return [ div('flex flex-col gap-4', [
|
||
foldable(() => [div('flex flex-col gap-2', { render: (e, _c) => _c ?? div('flex flex-col gap-1', [
|
||
div('flex flex-row justify-between', [dom('span', { class: 'text-lg font-semibold', text: config.passive[e]?.name }) ]),
|
||
markdown(getText(config.passive[e]?.description), undefined, { tags: { a: preview } }),
|
||
]), list: this.character.compiled.lists.passive })], [
|
||
div("flex flex-row items-center justify-center gap-4", [
|
||
div("flex flex-row items-center justify-center gap-2", [ dom("div", { class: 'text-xl font-bold', 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-2', { render: (e, _c) => _c ?? div('flex flex-col gap-1', [
|
||
div('flex flex-row justify-between', [dom('span', { class: 'text-lg font-semibold', text: config.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", [
|
||
div("flex flex-row items-center justify-center gap-2", [ dom("div", { class: 'text-xl font-bold', text: "Spécialisations" }) ]),
|
||
div("flex flex-1 border-t border-dashed border-light-50 dark:border-dark-50"),
|
||
]),
|
||
], { open: false }),
|
||
]) ];
|
||
}
|
||
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 dark:hover:bg-dark-40 cursor-pointer', listeners: { click: () => preference.sort = (preference.sort === 'rank-asc' ? 'rank-desc' : preference.sort === 'rank-desc' ? '' : 'rank-asc') } }, [text('Rang'), () => preference.sort.startsWith('rank') ? icon(preference.sort.endsWith('asc') ? 'ph:sort-ascending' : 'ph:sort-descending', { width: 16, height: 16 }) : undefined ]),
|
||
() => dom('div', { class: 'flex flex-row justify-between items-center gap-4 py-1 px-2 hover:bg-light-40 dark:hover:bg-dark-40 cursor-pointer', listeners: { click: () => preference.sort = (preference.sort === 'type-asc' ? 'type-desc' : preference.sort === 'type-desc' ? '' : 'type-asc') } }, [text('Type'), () => preference.sort.startsWith('type') ? icon(preference.sort.endsWith('asc') ? 'ph:sort-ascending' : 'ph:sort-descending', { width: 16, height: 16 }) : undefined ]),
|
||
() => dom('div', { class: 'flex flex-row justify-between items-center gap-4 py-1 px-2 hover:bg-light-40 dark:hover:bg-dark-40 cursor-pointer', listeners: { click: () => preference.sort = (preference.sort === 'element-asc' ? 'element-desc' : preference.sort === 'element-desc' ? '' : 'element-asc') } }, [text('Element'), () => preference.sort.startsWith('element') ? icon(preference.sort.endsWith('asc') ? 'ph:sort-ascending' : 'ph:sort-descending', { width: 16, height: 16 }) : undefined ]),
|
||
() => dom('div', { class: 'flex flex-row justify-between items-center gap-4 py-1 px-2 hover:bg-light-40 dark:hover:bg-dark-40 cursor-pointer', listeners: { click: () => preference.sort = (preference.sort === 'cost-asc' ? 'cost-desc' : preference.sort === 'cost-desc' ? '' : 'cost-asc') } }, [text('Coût'), () => preference.sort.startsWith('cost') ? icon(preference.sort.endsWith('asc') ? 'ph:sort-ascending' : 'ph:sort-descending', { width: 16, height: 16 }) : undefined ]),
|
||
() => dom('div', { class: 'flex flex-row justify-between items-center gap-4 py-1 px-2 hover:bg-light-40 dark:hover:bg-dark-40 cursor-pointer', listeners: { click: () => preference.sort = (preference.sort === 'range-asc' ? 'range-desc' : preference.sort === 'range-desc' ? '' : 'range-asc') } }, [text('Portée'), () => preference.sort.startsWith('range') ? icon(preference.sort.endsWith('asc') ? 'ph:sort-ascending' : 'ph:sort-descending', { width: 16, height: 16 }) : undefined ]),
|
||
() => dom('div', { class: 'flex flex-row justify-between items-center gap-4 py-1 px-2 hover:bg-light-40 dark:hover:bg-dark-40 cursor-pointer', listeners: { click: () => preference.sort = (preference.sort === 'speed-asc' ? 'speed-desc' : preference.sort === 'speed-desc' ? '' : 'speed-asc') } }, [text('Incantation'), () => preference.sort.startsWith('speed') ? icon(preference.sort.endsWith('asc') ? 'ph:sort-ascending' : 'ph:sort-descending', { width: 16, height: 16 }) : undefined ]),
|
||
], { class: 'text-light-100 dark:text-dark-100 w-32', offset: 8, placement: 'bottom-end', arrow: true });
|
||
}
|
||
|
||
return [
|
||
div('flex flex-col gap-2 h-full', [
|
||
div('flex flex-row justify-end items-center', [
|
||
div('flex flex-row gap-2 items-center', [
|
||
dom('span', { class: () => ['italic text-sm', { 'text-light-red dark:text-dark-red': 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', [ dom('span', { class: 'font-semibold text-lg', text: spell.name ?? 'Inconnu' }), div('flex-1 border-b border-dashed border-light-50 dark:border-dark-50'), dom('span', { class: 'text-light-70 dark:text-dark-70', text: `${spell.cost ?? 0} mana` }) ]),
|
||
div('flex flex-row 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'),
|
||
span(undefined, typeof spell.speed === 'number' ? `${spell.speed} minute${spell.speed > 1 ? 's' : ''}` : spell.speed)
|
||
]),
|
||
], { open: false, class: { container: 'flex flex-col gap-1', icon: 'px-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 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 !border-opacity-50 rounded-full !bg-opacity-20 px-2 py-px`, elementTexts[el].class],
|
||
text: elementTexts[el].text
|
||
})
|
||
)
|
||
),
|
||
div("flex flex-row text-sm gap-1", [
|
||
...(spell.rank !== 4 ? [
|
||
dom("span", { text: `Rang ${spell.rank}` }),
|
||
text("/"),
|
||
dom("span", { text: spellTypeTexts[spell.type] }),
|
||
text("/")
|
||
] : []),
|
||
dom("span", { text: `${spell.cost} mana` }),
|
||
text("/"),
|
||
dom("span", { text: typeof spell.speed === "string" ? spell.speed : `${spell.speed} minutes` })
|
||
]),
|
||
button(text(() => spells.includes(spell.id) ? 'Supprimer' : 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 justify-end items-center gap-8', [
|
||
div('flex flex-row gap-1 items-center', [ span('italic text-sm', 'Argent'), text("TODO") ]),
|
||
]),
|
||
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 lg:block', [text('Puis.')]),
|
||
div('w-12 shrink-0 text-center hidden lg:block', [text('Poids')]),
|
||
div('w-12 shrink-0 text-center hidden lg: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 !bg-opacity-20', { 'border-accent-blue bg-accent-blue': !e.cursed, 'border-light-purple bg-light-purple dark:border-dark-purple dark:bg-dark-purple': 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 lg: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 lg: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 lg: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 lg: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 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 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,
|
||
])
|
||
])
|
||
]),
|
||
];
|
||
}
|
||
} |