1156 lines
57 KiB
TypeScript
1156 lines
57 KiB
TypeScript
import type { Ability, Alignment, Character, CharacterConfig, CompiledCharacter, DoubleIndex, Feature, FeatureID, FeatureItem, Level, MainStat, SpellElement, SpellType, TrainingLevel } from "~/types/character";
|
|
import { z } from "zod/v4";
|
|
import characterConfig from './character-config.json';
|
|
import { button, fakeA, input, loading, numberpicker, select } from "./proses";
|
|
import { div, dom, icon, mergeClasses, text, type Class } from "./dom.util";
|
|
import { contextmenu, followermenu, popper } from "./floating.util";
|
|
import { clamp } from "./general.util";
|
|
import markdownUtil from "./markdown.util";
|
|
|
|
const config = characterConfig as CharacterConfig;
|
|
|
|
export const MAIN_STATS = ["strength","dexterity","constitution","intelligence","curiosity","charisma","psyche"] as const;
|
|
export const ABILITIES = ["athletics","acrobatics","intimidation","sleightofhand","stealth","survival","investigation","history","religion","arcana","understanding","perception","performance","medecine","persuasion","animalhandling","deception"] as const;
|
|
export const LEVELS = [1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20] as const;
|
|
export const TRAINING_LEVELS = [0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15] as const;
|
|
export const SPELL_TYPES = ["precision","knowledge","instinct","arts"] as const;
|
|
export const CATEGORIES = ["action","reaction","freeaction","misc"] as const;
|
|
export const SPELL_ELEMENTS = ["fire","ice","thunder","earth","arcana","air","nature","light","psyche"] as const;
|
|
|
|
export const defaultCharacter: Character = {
|
|
id: -1,
|
|
|
|
name: "",
|
|
people: undefined,
|
|
level: 1,
|
|
health: 0,
|
|
mana: 0,
|
|
|
|
training: MAIN_STATS.reduce((p, v) => { p[v] = { 0: 0 }; return p; }, {} as Record<MainStat, Partial<Record<TrainingLevel, number>>>),
|
|
leveling: { 1: 0 },
|
|
abilities: {},
|
|
spells: [],
|
|
modifiers: {},
|
|
choices: {},
|
|
|
|
owner: -1,
|
|
visibility: "private",
|
|
};
|
|
const defaultCompiledCharacter: (character: Character) => CompiledCharacter = (character: Character) => ({
|
|
id: character.id,
|
|
owner: character.owner,
|
|
username: character.username,
|
|
name: character.name,
|
|
health: 0,
|
|
mana: 0,
|
|
race: character.people!,
|
|
modifier: MAIN_STATS.reduce((p, v) => { p[v] = 0; return p; }, {} as Record<MainStat, number>),
|
|
level: character.level,
|
|
values: {
|
|
health: character.health,
|
|
mana: character.mana
|
|
},
|
|
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,
|
|
knowledge: 0,
|
|
precision: 0,
|
|
arts: 0,
|
|
},
|
|
speed: false,
|
|
defense: {
|
|
hardcap: Infinity,
|
|
static: 6,
|
|
activeparry: 0,
|
|
activedodge: 0,
|
|
passiveparry: 0,
|
|
passivedodge: 0,
|
|
},
|
|
mastery: {
|
|
strength: 0,
|
|
dexterity: 0,
|
|
shield: 0,
|
|
armor: 0,
|
|
multiattack: 1,
|
|
magicpower: 0,
|
|
magicspeed: 0,
|
|
magicelement: 0,
|
|
magicinstinct: 0,
|
|
},
|
|
bonus: {},
|
|
resistance: {},
|
|
initiative: 0,
|
|
capacity: 0,
|
|
lists: {
|
|
action: [],
|
|
freeaction: [],
|
|
reaction: [],
|
|
passive: [],
|
|
spells: character.spells,
|
|
},
|
|
aspect: "",
|
|
notes: character.notes ?? "",
|
|
});
|
|
|
|
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 function alignmentToString(alignment: Alignment): string
|
|
{
|
|
switch(alignment.loyalty)
|
|
{
|
|
case 'chaotic':
|
|
return alignment.kindness === 'evil' ? 'Chaotique mauvais' : alignment.kindness === 'neutral' ? 'Chaotique neutre' : 'Chaotique bon';
|
|
case 'loyal':
|
|
return alignment.kindness === 'evil' ? 'Loyal mauvais' : alignment.kindness === 'neutral' ? 'Loyal neutre' : 'Loyal bon';
|
|
case 'neutral':
|
|
return alignment.kindness === 'evil' ? 'Neutre mauvais' : alignment.kindness === 'neutral' ? 'Neutre' : 'Neutre bon';
|
|
}
|
|
}
|
|
export const spellTypeTexts: Record<SpellType, string> = { "instinct": "Instinct", "knowledge": "Savoir", "precision": "Précision", "arts": "Oeuvres" };
|
|
|
|
export const CharacterValidation = z.object({
|
|
id: z.number(),
|
|
name: z.string(),
|
|
people: z.number().nullable(),
|
|
level: z.number().min(1).max(20),
|
|
aspect: z.number().nullable().optional(),
|
|
notes: z.string().nullable().optional(),
|
|
health: z.number().default(0),
|
|
mana: z.number().default(0),
|
|
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()),
|
|
spells: z.string().array(),
|
|
modifiers: z.record(z.enum(MAIN_STATS), z.number().optional()),
|
|
choices: z.record(z.string(), z.array(z.number())),
|
|
owner: z.number(),
|
|
username: z.string().optional(),
|
|
visibility: z.enum(["public", "private"]),
|
|
thumbnail: z.any(),
|
|
});
|
|
|
|
const stepTexts: Record<number, string> = {
|
|
0: 'Choisissez un peuple afin de définir la progression de votre personnage au fil des niveaux.',
|
|
1: 'Déterminez la progression de votre personnage en choisissant une option par niveau disponible.',
|
|
2: 'Spécialisez votre personnage en attribuant vos points d\'entrainement parmi les 7 branches disponibles.\nChaque paliers de 3 points augmentent votre modifieur.',
|
|
3: 'Diversifiez vos possibilités en affectant vos points dans les différentes compétences disponibles.',
|
|
4: 'Déterminez l\'Aspect qui vous corresponds et benéficiez de puissants bonus.'
|
|
};
|
|
|
|
type Property = { value: number | string | false, id: string, operation: "set" | "add" };
|
|
type PropertySum = { list: Array<Property>, value: number, _dirty: boolean };
|
|
export class CharacterBuilder
|
|
{
|
|
private _container: HTMLDivElement;
|
|
private _content?: HTMLDivElement;
|
|
private _stepsHeader: HTMLDivElement[] = [];
|
|
private _stepsContent: BuilderTab[] = [];
|
|
private _helperText!: Text;
|
|
private id?: string;
|
|
|
|
private _character!: Character;
|
|
private _result!: CompiledCharacter;
|
|
private _buffer: Record<string, PropertySum> = {};
|
|
|
|
constructor(container: HTMLDivElement, id?: string)
|
|
{
|
|
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'}`;
|
|
|
|
if(character.people !== undefined)
|
|
{
|
|
const people = config.peoples[character.people]!;
|
|
|
|
this._result = defaultCompiledCharacter(this._character);
|
|
|
|
Object.entries(character.leveling).forEach(e => this.add(people.options[parseInt(e[0]) as Level][e[1]]!));
|
|
|
|
MAIN_STATS.forEach(stat => {
|
|
Object.entries(character.training[stat]).forEach(option => this.add(config.training[stat][parseInt(option[0]) as TrainingLevel][option[1]]))
|
|
});
|
|
}
|
|
load.remove();
|
|
|
|
this.render();
|
|
this.display(0);
|
|
}
|
|
});
|
|
}
|
|
else
|
|
{
|
|
this._character = Object.assign({}, defaultCharacter);
|
|
|
|
this._result = defaultCompiledCharacter(this._character);
|
|
|
|
document.title = `d[any] - Edition de nouveau personnage`;
|
|
|
|
this.render();
|
|
this.display(0);
|
|
}
|
|
}
|
|
private render()
|
|
{
|
|
this._stepsHeader = [
|
|
dom("div", { class: "group flex items-center", }, [
|
|
dom("div", { class: "px-2 py-1 border-b border-transparent hover:border-accent-blue disabled:text-light-50 dark:disabled:text-dark-50 disabled:hover:border-transparent group-data-[state=active]:text-accent-blue cursor-pointer", listeners: { click: e => this.display(0) } }, [text("Peuples")]),
|
|
]),
|
|
dom("div", { class: "group flex items-center", }, [
|
|
icon("radix-icons:chevron-right", { class: "w-6 h-6 flex justify-center items-center group-data-[disabled]:text-light-50 dark:group-data-[disabled]:text-dark-50 group-data-[disabled]:hover:border-transparent me-4" }),
|
|
dom("div", { class: "px-2 py-1 border-b border-transparent hover:border-accent-blue disabled:text-light-50 dark:disabled:text-dark-50 disabled:hover:border-transparent group-data-[state=active]:text-accent-blue cursor-pointer", listeners: { click: e => this.display(1) } }, [text("Niveaux")]),
|
|
]),
|
|
dom("div", { class: "group flex items-center", }, [
|
|
icon("radix-icons:chevron-right", { class: "w-6 h-6 flex justify-center items-center group-data-[disabled]:text-light-50 dark:group-data-[disabled]:text-dark-50 group-data-[disabled]:hover:border-transparent me-4" }),
|
|
dom("div", { class: "px-2 py-1 border-b border-transparent hover:border-accent-blue disabled:text-light-50 dark:disabled:text-dark-50 disabled:hover:border-transparent group-data-[state=active]:text-accent-blue cursor-pointer", listeners: { click: e => this.display(2) } }, [text("Entrainement")]),
|
|
]),
|
|
dom("div", { class: "group flex items-center", }, [
|
|
icon("radix-icons:chevron-right", { class: "w-6 h-6 flex justify-center items-center group-data-[disabled]:text-light-50 dark:group-data-[disabled]:text-dark-50 group-data-[disabled]:hover:border-transparent me-4" }),
|
|
dom("div", { class: "px-2 py-1 border-b border-transparent hover:border-accent-blue disabled:text-light-50 dark:disabled:text-dark-50 disabled:hover:border-transparent group-data-[state=active]:text-accent-blue cursor-pointer", listeners: { click: e => this.display(3) } }, [text("Compétences")]),
|
|
]),
|
|
dom("div", { class: "group flex items-center", }, [
|
|
icon("radix-icons:chevron-right", { class: "w-6 h-6 flex justify-center items-center group-data-[disabled]:text-light-50 dark:group-data-[disabled]:text-dark-50 group-data-[disabled]:hover:border-transparent me-4" }),
|
|
dom("div", { class: "px-2 py-1 border-b border-transparent hover:border-accent-blue disabled:text-light-50 dark:disabled:text-dark-50 disabled:hover:border-transparent group-data-[state=active]:text-accent-blue cursor-pointer", listeners: { click: e => this.display(4) } }, [text("Aspect")])
|
|
]),
|
|
];
|
|
this._stepsContent = [
|
|
new PeoplePicker(this),
|
|
new LevelPicker(this),
|
|
new TrainingPicker(this),
|
|
new AbilityPicker(this),
|
|
new AspectPicker(this),
|
|
];
|
|
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(), div("flex w-full flex-row gap-4 items-center justify-center relative", this._stepsHeader), div(undefined, [ popper(icon("radix-icons:question-mark-circled", { height: 20, width: 20 }), {
|
|
arrow: true,
|
|
offset: 8,
|
|
content: [ this._helperText ],
|
|
placement: "bottom-end",
|
|
class: "max-w-96 fixed hidden TooltipContent border border-light-30 dark:border-dark-30 px-2 py-1 bg-light-10 dark:bg-dark-10 text-light-70 dark:text-dark-70 z-50"
|
|
}) ]),
|
|
]),
|
|
this._content,
|
|
]));
|
|
}
|
|
display(step: number)
|
|
{
|
|
if(step < 0 || step >= this._stepsHeader.length)
|
|
return;
|
|
|
|
if(this._stepsContent.slice(0, step).some(e => !e.validate()))
|
|
return;
|
|
|
|
this._stepsHeader.forEach(e => e.setAttribute('data-state', 'inactive'));
|
|
this._stepsHeader[step]!.setAttribute('data-state', 'active');
|
|
|
|
this._stepsContent[step]!.update();
|
|
this._content?.replaceChildren(...this._stepsContent[step]!.dom);
|
|
|
|
this._helperText.textContent = stepTexts[step]!;
|
|
}
|
|
async save(leave: boolean = true)
|
|
{
|
|
if(this.id === 'new')
|
|
{
|
|
//@ts-ignore
|
|
this.id = this._character.id = this._result.id = await useRequestFetch()(`/api/character`, {
|
|
method: 'post',
|
|
body: this._character,
|
|
onResponseError: (e) => {
|
|
//add({ title: 'Erreur d\'enregistrement', content: e.response.status === 401 ? "Vous n'êtes pas autorisé à effectué cette opération" : e.response.statusText, type: 'error', duration: 25000, timer: true });
|
|
}
|
|
});
|
|
//add({ content: 'Personnage créé', type: 'success', duration: 25000, timer: true });
|
|
useRouter().replace({ name: 'character-id-edit', params: { id: this.id } })
|
|
if(leave) useRouter().push({ name: 'character-id', params: { id: this.id } });
|
|
}
|
|
else
|
|
{
|
|
//@ts-ignore
|
|
await useRequestFetch()(`/api/character/${this._character.id}`, {
|
|
method: 'post',
|
|
body: this._character,
|
|
onResponseError: (e) => {
|
|
//add({ title: 'Erreur d\'enregistrement', content: e.response.status === 401 ? "Vous n'êtes pas autorisé à effectué cette opération" : e.response.statusText, type: 'error', duration: 25000, timer: true });
|
|
}
|
|
});
|
|
//add({ content: 'Personnage enregistré', type: 'success', duration: 25000, timer: true });
|
|
if(leave) useRouter().push({ name: 'character-id', params: { id: this.id } });
|
|
}
|
|
}
|
|
|
|
get character(): Character
|
|
{
|
|
return this._character;
|
|
}
|
|
get compiled(): CompiledCharacter
|
|
{
|
|
this.compile(Object.keys(this._buffer));
|
|
|
|
return this._result;
|
|
}
|
|
get values(): Record<string, number>
|
|
{
|
|
const keys = Object.keys(this._buffer);
|
|
this.compile(keys);
|
|
|
|
return keys.reduce((p, v) => {
|
|
p[v] = this._buffer[v]!.value;
|
|
return p;
|
|
}, {} as Record<string, number>);
|
|
}
|
|
|
|
private compile(properties: string[])
|
|
{
|
|
const queue = properties;
|
|
queue.forEach(property => {
|
|
const buffer = this._buffer[property];
|
|
|
|
if(property === "")
|
|
return
|
|
|
|
if(buffer && buffer._dirty === true)
|
|
{
|
|
let sum = 0;
|
|
for(let i = 0; i < buffer.list.length; i++)
|
|
{
|
|
if(typeof buffer.list[i]!.value === 'string') // Add or set a modifier
|
|
{
|
|
const modifier = this._buffer[buffer.list[i]!.value as string];
|
|
if(!modifier)
|
|
{
|
|
queue.push(property);
|
|
return;
|
|
}
|
|
else if(modifier._dirty)
|
|
{
|
|
//Put it back in queue since its dependencies haven't been resolved yet
|
|
queue.push(property);
|
|
return;
|
|
}
|
|
else
|
|
{
|
|
if(buffer.list[i]?.operation === 'add')
|
|
sum += modifier.value;
|
|
else if(buffer.list[i]?.operation === 'set')
|
|
sum = modifier.value;
|
|
}
|
|
}
|
|
else
|
|
{
|
|
if(buffer.list[i]?.operation === 'add')
|
|
sum += buffer.list[i]!.value as number;
|
|
else if(buffer.list[i]?.operation === 'set')
|
|
sum = buffer.list[i]!.value as number;
|
|
}
|
|
}
|
|
|
|
const path = property.split("/");
|
|
const object = path.length === 1 ? this._result : path.slice(0, -1).reduce((p, v) => p[v], this._result as any);
|
|
|
|
if(object.hasOwnProperty(path.slice(-1)[0]!))
|
|
object[path.slice(-1)[0]!] = sum;
|
|
|
|
this._buffer[property]!.value = sum;
|
|
this._buffer[property]!._dirty = false;
|
|
}
|
|
});
|
|
}
|
|
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]!;
|
|
delete this._character.leveling[level];
|
|
|
|
this.remove(config.peoples[this._character.people!]!.options[level][option]);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
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
|
|
{
|
|
this.remove(config.peoples[this._character.people!]!.options[level][this._character.leveling[level]!]);
|
|
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))
|
|
{
|
|
this.remove(config.training[stat][i as TrainingLevel][this._character.training[stat][i as TrainingLevel]!]);
|
|
delete this._character.training[stat][i as TrainingLevel];
|
|
}
|
|
}
|
|
}
|
|
else
|
|
{
|
|
this.remove(config.training[stat][level][this._character.training[stat][level]!]);
|
|
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]);
|
|
}
|
|
}
|
|
private add(feature?: string)
|
|
{
|
|
if(!feature)
|
|
return;
|
|
|
|
config.features[feature]?.effect.forEach(this.apply.bind(this));
|
|
}
|
|
private remove(feature?: string)
|
|
{
|
|
if(!feature)
|
|
return;
|
|
|
|
config.features[feature]?.effect.forEach(this.undo.bind(this));
|
|
}
|
|
private choose(id: string, choices: number[])
|
|
{
|
|
const current = this._character.choices[id];
|
|
const [ feature, effect ] = id.split('-');
|
|
const option = config.features[feature!]!.effect.find(e => e.id === effect);
|
|
|
|
if(option?.category === 'choice')
|
|
{
|
|
if(current !== undefined)
|
|
{
|
|
current.forEach(e => this.undo(option.options[e]));
|
|
}
|
|
if(choices.length > 0)
|
|
{
|
|
choices.forEach(e => this.apply(option.options[e]));
|
|
}
|
|
|
|
this._character.choices[id] = choices;
|
|
}
|
|
}
|
|
private apply(feature?: FeatureItem)
|
|
{
|
|
if(!feature)
|
|
return;
|
|
|
|
switch(feature.category)
|
|
{
|
|
case "list":
|
|
if(feature.action === 'add' && !this._result.lists[feature.list]!.includes(feature.item))
|
|
this._result.lists[feature.list]!.push(feature.item);
|
|
else
|
|
this._result.lists[feature.list] = this._result.lists[feature.list]!.filter((e: string) => e !== feature.item);
|
|
|
|
return;
|
|
case "value":
|
|
this._buffer[feature.property] ??= { list: [], value: 0, _dirty: true };
|
|
|
|
this._buffer[feature.property]!.list.push({ operation: feature.operation, id: feature.id, value: feature.value });
|
|
|
|
this._buffer[feature.property]!._dirty = true;
|
|
|
|
return;
|
|
case "choice":
|
|
const choice = this._character.choices[feature.id];
|
|
|
|
if(choice)
|
|
choice.forEach(e => this.apply(feature.options[e]!));
|
|
|
|
return;
|
|
default:
|
|
return;
|
|
}
|
|
}
|
|
private undo(feature?: FeatureItem)
|
|
{
|
|
if(!feature)
|
|
return;
|
|
|
|
switch(feature.category)
|
|
{
|
|
case "list":
|
|
if(feature.action === 'remove' && !this._result.lists[feature.list]!.includes(feature.item))
|
|
this._result.lists[feature.list]!.push(feature.item);
|
|
else
|
|
this._result.lists[feature.list] = this._result.lists[feature.list]!.filter(e => e !== feature.item);
|
|
|
|
return;
|
|
case "value":
|
|
this._buffer[feature.property] ??= { list: [], value: 0, _dirty: true };
|
|
|
|
this._buffer[feature.property]!.list.splice(this._buffer[feature.property]!.list.findIndex(e => e.id === feature.id), 1);
|
|
|
|
this._buffer[feature.property]!._dirty = true;
|
|
|
|
return;
|
|
case "choice":
|
|
const choice = this._character.choices[feature.id];
|
|
|
|
if(choice)
|
|
choice.forEach(e => this.undo(feature.options[e]!));
|
|
|
|
return;
|
|
default:
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
|
|
type PickableFeatureSettings = { state?: boolean, onToggle?: (state: boolean) => void, onChoice?: (options: number[]) => void, disabled?: boolean, class?: { selected?: Class, container?: Class, disabled?: Class }, choices?: Record<string, number[]>, };
|
|
export class PickableFeature
|
|
{
|
|
private _content: HTMLElement;
|
|
|
|
private _feature: Feature;
|
|
|
|
private _characterChoices?: Record<string, number[]>;
|
|
private _choiceDom?: HTMLElement;
|
|
private _choices?: Extract<FeatureItem, { category: 'choice' }>[];
|
|
|
|
private _settings?: PickableFeatureSettings;
|
|
|
|
constructor(feature: FeatureID, settings?: PickableFeatureSettings)
|
|
{
|
|
this._feature = config.features[feature]!;
|
|
this._settings = settings;
|
|
|
|
if(settings?.choices)
|
|
{
|
|
this._characterChoices = settings.choices;
|
|
this._choices = this._feature.effect.filter(e => e.category === 'choice');
|
|
this._choiceDom = this._choices.length > 0 ? dom('div', { class: 'absolute -bottom-px -right-px border border-light-50 dark:border-dark-50 bg-light-10 dark:bg-dark-10 group-data-[active]:hover:border-light-70 dark:group-data-[active]:hover:border-dark-70 flex p-1 justify-center items-center', listeners: { click: (e) => e.stopImmediatePropagation() ?? this.choose() } }, [ icon('radix-icons:gear') ]) : undefined;
|
|
}
|
|
|
|
this._content = dom("div", { attributes: { 'data-active': settings?.state, 'data-disabled': settings?.disabled ?? false }, class: ["group border border-light-40 dark:border-dark-40 cursor-pointer px-2 py-1 w-[400px] hover:border-light-70 dark:hover:border-dark-70 relative data-[active]:!border-accent-blue data-[active]:bg-accent-blue data-[active]:bg-opacity-20 data-[disabled]:hover:border-light-40 dark:data-[disabled]:hover:border-dark-40 data-[disabled]:opacity-30 data-[disabled]:cursor-default", settings?.class?.container, settings?.class?.selected ? mergeClasses(settings?.class?.selected).split(' ').map(e => `data-[state='active']:${e}`).join(' ') : undefined, settings?.class?.disabled ? mergeClasses(settings?.class?.disabled).split(' ').map(e => `data-[disabled]:${e}`).join(' ') : undefined], listeners: { click: e => this.toggle() }}, [
|
|
markdownUtil(this._feature.description, undefined, { tags: { a: fakeA } }),
|
|
this._choiceDom,
|
|
]);
|
|
}
|
|
toggle(state?: boolean)
|
|
{
|
|
if(this._content.hasAttribute('data-disabled'))
|
|
return this._content.hasAttribute('data-active');
|
|
|
|
const s = this._content.toggleAttribute('data-active', state);
|
|
|
|
this._settings?.onToggle && this._settings?.onToggle(s);
|
|
|
|
return s;
|
|
}
|
|
choose()
|
|
{
|
|
if(!this._choices || this._choices.length === 0)
|
|
return;
|
|
|
|
const menu = followermenu(this._choiceDom!, [ div('px-24 py-6 flex flex-col items-center text-light-100 dark:text-dark-100', this._choices.map(e => div('flex flex-row items-center', [ text(e.text), div('flex flex-col', Array(e.settings?.amount ?? 1).fill(0).map((_, i) => (
|
|
select(e.options.map((_e, _i) => ({ text: _e.text, value: _i })), { defaultValue: this._characterChoices![e.id] !== undefined ? this._characterChoices![e.id]![i] : undefined, change: (value) => { this._characterChoices![e.id] ??= []; this._characterChoices![e.id]![i] = value }, class: { container: 'w-32' } })
|
|
))) ]))) ], { arrow: true, offset: { mainAxis: 8 }, cover: 'width', placement: 'bottom', priority: false, viewport: document.getElementById('characterEditorContainer') ?? undefined, });
|
|
}
|
|
get dom()
|
|
{
|
|
return this._content;
|
|
}
|
|
}
|
|
interface BuilderTab {
|
|
dom: Array<Node | string>;
|
|
update: () => void;
|
|
validate: () => boolean;
|
|
};
|
|
class PeoplePicker implements BuilderTab
|
|
{
|
|
private _builder: CharacterBuilder;
|
|
private _content: Array<Node | string>;
|
|
|
|
private _nameInput: HTMLInputElement;
|
|
private _visibilityInput: HTMLDivElement;
|
|
private _options: HTMLDivElement[];
|
|
|
|
private _activeOption?: HTMLDivElement;
|
|
|
|
constructor(builder: CharacterBuilder)
|
|
{
|
|
this._builder = builder;
|
|
|
|
this._nameInput = input('text', {
|
|
input: (value) => {
|
|
this._builder.character.name = value ?? '';
|
|
document.title = `d[any] - Edition de ${this._builder.character.name || 'nouveau personnage'}`;
|
|
}
|
|
});
|
|
this._visibilityInput = 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": "unckecked" }, listeners: {
|
|
click: (e: Event) => {
|
|
this._builder.character.visibility = this._builder.character.visibility === "private" ? "public" : "private";
|
|
this._visibilityInput.setAttribute('data-state', this._builder.character.visibility === "private" ? "checked" : "unchecked");
|
|
}
|
|
}}, [ 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._options = 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 = i;
|
|
"border-accent-blue outline-2 outline outline-accent-blue".split(" ").forEach(e => this._activeOption?.classList.toggle(e, false));
|
|
this._activeOption = this._options[i]!;
|
|
"border-accent-blue outline-2 outline outline-accent-blue".split(" ").forEach(e => this._activeOption?.classList.toggle(e, true));
|
|
}
|
|
} }, [div("h-[320px]"), div("text-xl font-bold text-center", [text(people.name)]), div("w-full border-b border-light-50 dark:border-dark-50"), div("text-wrap word-break", [text(people.description)])]),
|
|
);
|
|
|
|
this._content = [ div("flex flex-1 gap-12 px-2 py-4 justify-center items-center", [
|
|
dom("label", { class: "flex justify-center items-center my-2" }, [
|
|
dom("span", { class: "pb-1 md:p-0", text: "Nom" }),
|
|
this._nameInput,
|
|
]),
|
|
dom("label", { class: "flex justify-center items-center my-2" }, [
|
|
dom("span", { class: "md:text-base text-sm", text: "Privé ?" }),
|
|
this._visibilityInput,
|
|
]),
|
|
button(text('Suivant'), () => this._builder.display(1), 'h-[35px] px-[15px]'),
|
|
]), div('flex flex-1 gap-4 p-2 overflow-x-auto justify-center', this._options)];
|
|
}
|
|
update()
|
|
{
|
|
this._nameInput.value = this._builder.character.name;
|
|
this._visibilityInput.setAttribute('data-state', this._builder.character.visibility === "private" ? "checked" : "unchecked");
|
|
"border-accent-blue outline-2 outline outline-accent-blue".split(" ").forEach(e => this._activeOption?.classList.toggle(e, false));
|
|
|
|
if(this._builder.character.people !== undefined)
|
|
{
|
|
this._activeOption = this._options[this._builder.character.people]!;
|
|
"border-accent-blue outline-2 outline outline-accent-blue".split(" ").forEach(e => this._activeOption?.classList.toggle(e, true));
|
|
}
|
|
}
|
|
validate(): boolean
|
|
{
|
|
return this._builder.character.people !== undefined;
|
|
}
|
|
get dom()
|
|
{
|
|
return this._content;
|
|
}
|
|
}
|
|
class LevelPicker implements BuilderTab
|
|
{
|
|
private _builder: CharacterBuilder;
|
|
private _content: Array<Node | string>;
|
|
|
|
private _levelInput: HTMLInputElement;
|
|
private _pointsInput: HTMLInputElement;
|
|
private _healthText: Text;
|
|
private _manaText: Text;
|
|
private _options: HTMLDivElement[][];
|
|
|
|
constructor(builder: CharacterBuilder)
|
|
{
|
|
this._builder = builder;
|
|
|
|
this._levelInput = numberpicker({ defaultValue: this._builder.character.level, min: 1, max: 20, input: (value) => {
|
|
this._builder.character.level = value;
|
|
this.updateLevel();
|
|
} });
|
|
this._pointsInput = dom("input", { class: `w-14 mx-4 text-light-70 dark:text-dark-70 tabular-nums bg-light-10 dark:bg-dark-10 appearance-none outline-none ps-3 pe-1 py-1 focus:shadow-raw transition-[box-shadow] border bg-light-20 bg-dark-20 border-light-20 dark:border-dark-20`, attributes: { type: "number", disabled: true }});
|
|
this._healthText = text("0"), this._manaText = text("0");
|
|
|
|
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) => new PickableFeature(option, { disabled: parseInt(level[0], 10) > this._builder.character.level, state: this._builder.character.leveling[parseInt(level[0], 10) as Level] === j, choices: this._builder.character.choices }).dom))
|
|
]);
|
|
|
|
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" }),
|
|
this._healthText,
|
|
]),
|
|
div("flex justify-center items-center gap-2 my-2 md:text-base text-sm", [
|
|
dom("span", { text: "Mana" }),
|
|
this._manaText,
|
|
]),
|
|
button(text('Suivant'), () => this._builder.display(2), 'h-[35px] px-[15px]'),
|
|
]), div('flex flex-col flex-1 gap-4 mx-8 my-4', this._options.flatMap(e => [...e]))];
|
|
}
|
|
update()
|
|
{
|
|
const values = this._builder.values;
|
|
|
|
this._levelInput.value = this._builder.character.level.toString();
|
|
this._pointsInput.value = (this._builder.character.level - Object.keys(this._builder.character.leveling).length).toString();
|
|
this._healthText.textContent = values.health?.toString() ?? '0';
|
|
this._manaText.textContent = values.mana?.toString() ?? '0';
|
|
|
|
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 HTMLDivElement).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 HTMLDivElement).classList.toggle(_e, this._builder.character.leveling[((i + 1) as Level)] === j));
|
|
});
|
|
}); */
|
|
}
|
|
validate(): boolean
|
|
{
|
|
return this._builder.character.level - Object.keys(this._builder.character.leveling).length >= 0;
|
|
}
|
|
get dom()
|
|
{
|
|
return this._content;
|
|
}
|
|
}
|
|
class TrainingPicker implements BuilderTab
|
|
{
|
|
private _builder: CharacterBuilder;
|
|
private _content: Array<Node | string>;
|
|
|
|
private _pointsInput: HTMLInputElement;
|
|
private _healthText: Text;
|
|
private _manaText: Text;
|
|
private _options: Record<MainStat, HTMLDivElement[][]>;
|
|
|
|
private _tab: number = 0;
|
|
private _statIndicator: HTMLSpanElement;
|
|
private _statContainer: HTMLDivElement;
|
|
|
|
constructor(builder: CharacterBuilder)
|
|
{
|
|
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) => new PickableFeature(option, { state: level[0] == '0' || this._builder.character.training[stat as MainStat][level[0] as any as TrainingLevel] === j, choices: this._builder.character.choices }).dom))
|
|
])
|
|
}
|
|
this._builder = 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._healthText = text("0"), this._manaText = text("0");
|
|
|
|
this._options = MAIN_STATS.reduce((p, v) => { p[v] = statRenderBlock(v); return p; }, {} as Record<MainStat, HTMLDivElement[][]>);
|
|
|
|
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" }),
|
|
this._healthText,
|
|
]),
|
|
div("flex justify-center items-center gap-2 my-2 md:text-base text-sm", [
|
|
dom("span", { text: "Mana" }),
|
|
this._manaText,
|
|
]),
|
|
button(text('Suivant'), () => this._builder.display(3), 'h-[35px] px-[15px]'),
|
|
]), dom('span')
|
|
]), div('flex flex-1 px-6 overflow-hidden max-w-full', [ this._statContainer ])];
|
|
|
|
this.switchTab(0);
|
|
}
|
|
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}%`;
|
|
}
|
|
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();
|
|
this._healthText.textContent = values.health?.toString() ?? '0';
|
|
this._manaText.textContent = values.mana?.toString() ?? '0';
|
|
}
|
|
validate(): boolean
|
|
{
|
|
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);
|
|
|
|
return (values.training ?? 0) - training >= 0;
|
|
}
|
|
get dom()
|
|
{
|
|
return this._content;
|
|
}
|
|
}
|
|
class AbilityPicker implements BuilderTab
|
|
{
|
|
private _builder: CharacterBuilder;
|
|
private _content: Array<Node | string>;
|
|
|
|
private _pointsInput: HTMLInputElement;
|
|
private _options: HTMLDivElement[];
|
|
|
|
private _tooltips: Text[] = [];
|
|
private _maxs: HTMLElement[] = [];
|
|
|
|
constructor(builder: CharacterBuilder)
|
|
{
|
|
const numberInput = (value?: number, update?: (value: number) => number | undefined) => {
|
|
const input = dom("input", { class: `w-14 mx-4 caret-light-50 dark:caret-dark-50 text-light-100 dark:text-dark-100 placeholder:text-light-50 dark:placeholder:text-dark-50 bg-light-20 dark:bg-dark-20 appearance-none outline-none px-3 py-1 focus:shadow-raw transition-[box-shadow] focus:shadow-light-40 dark:focus:shadow-dark-40 border border-light-35 dark:border-dark-35 hover:border-light-50 dark:hover:border-dark-50 data-[disabled]:bg-light-20 dark:data-[disabled]:bg-dark-20 data-[disabled]:border-light-20 dark:data-[disabled]:border-dark-20`, listeners: {
|
|
input: (e: Event) => {
|
|
input.value = (update && update(parseInt(input.value))?.toString()) ?? input.value;
|
|
},
|
|
keydown: (e: KeyboardEvent) => {
|
|
let value = isNaN(parseInt(input.value)) ? '0' : input.value;
|
|
switch(e.key)
|
|
{
|
|
case "ArrowUp":
|
|
value = clamp(parseInt(value) + 1, 0, 99).toString();
|
|
break;
|
|
case "ArrowDown":
|
|
value = clamp(parseInt(value) - 1, 0, 99).toString();
|
|
break;
|
|
default:
|
|
break;
|
|
}
|
|
|
|
if(input.value !== value)
|
|
{
|
|
input.value = (update && update(parseInt(value))?.toString()) ?? value;
|
|
}
|
|
}
|
|
}});
|
|
|
|
input.value = value?.toString() ?? "0";
|
|
return input;
|
|
};
|
|
function pushAndReturn<T extends any>(arr: Array<T>, value: T): T
|
|
{
|
|
arr.push(value);
|
|
return value;
|
|
}
|
|
this._builder = 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) => div('flex flex-col border border-light-50 dark:border-dark-50 p-4 gap-2 w-[200px] relative', [
|
|
div('flex justify-between', [ numberInput(this._builder.character.abilities[e], (value) => {
|
|
const values = this._builder.values;
|
|
const max = (values[`abilities/${e}/max`] ?? 0) + (values[`modifier/${config.abilities[e].max[0]}`] ?? 0) + (values[`modifier/${config.abilities[e].max[1]}`] ?? 0);
|
|
|
|
this._builder.character.abilities[e] = clamp(value, 0, max);
|
|
Object.assign((this._options[i]?.lastElementChild as HTMLSpanElement | undefined)?.style ?? {}, { width: `${(max === 0 ? 0 : (this._builder.character.abilities[e] ?? 0) / max) * 100}%` });
|
|
this._tooltips[i]!.textContent = `${mainStatTexts[config.abilities[e].max[0]]} (${values[`modifier/${config.abilities[e].max[0]}`] ?? 0}) + ${mainStatTexts[config.abilities[e].max[1]]} (${values[`modifier/${config.abilities[e].max[1]}`] ?? 0}) + ${values[`abilities/${e}/max`] ?? 0}`;
|
|
this._maxs[i]!.textContent = `/ ${max ?? 0}`;
|
|
|
|
const abilities = Object.values(this._builder.character.abilities).reduce((p, v) => p + v, 0);
|
|
this._pointsInput.value = ((values.ability ?? 0) - abilities).toString();
|
|
|
|
return this._builder.character.abilities[e];
|
|
}), popper(pushAndReturn(this._maxs, dom('span', { class: 'text-lg text-end cursor-pointer', text: '' })), {
|
|
arrow: true,
|
|
offset: 6,
|
|
placement: 'bottom-end',
|
|
class: 'max-w-96 fixed hidden TooltipContent border border-light-30 dark:border-dark-30 px-2 py-1 bg-light-10 dark:bg-dark-10 text-light-70 dark:text-dark-70 z-50',
|
|
content: [ pushAndReturn(this._tooltips, text('')) ]
|
|
})]),
|
|
dom('span', { class: "text-xl text-center font-bold", text: config.abilities[e].name }),
|
|
dom('span', { class: "absolute -bottom-px -left-px h-[3px] bg-accent-blue" }),
|
|
]));
|
|
|
|
this._content = [ div("flex flex-1 gap-12 px-2 py-4 justify-center items-center sticky top-0 bg-light-0 dark:bg-dark-0 w-full z-10", [
|
|
dom("label", { class: "flex justify-center items-center my-2" }, [
|
|
dom("span", { class: "md:text-base text-sm", text: "Points restantes" }),
|
|
this._pointsInput,
|
|
]),
|
|
button(text('Suivant'), () => this._builder.display(4), 'h-[35px] px-[15px]'),
|
|
]), div('flex flex-row flex-wrap justify-center items-center flex-1 gap-12 mx-8 my-4 px-48', this._options)];
|
|
}
|
|
update()
|
|
{
|
|
const values = this._builder.values;
|
|
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[`abilities/${e}/max`] ?? 0) + (values[`modifier/${config.abilities[e].max[0]}`] ?? 0) + (values[`modifier/${config.abilities[e].max[1]}`] ?? 0);
|
|
|
|
Object.assign((this._options[i]?.lastElementChild as HTMLSpanElement | undefined)?.style ?? {}, { width: `${(max === 0 ? 0 : (this._builder.character.abilities[e] ?? 0) / max) * 100}%` });
|
|
this._tooltips[i]!.textContent = `${mainStatTexts[config.abilities[e].max[0]]} (${values[`modifier/${config.abilities[e].max[0]}`] ?? 0}) + ${mainStatTexts[config.abilities[e].max[1]]} (${values[`modifier/${config.abilities[e].max[1]}`] ?? 0}) + ${values[`abilities/${e}/max`] ?? 0}`;
|
|
this._maxs[i]!.textContent = `/ ${max ?? 0}`;
|
|
|
|
return this._builder.character.abilities[e];
|
|
})
|
|
}
|
|
validate(): boolean
|
|
{
|
|
const values = this._builder.values;
|
|
const abilities = Object.values(this._builder.character.abilities).reduce((p, v) => p + v, 0);
|
|
|
|
return (values.ability ?? 0) - abilities >= 0;
|
|
}
|
|
get dom()
|
|
{
|
|
return this._content;
|
|
}
|
|
}
|
|
class AspectPicker implements BuilderTab
|
|
{
|
|
private _builder: CharacterBuilder;
|
|
private _content: Array<Node | string>;
|
|
|
|
private _physicInput: HTMLInputElement;
|
|
private _mentalInput: HTMLInputElement;
|
|
private _personalityInput: HTMLInputElement;
|
|
|
|
private _filter: boolean = true;
|
|
|
|
private _options: HTMLDivElement[];
|
|
|
|
constructor(builder: CharacterBuilder)
|
|
{
|
|
this._builder = 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 = config.aspects.map((e, i) => dom('div', { attributes: { "data-aspect": i.toString() }, listeners: { click: () => {
|
|
this._builder.character.aspect = i;
|
|
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(alignmentToString(e.alignment)) ]),
|
|
div(['text-sm font-bold', { "text-light-purple dark:text-dark-purple italic": e.magic, "text-light-orange dark:text-dark-orange": !e.magic }], [ text(e.magic ? 'Magie autorisée' : 'Magie interdite') ]),
|
|
]),
|
|
])
|
|
]));
|
|
|
|
const filterSwitch = dom("div", { class: `group mx-3 w-12 h-6 select-none transition-all border border-light-35 dark:border-dark-35 bg-light-20 dark:bg-dark-20 outline-none data-[state=checked]:bg-light-35 dark:data-[state=checked]:bg-dark-35 hover:border-light-50 dark:hover:border-dark-50 focus:shadow-raw focus:shadow-light-40 dark:focus:shadow-dark-40 data-[disabled]:bg-light-20 dark:data-[disabled]:bg-dark-20 data-[disabled]:border-light-20 dark:data-[disabled]:border-dark-20 relative py-[2px]`, attributes: { "data-state": this._filter ? "checked" : "unchecked" }, listeners: {
|
|
click: (e: Event) => {
|
|
this._filter = !this._filter;
|
|
filterSwitch.setAttribute('data-state', this._filter ? "checked" : "unchecked");
|
|
this.update();
|
|
}
|
|
}}, [ div('block w-[18px] h-[18px] translate-x-[2px] will-change-transform transition-transform bg-light-50 dark:bg-dark-50 group-data-[state=checked]:translate-x-[26px] group-data-[disabled]:bg-light-30 dark:group-data-[disabled]:bg-dark-30 group-data-[disabled]:border-light-30 dark:group-data-[disabled]:border-dark-30') ]);
|
|
|
|
this._content = [ div("flex flex-1 gap-12 px-2 py-4 justify-center items-center sticky top-0 bg-light-0 dark:bg-dark-0 w-full z-10", [
|
|
dom("label", { class: "flex justify-center items-center my-2" }, [
|
|
dom("span", { class: "md:text-base text-sm", text: "Physique" }),
|
|
this._physicInput,
|
|
]),
|
|
dom("label", { class: "flex justify-center items-center my-2" }, [
|
|
dom("span", { class: "md:text-base text-sm", text: "Mental" }),
|
|
this._mentalInput,
|
|
]),
|
|
dom("label", { class: "flex justify-center items-center my-2" }, [
|
|
dom("span", { class: "md:text-base text-sm", text: "Caractère" }),
|
|
this._personalityInput,
|
|
]),
|
|
dom("label", { class: "flex justify-center items-center my-2" }, [
|
|
dom("span", { class: "md:text-base text-sm", text: "Filtrer ?" }),
|
|
filterSwitch,
|
|
]),
|
|
button(text('Enregistrer'), () => this._builder.save(), 'h-[35px] px-[15px]'),
|
|
]), div('flex flex-row flex-wrap justify-center items-center flex-1 gap-8 mx-8 my-4 px-8', this._options)];
|
|
}
|
|
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 index = parseInt(e.getAttribute('data-aspect')!);
|
|
const aspect = config.aspects[index]!;
|
|
|
|
e.setAttribute('data-state', this._builder.character.aspect === index ? '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;
|
|
}));
|
|
}
|
|
validate(): boolean
|
|
{
|
|
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;
|
|
|
|
if(this._builder.character.aspect === undefined)
|
|
return false;
|
|
|
|
const aspect = config.aspects[this._builder.character.aspect]!
|
|
|
|
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;
|
|
}
|
|
get dom()
|
|
{
|
|
return this._content;
|
|
}
|
|
} |