From 323cb0ba7fbfdbacf4e294bc2a1aa6eefe861ff3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A9ment=20Pons?= Date: Wed, 10 Dec 2025 18:05:52 +0100 Subject: [PATCH] Reworking reactivity with a proxy/reflect mecanic --- db.sqlite | Bin 724992 -> 724992 bytes shared/character.util.ts | 212 ++++++++++++++++++++------------------ shared/components.util.ts | 32 +++--- shared/dom.util.ts | 211 ++++++++++++++++++------------------- shared/general.util.ts | 37 +++++++ 5 files changed, 263 insertions(+), 229 deletions(-) diff --git a/db.sqlite b/db.sqlite index 27eb294dfb5b1b272e719c36d420c3b32dec1580..6f3c060756fb70c51324273126be75083d837cd4 100644 GIT binary patch delta 76 zcmZozpwqBGXT!gEra8=;68^~psiuau2FA7qrnUy=wg#5A2G+I)wzdZLwg!&22F`5_ fTuXYm+aeix86-Jv8GAIRJMwTlY=6(gEyxT2WR4m6 delta 66 zcmZozpwqBGXT!gEri%Ja3IF7QR8vD+17lkQQ(FUbTLVj518Z9YTU!HrTLVX11Lw8| Vt|dLwV|lr~7~8hL=iwG)1^}9I8A<>E diff --git a/shared/character.util.ts b/shared/character.util.ts index 4889060..ed5599f 100644 --- a/shared/character.util.ts +++ b/shared/character.util.ts @@ -1,11 +1,11 @@ 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 { keyof, z } from "zod/v4"; import characterConfig from '#shared/character-config.json'; import proses, { preview } from "#shared/proses"; import { button, buttongroup, checkbox, floater, foldable, input, loading, multiselect, numberpicker, select, tabgroup, Toaster, toggle } from "#shared/components.util"; import { div, dom, icon, span, text, type DOMList, type RedrawableHTML } from "#shared/dom.util"; import { followermenu, fullblocker, tooltip } from "#shared/floating.util"; -import { clamp } from "#shared/general.util"; +import { clamp, deepEquals } from "#shared/general.util"; import markdown from "#shared/markdown.util"; import { getText } from "#shared/i18n"; import type { User } from "~/types/auth"; @@ -51,97 +51,91 @@ export const defaultCharacter: Character = { owner: -1, visibility: "private", }; -const defaultCompiledCharacter: (character: Character) => CompiledCharacter = (character: Character) => { - const compiled = { - 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), - level: character.level, - variables: character.variables, - action: 0, - reaction: 0, - exhaust: 0, - itempower: 0, - features: { - action: [], - reaction: [], - freeaction: [], - passive: [], - }, - abilities: { - athletics: 0, - acrobatics: 0, - intimidation: 0, - sleightofhand: 0, - stealth: 0, - survival: 0, - investigation: 0, - history: 0, - religion: 0, - arcana: 0, - understanding: 0, - perception: 0, - performance: 0, - medecine: 0, - persuasion: 0, - animalhandling: 0, - deception: 0 - }, - spellslots: 0, - artslots: 0, - spellranks: { - instinct: 0 as 0 | 1 | 2 | 3, - knowledge: 0 as 0 | 1 | 2 | 3, - precision: 0 as 0 | 1 | 2 | 3, - arts: 0 as 0 | 1 | 2 | 3, - }, - speed: false as number | false, - defense: { - hardcap: Infinity, - static: 6, - activeparry: 0, - activedodge: 0, - passiveparry: 0, - passivedodge: 0, - get passive() { return clamp(compiled.defense.static + compiled.defense.passivedodge + compiled.defense.passiveparry, 0, compiled.defense.hardcap) }, - get parry() { return clamp(compiled.defense.static + compiled.defense.passivedodge + compiled.defense.activeparry, 0, compiled.defense.hardcap) }, - get dodge() { return clamp(compiled.defense.static + compiled.defense.activedodge + compiled.defense.passiveparry, 0, compiled.defense.hardcap) }, - }, - mastery: { - strength: 0, - dexterity: 0, - shield: 0, - armor: 0, - multiattack: 1, - magicpower: 0, - magicspeed: 0, - magicelement: 0, - magicinstinct: 0, - }, - bonus: { - abilities: {}, - defense: {}, - }, - resistance: {}, - initiative: 0, - capacity: 0, - lists: { - action: [], - freeaction: [], - reaction: [], - passive: [], - spells: [], - }, - aspect: "", - notes: Object.assign({ public: '', private: '' }, character.notes), - } - return compiled; -}; +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), + level: character.level, + variables: character.variables, + action: 0, + reaction: 0, + exhaust: 0, + itempower: 0, + features: { + action: [], + reaction: [], + freeaction: [], + passive: [], + }, + abilities: { + athletics: 0, + acrobatics: 0, + intimidation: 0, + sleightofhand: 0, + stealth: 0, + survival: 0, + investigation: 0, + history: 0, + religion: 0, + arcana: 0, + understanding: 0, + perception: 0, + performance: 0, + medecine: 0, + persuasion: 0, + animalhandling: 0, + deception: 0 + }, + spellslots: 0, + artslots: 0, + spellranks: { + instinct: 0 as 0 | 1 | 2 | 3, + knowledge: 0 as 0 | 1 | 2 | 3, + precision: 0 as 0 | 1 | 2 | 3, + arts: 0 as 0 | 1 | 2 | 3, + }, + speed: false as number | false, + defense: { + hardcap: Infinity, + static: 6, + activeparry: 0, + activedodge: 0, + passiveparry: 0, + passivedodge: 0, + }, + mastery: { + strength: 0, + dexterity: 0, + shield: 0, + armor: 0, + multiattack: 1, + magicpower: 0, + magicspeed: 0, + magicelement: 0, + magicinstinct: 0, + }, + bonus: { + abilities: {}, + defense: {}, + }, + resistance: {}, + initiative: 0, + capacity: 0, + lists: { + action: [], + freeaction: [], + reaction: [], + passive: [], + spells: [], + }, + aspect: "", + notes: Object.assign({ public: '', private: '' }, character.notes), +}); export const mainStatTexts: Record = { "strength": "Force", "dexterity": "Dextérité", @@ -923,11 +917,11 @@ class LevelPicker extends BuilderTab ]), div("flex justify-center items-center gap-2 my-2 md:text-base text-sm", [ dom("span", { text: "Vie" }), - text(this._builder, '{{compiled.health}}'), + text(() => this._builder.compiled.health), ]), div("flex justify-center items-center gap-2 my-2 md:text-base text-sm", [ dom("span", { text: "Mana" }), - text(this._builder, '{{compiled.mana}}'), + text(() => this._builder.compiled.mana), ]), 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]))]; @@ -1305,7 +1299,7 @@ export class CharacterSheet private user: ComputedRef; private character?: CharacterCompiler; container: RedrawableHTML = div('flex flex-1 h-full w-full items-start justify-center'); - private tabs?: RedrawableHTML & { refresh: () => void }; + private tabs?: RedrawableHTML; private tab: string = 'abilities'; ws?: Socket; @@ -1333,6 +1327,22 @@ export class CharacterSheet } }); }) + this.ws.handleMessage<{ action: 'set' | 'add' | 'remove', key: keyof CharacterVariables, value: any }>('VARIABLE', (variable) => { + const prop = this.character?.character.variables[variable.key]; + if(variable.action === 'set') + this.character?.variable(variable.key, variable.value, false); + else if(Array.isArray(prop)) + { + if(variable.action === 'add') + prop.push(variable.value); + else if(variable.action === 'remove') + { + const idx = prop.findIndex(e => deepEquals(e, variable.value)); + if(idx !== -1) prop.splice(idx, 1); + } + this.character?.variable(variable.key, prop, false); + } + }) } document.title = `d[any] - ${character.name}`; @@ -1692,6 +1702,7 @@ export class CharacterSheet ]) ] } + //TODO: Update to handle reactivity spellPanel(character: CompiledCharacter) { const availableSpells = Object.values(config.spells).filter(spell => { @@ -1724,7 +1735,6 @@ export class CharacterSheet } toggleText.textContent = state === 'choosen' ? 'Supprimer' : state === 'given' ? 'Inné' : 'Ajouter'; textAmount.textContent = character.variables.spells.length.toString(); - this.tabs?.refresh(); }, "px-2 py-1 text-sm font-normal"); toggleButton.disabled = state === 'given'; return foldable(() => [ @@ -1761,9 +1771,9 @@ export class CharacterSheet } itemsTab(character: CompiledCharacter) { - 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) * v.amount), 0); - const weight = character.variables.items.reduce((p, v) => p + (config.items[v.id]?.weight ?? 0) * v.amount, 0); - const items = this.character!.character.variables.items; + const items = character.variables.items; + const power = 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) * v.amount), 0); + const weight = items.reduce((p, v) => p + (config.items[v.id]?.weight ?? 0) * v.amount, 0); return [ div('flex flex-col gap-2', [ @@ -1815,7 +1825,7 @@ export class CharacterSheet ]), div('flex flex-row items-center divide-x divide-light-50 dark:divide-dark-50 divide-dashed px-2', [ e.amount > 1 && !!item.price ? tooltip(price, `Prix unitaire: ${item.price}`, 'bottom') : price, - div('flex flex-row min-w-16 gap-2 justify-between items-center px-2', [ icon('radix-icons:cross-2', { width: 16, height: 16, class: 'text-light-70 dark:text-dark-70' }), span('', () => e.amount?.toString() ?? '-') ]), + div('flex flex-row min-w-16 gap-2 justify-between items-center px-2', [ icon('radix-icons:cross-2', { width: 16, height: 16, class: 'text-light-70 dark:text-dark-70' }), span('', () => e.amount ?? '-') ]), div('flex flex-row min-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}` : '-') ]), e.amount > 1 && !!item.weight ? tooltip(weight, `Poids unitaire: ${item.weight}`, 'bottom') : weight, div('flex flex-row min-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}` : '-') ]), diff --git a/shared/components.util.ts b/shared/components.util.ts index bf232aa..78d1992 100644 --- a/shared/components.util.ts +++ b/shared/components.util.ts @@ -497,32 +497,24 @@ export function checkbox(settings?: { defaultValue?: boolean, change?: (this: Re export function tabgroup(tabs: Array<{ id: string, title: NodeChildren, content: Reactive }>, settings?: { focused?: string, class?: { container?: Class, tabbar?: Class, title?: Class, content?: Class }, switch?: (tab: string) => void | boolean }): RedrawableHTML { let focus = settings?.focused ?? tabs[0]?.id; - const lazyTabs = tabs.map((e, i) => ({ - title: dom('div', { class: ['px-2 py-1 border-b border-transparent hover:border-accent-blue data-[focus]:border-accent-blue data-[focus]:border-b-[3px] cursor-pointer', settings?.class?.title], attributes: { 'data-focus': e.id === focus }, listeners: { click: function() { - if(this.hasAttribute('data-focus')) - return; + const titles = tabs.map(e => dom('div', { class: ['px-2 py-1 border-b border-transparent hover:border-accent-blue data-[focus]:border-accent-blue data-[focus]:border-b-[3px] cursor-pointer', settings?.class?.title], attributes: { 'data-focus': e.id === focus }, listeners: { click: function() { + if(this.hasAttribute('data-focus')) + return; - if(settings?.switch && settings.switch(e.id) === false) - return; + if(settings?.switch && settings.switch(e.id) === false) + return; - lazyTabs.forEach(e => e.title.toggleAttribute('data-focus', false)); - this.toggleAttribute('data-focus', true); - focus = e.id; - - if(!lazyTabs[i]!.content) - { - lazyTabs[i]!.content = (typeof e.content === 'function' ? e.content() : e.content); - lazyTabs[i]!.content && content.replaceChildren(...lazyTabs[i]!.content?.map(e => typeof e ==='function' ? e() : e)?.filter(e => !!e)); - } - else content.update && content.update(true); - }}}, e.title), - content: undefined as undefined | NodeChildren, - })); + titles.forEach(e => e.toggleAttribute('data-focus', false)); + this.toggleAttribute('data-focus', true); + focus = e.id; + const lazyContent = typeof e.content === 'function' ? e.content() : e.content; + lazyContent && content.replaceChildren(...lazyContent?.map(e => typeof e === 'function' ? e() : e)?.filter(e => !!e)); + }}}, e.title)); const _content = tabs.find(e => e.id === focus)?.content; const content = div(['', settings?.class?.content], typeof _content === 'function' ? _content() : _content); const container = div(['flex flex-col', settings?.class?.container], [ - div(['flex flex-row items-center gap-1', settings?.class?.tabbar], lazyTabs.map(e => e.title)), + div(['flex flex-row items-center gap-1', settings?.class?.tabbar], titles), content ]); return container as RedrawableHTML; diff --git a/shared/dom.util.ts b/shared/dom.util.ts index f0d98a5..88b278c 100644 --- a/shared/dom.util.ts +++ b/shared/dom.util.ts @@ -1,5 +1,6 @@ import { buildIcon, getIcon, iconLoaded, loadIcon, type IconifyIcon } from 'iconify-icon'; import { loading } from './components.util'; +import { deepEquals } from './general.util'; export type RedrawableHTML = HTMLElement & { update?: (recursive: boolean) => void } export type Node = HTMLElement & { update?: (recursive: boolean) => void } | SVGElement | Text | undefined; @@ -19,7 +20,7 @@ export interface DOMList extends Array{ export interface NodeProperties { attributes?: Record>; - text?: Reactive; + text?: Reactive; class?: Class; style?: Reactive | string>; listeners?: { @@ -27,19 +28,103 @@ export interface NodeProperties }; } -let defered = false, _set = new Set<() => void>(); +let defered = false, _deferSet = new Set<() => void>(); const _defer = (fn: () => void) => { if(!defered) { defered = true; queueMicrotask(() => { - _set.forEach(e => e()); - _set.clear(); + _deferSet.forEach(e => e()); + _deferSet.clear(); defered = false; }); } - _set.add(fn); + _deferSet.add(fn); +} +let reactiveEffect: (() => void) | null = null; +const _reactiveCache = new WeakMap(); +// Store a Weak map of all the tracked object. +// For each object, we have a map of its properties, allowing us to effectively listen to absolutely everything on the object +// For a given property, we have a set of "effect" (function called on value update) +const _tracker = new WeakMap<{}, Map void>>>(); +function trigger(target: T, key: string | symbol) +{ + const dependencies = _tracker.get(target) + if(!dependencies) return; + + const set = dependencies.get(key); + set?.forEach(e => e()); +} +function track(target: T, key: string | symbol) +{ + if(!reactiveEffect) return; + + let dependencies = _tracker.get(target); + if(!dependencies) + { + dependencies = new Map(); + _tracker.set(target, dependencies); + } + + let set = dependencies.get(key); + if(!set) + { + set = new Set(); + dependencies.set(key, set); + } + + set.add(reactiveEffect); +} +export function reactive(obj: T): T +{ + if(_reactiveCache.has(obj)) + return _reactiveCache.get(obj)!; + + const proxy = new Proxy(obj, { + get: (target, key, receiver) => { + track(target, key); + const value = Reflect.get(target, key, receiver); + + if(value && typeof value === 'object') + return reactive(value); + + return value; + }, + set: (target, key, value, receiver) => { + const old = Reflect.get(target, key, receiver); + const result = Reflect.set(target, key, value, receiver); + + if(old !== value) + _defer(() => trigger(target, key)); + + return result; + }, + }); + + _reactiveCache.set(obj, proxy); + return proxy; +} +function requireReactive(reactiveProperty: Reactive, effect: (processed: T) => void) +{ + if(typeof reactiveProperty !== 'function') + return effect(reactiveProperty); + else + { + // Function wrapping to keep the context safe and secured. + // Also useful to retrigger the tracking system if the reactive property provides new properties (via conditions for example) + const secureEffect = () => effect((reactiveProperty as () => T)()); + const secureContext = () => { + reactiveEffect = secureContext; + try { + secureEffect(); + } finally { + reactiveEffect = null; + } + }; + + secureContext(); + } } export const cancelPropagation = (e: Event) => e.stopImmediatePropagation(); @@ -128,11 +213,14 @@ export function dom(tag: T if(properties?.text && (setup || typeof properties.text === 'function')) { - const text = typeof properties.text === 'function' ? properties.text() : properties.text; - if(typeof text === 'string') - element.textContent = text; - else - element.appendChild(text as Text); + requireReactive(properties.text, (text) => { + if(typeof text === 'string') + element.textContent = text; + else if(typeof text === 'number') + element.textContent = text.toString(); + else + element.appendChild(text as Text); + }) } if(properties?.listeners && setup) @@ -164,7 +252,7 @@ export function div(cls?: Class, children?: NodeChildren | { rend //@ts-expect-error return dom("div", { class: cls }, children); } -export function span(cls?: Class, text?: Reactive): HTMLElementTagNameMap['span'] & { update?: (recursive: boolean) => void } +export function span(cls?: Class, text?: Reactive): HTMLElementTagNameMap['span'] & { update?: (recursive: boolean) => void } { return dom("span", { class: cls, text: text }); } @@ -187,104 +275,11 @@ export function svg(tag: K, properties?: N return element; } -export function text(data: string): Text; -export function text(data: {}, _txt: Reactive): Text; -export function text(data: any, _txt?: Reactive): Text +export function text(data: Reactive): Text { - if(typeof data === 'string') - return document.createTextNode(data); - else if(_txt) - { - const cache = new Map(); - let txtCache = (typeof _txt === 'function' ? _txt() : _txt); - const setup = (property: string) => { - const prop = property.split('.'); - let obj = data; - - for(let i = 0; i < prop.length - 1; i++) - { - if(prop[i]! in obj) obj = obj[prop[i]!]; - else return 0; - } - - const last = prop.slice(-1)[0]!; - if(last in obj) - { - const prototype = Object.getOwnPropertyDescriptor(Object.getPrototypeOf(obj), last); - let clone = obj[last]; - delete obj[last]; - Object.defineProperty(obj, last, { ...prototype, get: () => prototype?.get ? prototype.get() : clone, set: (v) => { if(prototype?.set) { prototype.set(v); clone = obj[last]; } else if(!prototype?.get) { clone = v; } cache.set(property, v); replace(); }, enumerable: true, configurable: true, }); - cache.set(property, clone); - - return obj[last]; - } - else return 0; - } - const apply = (_setup: boolean) => txtCache.replace(/\{\{(.+?)\}\}/g, (_, txt: string) => { - let i = 0, current = 0, property = '', nextOp = ''; - const _compute = () => { - if(property.length > 0) - { - let value = 0; - if(_setup) - value = setup(property); - else - value = cache.get(property)!; - - if(nextOp === '+') - current += value; - else if(nextOp === '-') - current -= value; - else if(nextOp === '*') - current *= value; - else if(nextOp === '/') - current /= value; - else if(nextOp === '%') - current /= value; - else if(nextOp === '') - current = value; - - nextOp = ''; - property = ''; - } - } - while(i < txt.length) - { - switch(txt.charAt(i)) - { - case '+': - case '-': - case '*': - case '/': - case '%': - _compute(); - - nextOp = txt.charAt(i).trim(); - break; - case ' ': - break; - default: - property += txt.charAt(i); - break; - } - i++; - } - _compute(); - return current.toString(); - }); - const replace = () => { - const txt = (typeof _txt === 'function' ? _txt() : _txt); - if(txt !== txtCache) - { - txtCache = txt; - node.textContent = apply(true); - } - else node.textContent = apply(false); - }; - const node = document.createTextNode(apply(true)); - return node; - } - else return document.createTextNode(''); + const text = document.createTextNode(''); + requireReactive(data, (txt) => text.textContent = txt.toString()); + return text; } export function styling(element: SVGElement | RedrawableHTML, properties: { class?: Class; diff --git a/shared/general.util.ts b/shared/general.util.ts index bd069ce..49e26de 100644 --- a/shared/general.util.ts +++ b/shared/general.util.ts @@ -73,6 +73,43 @@ export function padRight(text: string, pad: string, length: number): string { return pad.repeat(length - text.length).concat(text); } +export function deepEquals(a: any, b: any): boolean +{ + if(a === b) return true; + + if (a && b && typeof a == 'object' && typeof b == 'object') + { + if (a.constructor !== b.constructor) return false; + + let length, i, keys; + if (Array.isArray(a)) + { + length = a.length; + if (length != b.length) return false; + for (i = length; i-- !== 0;) + if (!deepEquals(a[i], b[i])) return false; + return true; + } + + if (a.constructor === RegExp) return a.source === b.source && a.flags === b.flags; + if (a.valueOf !== Object.prototype.valueOf) return a.valueOf() === b.valueOf(); + if (a.toString !== Object.prototype.toString) return a.toString() === b.toString(); + + keys = Object.keys(a) as Array; + length = keys.length; + if (length !== Object.keys(b).length) return false; + + for (i = length; i-- !== 0;) + if (!Object.prototype.hasOwnProperty.call(b, keys[i]!)) return false; + + for (i = length; i-- !== 0;) + if(!deepEquals(a[keys[i]!], b[keys[i]!])) return false; + + return true; + } + + return a !== a && b !== b; +} export function format(date: Date, template: string): string { const months = {