import { System, componentTypeId } from '@babylonjsmarket/ecs';
import type { Component, Entity, EventBus } from '@babylonjsmarket/ecs';
import { knobEntity, knobKey, knobStep, parseSaved, snapToKnob, toSceneShape, type TunerKnob, type TunerValues } from './LiveTuner.core';
import { LiveTunerComponent } from './LiveTuner.defs';
interface Panel {
root: HTMLElement;
rows: Map<string, { input: HTMLInputElement; out: HTMLElement; knob: TunerKnob }>;
status: HTMLElement;
}
interface Runtime {
original: TunerValues;
values: TunerValues;
appliedTo: Map<string, Entity>;
panel: Panel | null;
wanted: boolean;
sinceRefresh: number;
}
const REFRESH_EVERY = 0.3;
export class LiveTunerSystem extends System {
protected query = { required: [LiveTunerComponent] };
private runtime = new WeakMap<LiveTunerComponent, Runtime>();
private keyHandler: ((e: KeyboardEvent) => void) | null = null;
constructor(eventBus: EventBus) {
super(eventBus);
this.pauseable = false;
}
protected onInitialize(): void {
if (typeof window === 'undefined') return;
this.keyHandler = (e: KeyboardEvent) => {
for (const entity of this.entities) {
const c = entity.get(LiveTunerComponent);
if (!c || !c.toggleKey || e.code !== c.toggleKey) continue;
const rt = this.getRuntime(c);
rt.wanted = !rt.wanted;
if (rt.panel) rt.panel.root.style.display = rt.wanted ? '' : 'none';
}
};
window.addEventListener('keydown', this.keyHandler);
}
protected onShutdown(): void {
if (this.keyHandler && typeof window !== 'undefined') window.removeEventListener('keydown', this.keyHandler);
this.keyHandler = null;
for (const entity of this.entities) {
const c = entity.get(LiveTunerComponent);
const rt = c ? this.runtime.get(c) : undefined;
rt?.panel?.root.remove();
}
}
protected onEntityRemoved(entity: Entity): void {
const c = entity.get(LiveTunerComponent);
const rt = c ? this.runtime.get(c) : undefined;
rt?.panel?.root.remove();
if (rt) rt.panel = null;
}
protected onUpdate(dt: number): void {
for (const entity of this.entities) {
const c = entity.get(LiveTunerComponent);
if (!c) continue;
const rt = this.getRuntime(c);
for (const id of this.entityIds(c)) {
const live = this.world?.getEntity(id);
if (!live || rt.appliedTo.get(id) === live) continue;
for (const k of c.knobs) {
if (knobEntity(k, c.target) !== id) continue;
const v = this.readOn(live, k);
if (v !== undefined && !(knobKey(k) in rt.original)) rt.original[knobKey(k)] = v;
const saved = rt.values[knobKey(k)];
if (saved !== undefined) this.writeOn(live, k, saved);
}
rt.appliedTo.set(id, live);
}
if (rt.wanted && !rt.panel && typeof document !== 'undefined') rt.panel = this.buildPanel(c, rt);
rt.sinceRefresh += dt;
if (rt.panel && rt.sinceRefresh >= REFRESH_EVERY) {
rt.sinceRefresh = 0;
this.refresh(c, rt);
}
}
}
private componentOn(entity: Entity, type: string): Component | undefined {
return entity.getComponents().find((x) => componentTypeId(x.constructor as never) === type);
}
private entityIds(c: LiveTunerComponent): Set<string> {
return new Set(c.knobs.map((k) => knobEntity(k, c.target)));
}
private readOn(entity: Entity, k: TunerKnob): number | undefined {
const comp = this.componentOn(entity, k.component) as unknown as Record<string, unknown> | undefined;
const v = comp?.[k.field];
return typeof v === 'number' ? v : undefined;
}
private writeOn(entity: Entity, k: TunerKnob, value: number): void {
const comp = this.componentOn(entity, k.component) as unknown as Record<string, unknown> | undefined;
if (comp && typeof comp[k.field] === 'number') comp[k.field] = value;
}
private read(c: LiveTunerComponent, rt: Pick<Runtime, 'appliedTo'>, k: TunerKnob): number | undefined {
const e = rt.appliedTo.get(knobEntity(k, c.target));
return e ? this.readOn(e, k) : undefined;
}
private write(c: LiveTunerComponent, rt: Pick<Runtime, 'appliedTo'>, k: TunerKnob, value: number): void {
const e = rt.appliedTo.get(knobEntity(k, c.target));
if (e) this.writeOn(e, k, value);
}
private load(c: LiveTunerComponent): TunerValues {
try {
return parseSaved(c.knobs, globalThis.localStorage?.getItem(c.storeKey()));
} catch {
return {};
}
}
private save(c: LiveTunerComponent, values: TunerValues): void {
try {
if (Object.keys(values).length) globalThis.localStorage?.setItem(c.storeKey(), JSON.stringify(values));
else globalThis.localStorage?.removeItem(c.storeKey());
} catch {
}
}
private getRuntime(c: LiveTunerComponent): Runtime {
let rt = this.runtime.get(c);
if (!rt) {
let wanted = false;
try {
wanted = !!c.urlFlag && typeof location !== 'undefined' && new URLSearchParams(location.search).has(c.urlFlag);
} catch {
wanted = false;
}
rt = { original: {}, values: this.load(c), appliedTo: new Map(), panel: null, wanted, sinceRefresh: 0 };
this.runtime.set(c, rt);
}
return rt;
}
private buildPanel(c: LiveTunerComponent, rt: Runtime): Panel {
const root = document.createElement('div');
root.className = 'live-tuner';
root.setAttribute('data-live-tuner', c.target);
Object.assign(root.style, {
position: 'fixed', top: '104px', right: '12px', zIndex: '60', width: '300px', maxWidth: 'calc(100vw - 24px)',
maxHeight: 'calc(100vh - 180px)', overflowY: 'auto', padding: '10px 12px', borderRadius: '10px',
background: 'rgba(12,14,18,0.86)', color: '#e8e8e8', font: '12px/1.35 system-ui, sans-serif',
boxShadow: '0 6px 24px rgba(0,0,0,0.45)', pointerEvents: 'auto', touchAction: 'pan-y',
} satisfies Partial<CSSStyleDeclaration>);
const head = document.createElement('div');
Object.assign(head.style, { display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '6px' });
const title = document.createElement('strong');
title.textContent = c.title;
const close = document.createElement('button');
close.textContent = '×';
close.title = 'Hide';
this.styleButton(close);
close.onclick = () => {
rt.wanted = false;
root.style.display = 'none';
};
head.append(title, close);
root.append(head);
const rows = new Map<string, { input: HTMLInputElement; out: HTMLElement; knob: TunerKnob }>();
let section = '';
for (const k of c.knobs) {
const where = knobEntity(k, c.target);
const heading = where === c.target ? k.component : `${where} · ${k.component}`;
if (heading !== section) {
section = heading;
const h = document.createElement('div');
h.textContent = section;
Object.assign(h.style, { margin: '8px 0 2px', opacity: '0.65', textTransform: 'uppercase', letterSpacing: '0.08em', fontSize: '10px' });
root.append(h);
}
const row = document.createElement('label');
Object.assign(row.style, { display: 'grid', gridTemplateColumns: '1fr 52px', gap: '0 8px', alignItems: 'center', margin: '2px 0' });
const name = document.createElement('span');
name.textContent = k.label ?? k.field;
const out = document.createElement('span');
Object.assign(out.style, { textAlign: 'right', fontVariantNumeric: 'tabular-nums' });
const input = document.createElement('input');
input.type = 'range';
input.min = String(k.min);
input.max = String(k.max);
input.step = String(knobStep(k));
Object.assign(input.style, { gridColumn: '1 / span 2', width: '100%' });
input.oninput = () => {
const v = snapToKnob(k, Number(input.value));
rt.values[knobKey(k)] = v;
this.write(c, rt, k, v);
out.textContent = String(v);
this.save(c, rt.values);
};
input.onchange = () => input.blur();
row.append(name, out, input);
root.append(row);
rows.set(knobKey(k), { input, out, knob: k });
}
const foot = document.createElement('div');
Object.assign(foot.style, { display: 'flex', gap: '6px', marginTop: '10px', alignItems: 'center', flexWrap: 'wrap' });
const copy = document.createElement('button');
copy.textContent = 'Copy';
copy.title = 'Copy the values as scene JSON';
this.styleButton(copy);
const reset = document.createElement('button');
reset.textContent = 'Reset';
reset.title = "Back to the scene's own values";
this.styleButton(reset);
const status = document.createElement('span');
Object.assign(status.style, { opacity: '0.7', fontSize: '11px' });
copy.onclick = () => {
const json = this.copyText(c, rt);
const done = () => (status.textContent = 'Copied');
const fail = () => {
status.textContent = 'Select and copy:';
const box = document.createElement('textarea');
box.value = json;
Object.assign(box.style, { width: '100%', height: '90px', marginTop: '6px', font: '11px monospace' });
root.append(box);
box.select();
};
try {
const p = navigator.clipboard?.writeText(json);
if (p) p.then(done, fail);
else fail();
} catch {
fail();
}
};
reset.onclick = () => {
rt.values = {};
this.save(c, rt.values);
for (const k of c.knobs) {
const v = rt.original[knobKey(k)];
if (v !== undefined) this.write(c, rt, k, v);
}
status.textContent = 'Reset';
this.refresh(c, rt);
};
foot.append(copy, reset, status);
root.append(foot);
(document.body ?? document.documentElement).append(root);
const panel = { root, rows, status };
this.refresh(c, { ...rt, panel });
return panel;
}
copyText(c: LiveTunerComponent, rt: Pick<Runtime, 'appliedTo'>): string {
const now: TunerValues = {};
for (const k of c.knobs) {
const v = this.read(c, rt, k);
if (v !== undefined) now[knobKey(k)] = v;
}
return JSON.stringify(toSceneShape(c.knobs, now, c.target), null, 2);
}
private refresh(c: LiveTunerComponent, rt: Runtime): void {
if (!rt.panel) return;
for (const { input, out, knob } of rt.panel.rows.values()) {
const v = this.read(c, rt, knob);
if (v === undefined) {
out.textContent = '—';
input.disabled = true;
continue;
}
input.disabled = false;
if (typeof document !== 'undefined' && document.activeElement === input) continue;
input.value = String(v);
out.textContent = String(Number(v.toFixed(3)));
}
}
private styleButton(b: HTMLButtonElement): void {
Object.assign(b.style, {
background: 'rgba(255,255,255,0.1)', color: 'inherit', border: '1px solid rgba(255,255,255,0.2)',
borderRadius: '6px', padding: '3px 10px', font: 'inherit', cursor: 'pointer',
} satisfies Partial<CSSStyleDeclaration>);
}
}
export { LiveTunerComponent, type LiveTunerInput, type TunerKnob, type TunerValues } from './LiveTuner.defs';
export { knobKey, snapToKnob, toSceneShape, parseSaved } from './LiveTuner.core';