Add inventory management in character sheet.

This commit is contained in:
Clément Pons 2025-10-22 17:57:19 +02:00
parent 73b0fdf3f5
commit b9970ccdf8
8 changed files with 195 additions and 11035 deletions

BIN
db.sqlite

Binary file not shown.

Binary file not shown.

View File

File diff suppressed because one or more lines are too long

View File

@ -1,15 +1,15 @@
import type { Ability, Alignment, Character, CharacterConfig, CharacterVariables, CompiledCharacter, DamageType, FeatureItem, Level, MainStat, Resistance, SpellConfig, SpellElement, SpellType, TrainingLevel, WeaponType } from "~/types/character";
import type { Ability, Alignment, ArmorConfig, Character, CharacterConfig, CharacterVariables, CompiledCharacter, DamageType, FeatureItem, ItemConfig, ItemState, Level, MainStat, Resistance, SpellConfig, SpellElement, SpellType, TrainingLevel, WeaponConfig, WeaponType } from "~/types/character";
import { z } from "zod/v4";
import characterConfig from '#shared/character-config.json';
import proses, { preview } from "#shared/proses";
import { button, buttongroup, floater, foldable, input, loading, numberpicker, select, tabgroup, Toaster, toggle } from "#shared/components.util";
import { button, buttongroup, checkbox, floater, foldable, input, loading, multiselect, numberpicker, select, tabgroup, Toaster, toggle } from "#shared/components.util";
import { div, dom, icon, span, text } from "#shared/dom.util";
import { followermenu, fullblocker, tooltip } from "#shared/floating.util";
import { clamp } from "#shared/general.util";
import markdown from "#shared/markdown.util";
import { getText } from "./i18n";
import { getText } from "#shared/i18n";
import type { User } from "~/types/auth";
import { MarkdownEditor } from "./editor.util";
import { MarkdownEditor } from "#shared/editor.util";
const config = characterConfig as CharacterConfig;
@ -134,7 +134,6 @@ const defaultCompiledCharacter: (character: Character) => CompiledCharacter = (c
aspect: "",
notes: Object.assign({ public: '', private: '' }, character.notes),
});
export const mainStatTexts: Record<MainStat, string> = {
"strength": "Force",
"dexterity": "Dextérité",
@ -153,7 +152,6 @@ export const mainStatShortTexts: Record<MainStat, string> = {
"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' },
@ -169,7 +167,6 @@ export const elementDom = (element: SpellElement) => dom("span", {
class: [`border !border-opacity-50 rounded-full !bg-opacity-20 px-2 py-px`, elementTexts[element].class],
text: elementTexts[element].text
});
export const alignmentTexts: Record<Alignment, string> = {
'loyal_good': 'Loyal bon',
'neutral_good': 'Neutre bon',
@ -182,7 +179,6 @@ export const alignmentTexts: Record<Alignment, string> = {
'chaotic_evil': 'Chaotique mauvais',
};
export const spellTypeTexts: Record<SpellType, string> = { "instinct": "Instinct", "knowledge": "Savoir", "precision": "Précision", "arts": "Oeuvres" };
export const abilityTexts: Record<Ability, string> = {
"athletics": "Athlétisme",
"acrobatics": "Acrobatique",
@ -202,7 +198,6 @@ export const abilityTexts: Record<Ability, string> = {
"animalhandling": "Dressage",
"deception": "Mensonge"
};
export const resistanceTexts: Record<Resistance, string> = {
'stun': 'Hébètement',
'bleed': 'Saignement',
@ -224,23 +219,19 @@ export const damageTypeTexts: Record<DamageType, string> = {
'slashing': 'Tranchant',
'thunder': 'Foudre',
};
export const weaponTypeTexts: Record<WeaponType, string> = {
"light": "Arme légère",
"shield": "Bouclier",
"heavy": "Arme lourde",
"classic": "Arme",
"throw": "Arme de jet",
"natural": "Arme naturelle",
"twohanded": "Deux mains",
"finesse": "Arme maniable",
"reach": "Arme longue",
"projectile": "Arme à projectile",
};
export const CharacterNotesValidation = z.object({
public: z.string().optional(),
private: z.string().optional(),
});
export const ItemStateValidation = z.object({
id: z.string(),
amount: z.number().min(1),
enchantments: z.array(z.string()).optional(),
charges: z.number().optional(),
equipped: z.boolean().optional(),
state: z.any().optional(),
})
export const CharacterVariablesValidation = z.object({
health: z.number(),
mana: z.number(),
@ -255,7 +246,9 @@ export const CharacterVariablesValidation = z.object({
state: z.number().min(1).max(7).or(z.literal(true)),
})),
spells: z.array(z.string()),
items: z.array(z.string()),
items: z.array(ItemStateValidation),
money: z.number(),
});
export const CharacterValidation = z.object({
id: z.number(),
@ -1229,6 +1222,65 @@ class AspectPicker extends BuilderTab
}
}
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-cyan dark:text-dark-cyan',
'rare': 'text-light-purple dark:text-dark-purple',
'legendary': 'text-light-orange dark:text-dark-orange'
}
export const weaponTypeTexts: Record<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',
}
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': 'Atypique',
'rare': 'Rare',
'legendary': 'Légendaire'
};
const subnameFactory = (item: ItemConfig, state?: ItemState): string[] => {
let result = [];
switch(item.category)
{
case 'armor':
result = [armorTypeTexts[(item as ArmorConfig).type]];
break;
case 'weapon':
result = ['Arme', ...(item as WeaponConfig).type.filter(e => e !== 'classic').map(e => weaponTypeTexts[e])];
break;
case 'mundane':
result = ['Objet'];
break;
case 'wondrous':
result = ['Objet magique'];
break;
}
if(state && state.enchantments !== undefined && state.enchantments.length > 0) result.push('Enchanté');
if(item.consummable) result.push('Consommable');
return result;
}
export class CharacterSheet
{
user: ComputedRef<User | null>;
@ -1290,9 +1342,7 @@ export class CharacterSheet
{ id: 'spells', title: [ text('Sorts') ], content: () => this.spellTab(character) },
{ id: 'inventory', title: [ text('Inventaire') ], content: () => [
] },
{ id: 'inventory', title: [ text('Inventaire') ], content: () => this.itemsTab(character) },
{ id: 'notes', title: [ text('Notes') ], content: () => [
div('flex flex-col gap-2', [
@ -1488,7 +1538,7 @@ export class CharacterSheet
div('flex flex-col gap-8', [
div('flex flex-col gap-2', [
div("flex flex-row items-center justify-center gap-4", [
div("flex flex-row items-center justify-center gap-2", [ dom("div", { class: 'text-lg font-semibold', text: "Actions" }), proses('a', preview, [ icon('radix-icons:question-mark-circled', { width: 12, height: 12, class: 'text-light-70 dark:text-dark-70' }) ], { href: 'regles/le-combat/actions-en-combat#Actions', class: 'h-4' }) ]),
div("flex flex-row items-center justify-center gap-2", [ dom("div", { class: 'text-lg font-semibold', text: "Actions" }), proses('a', preview, [ icon('radix-icons:question-mark-circled', { width: 16, height: 16, class: 'text-light-70 dark:text-dark-70' }) ], { href: 'regles/le-combat/actions-en-combat#Actions', class: 'h-4' }) ]),
div("flex flex-1 border-t border-dashed border-light-50 dark:border-dark-50"),
div('flex flex-row items-center gap-2', [ ...Array(character.action).fill(undefined).map(e => div('border border-dashed border-light-50 dark:border-dark-50 w-5 h-5')), dom('span', { class: 'tracking-tight', text: '/ round' }) ]),
]),
@ -1503,7 +1553,7 @@ export class CharacterSheet
]),
div('flex flex-col gap-2', [
div("flex flex-row items-center justify-center gap-4", [
div("flex flex-row items-center justify-center gap-2", [ dom("div", { class: 'text-lg font-semibold', text: "Réactions" }), proses('a', preview, [ icon('radix-icons:question-mark-circled', { width: 12, height: 12, class: 'text-light-70 dark:text-dark-70' }) ], { href: 'regles/le-combat/actions-en-combat#Réaction', class: 'h-4' }) ]),
div("flex flex-row items-center justify-center gap-2", [ dom("div", { class: 'text-lg font-semibold', text: "Réactions" }), proses('a', preview, [ icon('radix-icons:question-mark-circled', { width: 16, height: 16, class: 'text-light-70 dark:text-dark-70' }) ], { href: 'regles/le-combat/actions-en-combat#Réaction', class: 'h-4' }) ]),
div("flex flex-1 border-t border-dashed border-light-50 dark:border-dark-50"),
div('flex flex-row items-center gap-2', [ ...Array(character.reaction).fill(undefined).map(e => div('border border-dashed border-light-50 dark:border-dark-50 w-5 h-5')), dom('span', { class: 'tracking-tight', text: '/ round' }) ]),
]),
@ -1518,7 +1568,7 @@ export class CharacterSheet
]),
div('flex flex-col gap-2', [
div("flex flex-row items-center justify-center gap-4", [
div("flex flex-row items-center justify-center gap-2", [ dom("div", { class: 'text-lg font-semibold', text: "Actions libres" }), proses('a', preview, [ icon('radix-icons:question-mark-circled', { width: 12, height: 12, class: 'text-light-70 dark:text-dark-70' }) ], { href: 'regles/le-combat/actions-en-combat#Action libre', class: 'h-4' }) ]),
div("flex flex-row items-center justify-center gap-2", [ dom("div", { class: 'text-lg font-semibold', text: "Actions libres" }), proses('a', preview, [ icon('radix-icons:question-mark-circled', { width: 16, height: 16, class: 'text-light-70 dark:text-dark-70' }) ], { href: 'regles/le-combat/actions-en-combat#Action libre', class: 'h-4' }) ]),
div("flex flex-1 border-t border-dashed border-light-50 dark:border-dark-50"),
]),
@ -1576,14 +1626,14 @@ export class CharacterSheet
]),
div('flex flex-row gap-2 items-center', [
dom('span', { class: ['italic text-sm', { 'text-light-red dark:text-dark-red': character.variables.spells.length !== character.spellslots }], text: `${character.variables.spells.length}/${character.spellslots} sort${character.variables.spells.length > 1 ? 's' : ''} maitrisé${character.variables.spells.length > 1 ? 's' : ''}` }),
button(text('Modifier'), () => this.spellPanel(character, spells), 'py-1 px-4'),
button(text('Modifier'), () => this.spellPanel(character), 'py-1 px-4'),
])
]),
div('flex flex-col gap-2', spells.map(e => e.dom))
])
]
}
spellPanel(character: CompiledCharacter, spelllist: Array<{ id: string, spell?: SpellConfig, source: string }>)
spellPanel(character: CompiledCharacter)
{
const availableSpells = Object.values(config.spells).filter(spell => {
if (spell.rank === 4) return false;
@ -1650,4 +1700,113 @@ export class CharacterSheet
const blocker = fullblocker([ container ], { closeWhenOutside: true, onClose: () => this.character?.saveVariables() });
setTimeout(() => container.setAttribute('data-state', 'active'), 1);
}
itemsTab(character: CompiledCharacter)
{
let debounceId: NodeJS.Timeout | undefined;
//TODO: Recompile values on "equip" checkbox change
const items = (character.variables.items.map(e => ({ ...e, item: config.items[e.id] })).filter(e => !!e.item) as Array<ItemState & { item: ItemConfig }>).map(e => div('flex flex-row justify-between', [
div('flex flex-row items-center gap-4', [
div('flex flex-col gap-1', [ e.item.equippable ? checkbox({ defaultValue: e.equipped, change: v => {
e.equipped = v;
this.character!.variable('items', this.character!.character.variables.items);
debounceId && clearTimeout(debounceId);
debounceId = setTimeout(() => this.character?.saveVariables(), 2000);
this.tabs?.refresh();
}, class: { container: '!w-5 !h-5' } }) : checkbox({ disabled: true, class: { container: '!w-5 !h-5' } }), button(icon('radix-icons:trash', { width: 16, height: 17 }), () => {
const idx = this.character!.character.variables.items.findIndex(_e => _e.id === e.id);
if(idx === -1) return;
this.character!.character.variables.items[idx]!.amount--;
if(this.character!.character.variables.items[idx]!.amount >= 0) this.character!.character.variables.items.splice(idx, 1);
this.character!.variable('items', this.character!.character.variables.items);
debounceId && clearTimeout(debounceId);
debounceId = setTimeout(() => this.character?.saveVariables(), 2000);
this.tabs?.refresh();
}, 'p-px') ]),
div('flex flex-col gap-1', [ span([colorByRarity[e.item.rarity], 'text-lg'], e.item.name), div('flex flex-row gap-4 text-light-60 dark:text-dark-60 text-sm italic', subnameFactory(e.item, e).map(text)) ]),
]),
div('grid grid-cols-2 row-gap-2 col-gap-8', [
div('flex flex-row w-16 gap-2 justify-between items-center', [ icon('game-icons:bolt-drop', { width: 16, height: 16, class: 'text-light-70 dark:text-dark-70' }), span('flex-1', (e.item.powercost || (e.enchantments && e.enchantments.length > 0)) && e.item.capacity ? `${(e.item?.powercost ?? 0) + (e.enchantments?.reduce((p, v) => (config.enchantments[v]?.power ?? 0) + p, 0) ?? 0)}/${e.item.capacity}` : '-') ]),
div('flex flex-row w-16 gap-2 justify-between items-center', [ icon('mdi:weight', { width: 16, height: 16, class: 'text-light-70 dark:text-dark-70' }), span('flex-1', e.item.weight?.toString() ?? '-') ]),
div('flex flex-row w-16 gap-2 justify-between items-center', [ icon('game-icons:battery-pack', { width: 16, height: 16, class: 'text-light-70 dark:text-dark-70' }), span('flex-1', e.charges && e.item.charge ? `${e.charges}/${e.item.charge}` : '-') ]),
div('flex flex-row w-16 gap-2 justify-between items-center', [ icon('radix-icons:cross-2', { width: 16, height: 16, class: 'text-light-70 dark:text-dark-70' }), span('flex-1', e.amount?.toString() ?? '-') ])
])
]));
const power = character.variables.items.filter(e => config.items[e.id]?.equippable && e.equipped).reduce((p, v) => p + (config.items[v.id]?.powercost ?? 0) + (v.enchantments?.reduce((_p, _v) => (config.enchantments[_v]?.power ?? 0) + _p, 0) ?? 0), 0);
const weight = character.variables.items.reduce((p, v) => p + (config.items[v.id]?.weight ?? 0), 0);
return [
div('flex flex-col gap-2', [
div('flex flex-row justify-end items-center gap-8', [
dom('span', { class: ['italic text-sm', { 'text-light-red dark:text-dark-red': weight > character.itempower }], text: `Poids total: ${weight}/${character.itempower}` }),
dom('span', { class: ['italic text-sm', { 'text-light-red dark:text-dark-red': power > (character.capacity === false ? 0 : character.capacity) }], text: `Puissance magique: ${power}/${character.capacity}` }),
button(text('Modifier'), () => this.itemsPanel(character), 'py-1 px-4'),
]),
div('grid grid-cols-2 flex-1 gap-4', items)
])
]
}
itemsPanel(character: CompiledCharacter)
{
const items = Object.values(config.items).map(item => ({ item, dom: foldable(() => [ markdown(getText(item.description)) ], [div('flex flex-row justify-between', [
div('flex flex-row items-center gap-4', [
div('flex flex-row items-center gap-4', [ span([colorByRarity[item.rarity], 'text-lg'], item.name), div('flex flex-row gap-2 text-light-60 dark:text-dark-60 text-sm italic', subnameFactory(item).map(e => span('', e))) ]),
]),
div('flex flex-row items-center divide-x divide-light-50 dark:divide-dark-50 divide-dashed px-2', [
div('flex flex-row w-16 gap-2 justify-between items-center px-2', [ icon('game-icons:bolt-drop', { width: 16, height: 16, class: 'text-light-70 dark:text-dark-70' }), span('', item.powercost || item.capacity ? `${item.powercost ?? 0}/${item.capacity ?? 0}` : '-') ]),
div('flex flex-row w-16 gap-2 justify-between items-center px-2', [ icon('mdi:weight', { width: 16, height: 16, class: 'text-light-70 dark:text-dark-70' }), span('', item.weight?.toString() ?? '-') ]),
div('flex flex-row w-16 gap-2 justify-between items-center px-2', [ icon('game-icons:battery-pack', { width: 16, height: 16, class: 'text-light-70 dark:text-dark-70' }), span('', item.charge ? `${item.charge}` : '-') ]),
div('flex flex-row w-16 gap-2 justify-between items-center px-2', [ icon('ph:coin', { width: 16, height: 16, class: 'text-light-70 dark:text-dark-70' }), span('', item.price ? `${item.price}` : '-') ]),
button(icon('radix-icons:plus', { width: 16, height: 16 }), () => {
const list = [...this.character!.character.variables.items];
if(item.equippable) list.push({ id: item.id, amount: 1, charges: item.charge, enchantments: [], equipped: false });
else if(list.find(e => e.id === item.id)) this.character!.character.variables.items.find(e => e.id === item.id)!.amount++;
else list.push({ id: item.id, amount: 1, charges: item.charge, enchantments: [] });
this.character!.variable('items', list); //TO REWORK
this.tabs?.refresh();
}, 'p-1 !border-solid !border-r'),
]),
])], { open: false, class: { icon: 'px-2', container: 'border border-light-35 dark:border-dark-35 p-1 gap-2', content: 'px-2 pb-1' } }) }));
const filters: { category: Category[], rarity: Rarity[], name: string, power: { min: number, max: number } } = {
category: [],
rarity: [],
name: '',
power: { min: 0, max: Infinity },
};
const applyFilters = () => {
content.replaceChildren(...items.filter(e =>
(filters.category.length === 0 || filters.category.includes(e.item.category)) &&
(filters.rarity.length === 0 || filters.rarity.includes(e.item.rarity)) &&
(filters.name === '' || e.item.name.toLowerCase().includes(filters.name.toLowerCase()))
).map(e => e.dom));
}
const content = div('grid grid-cols-1 -my-2 overflow-y-auto gap-1');
const container = div("border-light-35 dark:border-dark-35 bg-light-10 dark:bg-dark-10 border-l absolute top-0 bottom-0 right-0 w-[10%] data-[state=active]:w-1/2 flex flex-col gap-4 text-light-100 dark:text-dark-100 p-8 transition-[width] transition-delay-[150ms]", [
div("flex flex-row justify-between items-center mb-4", [
dom("h2", { class: "text-xl font-bold", text: "Gestion de l'inventaire" }),
div('flex flex-row gap-4 items-center', [ tooltip(button(icon("radix-icons:cross-1", { width: 20, height: 20 }), () => {
setTimeout(blocker.close, 150);
container.setAttribute('data-state', 'inactive');
}, "p-1"), "Fermer", "left") ])
]),
div('flex flex-row items-center gap-4', [
div('flex flex-row gap-2 items-center', [ text('Catégorie'), multiselect(Object.keys(categoryText).map(e => ({ text: categoryText[e as Category], value: e as Category })), { defaultValue: filters.category, change: v => { filters.category = v; applyFilters(); }, class: { container: 'w-40' } }) ]),
div('flex flex-row gap-2 items-center', [ text('Rareté'), multiselect(Object.keys(rarityText).map(e => ({ text: rarityText[e as Rarity], value: e as Rarity })), { defaultValue: filters.rarity, change: v => { filters.rarity = v; applyFilters(); }, class: { container: 'w-40' } }) ]),
div('flex flex-row gap-2 items-center', [ text('Nom'), input('text', { defaultValue: filters.name, input: v => { filters.name = v; applyFilters(); }, class: 'w-64' }) ]),
]),
content,
]);
applyFilters();
const blocker = fullblocker([ container ], { closeWhenOutside: true, onClose: () => this.character?.saveVariables() });
setTimeout(() => container.setAttribute('data-state', 'active'), 1);
}
}

View File

@ -481,7 +481,7 @@ export function checkbox(settings?: { defaultValue?: boolean, change?: (this: HT
let state = settings?.defaultValue ?? false;
const element = dom("div", { class: [`group w-6 h-6 box-content flex items-center justify-center border border-light-50 dark:border-dark-50 bg-light-20 dark:bg-dark-20
cursor-pointer hover:bg-light-30 dark:hover:bg-dark-30 hover:border-light-60 dark:hover:border-dark-60
data-[disabled]:cursor-default data-[disabled]:border-dashed data-[disabled]:border-light-40 dark:data-[disabled]:border-dark-40 data-[disabled]:bg-0 dark:data-[disabled]:bg-0`, settings?.class?.container], attributes: { "data-state": state ? "checked" : "unchecked", "data-disabled": settings?.disabled }, listeners: {
data-[disabled]:cursor-default data-[disabled]:border-dashed data-[disabled]:border-light-40 dark:data-[disabled]:border-dark-40 data-[disabled]:bg-0 dark:data-[disabled]:bg-0 hover:data-[disabled]:bg-0 dark:hover:data-[disabled]:bg-0`, settings?.class?.container], attributes: { "data-state": state ? "checked" : "unchecked", "data-disabled": settings?.disabled }, listeners: {
click: function(e: Event) {
if(this.hasAttribute('data-disabled'))
return;

View File

@ -4,7 +4,7 @@ import { MarkdownEditor } from "#shared/editor.util";
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 { confirm, contextmenu, fullblocker, tooltip } from "#shared/floating.util";
import { ABILITIES, abilityTexts, ALIGNMENTS, alignmentTexts, damageTypeTexts, elementTexts, LEVELS, MAIN_STATS, mainStatShortTexts, mainStatTexts, 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, rarityText, RESISTANCES, resistanceTexts, SPELL_ELEMENTS, SPELL_TYPES, spellTypeTexts, weaponTypeTexts } from "#shared/character.util";
import characterConfig from "#shared/character-config.json";
import { getID } from "#shared/general.util";
import markdown, { markdownReference, renderMDAsText } from "#shared/markdown.util";
@ -13,18 +13,6 @@ import { getText } from "#shared/i18n";
type Category = ItemConfig['category'];
type Rarity = ItemConfig['rarity'];
const categoryText: Record<Category, string> = {
'mundane': 'Objet inerte',
'armor': 'Armure',
'weapon': 'Arme',
'wondrous': 'Objet magique'
};
const rarityText: Record<Rarity, string> = {
'common': 'Commun',
'uncommon': 'Peu commun',
'rare': 'Rare',
'legendary': 'Légendaire'
};
const config = characterConfig as CharacterConfig;
export class HomebrewBuilder

View File

@ -52,11 +52,13 @@ export type CharacterVariables = {
poisons: Array<{ id: string, state: number | true }>;
spells: string[]; //Spell ID
items: ItemState[];
money: number;
};
type ItemState = {
id: string;
amount: number;
enchantments?: [];
enchantments?: string[];
charges?: number;
equipped?: boolean;
state?: any;