Add Trees and Masteries in the feature editor. Add some items.

This commit is contained in:
Clément Pons 2026-01-14 22:40:58 +01:00
parent 796b335b2e
commit ce3dbb0d6e
6 changed files with 108 additions and 12187 deletions

View File

@ -61,8 +61,7 @@ export type TreeStructure = {
name: string; name: string;
nodes: FeatureID[]; nodes: FeatureID[];
// { 'from_id': { 'pathname': 'to_id' } }; paths: Record<number, number | number[]>;
paths: Record<number, Record<string, number>>;
}; };
type CommonState = { type CommonState = {
capacity?: number; capacity?: number;
@ -199,16 +198,22 @@ export type FeatureEquipment = {
id: FeatureID; id: FeatureID;
category: "value"; category: "value";
operation: "add" | "set" | "min"; operation: "add" | "set" | "min";
property: `item/${RecursiveKeyOf<(ArmorState | WeaponState | WondrousState | MundaneState) & CommonState>}`; property: `item/${RecursiveKeyOf<ArmorState & WeaponState & WondrousState & MundaneState & CommonState>}`;
value: number | `modifier/${MainStat}` | false; value: number | `modifier/${MainStat}` | false;
} };
export type FeatureList = { export type FeatureList = {
id: FeatureID; id: FeatureID;
category: "list"; category: "list";
list: "spells" | "sickness" | "action" | "reaction" | "freeaction" | "passive"; list: "spells" | "sickness" | "action" | "reaction" | "freeaction" | "passive" | "mastery";
action: "add" | "remove"; action: "add" | "remove";
item: string; item: string;
}; };
export type FeatureTree = {
id: FeatureID;
category: "tree";
tree: string;
option?: number;
};
export type FeatureChoice = { export type FeatureChoice = {
id: FeatureID; id: FeatureID;
category: "choice"; category: "choice";
@ -217,9 +222,9 @@ export type FeatureChoice = {
amount: number; amount: number;
exclusive: boolean; //Disallow to pick the same option twice exclusive: boolean; //Disallow to pick the same option twice
}; };
options: Array<{ text: string, effects: Array<FeatureValue | FeatureList> }>; //TODO -> TextID options: Array<{ text: string, effects: Array<FeatureValue | FeatureList | FeatureTree> }>; //TODO -> TextID
}; };
export type FeatureItem = FeatureValue | FeatureList | FeatureChoice; export type FeatureItem = FeatureValue | FeatureList | FeatureChoice | FeatureTree;
export type Feature = { export type Feature = {
id: FeatureID; id: FeatureID;
description: string; //TODO -> TextID description: string; //TODO -> TextID
@ -246,6 +251,7 @@ export type CompiledCharacter = {
bonus?: Partial<CompiledCharacter['bonus']>; bonus?: Partial<CompiledCharacter['bonus']>;
}; };
mastery: Array<`weapon/${WeaponType}` | `armor/${'light' | 'medium' | 'heavy'}`>;
speed: number | false; speed: number | false;
capacity: number | false; capacity: number | false;
initiative: number; initiative: number;
@ -263,18 +269,6 @@ export type CompiledCharacter = {
passivedodge: number; passivedodge: number;
}; };
mastery: {
strength: number;
dexterity: number;
shield: number;
armor: number;
multiattack: number;
magicpower: number;
magicspeed: number;
magicelement: number;
magicinstinct: number;
};
bonus: { bonus: {
defense: Partial<Record<MainStat, number>>; //Defense aux jets de resistance defense: Partial<Record<MainStat, number>>; //Defense aux jets de resistance
abilities: Partial<Record<Ability, number>>; abilities: Partial<Record<Ability, number>>;

BIN
db.sqlite

Binary file not shown.

File diff suppressed because one or more lines are too long

View File

@ -1,8 +1,8 @@
import type { Ability, Alignment, ArmorConfig, ArmorState, Character, CharacterConfig, CharacterVariables, CompiledCharacter, DamageType, EnchantementConfig, FeatureEquipment, FeatureItem, ItemConfig, ItemState, Level, MainStat, MundaneState, Resistance, SpellConfig, SpellElement, SpellType, TrainingLevel, WeaponConfig, WeaponState, WeaponType, WondrousState } from "~/types/character"; import type { Ability, Alignment, ArmorConfig, ArmorState, Character, CharacterConfig, CompiledCharacter, DamageType, EnchantementConfig, FeatureEquipment, FeatureID, FeatureItem, ItemConfig, ItemState, Level, MainStat, MundaneState, Resistance, SpellConfig, SpellElement, SpellType, TrainingLevel, WeaponConfig, WeaponState, WeaponType, WondrousState } from "~/types/character";
import { z } from "zod/v4"; import { z } from "zod/v4";
import characterConfig from '#shared/character-config.json'; import characterConfig from '#shared/character-config.json';
import proses, { a, preview } from "#shared/proses"; import proses, { preview } from "#shared/proses";
import { button, buttongroup, checkbox, floater, foldable, input, loading, multiselect, numberpicker, optionmenu, select, tabgroup, Toaster, toggle } from "#shared/components.util"; import { button, checkbox, floater, foldable, input, loading, multiselect, numberpicker, select, tabgroup, Toaster, toggle } from "#shared/components.util";
import { div, dom, icon, span, text, type RedrawableHTML } from "#shared/dom.util"; import { div, dom, icon, span, text, type RedrawableHTML } from "#shared/dom.util";
import { followermenu, fullblocker, tooltip } from "#shared/floating.util"; import { followermenu, fullblocker, tooltip } from "#shared/floating.util";
import { clamp } from "#shared/general.util"; import { clamp } from "#shared/general.util";
@ -11,7 +11,7 @@ import { getText } from "#shared/i18n";
import type { User } from "~/types/auth"; import type { User } from "~/types/auth";
import { MarkdownEditor } from "#shared/editor.util"; import { MarkdownEditor } from "#shared/editor.util";
import { Socket } from "#shared/websocket.util"; import { Socket } from "#shared/websocket.util";
import { raw, reactive, reactivity } from '#shared/reactive'; import { raw, reactive } from '#shared/reactive';
const config = characterConfig as CharacterConfig; const config = characterConfig as CharacterConfig;
@ -25,7 +25,7 @@ export const SPELL_ELEMENTS = ["fire","ice","thunder","earth","arcana","air","na
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 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 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 DAMAGE_TYPES = ['slashing', 'piercing', 'bludgening', 'magic', 'fire', 'thunder', 'cold'] as const;
export const WEAPON_TYPES = ["light", "shield", "heavy", "classic", "throw", "natural", "twohanded", "finesse", "reach", "projectile"] as const; export const WEAPON_TYPES = ["light", "shield", "heavy", "classic", "throw", "natural", "twohanded", "finesse", "reach", "projectile", "improvised"] as const;
export const defaultCharacter: Character = { export const defaultCharacter: Character = {
id: -1, id: -1,
@ -34,7 +34,7 @@ export const defaultCharacter: Character = {
people: undefined, people: undefined,
level: 1, level: 1,
training: MAIN_STATS.reduce((p, v) => { p[v] = { 0: 0, 1: 0, 2: 0, 3: 0, 4: 0 }; return p; }, {} as Record<MainStat, Partial<Record<TrainingLevel, number>>>), training: MAIN_STATS.reduce((p, v) => { p[v] = { 0: 0 }; return p; }, {} as Record<MainStat, Partial<Record<TrainingLevel, number>>>),
leveling: { 1: 0 }, leveling: { 1: 0 },
abilities: {}, abilities: {},
choices: {}, choices: {},
@ -107,17 +107,7 @@ const defaultCompiledCharacter = (character: Character) => ({
passiveparry: 0, passiveparry: 0,
passivedodge: 0, passivedodge: 0,
}, },
mastery: { mastery: [],
strength: 0,
dexterity: 0,
shield: 0,
armor: 0,
multiattack: 1,
magicpower: 0,
magicspeed: 0,
magicelement: 0,
magicinstinct: 0,
},
bonus: { bonus: {
abilities: {}, abilities: {},
defense: {}, defense: {},
@ -334,7 +324,8 @@ export class CharacterCompiler
Object.entries(value.training[stat]).forEach(option => this.add(config.training[stat][parseInt(option[0]) as TrainingLevel][option[1]])) Object.entries(value.training[stat]).forEach(option => this.add(config.training[stat][parseInt(option[0]) as TrainingLevel][option[1]]))
}); });
Object.entries(value.abilities).forEach(e => this._result.abilities[e[0] as Ability] = e[1]); Object.entries(value.abilities).forEach(e => this._buffer[`abilities/${e[0]}`] = { value: 0, _dirty: true, min: -Infinity, list: [{ id: '', operation: 'add', value: e[1] }] })
//Object.entries(value.abilities).forEach(e => this._result.abilities[e[0] as Ability] = e[1]);
} }
} }
get character(): Character get character(): Character
@ -397,21 +388,21 @@ export class CharacterCompiler
Toaster.add({ type: 'error', content: 'Impossible de mettre à jour les données', duration: 5000, timer: true }); Toaster.add({ type: 'error', content: 'Impossible de mettre à jour les données', duration: 5000, timer: true });
}); });
} }
protected add(feature?: string) protected add(feature?: FeatureID)
{ {
if(!feature) if(!feature)
return; return;
config.features[feature]?.effect.forEach((effect) => this.apply(effect)); config.features[feature]?.effect.forEach((effect) => this.apply(effect));
} }
protected remove(feature?: string) protected remove(feature?: FeatureID)
{ {
if(!feature) if(!feature)
return; return;
config.features[feature]?.effect.forEach((effect) => this.undo(effect)); config.features[feature]?.effect.forEach((effect) => this.undo(effect));
} }
protected apply(feature?: FeatureItem | FeatureEquipment) protected apply(feature?: FeatureItem)
{ {
if(!feature) if(!feature)
return; return;
@ -449,7 +440,7 @@ export class CharacterCompiler
return; return;
} }
} }
protected undo(feature?: FeatureItem | FeatureEquipment) protected undo(feature?: FeatureItem)
{ {
if(!feature) if(!feature)
return; return;
@ -542,11 +533,7 @@ export class CharacterCompiler
if(stop === true) if(stop === true)
continue; continue;
const path = property.split("/"); setProperty(this._result, property, Math.max(sum, this._buffer[property]!.min));
const object = path.length === 1 ? this._result : path.slice(0, -1).reduce((p, v) => { p[v] ??= {}; return p[v]; }, this._result as any);
if(object.hasOwnProperty(path.slice(-1)[0]!))
object[path.slice(-1)[0]!] = Math.max(sum, this._buffer[property]!.min);
this._buffer[property]!.value = Math.max(sum, this._buffer[property]!.min); this._buffer[property]!.value = Math.max(sum, this._buffer[property]!.min);
this._buffer[property]!._dirty = false; this._buffer[property]!._dirty = false;
@ -554,6 +541,14 @@ export class CharacterCompiler
} }
} }
} }
function setProperty<T>(root: any, path: string, value: T | ((old: T) => T))
{
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(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 export class CharacterBuilder extends CharacterCompiler
{ {
private _container: RedrawableHTML; private _container: RedrawableHTML;
@ -1253,6 +1248,22 @@ class AspectPicker extends BuilderTab
} }
} }
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 Category = ItemConfig['category'];
type Rarity = ItemConfig['rarity']; type Rarity = ItemConfig['rarity'];
export const colorByRarity: Record<Rarity, string> = { export const colorByRarity: Record<Rarity, string> = {
@ -1272,6 +1283,7 @@ export const weaponTypeTexts: Record<WeaponType, string> = {
"finesse": 'maniable', "finesse": 'maniable',
"reach": 'longue', "reach": 'longue',
"projectile": 'à projectile', "projectile": 'à projectile',
"improvised": "improvisée"
} }
export const armorTypeTexts: Record<ArmorConfig["type"], string> = { export const armorTypeTexts: Record<ArmorConfig["type"], string> = {
'heavy': 'Armure lourde', 'heavy': 'Armure lourde',
@ -1594,7 +1606,8 @@ export class CharacterSheet
div("flex flex-1 border-t border-dashed border-light-50 dark:border-dark-50") div("flex flex-1 border-t border-dashed border-light-50 dark:border-dark-50")
]), ]),
() => character.mastery.strength + character.mastery.dexterity > 0 ? div("grid grid-cols-2 gap-x-3 gap-y-1 text-sm", [ div("grid grid-cols-2 gap-x-3 gap-y-1 text-sm", { list: character.mastery, render: (e, _c) => proses('a', preview, [ text('Arme légère') ], { href: 'regles/annexes/equipement#Les armes légères', label: 'Arme légère', class: 'text-sm text-light-70 dark:text-dark-70 cursor-help decoration-dotted underline', }) }),
/* () => character.mastery.strength + character.mastery.dexterity > 0 ? div("grid grid-cols-2 gap-x-3 gap-y-1 text-sm", [
() => character.mastery.strength + character.mastery.dexterity > 0 ? proses('a', preview, [ text('Arme légère') ], { href: 'regles/annexes/equipement#Les armes légères', label: 'Arme légère', class: 'text-sm text-light-70 dark:text-dark-70 cursor-help decoration-dotted underline', }) : undefined, () => character.mastery.strength + character.mastery.dexterity > 0 ? proses('a', preview, [ text('Arme légère') ], { href: 'regles/annexes/equipement#Les armes légères', label: 'Arme légère', class: 'text-sm text-light-70 dark:text-dark-70 cursor-help decoration-dotted underline', }) : undefined,
() => character.mastery.strength + character.mastery.dexterity > 0 ? proses('a', preview, [ text('Arme de jet') ], { href: 'regles/annexes/equipement#Les armes de jet', label: 'Arme de jet', class: 'text-sm text-light-70 dark:text-dark-70 cursor-help decoration-dotted underline' }) : undefined, () => character.mastery.strength + character.mastery.dexterity > 0 ? proses('a', preview, [ text('Arme de jet') ], { href: 'regles/annexes/equipement#Les armes de jet', label: 'Arme de jet', class: 'text-sm text-light-70 dark:text-dark-70 cursor-help decoration-dotted underline' }) : undefined,
() => character.mastery.strength + character.mastery.dexterity > 0 ? proses('a', preview, [ text('Arme naturelle') ], { href: 'regles/annexes/equipement#Les armes naturelles', label: 'Arme naturelle', class: 'text-sm text-light-70 dark:text-dark-70 cursor-help decoration-dotted underline' }) : undefined, () => character.mastery.strength + character.mastery.dexterity > 0 ? proses('a', preview, [ text('Arme naturelle') ], { href: 'regles/annexes/equipement#Les armes naturelles', label: 'Arme naturelle', class: 'text-sm text-light-70 dark:text-dark-70 cursor-help decoration-dotted underline' }) : undefined,
@ -1613,7 +1626,7 @@ export class CharacterSheet
() => character.mastery.armor > 0 ? proses('a', preview, [ text('Armure légère') ], { href: 'regles/annexes/equipement#Les armures légères', label: 'Armure légère', class: 'text-sm text-light-70 dark:text-dark-70 cursor-help decoration-dotted underline' }) : undefined, () => character.mastery.armor > 0 ? proses('a', preview, [ text('Armure légère') ], { href: 'regles/annexes/equipement#Les armures légères', label: 'Armure légère', class: 'text-sm text-light-70 dark:text-dark-70 cursor-help decoration-dotted underline' }) : undefined,
() => character.mastery.armor > 1 ? proses('a', preview, [ text('Armure standard') ], { href: 'regles/annexes/equipement#Les armures', label: 'Armure standard', class: 'text-sm text-light-70 dark:text-dark-70 cursor-help decoration-dotted underline' }) : undefined, () => character.mastery.armor > 1 ? proses('a', preview, [ text('Armure standard') ], { href: 'regles/annexes/equipement#Les armures', label: 'Armure standard', class: 'text-sm text-light-70 dark:text-dark-70 cursor-help decoration-dotted underline' }) : undefined,
() => character.mastery.armor > 2 ? proses('a', preview, [ text('Armure lourde') ], { href: 'regles/annexes/equipement#Les armures lourdes', label: 'Armure lourde', class: 'text-sm text-light-70 dark:text-dark-70 cursor-help decoration-dotted underline' }) : undefined, () => character.mastery.armor > 2 ? proses('a', preview, [ text('Armure lourde') ], { href: 'regles/annexes/equipement#Les armures lourdes', label: 'Armure lourde', class: 'text-sm text-light-70 dark:text-dark-70 cursor-help decoration-dotted underline' }) : undefined,
]) : undefined, ]) : undefined, */
div("grid grid-cols-2 gap-x-3 gap-y-1 text-sm", [ div("grid grid-cols-2 gap-x-3 gap-y-1 text-sm", [
() => character.spellranks.precision > 0 ? div('flex flex-row items-center gap-2', [ proses('a', preview, [ text('Précision') ], { href: 'regles/la-magie/magie#Les sorts de précision', label: 'Précision', class: 'text-sm text-light-70 dark:text-dark-70 cursor-help decoration-dotted underline' }), span('font-bold', text(() => character.spellranks.precision)) ]) : undefined, () => character.spellranks.precision > 0 ? div('flex flex-row items-center gap-2', [ proses('a', preview, [ text('Précision') ], { href: 'regles/la-magie/magie#Les sorts de précision', label: 'Précision', class: 'text-sm text-light-70 dark:text-dark-70 cursor-help decoration-dotted underline' }), span('font-bold', text(() => character.spellranks.precision)) ]) : undefined,
@ -2041,9 +2054,14 @@ export class CharacterSheet
div('flex flex-row items-center gap-4', [ span('text-lg', enchant.name) ]), div('flex flex-row items-center gap-4', [ span('text-lg', enchant.name) ]),
div('flex flex-row items-center divide-x divide-light-50 dark:divide-dark-50 divide-dashed px-2 gap-4', [ div('flex flex-row items-center divide-x divide-light-50 dark:divide-dark-50 divide-dashed px-2 gap-4', [
span('italic text-sm', `Puissance magique: ${enchant.power}`), span('italic text-sm', `Puissance magique: ${enchant.power}`),
button(icon('radix-icons:plus', { width: 16, height: 16 }), () => { button(icon(() => current.item?.enchantments?.includes(enchant.id) ? 'radix-icons:minus' : 'radix-icons:plus', { width: 16, height: 16 }), () => {
const idx = current.item!.enchantments?.findIndex(e => e === enchant.id) ?? -1;
if(idx === -1)
current.item!.enchantments?.push(enchant.id); current.item!.enchantments?.push(enchant.id);
// TODO: Object.assign(current.item!.state, current.item!.enchantments?.reduce((p, id) => { config.enchantments[id]?.effect.filter(e => e.category === "value" && e.property.startsWith('item/')); return p; }, {})); else
current.item!.enchantments?.splice(idx, 1);
current.item!.state ??= {};
this.character?.saveVariables(); this.character?.saveVariables();
}, 'p-1 !border-solid !border-r'), }, 'p-1 !border-solid !border-r'),

View File

@ -344,7 +344,7 @@ export function combobox<T extends NonNullable<any>>(options: Option<T>[], setti
return; return;
default: return; default: return;
} }
} }, attributes: { type: 'text', }, class: 'flex-1 outline-none px-3 leading-none appearance-none py-1 bg-light-25 dark:bg-dark-25 disabled:bg-light-20 dark:disabled:bg-dark-20' }); } }, attributes: { type: 'text', }, class: 'flex-1 outline-none px-3 leading-none appearance-none py-1 bg-light-25 dark:bg-dark-25 disabled:bg-light-20 dark:disabled:bg-dark-20 w-full' });
const container = dom('label', { class: ['inline-flex outline-none px-3 items-center justify-between text-sm font-semibold leading-none gap-1 bg-light-25 dark:bg-dark-25 border border-light-35 dark:border-dark-35 focus:shadow-raw transition-[box-shadow] focus:shadow-light-40 dark:focus:shadow-dark-40 hover:border-light-50 dark:hover:border-dark-50 data-[disabled]:border-light-25 dark:data-[disabled]:border-dark-25 data-[disabled]:bg-light-20 dark: data-[disabled]:bg-dark-20', settings?.class?.container] }, [ select, icon('radix-icons:caret-down') ]) as HTMLLabelElement & { disabled: boolean, value: T | undefined }; const container = dom('label', { class: ['inline-flex outline-none px-3 items-center justify-between text-sm font-semibold leading-none gap-1 bg-light-25 dark:bg-dark-25 border border-light-35 dark:border-dark-35 focus:shadow-raw transition-[box-shadow] focus:shadow-light-40 dark:focus:shadow-dark-40 hover:border-light-50 dark:hover:border-dark-50 data-[disabled]:border-light-25 dark:data-[disabled]:border-dark-25 data-[disabled]:bg-light-20 dark: data-[disabled]:bg-dark-20', settings?.class?.container] }, [ select, icon('radix-icons:caret-down') ]) as HTMLLabelElement & { disabled: boolean, value: T | undefined };
let value: T | undefined = undefined; let value: T | undefined = undefined;

View File

@ -1,10 +1,10 @@
import type { Ability, AspectConfig, CharacterConfig, CommonItemConfig, DamageType, Feature, FeatureChoice, FeatureEquipment, FeatureItem, FeatureList, FeatureValue, ItemConfig, Level, MainStat, RaceConfig, Resistance, SpellConfig, TrainingLevel, WeaponType } from "~/types/character"; import type { Ability, AspectConfig, CharacterConfig, CommonItemConfig, DamageType, Feature, FeatureChoice, FeatureEquipment, FeatureItem, FeatureList, FeatureTree, FeatureValue, ItemConfig, Level, MainStat, RaceConfig, Resistance, SpellConfig, TrainingLevel, WeaponType } from "~/types/character";
import { div, dom, icon, span, text, type NodeChildren, type RedrawableHTML } from "#shared/dom.util"; import { div, dom, icon, span, text, type NodeChildren, type RedrawableHTML } from "#shared/dom.util";
import { MarkdownEditor } from "#shared/editor.util"; import { MarkdownEditor } from "#shared/editor.util";
import { preview } from "#shared/proses"; import { preview } from "#shared/proses";
import { button, checkbox, combobox, foldable, input, multiselect, numberpicker, optionmenu, select, tabgroup, table, toggle, type Option } from "#shared/components.util"; import { button, checkbox, combobox, foldable, input, multiselect, numberpicker, optionmenu, select, tabgroup, table, toggle, type Option } from "#shared/components.util";
import { confirm, contextmenu, fullblocker, tooltip } from "#shared/floating.util"; import { confirm, contextmenu, fullblocker, tooltip } from "#shared/floating.util";
import { ABILITIES, abilityTexts, ALIGNMENTS, alignmentTexts, categoryText, damageTypeTexts, elementTexts, LEVELS, MAIN_STATS, mainStatShortTexts, mainStatTexts, rarityText, RESISTANCES, resistanceTexts, SPELL_ELEMENTS, SPELL_TYPES, spellTypeTexts, weaponTypeTexts } from "#shared/character.util"; import { ABILITIES, abilityTexts, ALIGNMENTS, alignmentTexts, categoryText, damageTypeTexts, elementTexts, LEVELS, MAIN_STATS, mainStatShortTexts, mainStatTexts, masteryTexts, rarityText, RESISTANCES, resistanceTexts, SPELL_ELEMENTS, SPELL_TYPES, spellTypeTexts, weaponTypeTexts } from "#shared/character.util";
import characterConfig from "#shared/character-config.json"; import characterConfig from "#shared/character-config.json";
import { getID } from "#shared/general.util"; import { getID } from "#shared/general.util";
import markdown, { markdownReference, renderMDAsText } from "#shared/markdown.util"; import markdown, { markdownReference, renderMDAsText } from "#shared/markdown.util";
@ -491,7 +491,7 @@ export class HomebrewBuilder
} }
} }
type FeatureOption = Partial<FeatureValue | FeatureEquipment | FeatureList | FeatureChoice> & { id: string }; type FeatureOption = Partial<FeatureValue | FeatureEquipment | FeatureList | FeatureChoice | FeatureTree> & { id: string };
class FeatureEditor class FeatureEditor
{ {
private _list: Record<string, FeatureOption> | FeatureOption[]; private _list: Record<string, FeatureOption> | FeatureOption[];
@ -576,11 +576,13 @@ class FeatureEditor
switch(effect.category) switch(effect.category)
{ {
case 'value': case 'value':
return flattenFeatureChoices.findLast(e => e.category === 'value' && e.property === effect.property); return flattenFeatureChoices.find(e => e.category === 'value' && e.property === effect.property);
case 'choice': case 'choice':
return flattenFeatureChoices.findLast(e => e.category === 'choice'); return flattenFeatureChoices.findLast(e => e.category === 'choice');
case 'tree':
return flattenFeatureChoices.findLast(e => e.category === 'tree');
case 'list': case 'list':
return flattenFeatureChoices.findLast(e => e.category === 'list' && e.list === effect.list); return flattenFeatureChoices.find(e => e.category === 'list' && e.list === effect.list);
} }
} }
private editByCategory(buffer: FeatureOption) private editByCategory(buffer: FeatureOption)
@ -594,6 +596,8 @@ class FeatureEditor
return this.editList(buffer as Partial<FeatureList>); return this.editList(buffer as Partial<FeatureList>);
case 'choice': case 'choice':
return this.editChoice(buffer as Partial<FeatureChoice>); return this.editChoice(buffer as Partial<FeatureChoice>);
case 'tree':
return this.editTree(buffer as Partial<FeatureTree>);
default: break; default: break;
} }
return { top, bottom }; return { top, bottom };
@ -620,21 +624,20 @@ class FeatureEditor
} }
private editList(buffer: Partial<FeatureList>) private editList(buffer: Partial<FeatureList>)
{ {
let list: Option<string>[]; let list: Option<string>[] = [];
if(buffer.action === 'add') switch(buffer.list)
{
if(buffer.list === 'spells')
{ {
case undefined:
break;
case "spells":
list = Object.values(config.spells).map(e => ({ text: e.name, render: () => div('flex flex-col', [ div('flex flex-row justify-between', [ dom('span', { text: e.name, class: 'font-bold' }), div('flex flex-row gap-8', [ dom('span', { class: 'italic', text: `Rang ${e.rank === 4 ? 'spécial' : e.rank}` }), dom('span', { text: spellTypeTexts[e.type] }) ]) ]), div('text-sm text-light-70 dark:text-dark-70', [ text(renderMDAsText(e.description)) ]) ]), value: e.id })); list = Object.values(config.spells).map(e => ({ text: e.name, render: () => div('flex flex-col', [ div('flex flex-row justify-between', [ dom('span', { text: e.name, class: 'font-bold' }), div('flex flex-row gap-8', [ dom('span', { class: 'italic', text: `Rang ${e.rank === 4 ? 'spécial' : e.rank}` }), dom('span', { text: spellTypeTexts[e.type] }) ]) ]), div('text-sm text-light-70 dark:text-dark-70', [ text(renderMDAsText(e.description)) ]) ]), value: e.id }));
} break;
else if(buffer.list) case "mastery":
{ list = Object.entries(masteryTexts).map(e => ({ text: e[1].text, value: e[0] }));
break;
default:
list = Object.values(config[buffer.list]).map(e => ({ text: e.name, render: () => div('flex flex-col', [ div('flex flex-row justify-between', [ dom('span', { text: e.name, class: 'font-bold' }) ]), div('text-sm text-light-70 dark:text-dark-70', [ text(renderMDAsText(getText(e.description))) ]) ]), value: e.id })); list = Object.values(config[buffer.list]).map(e => ({ text: e.name, render: () => div('flex flex-col', [ div('flex flex-row justify-between', [ dom('span', { text: e.name, class: 'font-bold' }) ]), div('text-sm text-light-70 dark:text-dark-70', [ text(renderMDAsText(getText(e.description))) ]) ]), value: e.id }));
} break;
}
else
{
list = (Object.values(config.features).flatMap(e => e.effect).filter(e => e.category === 'list' && e.list === buffer.list && e.action === 'add') as FeatureList[]).map((e) => ({ text: config[e.list][e.item]!.name, render: () => div('flex flex-col', [ div('flex flex-row justify-between', [ dom('span', { text: config[e.list][e.item]!.name, class: 'font-bold' }) ]), div('text-sm text-light-70 dark:text-dark-70', [ text(renderMDAsText(getText(config[e.list === 'sickness' ? 'features' : e.list][e.item]?.description))) ]) ]), value: e.item }));
} }
return { return {
@ -642,7 +645,22 @@ class FeatureEditor
buffer.action = value as 'add' | 'remove'; buffer.action = value as 'add' | 'remove';
this.edit(); this.edit();
}, class: { container: 'bg-light-25 dark:bg-dark-25 !-m-px hover:z-10 h-[36px] w-32' } }) ], }, class: { container: 'bg-light-25 dark:bg-dark-25 !-m-px hover:z-10 h-[36px] w-32' } }) ],
bottom: [ combobox(list!, { defaultValue: buffer.item, change: (item) => buffer.item = item, class: { container: 'bg-light-25 dark:bg-dark-25 !-m-px hover:z-10 h-[36px] w-full overflow-hidden truncate', option: 'max-h-[90px] text-sm' }, fill: 'contain' }) ] bottom: [ combobox(list, { defaultValue: buffer.item, change: (item) => buffer.item = item, class: { container: 'bg-light-25 dark:bg-dark-25 !-m-px hover:z-10 h-[36px] w-full overflow-hidden truncate', option: 'max-h-[90px] text-sm' }, fill: 'contain' }) ]
}
}
private editTree(buffer: Partial<FeatureTree>)
{
const edit = tooltip(button(icon('radix-icons:gear', { width: 16, height: 16 }), function() {
buffer.option = 0;
this.replaceWith(remove);
}, 'px-2 -m-px hover:z-10 border border-light-35 dark:border-dark-35 hover:border-light-50 dark:hover:border-dark-50'), 'Option fixe', 'right'), remove = tooltip(button(icon('radix-icons:cross-2', { width: 16, height: 16 }), function() {
buffer.option = undefined;
this.replaceWith(edit);
}, 'px-2 -m-px hover:z-10 border border-light-35 dark:border-dark-35 hover:border-light-50 dark:hover:border-dark-50'), 'Option fixe', 'right');
return {
top: [ combobox(Object.values(config.trees).map(e => ({ text: e.name, value: e.name })), { defaultValue: buffer.tree, change: v => buffer.tree = v, class: { container: 'bg-light-25 dark:bg-dark-25 w-48 -m-px hover:z-10 h-[36px]' }, fill: 'cover' }), buffer.option === undefined ? edit : remove ],
bottom: [ ],
} }
} }
private editChoice(buffer: Partial<FeatureChoice>) private editChoice(buffer: Partial<FeatureChoice>)
@ -709,7 +727,7 @@ class FeatureEditor
...top, ...top,
]), ]),
div('flex', [ tooltip(button(icon('radix-icons:check'), () => { this.update(); this.read(); this.show(); this._draft = false; }, 'p-2 -m-px hover:z-10 border border-light-35 dark:border-dark-35 hover:border-light-50 dark:hover:border-dark-50'), "Valider", "bottom"), tooltip(button(icon('radix-icons:cross-1'), () => { if(this._draft) { this.delete(); this.container.remove(); } else { this.read(); this.show(); } }, 'p-2 -m-px hover:z-10 border border-light-35 dark:border-dark-35 hover:border-light-50 dark:hover:border-dark-50'), "Annuler", "bottom") ]) div('flex', [ tooltip(button(icon('radix-icons:check'), () => { this.update(); this.read(); this.show(); this._draft = false; }, 'p-2 -m-px hover:z-10 border border-light-35 dark:border-dark-35 hover:border-light-50 dark:hover:border-dark-50'), "Valider", "bottom"), tooltip(button(icon('radix-icons:cross-1'), () => { if(this._draft) { this.delete(); this.container.remove(); } else { this.read(); this.show(); } }, 'p-2 -m-px hover:z-10 border border-light-35 dark:border-dark-35 hover:border-light-50 dark:hover:border-dark-50'), "Annuler", "bottom") ])
]), div('flex border-t border-light-35 dark:border-dark-35 max-h-[300px] min-h-[36px] overflow-y-auto overflow-x-hidden', bottom) ]); ]), bottom.length > 0 ? div('flex border-t border-light-35 dark:border-dark-35 max-h-[300px] min-h-[36px] overflow-y-auto overflow-x-hidden', bottom) : undefined ]);
} }
let content = redraw(); let content = redraw();
@ -880,17 +898,8 @@ const featureChoices: Option<Partial<FeatureOption>>[] = [
{ text: 'Esquive active', value: { category: 'value', property: 'defense/activedodge', operation: 'add', value: 1 } }, { text: 'Esquive active', value: { category: 'value', property: 'defense/activedodge', operation: 'add', value: 1 } },
{ text: 'Esquive passive', value: { category: 'value', property: 'defense/passivedodge', operation: 'add', value: 1 } } { text: 'Esquive passive', value: { category: 'value', property: 'defense/passivedodge', operation: 'add', value: 1 } }
] }, ] },
{ text: 'Maitrise', value: [ { text: 'Arbre', value: { category: 'tree', option: 0 } },
{ text: 'Maitrise des armes (for.)', value: { category: 'value', property: 'mastery/strength', operation: 'add', value: 1 } }, { text: 'Maitrise', value: Object.keys(masteryTexts).map(e => ({ text: `Maitrise > ${masteryTexts[e as keyof typeof masteryTexts].text}`, value: { category: 'list', action: 'add', list: 'mastery', item: e } })) },
{ text: 'Maitrise des armes (dex.)', value: { category: 'value', property: 'mastery/dexterity', operation: 'add', value: 1 } },
{ text: 'Maitrise des boucliers', value: { category: 'value', property: 'mastery/shield', operation: 'add', value: 1 } },
{ text: 'Maitrise des armure', value: { category: 'value', property: 'mastery/armor', operation: 'add', value: 1 } },
{ text: 'Attaque multiple', value: { category: 'value', property: 'mastery/multiattack', operation: 'add', value: 1 } },
{ text: 'Arbre de magie (Puissance)', value: { category: 'value', property: 'mastery/magicpower', operation: 'add', value: 1 } },
{ text: 'Arbre de magie (Rapidité)', value: { category: 'value', property: 'mastery/magicspeed', operation: 'add', value: 1 } },
{ text: 'Arbre de magie (Elements)', value: { category: 'value', property: 'mastery/magicelement', operation: 'add', value: 1 } },
{ text: 'Arbre de magie (Instinct)', value: { category: 'value', property: 'mastery/magicinstinct', operation: 'add', value: 1 } }
] },
{ text: 'Compétences', value: [ { text: 'Compétences', value: [
...ABILITIES.map((e) => ({ text: abilityTexts[e as Ability], value: { category: 'value', property: `abilities/${e}`, operation: 'add', value: 1 } })) as Option<Partial<FeatureItem>>[], ...ABILITIES.map((e) => ({ text: abilityTexts[e as Ability], value: { category: 'value', property: `abilities/${e}`, operation: 'add', value: 1 } })) as Option<Partial<FeatureItem>>[],
{ text: 'Max de compétence', value: ABILITIES.map((e) => ({ text: `Max > ${abilityTexts[e as Ability]}`, value: { category: 'value', property: `bonus/abilities/${e}`, operation: 'add', value: 1 } })) } { text: 'Max de compétence', value: ABILITIES.map((e) => ({ text: `Max > ${abilityTexts[e as Ability]}`, value: { category: 'value', property: `bonus/abilities/${e}`, operation: 'add', value: 1 } })) }
@ -1114,6 +1123,10 @@ function textFromEffect(effect: Partial<FeatureOption>): string
{ {
return `${effect.text} (${effect.options?.length ?? 0} options).`; return `${effect.text} (${effect.options?.length ?? 0} options).`;
} }
else if(effect.category === 'tree')
{
return `Progression dans l'arbre ${effect.tree && config.trees[effect.tree] ? config.trees[effect.tree]?.name : "'Inconnu'"}`;
}
return `Inconnu`; return `Inconnu`;
} }