Reworking reactivity with a proxy/reflect mecanic

This commit is contained in:
Clément Pons 2025-12-10 18:05:52 +01:00
parent 4cd478b47a
commit 323cb0ba7f
5 changed files with 263 additions and 229 deletions

BIN
db.sqlite

Binary file not shown.

View File

@ -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 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 characterConfig from '#shared/character-config.json';
import proses, { preview } from "#shared/proses"; 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 { 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 { div, dom, icon, span, text, type DOMList, 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, deepEquals } from "#shared/general.util";
import markdown from "#shared/markdown.util"; import markdown from "#shared/markdown.util";
import { getText } from "#shared/i18n"; import { getText } from "#shared/i18n";
import type { User } from "~/types/auth"; import type { User } from "~/types/auth";
@ -51,8 +51,7 @@ export const defaultCharacter: Character = {
owner: -1, owner: -1,
visibility: "private", visibility: "private",
}; };
const defaultCompiledCharacter: (character: Character) => CompiledCharacter = (character: Character) => { const defaultCompiledCharacter: (character: Character) => CompiledCharacter = (character: Character) => ({
const compiled = {
id: character.id, id: character.id,
owner: character.owner, owner: character.owner,
username: character.username, username: character.username,
@ -108,9 +107,6 @@ const defaultCompiledCharacter: (character: Character) => CompiledCharacter = (c
activedodge: 0, activedodge: 0,
passiveparry: 0, passiveparry: 0,
passivedodge: 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: { mastery: {
strength: 0, strength: 0,
@ -139,9 +135,7 @@ const defaultCompiledCharacter: (character: Character) => CompiledCharacter = (c
}, },
aspect: "", aspect: "",
notes: Object.assign({ public: '', private: '' }, character.notes), notes: Object.assign({ public: '', private: '' }, character.notes),
} });
return compiled;
};
export const mainStatTexts: Record<MainStat, string> = { export const mainStatTexts: Record<MainStat, string> = {
"strength": "Force", "strength": "Force",
"dexterity": "Dextérité", "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", [ div("flex justify-center items-center gap-2 my-2 md:text-base text-sm", [
dom("span", { text: "Vie" }), 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", [ div("flex justify-center items-center gap-2 my-2 md:text-base text-sm", [
dom("span", { text: "Mana" }), 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]'), 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]))]; ]), 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<User | null>; private user: ComputedRef<User | null>;
private character?: CharacterCompiler; private character?: CharacterCompiler;
container: RedrawableHTML = div('flex flex-1 h-full w-full items-start justify-center'); 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'; private tab: string = 'abilities';
ws?: Socket; 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}`; document.title = `d[any] - ${character.name}`;
@ -1692,6 +1702,7 @@ export class CharacterSheet
]) ])
] ]
} }
//TODO: Update to handle reactivity
spellPanel(character: CompiledCharacter) spellPanel(character: CompiledCharacter)
{ {
const availableSpells = Object.values(config.spells).filter(spell => { const availableSpells = Object.values(config.spells).filter(spell => {
@ -1724,7 +1735,6 @@ export class CharacterSheet
} }
toggleText.textContent = state === 'choosen' ? 'Supprimer' : state === 'given' ? 'Inné' : 'Ajouter'; toggleText.textContent = state === 'choosen' ? 'Supprimer' : state === 'given' ? 'Inné' : 'Ajouter';
textAmount.textContent = character.variables.spells.length.toString(); textAmount.textContent = character.variables.spells.length.toString();
this.tabs?.refresh();
}, "px-2 py-1 text-sm font-normal"); }, "px-2 py-1 text-sm font-normal");
toggleButton.disabled = state === 'given'; toggleButton.disabled = state === 'given';
return foldable(() => [ return foldable(() => [
@ -1761,9 +1771,9 @@ export class CharacterSheet
} }
itemsTab(character: CompiledCharacter) 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 items = character.variables.items;
const weight = character.variables.items.reduce((p, v) => p + (config.items[v.id]?.weight ?? 0) * v.amount, 0); 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 items = this.character!.character.variables.items; const weight = items.reduce((p, v) => p + (config.items[v.id]?.weight ?? 0) * v.amount, 0);
return [ return [
div('flex flex-col gap-2', [ 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', [ 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, 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}` : '-') ]), 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, 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}` : '-') ]), 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}` : '-') ]),

View File

@ -497,32 +497,24 @@ export function checkbox(settings?: { defaultValue?: boolean, change?: (this: Re
export function tabgroup(tabs: Array<{ id: string, title: NodeChildren, content: Reactive<NodeChildren> }>, settings?: { focused?: string, class?: { container?: Class, tabbar?: Class, title?: Class, content?: Class }, switch?: (tab: string) => void | boolean }): RedrawableHTML export function tabgroup(tabs: Array<{ id: string, title: NodeChildren, content: Reactive<NodeChildren> }>, 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; let focus = settings?.focused ?? tabs[0]?.id;
const lazyTabs = tabs.map((e, i) => ({ 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() {
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')) if(this.hasAttribute('data-focus'))
return; return;
if(settings?.switch && settings.switch(e.id) === false) if(settings?.switch && settings.switch(e.id) === false)
return; return;
lazyTabs.forEach(e => e.title.toggleAttribute('data-focus', false)); titles.forEach(e => e.toggleAttribute('data-focus', false));
this.toggleAttribute('data-focus', true); this.toggleAttribute('data-focus', true);
focus = e.id; focus = e.id;
const lazyContent = typeof e.content === 'function' ? e.content() : e.content;
if(!lazyTabs[i]!.content) lazyContent && content.replaceChildren(...lazyContent?.map(e => typeof e === 'function' ? e() : e)?.filter(e => !!e));
{ }}}, e.title));
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,
}));
const _content = tabs.find(e => e.id === focus)?.content; const _content = tabs.find(e => e.id === focus)?.content;
const content = div(['', settings?.class?.content], typeof _content === 'function' ? _content() : _content); const content = div(['', settings?.class?.content], typeof _content === 'function' ? _content() : _content);
const container = div(['flex flex-col', settings?.class?.container], [ 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 content
]); ]);
return container as RedrawableHTML; return container as RedrawableHTML;

View File

@ -1,5 +1,6 @@
import { buildIcon, getIcon, iconLoaded, loadIcon, type IconifyIcon } from 'iconify-icon'; import { buildIcon, getIcon, iconLoaded, loadIcon, type IconifyIcon } from 'iconify-icon';
import { loading } from './components.util'; import { loading } from './components.util';
import { deepEquals } from './general.util';
export type RedrawableHTML = HTMLElement & { update?: (recursive: boolean) => void } export type RedrawableHTML = HTMLElement & { update?: (recursive: boolean) => void }
export type Node = HTMLElement & { update?: (recursive: boolean) => void } | SVGElement | Text | undefined; export type Node = HTMLElement & { update?: (recursive: boolean) => void } | SVGElement | Text | undefined;
@ -19,7 +20,7 @@ export interface DOMList<T> extends Array<T>{
export interface NodeProperties export interface NodeProperties
{ {
attributes?: Record<string, Reactive<string | undefined | boolean | number>>; attributes?: Record<string, Reactive<string | undefined | boolean | number>>;
text?: Reactive<string | Text>; text?: Reactive<string | number | Text>;
class?: Class; class?: Class;
style?: Reactive<Record<string, string | undefined | boolean | number> | string>; style?: Reactive<Record<string, string | undefined | boolean | number> | string>;
listeners?: { 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) => { const _defer = (fn: () => void) => {
if(!defered) if(!defered)
{ {
defered = true; defered = true;
queueMicrotask(() => { queueMicrotask(() => {
_set.forEach(e => e()); _deferSet.forEach(e => e());
_set.clear(); _deferSet.clear();
defered = false; 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<string | symbol, Set<() => void>>>();
function trigger<T extends {}>(target: T, key: string | symbol)
{
const dependencies = _tracker.get(target)
if(!dependencies) return;
const set = dependencies.get(key);
set?.forEach(e => e());
}
function track<T extends {}>(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<T extends {}>(obj: T): T
{
if(_reactiveCache.has(obj))
return _reactiveCache.get(obj)!;
const proxy = new Proxy<T>(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<T>(reactiveProperty: Reactive<T>, 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(); export const cancelPropagation = (e: Event) => e.stopImmediatePropagation();
@ -128,11 +213,14 @@ export function dom<T extends keyof HTMLElementTagNameMap, U extends any>(tag: T
if(properties?.text && (setup || typeof properties.text === 'function')) if(properties?.text && (setup || typeof properties.text === 'function'))
{ {
const text = typeof properties.text === 'function' ? properties.text() : properties.text; requireReactive(properties.text, (text) => {
if(typeof text === 'string') if(typeof text === 'string')
element.textContent = text; element.textContent = text;
else if(typeof text === 'number')
element.textContent = text.toString();
else else
element.appendChild(text as Text); element.appendChild(text as Text);
})
} }
if(properties?.listeners && setup) if(properties?.listeners && setup)
@ -164,7 +252,7 @@ export function div<U extends any>(cls?: Class, children?: NodeChildren | { rend
//@ts-expect-error //@ts-expect-error
return dom("div", { class: cls }, children); return dom("div", { class: cls }, children);
} }
export function span(cls?: Class, text?: Reactive<string | Text>): HTMLElementTagNameMap['span'] & { update?: (recursive: boolean) => void } export function span(cls?: Class, text?: Reactive<string | number | Text>): HTMLElementTagNameMap['span'] & { update?: (recursive: boolean) => void }
{ {
return dom("span", { class: cls, text: text }); return dom("span", { class: cls, text: text });
} }
@ -187,104 +275,11 @@ export function svg<K extends keyof SVGElementTagNameMap>(tag: K, properties?: N
return element; return element;
} }
export function text(data: string): Text; export function text(data: Reactive<string | number>): Text
export function text(data: {}, _txt: Reactive<string>): Text;
export function text(data: any, _txt?: Reactive<string>): Text
{ {
if(typeof data === 'string') const text = document.createTextNode('');
return document.createTextNode(data); requireReactive(data, (txt) => text.textContent = txt.toString());
else if(_txt) return text;
{
const cache = new Map<string, number>();
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('');
} }
export function styling(element: SVGElement | RedrawableHTML, properties: { export function styling(element: SVGElement | RedrawableHTML, properties: {
class?: Class; class?: Class;

View File

@ -73,6 +73,43 @@ export function padRight(text: string, pad: string, length: number): string
{ {
return pad.repeat(length - text.length).concat(text); 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<keyof typeof a>;
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 export function format(date: Date, template: string): string
{ {
const months = { const months = {