Files
lhns--steam-frame-nix/modules/pet/systemui.js
T
Pierre Kisters 4034fd4ded VR pet: a cat or dog in SteamVR's scene (steamFrame.pet)
A SteamVR dashboard patch (vr-pet) drawing a baked 3D pet next to the
windows: the Toon Cat in five coats, a Shiba Inu, a Fox and a Dachshund,
plus models from steamFrame.pet.extraModels. It walks around, follows,
sits, lies and sleeps, can be picked up by its grip bar, petted and
switched in its menu; its spot, pose and model are saved state.

Also the vr-pet command, "Pet" in the "+" menu with the current model's
icon (steam-frame-nix-pet-icon), the flake outputs pet-models,
pet-icons and pet-preview, and the check pet. A built-in model with
"private": true fails evaluation; private models belong in extraModels.
2026-10-01 04:33:39 +02:00

923 lines
52 KiB
JavaScript

// VR pet, SteamVR adapter: draws the core's pet (core.js) in SteamVR's
// systemui page (DevTools 127.0.0.1:8087). steam-ui-patches injects
// ((opts, baked, find, hooks) => { <core.js> <this file> return vrPetSystemui(…); })(…)
// every 15 s (find, hooks: steam-frame-nix's finders.js, hooks.js); an
// unchanged pet returns "unchanged".
//
// Frames: systemui builds vrcompositor's scene graph from its DOM, and a
// "rendermodel" node draws an OBJ (absolute path). Render models are static,
// so every baked frame is its own OBJ under one world-locked root (standing
// space, y = 0 the floor). vrcompositor loads a model when its node appears
// (3-8 ms each, one after the other), drops it when it goes, reloads it when
// its source changes, and draws every mounted one (hidden ones at scale ~0
// too). So frames are mounted gradually and kept while they may be needed:
// opts.mount "dynamic" the clips the core prefetches, LOADS_PER_TICK frames
// per tick, most urgent clip first; a clip shows once
// mounted SETTLE_MS; unused clips stay KEEP_MS, fewer
// beyond CAP frames (actions first, then the least
// recently used);
// "all" every frame (gradually), all the time.
// Sends: every change resends the whole scene graph (~65 kB). The scene graph
// module's send walks the DOM (~16 ms), the public forceLayoutUpdate also
// re-measures and repaints the page (at 12 Hz it lagged the dashboard), so
// the pet resends the dashboard's last message itself with only its subtree
// replaced (fastSend); the DOM is kept in sync for the dashboard's own sends.
// At most one send per tick, only on a change, deferred while the head turns
// fast (TURN_*) or the pet is out of view (VIEW_DEFER_MS; mounts still go
// out); a drag's start and end at once. Input is read-only: the head and hand
// poses once per tick, the laser's mouse events on the pet's own panels; no
// buttons, nothing synthesized.
//
// Laser drag: a grip bar panel above the pet (only-visible-with-laser, a grab
// handle like a window's). Pressing it parents the root to that hand
// (parent-path; the hand is in the event's CapsLock / NumLock state, as in
// SteamVR's GrabHandle) at the grab point's pose in the controller's frame,
// so vrcompositor carries it rigidly every display frame, like SteamVR's
// floating windows (the dashboard's grab-transform would re-aim it at the
// head instead). A grab-scale under the root (its event panel the bar) is the
// thumbstick push; on release its distance is asked (SGQueryService) and the
// pet falls from there. The body (frames and bar) is set once per tick:
// upright below the grab point, turned with the controller's heading.
// Controls: ⋯ and X below the bar, the ⋯ menu above it (the models, then
// Summon / Sit / Lie down / Sleep); sizes in VrPetCore.ui. Their textures are
// regions of this page (window.__sfuiRegions, a shared allocator, if there
// is one; else free spots clear of the other panels and the keyboard strip's
// band). Buttons act only on a press and a release both on them (a stray
// release does nothing), never during a drag or AFTER_DRAG_MS after. The
// menu's panel is in the scene graph only while open, but its DOM is laid out
// all along, so its embedded-UV slot is valid when it opens (no layout then).
//
// window.__sfuiPet (S):
// api { version, show, hide, status, models, setModel } for cli.mjs
// cat the core: cat.command('sit' | 'summon' | …), cat.state() (DevTools)
// debug: stats(), log, drag('left' | 'right' | null), where(id) (a node's
// pose in vrcompositor), dragTrace (4 Hz while dragged), pause(), resume()
// Hidden: no timers, no sends; `hidden` and `model` are kept in the patch's
// store next to the core's spot and pose, so a fresh page builds a hidden pet
// without putting it in the page.
function vrPetSystemui(opts, catalog, find, hooks) {
const VERSION = 16;
const G = window;
const KEY = 'vr-pet';
const old = G.__sfuiPet;
const sig = JSON.stringify([VERSION, opts, catalog]);
// (a hidden cat's root is out of the page on purpose: unchanged too)
if (old?.sig === sig && (old.hidden || old.root?.isConnected)) return 'unchanged';
if (typeof G.VRHTML?.NextSGID !== 'function' || typeof G.VRHTML?.GetPose !== 'function' || typeof G.forceLayoutUpdate !== 'function')
return 'SteamVR internals changed, no cat: missing VRHTML.NextSGID / VRHTML.GetPose / forceLayoutUpdate';
const M = VrPetCore.math, UI = VrPetCore.ui;
try { old?.dispose?.(); } catch { /* gone */ }
// Scene graph updates. Fast path: resend the dashboard's last scene graph
// message (captured, with the other patches' rewrites already applied) with
// only the cat's subtree replaced, straight to the mailbox's websocket; no
// DOM walk. Slow path (until a message is captured, and whenever the cat's
// root is not in it): the dashboard's own send function. Last resort:
// forceLayoutUpdate.
const PREFIX = 'mailbox_send vrcompositor_systemlayer ';
const HEAD = PREFIX + '{"type":"update_scene_graph"';
let slowSend = null, sender = 'forceLayoutUpdate (fallback)', proto = null;
try {
const req = find.getWebpackRequire('webpackChunkvrwebui');
const mod = find.findModule(req, { includes: ['update_scene_graph', 'sg_mailbox'] }, 'scene graph module');
slowSend = find.findExport(mod.exports, { type: 'function', includes: ['update_scene_graph'] }, 'scene graph send').value;
sender = 'scene graph send';
const mb = find.findModule(req, { includes: ['"mailbox_send "', 'WebSocketSend(', '"vrcompositor_systemlayer"'] }, 'mailbox module');
proto = find.findExport(mb.exports, { type: 'class', protoMethods: ['SendMessage', 'SendMessageAndWaitForResponse', 'RegisterHandler'] }, 'Mailbox').value.prototype;
} catch (e) { sender += `: ${e.message}`; }
// The scene graph mailbox's websocket (its WebSocketSend is a read-only
// bound method, so the socket's send is wrapped; re-wrapped on reconnect).
const cap = { sock: null, send: null, str: null, parsed: null, node: null, pre: '', post: '', seen: 0 };
const capture = (mailbox) => {
const sock = mailbox?.m_wsWebSocketToServer;
if (!sock || cap.sock === sock || typeof sock.send !== 'function') return;
const orig = sock.send.__sfuiPetOrig ?? sock.send;
const wrapper = function (str) {
if (typeof str === 'string' && str.startsWith(HEAD)) G.__sfuiPet?.onSceneGraph?.(str);
return orig.apply(this, arguments);
};
wrapper.__sfuiPetOrig = orig;
sock.send = wrapper;
Object.assign(cap, { sock, send: (str) => sock.readyState === 1 && (orig.call(sock, str), true) });
};
if (proto && hooks) {
hooks.before(proto, 'SendMessage', 'vr-pet', function (args) {
cap.seen++;
if (args[1]?.type === 'update_scene_graph' && cap.sock !== this.m_wsWebSocketToServer) capture(this);
});
}
let sends = 0, fast = 0;
let fastSend = () => false; // set below, once the cat's nodes exist
const update = () => {
sends++;
if (fastSend()) { fast++; return; }
slowSend ? slowSend() : G.forceLayoutUpdate();
};
const HIDDEN = '0.0001 0.0001 0.0001', SHOWN = '1 1 1';
const S = G.__sfuiPet = { version: VERSION, sig, log: [], hidden: false };
const log = (...a) => { S.log.push([Math.round(performance.now()), ...a]); if (S.log.length > 80) S.log.shift(); };
const setAttrs = (e, o) => { for (const [k, v] of Object.entries(o)) e.setAttribute(k, String(v)); };
// The persistent value (steam-ui-patches' store): the core's spot and pose
// plus `hidden`; every write merges (the core's save() writes its own keys only).
const stored = () => { try { const v = G.__sfuiStore?.get?.(KEY); return v && typeof v === 'object' ? v : {}; } catch { return {}; } };
const store = (patch) => { try { G.__sfuiStore?.set?.(KEY, { ...stored(), ...patch }); } catch { /* no store */ } };
const startHidden = stored().hidden === true;
// ---- the model (catalog: index.py's models.json; its frame set is the core's baked data) ----
const models = catalog.models;
let modelId = models[stored().model] ? stored().model : catalog.default;
const unknownModel = stored().model != null && !models[stored().model] ? stored().model : null;
let baked = catalog.frameSets[models[modelId].frames];
// ---- scene graph: root (position + yaw) > one transform per frame > rendermodel ----
const root = document.createElement('vsg-transform');
root.id = 'sfui-vr-pet';
setAttrs(root, { translation: '0 0 0', rotation: '1 0 0 0', scale: '1 1 1', sgid: G.VRHTML.NextSGID() });
// ---- laser drag: root (carried: parent-path the hand) > push (grab-scale) > turn > frames, and > barBody > the handle ----
let overlayKey = 'system.systemui';
try { overlayKey = G.VRHTML.VROverlay.ThisOverlayKey() || overlayKey; } catch { /* default */ }
// The thumbstick push / pull while dragged: a grab-scale, as SteamVR's
// floating windows (min / max distance from the hand, scroll speed).
const PUSH = { 'min-distance': 0.25, 'max-distance': 5, 'scroll-speed': 10 };
const PUSHED_ID = 'sfui-vr-pet-pushed';
const xform = (id) => {
const t = document.createElement('vsg-transform');
setAttrs(t, { translation: '0 0 0', rotation: '1 0 0 0', scale: '1 1 1', sgid: G.VRHTML.NextSGID() });
t.sg = { type: 'transform', properties: { sgid: +t.getAttribute('sgid'), translation: [0, 0, 0], rotation: [1, 0, 0, 0], scale: [1, 1, 1] }, children: [] };
if (id) { t.id = id; t.sg.properties.id = `${overlayKey}::${id}`; }
return t;
};
// (no DOM tag of its own: a node built by buildNode, its children walked)
const push = document.createElement('vsg-node');
const pushSgid = G.VRHTML.NextSGID();
let pushGen = 0;
push.setAttribute('sgid', String(pushSgid));
push.sg = { type: 'grab-scale', properties: { sgid: pushSgid, 'is-active': false, 'reset-generation': 0, ...PUSH }, children: [] };
push.buildNode = (ctx) => [ctx, { type: 'grab-scale', properties: { ...push.sg.properties } }];
root.appendChild(push);
// `pushed`: where the push has the carried frame (asked on release);
// `turn` (the frames) and `barBody` (the handle): the cat's upright body,
// identity unless dragged (then below the grab point, see dragBody()).
const pushed = xform(PUSHED_ID), turn = xform(), barBody = xform();
push.appendChild(pushed);
push.appendChild(turn);
push.appendChild(barBody);
let bodyQ = [1, 0, 0, 0], bodyP = [0, 0, 0];
const setBody = (q, p) => { // -> changed
const home = q[0] === 1 && p.every((v) => v === 0);
if (!(home && (bodyQ[0] !== 1 || bodyP.some((v) => v !== 0)))
&& M.qdiffDeg(q, bodyQ) < 0.2 && Math.hypot(...p.map((v, k) => v - bodyP[k])) < 5e-4) return false;
bodyQ = q; bodyP = p;
for (const t of [turn, barBody]) {
t.setAttribute('rotation', fmt(q, 5)); t.setAttribute('translation', fmt(p, 5));
t.sg.properties.rotation = q; t.sg.properties.translation = p;
}
return true;
};
const setShown = (xf, on) => { xf.setAttribute('scale', on ? SHOWN : HIDDEN); xf.sg.properties.scale = on ? [1, 1, 1] : [1e-4, 1e-4, 1e-4]; };
let frames = {}; // clip -> [transform]
// One transform + rendermodel per baked frame of the model's frame set.
const buildFrames = (dir) => {
frames = {};
for (const [name, c] of Object.entries(baked.clips)) {
frames[name] = [];
for (let i = 0; i < c.frames; i++) {
const xf = document.createElement('vsg-transform');
setAttrs(xf, { translation: '0 0 0', rotation: '1 0 0 0', scale: HIDDEN, sgid: G.VRHTML.NextSGID() });
const node = document.createElement('vsg-node');
const sgid = G.VRHTML.NextSGID();
node.setAttribute('sgid', String(sgid));
const rm = { type: 'rendermodel', properties: { sgid, source: `${dir}/${name}_${i}.obj`, culling: 0 } };
node.buildNode = (ctx) => [ctx, { type: rm.type, properties: { ...rm.properties } }];
xf.appendChild(node);
// The same node as the dashboard's DOM walk builds it, for the fast path.
xf.sg = { type: 'transform', properties: { sgid: +xf.getAttribute('sgid'), translation: [0, 0, 0], rotation: [1, 0, 0, 0], scale: [1e-4, 1e-4, 1e-4], 'parent-id': null },
children: [rm] };
xf.rm = rm; xf.file = `${name}_${i}.obj`; // (its source: the model's directory + file)
frames[name].push(xf);
}
}
};
buildFrames(models[modelId].dir);
// ---- mounted frames (see the header) ----
const LOADS_PER_TICK = 3, SETTLE_MS = 150, KEEP_MS = 60000;
const all = opts.mount === 'all';
const CAP = all ? Infinity : 170;
// one-shot actions (the cat's, an animal's own): evicted before the poses' clips
const rareOf = () => new Set([...Object.keys(VrPetCore.ACTIONS), ...Object.keys(baked.species?.actions ?? {})]);
let RARE = rareOf();
const mounted = new Map(); // clip -> { n: frames mounted (in order), last: time of the last mount, used }
let want = new Set(), nMounted = 0, loads = 0, evictions = 0;
function pump(now) { // mount the wanted clips' next frames, most urgent clip first
let budget = LOADS_PER_TICK, changed = false;
for (const name of want) {
let m = mounted.get(name);
if (!m) mounted.set(name, m = { n: 0, last: now, used: now });
m.used = now;
const fr = frames[name];
while (budget > 0 && m.n < fr.length) { turn.appendChild(fr[m.n++]); m.last = now; budget--; nMounted++; loads++; changed = true; }
}
return changed;
}
function evict(now) { // unused clips after KEEP_MS; beyond CAP actions first, then the least recently used
const idle = [...mounted].filter(([name]) => !want.has(name) && !frames[name].includes(shown))
.sort((a, b) => RARE.has(b[0]) - RARE.has(a[0]) || a[1].used - b[1].used);
let changed = false;
for (const [name, m] of idle) {
if (now - m.used < KEEP_MS && nMounted <= CAP) continue;
for (let i = 0; i < m.n; i++) frames[name][i].remove();
nMounted -= m.n; evictions++;
mounted.delete(name);
changed = true;
}
return changed;
}
const rootSgid = +root.getAttribute('sgid');
S.onSceneGraph = (str) => { cap.str = str; };
// A captured message is parsed once into the text before and after the
// pet's node (cap.pre / cap.post); a send serializes only the pet's node.
const HOLE = '"__sfuiPetNode__"';
fastSend = () => {
if (!cap.send || !cap.str || !root.isConnected) return false;
if (cap.parsed !== cap.str) {
cap.parsed = cap.str;
cap.node = null;
const msg = JSON.parse(cap.str.slice(PREFIX.length));
msg.retired_sgids = [];
if (msg.scene_graph?.properties && 'eDashboardRelatch' in msg.scene_graph.properties)
msg.scene_graph.properties.eDashboardRelatch = 0; // None: never repeat a relatch request
const walk = (n, parent, k) => {
if (cap.node || !n || typeof n !== 'object') return;
if (Array.isArray(n)) { n.forEach((c, i) => walk(c, n, i)); return; }
if (n.properties?.sgid === rootSgid) { cap.node = n; parent[k] = JSON.parse(HOLE); return; }
if (n.children) walk(n.children);
};
walk(msg.scene_graph);
if (cap.node) [cap.pre, cap.post] = JSON.stringify(msg).split(HOLE);
}
if (!cap.node) return false;
const p = cap.node.properties;
p.translation = lastT.split(' ').map(Number);
p.rotation = lastR.split(' ').map(Number);
const pp = root.getAttribute('parent-path');
if (pp) p['parent-path'] = pp; else delete p['parent-path'];
turn.sg.children = [...turn.children].map((xf) => xf.sg).filter(Boolean);
barBody.sg.children = handle ? [handle.sg()] : [];
push.sg.children = [pushed.sg, turn.sg, barBody.sg];
cap.node.children = [push.sg];
return cap.send(PREFIX + cap.pre + JSON.stringify(cap.node) + cap.post);
};
// ---- the grip bar, the controls and the menu (sizes: VrPetCore.ui) ----
const { HANDLE, CONTROLS, MENU } = UI;
const liftOf = () => UI.liftOf(baked.height);
// drag: { hand, phase: 'drag' | 'ending' (let go) | 'asking' (how far
// pushed?) | 'done', w0: the pet's pose at the grab, rel: the grab point in
// the controller's frame, push, want: the body's orientation } while the
// laser drags the pet
let drag = null;
// A button's press (X, ⋯, the menu's rows): armed by a press on it (not
// while dragged or just after), carried to the release by the window's
// capture listener, which runs before the button's own mouseup: it acts
// only if the release is on the button that was pressed.
let armed = null, upArmed = null, dragEndAt = -Infinity;
const dragBusy = () => !!drag || performance.now() - dragEndAt < UI.AFTER_DRAG_MS;
const handSide = (e) => ({ 2: 'left', 3: 'right' })[(e.getModifierState?.('CapsLock') ? 1 : 0) | (e.getModifierState?.('NumLock') ? 2 : 0)];
// SteamVR's scene graph query service (looked up once).
let sgq = null;
const sgQuery = () => {
if (sgq) return sgq;
if (G.SGQueryService?.requestSGTransform) return (sgq = G.SGQueryService);
try {
const req = find.getWebpackRequire('webpackChunkvrwebui');
const mod = find.findModule(req, { includes: ['requestSGTransform', '"sgqueryservice"'] }, 'scene graph query module');
return (sgq = find.findExport(mod.exports, { type: 'class', protoMethods: ['requestSGTransform'] }, 'SGQueryService').value.getInstance());
} catch (e) { log('no SGQueryService', String(e)); return null; }
};
// A promise, rejected after ms (its timer cleared once it settles).
const within = (promise, ms) => {
let timer;
return Promise.race([promise, new Promise((_, no) => { timer = setTimeout(() => no(new Error('timeout')), ms); })])
.finally(() => clearTimeout(timer));
};
// Where vrcompositor has a node of ours: its world pose (standing space).
const where = (id, timeoutMs = 600) => {
const q = sgQuery();
if (!q) return Promise.reject(new Error('no SGQueryService'));
return within(q.requestSGTransform(`${overlayKey}::${id}`, 0).then(toPose), timeoutMs);
};
// How far the thumbstick pushed the carried frame (m, along its -Z): the
// pushed origin in the root's frame, as SteamVR's floating windows ask it.
const pushOf = (timeoutMs = 250) => {
const q = sgQuery();
if (typeof q?.requestSGTransformRelative !== 'function') return Promise.reject(new Error('no SGQueryService'));
return within(q.requestSGTransformRelative(`${overlayKey}::${root.id}`, `${overlayKey}::${PUSHED_ID}`, 0).then((t) => {
const z = -t?.translation?.z;
if (!Number.isFinite(z)) throw new Error('no push');
S.pushSeen = { z: +z.toFixed(4), scale: t.scale ? [t.scale.x, t.scale.y, t.scale.z].map((v) => +(+v).toFixed(4)) : null };
return z;
}), timeoutMs);
};
const startDrag = (side) => {
if (!side || drag || !handle || S.hidden || S.cat?.state().falling) return; // (a falling cat is not caught)
drag = { hand: side, phase: 'drag', w0: null, rel: null, push: 0, want: null, psi0: null };
disarm(); barActive(true);
log('drag', side);
};
const endDrag = () => {
barActive(false);
if (drag?.phase !== 'drag') return;
drag.phase = 'ending'; dragEndAt = performance.now(); log('drag end');
};
// The bar's dragging look ([data-active], as the stock GrabHandle's ForceActive): from the press to the release.
const barActive = (on) => { const el = handle?.barContent; if (on) el?.setAttribute('data-active', ''); else el?.removeAttribute('data-active'); };
const disarm = () => { armed?.removeAttribute('data-armed'); armed = null; };
// A button (X, ⋯, a menu row): press and release on it -> act() (not while dragged or just after).
const button = (el, act) => {
el.addEventListener('mousedown', () => { disarm(); if (!dragBusy()) { armed = el; el.setAttribute('data-armed', ''); } });
el.addEventListener('mouseup', () => { const ok = upArmed === el; upArmed = null; if (ok && !dragBusy()) act(); });
el.addEventListener('mouseleave', () => { if (armed === el) disarm(); });
};
const div = (parent, css, text) => {
const e = document.createElement('div');
if (css) e.setAttribute('class', css);
if (text != null) e.textContent = text;
parent.appendChild(e);
return e;
};
const inside = (t, el) => { for (let n = t; n; n = n.parentNode) if (n === el) return true; return false; };
function makeHandle() {
const app = G.SGApp;
if (typeof app?.addEmbeddedPanelUVs !== 'function') { log('no drag handle: SGApp.addEmbeddedPanelUVs missing'); return null; }
// Page regions for the textures: the bar with the controls centred below
// it (vr-pet:bar), the menu (vr-pet:menu). Claimed from the shared page
// region allocator when there is one (window.__sfuiRegions);
// else free spots of the page (from the bottom right, 4 px apart) clear of
// the page's other panels (SGApp.m_mapPanels' non-external ones and every
// [vsg-type=panel]), the keyboard suggestions strip's band (the bottom
// 120 px, up to 1706 px across; it is only in the page while shown) and
// row 0 (the embedded-data scanline).
const R = G.__sfuiRegions ?? null;
const claimed = [];
const GAP = 4, STRIP = { w: 1706, h: 120 };
const rs = [];
const occupy = (x, y, w, h) => { if (w > 0 && h > 0) rs.push({ left: x, top: y, right: x + w, bottom: y + h }); };
const ours = (name) => String(name ?? '').startsWith('sfui-vr-pet');
const mp = app.m_mapPanels;
for (const fp of (mp?.values ? [...mp.values()] : Object.values(mp ?? {}))) {
if (!fp || fp.isExternal || fp.props?.overlay_key || ours(fp.props?.debug_name)) continue;
let r = null;
try { r = fp.getCurrentRootElement?.()?.getBoundingClientRect?.(); } catch { /* gone */ }
if (r?.width > 0 && r?.height > 0) occupy(r.left ?? r.x, r.top ?? r.y, r.width, r.height);
else if (fp.m_Rect) occupy(fp.m_Rect.x, fp.m_Rect.y, fp.m_Rect.width, fp.m_Rect.height);
}
for (const p of document.querySelectorAll('[vsg-type="panel"]')) {
if (ours(p.id)) continue;
const r = p.getBoundingClientRect();
occupy(r.left, r.top, r.width, r.height);
}
occupy(0, innerHeight - STRIP.h, Math.min(STRIP.w, innerWidth), STRIP.h);
occupy(0, 0, innerWidth, 1);
const freeSpot = (W, H) => {
for (let y = innerHeight - H - GAP; y >= GAP; y -= 8) {
for (let x = innerWidth - W - GAP; x >= GAP; x -= 8) {
if (!rs.some((r) => r.top < y + H + GAP && r.bottom > y - GAP && r.left < x + W + GAP && r.right > x - GAP)) {
occupy(x, y, W, H);
return { x, y };
}
}
}
return null;
};
// -> { x, y } of the region (onMove(spot): the allocator moved it)
const region = (owner, W, H, onMove) => {
if (R) {
const at = (r) => r && { x: r.x ?? r.left, y: r.y ?? r.top };
try {
const r = at(R.claim(owner, W, H, { onMove: (nr) => { const s = at(nr); if (s) onMove(s); } }));
claimed.push(owner);
if (!r) log(`${owner}: no region`);
return r;
} catch (e) { log(`regions.claim ${owner}`, String(e)); }
}
return freeSpot(W, H);
};
const moved = () => { try { G.forceLayoutUpdate(); } catch { /* next layout */ } };
const moveTo = (el, x, y) => { if (el) Object.assign(el.style, { left: `${x}px`, top: `${y}px` }); };
let bar = null, ctl = null, menu = null, menuOpen = false;
const W = Math.max(HANDLE.widthPx, CONTROLS.widthPx), H = HANDLE.heightPx + 4 + CONTROLS.heightPx;
const barSpot = (s) => ({ x: s.x + (W - HANDLE.widthPx) / 2, y: s.y }), ctlSpot = (s) => ({ x: s.x + (W - CONTROLS.widthPx) / 2, y: s.y + HANDLE.heightPx + 4 });
const spot = region('vr-pet:bar', W, H, (s) => {
if (bar) { const b = barSpot(s); moveTo(bar.content, b.x, b.y); }
if (ctl) { const c = ctlSpot(s); moveTo(ctl.content, c.x, c.y); }
log('bar region moved', s.x, s.y); moved();
});
if (!spot) { log('no drag handle: no free spot'); return null; }
const { ids, breaks, px: menuPx } = UI.menuLayout(catalog);
const mspot = region('vr-pet:menu', MENU.widthPx, menuPx, (s) => { if (menu) moveTo(menu.content, s.x, s.y); log('menu region moved', s.x, s.y); moved(); });
if (!mspot) log('no menu: no free spot');
// An embedded panel: `content` (fixed at `rect` of the page) is its
// texture, registered in SGApp's embedded-UV table. With `when`: in the
// scene graph only while when() (its node stays in the page, so its slot
// is measured all along).
const embed = (id, rect, props, when = null) => {
const content = document.createElement('div');
content.id = `${id}-content`;
Object.assign(content.style, { position: 'fixed', left: `${rect.x}px`, top: `${rect.y}px`, width: `${rect.w}px`, height: `${rect.h}px`,
display: 'flex', alignItems: 'center', justifyContent: 'center', boxSizing: 'border-box', zIndex: 1, pointerEvents: 'auto' });
for (const t of ['mousedown', 'mouseup', 'click', 'dblclick', 'pointerdown', 'pointerup', 'contextmenu', 'wheel'])
content.addEventListener(t, (e) => e.stopPropagation()); // nothing reaches the dashboard's handlers
const node = document.createElement('vsg-node');
node.id = id;
node.setAttribute('vsg-type', 'panel');
const sgid = G.VRHTML.NextSGID();
node.setAttribute('sgid', String(sgid));
node.appendChild(content);
const fp = { // what SGApp's embedded-UV table reads of a panel
props: { debug_name: id }, isExternal: false, m_Rect: { x: 0, y: 0, width: 0, height: 0 }, idx: undefined,
getSGID: () => sgid, getEmbeddedIndex: () => fp.idx, getCurrentRootElement: () => node,
updateLayoutValues() { const r = content.getBoundingClientRect(); fp.m_Rect = { x: r.x, y: r.y, width: r.width, height: r.height }; },
};
fp.idx = app.addEmbeddedPanelUVs(fp);
if (fp.idx == null) { log(`${id}: no embedded UV slot`); return null; }
const panel = () => {
const r = content.getBoundingClientRect(), PW = innerWidth, PH = innerHeight;
return { type: 'panel', properties: {
id: `${overlayKey}::${id}`, sgid, key: overlayKey, debug_name: id, origin: [0, -1], visibility: 0, 'lasermouse-filtering': 0,
'can-take-keyboard-focus': false, 'sort-depth-bias': -1, 'scale-index': 0, ...props,
'embedded-uv-index': fp.idx, uv_min: [r.x / PW, r.y / PH], uv_max: [(r.x + r.width) / PW, (r.y + r.height) / PH],
} };
};
node.buildNode = (ctx) => (when && !when() ? [ctx, null] : [{ ...ctx, currentPanel: fp, bInsideReparentedPanel: false }, panel()]);
return { content, node, sgid, panel, dispose() { content.remove(); try { app.removeEmbeddedPanelUVs(fp); } catch { /* gone */ } } };
};
// The grip bar (like SteamVR's window grab handles): the push's event panel.
bar = embed('sfui-vr-pet-handle', { ...barSpot(spot), w: HANDLE.widthPx, h: HANDLE.heightPx },
{ width: HANDLE.widthM, interactive: true, scrollable: true, 'only-visible-with-laser': true, 'is-grab-handle': true, 'hide-laser-when-clicking': true });
if (!bar) { for (const o of claimed) { try { R.release?.(o); } catch { /* gone */ } } return null; }
div(bar.content, 'sfui-vr-pet-grip');
bar.content.addEventListener('mousedown', (e) => startDrag(handSide(e)));
// Every release (capture phase: before the target's own handlers, even
// those that stop it): hand the armed button over to its mouseup, end a drag.
const onUp = () => { upArmed = armed; disarm(); endDrag(); };
G.addEventListener('mouseup', onUp, true);
// The controls row below the bar: ⋯ (the menu) and X (hide). The stock
// window bar's look (legacydashboardframecontrols_*: one #23262E section,
// buttons hover #3D4450, pressed #67707b, .6 opacity until hovered).
ctl = embed('sfui-vr-pet-controls', { ...ctlSpot(spot), w: CONTROLS.widthPx, h: CONTROLS.heightPx },
{ width: CONTROLS.widthM, interactive: true, scrollable: false, 'only-visible-with-laser': true, 'is-grab-handle': false, 'hide-laser-when-clicking': false });
const css = document.createElement('style');
// The grip: the stock GrabHandleBar (grabhandle_*: a 12 px #3d4450 pill at
// 1/1.1 width; hovered or dragged (ForceActive): white at full width, .08 s).
css.textContent = '.sfui-vr-pet-grip{width:84%;height:12px;background-color:var(--dashboard-control-bar-button-color-a,#3d4450);border-radius:20px;'
+ 'transition:transform .08s ease-out,background-color .08s ease-out;transform-origin:center;transform:scaleX(calc(1 / 1.1))}'
+ '#sfui-vr-pet-handle-content:hover .sfui-vr-pet-grip,#sfui-vr-pet-handle-content[data-active] .sfui-vr-pet-grip{background-color:#fff;transform:scaleX(1)}'
+ '#sfui-vr-pet-controls-content{background:#23262E;border-radius:10px;opacity:.6;transition:opacity .4s ease-out;overflow:hidden}'
+ '#sfui-vr-pet-controls-content:hover{opacity:1;transition:opacity 0s}'
+ '.sfui-vr-pet-btn{flex:1;height:100%;display:flex;align-items:center;justify-content:center}'
+ '.sfui-vr-pet-btn:hover{background:#3D4450}.sfui-vr-pet-btn[data-armed]{background:#67707b}'
+ '#sfui-vr-pet-menu-content{display:block;background:#2d3239;border-radius:10px;overflow:hidden;color:#fff;'
+ `font:22px/1.2 "Motiva Sans",Arial,sans-serif;padding:${MENU.padPx}px 0}`
+ '.sfui-vr-pet-row{display:flex;align-items:center;gap:16px;padding:8px 24px 8px 20px;box-sizing:border-box;white-space:nowrap}'
+ '.sfui-vr-pet-row:hover{background:#3d4450}.sfui-vr-pet-row[data-armed]{background:#67707b}'
+ '.sfui-vr-pet-row img{width:32px;height:32px;flex:none}.sfui-vr-pet-name{flex:1}'
+ '.sfui-vr-pet-check{color:#1a9fff;font-weight:bold;visibility:hidden}.sfui-vr-pet-check[data-on]{visibility:visible}'
+ '.sfui-vr-pet-sep{height:1px;margin:4px 0;background:rgba(255,255,255,.07)}';
document.head?.appendChild(css);
const icon = (el, path) => el.insertAdjacentHTML?.('beforeend', `<svg width="60%" height="60%" viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="2.6" stroke-linecap="round">${path}</svg>`);
let more = null, x = null;
if (ctl) {
more = div(ctl.content, 'sfui-vr-pet-btn');
more.setAttribute('data-act', 'menu');
icon(more, '<path d="M5 12h.01M12 12h.01M19 12h.01" stroke-width="3.6"/>');
x = div(ctl.content, 'sfui-vr-pet-btn');
x.setAttribute('data-act', 'close');
icon(x, '<path d="M6 6L18 18M18 6L6 18"/>');
button(x, () => hide());
button(more, () => (menuOpen ? closeMenu() : openMenu()));
}
// The ⋯ menu: the models (thumbnail, name, a check on the current one), then commands.
menu = mspot && ctl && embed('sfui-vr-pet-menu', { x: mspot.x, y: mspot.y, w: MENU.widthPx, h: menuPx },
{ width: MENU.widthM, interactive: true, scrollable: false, 'only-visible-with-laser': true, 'is-grab-handle': false, 'hide-laser-when-clicking': false },
() => menuOpen);
const checks = {};
if (menu) {
menu.content.style.display = 'block'; // rows one below the other (embed() centres a flex row)
menu.content.style.visibility = 'hidden'; // while closed (laid out all along: its UV slot stays valid)
for (const id of ids) {
if (breaks.includes(id)) div(menu.content, 'sfui-vr-pet-sep');
const row = div(menu.content, 'sfui-vr-pet-row');
row.setAttribute('data-model', id);
row.style.height = `${MENU.modelRowPx}px`;
const img = document.createElement('img');
img.setAttribute('src', models[id].thumb ?? '');
row.appendChild(img);
div(row, 'sfui-vr-pet-name', models[id].name);
checks[id] = div(row, 'sfui-vr-pet-check', '✓');
button(row, () => { closeMenu(); setModel({ id }); });
}
div(menu.content, 'sfui-vr-pet-sep');
for (const [cmd, label] of UI.MENU_CMDS) {
const row = div(menu.content, 'sfui-vr-pet-row', label);
row.setAttribute('data-cmd', cmd);
row.style.height = `${MENU.cmdRowPx}px`;
button(row, () => { closeMenu(); S.cat.command(cmd); log('menu', cmd); });
}
}
const markModel = () => { for (const [id, c] of Object.entries(checks)) { if (id === modelId) c.setAttribute('data-on', ''); else c.removeAttribute('data-on'); } };
markModel();
// Closing: a press outside the menu and the controls (capture: every
// press on this page), or the laser away from both for MENU.leaveMs.
let leaveTimer = null;
const stay = () => { clearTimeout(leaveTimer); leaveTimer = null; };
const leave = () => { stay(); if (menuOpen) leaveTimer = setTimeout(() => { leaveTimer = null; closeMenu(); }, MENU.leaveMs); };
for (const c of [ctl?.content, menu?.content].filter(Boolean)) { c.addEventListener('mouseenter', stay); c.addEventListener('mouseleave', leave); }
const onDown = (e) => { if (menuOpen && !inside(e.target, menu?.content) && !inside(e.target, ctl?.content)) closeMenu(); };
G.addEventListener('mousedown', onDown, true);
// lift above the cat > facing the user > the bar; the controls CTL_Y below
// it, the menu (in the scene graph while open) MENU_Y above it and MENU.forwardM nearer
const outer = document.createElement('vsg-transform'), facing = document.createElement('vsg-head-facing-transform');
const osgid = G.VRHTML.NextSGID(), fsgid = G.VRHTML.NextSGID();
let lift = liftOf();
setAttrs(outer, { translation: `0 ${lift} 0`, rotation: '1 0 0 0', scale: '1 1 1', sgid: osgid });
facing.setAttribute('sgid', String(fsgid));
facing.appendChild(bar.node);
const sub = (y, z, node) => { // a transform under `facing` holding one panel
const t = document.createElement('vsg-transform'), sgid = G.VRHTML.NextSGID();
setAttrs(t, { translation: `0 ${y} ${z}`, rotation: '1 0 0 0', scale: '1 1 1', sgid });
t.appendChild(node);
return { t, sg: (panel) => ({ type: 'transform', properties: { sgid, translation: [0, y, z], rotation: [1, 0, 0, 0], scale: [1, 1, 1] }, children: [panel] }) };
};
const ctlH = CONTROLS.widthM * CONTROLS.heightPx / CONTROLS.widthPx;
const CTL_Y = -(CONTROLS.gapM + ctlH); // its bottom (origin [0, -1]): its top gapM below the bar
const MENU_Y = HANDLE.widthM * HANDLE.heightPx / HANDLE.widthPx + MENU.gapM; // its bottom: gapM above the bar's top
const below = ctl && sub(CTL_Y, 0, ctl.node);
if (below) facing.appendChild(below.t);
// The menu's transform stays in the page (its panel is built only while
// open); opening / closing shows / hides its content and sends once.
const above = menu && sub(MENU_Y, MENU.forwardM, menu.node);
if (above) facing.appendChild(above.t);
const openMenu = () => {
if (menuOpen || !above || S.hidden) return;
menuOpen = true; markModel(); stay();
menu.content.style.visibility = '';
update();
log('menu open');
};
const closeMenu = ({ send = true } = {}) => {
stay();
if (!menuOpen) return;
menuOpen = false; disarm();
menu.content.style.visibility = 'hidden';
if (send) update();
log('menu closed');
};
outer.appendChild(facing);
barBody.appendChild(outer);
return {
sgid: bar.sgid, barContent: bar.content,
closeMenu, markModel, isOpen: () => menuOpen, lift: () => lift,
setLift(y) { // -> changed
if (y === lift) return false;
lift = y; outer.setAttribute('translation', `0 ${y} 0`);
return true;
},
sg: () => ({ type: 'transform', properties: { sgid: osgid, translation: [0, lift, 0], rotation: [1, 0, 0, 0], scale: [1, 1, 1] },
children: [{ type: 'head-facing-transform', properties: { sgid: fsgid },
children: [bar.panel(), ...(below ? [below.sg(ctl.panel())] : []), ...(menuOpen ? [above.sg(menu.panel())] : [])] }] }),
dispose() {
stay(); outer.remove(); bar.dispose(); ctl?.dispose(); menu?.dispose(); css.remove();
for (const o of claimed) { try { R.release?.(o); } catch { /* gone */ } }
G.removeEventListener('mouseup', onUp, true); G.removeEventListener('mousedown', onDown, true);
},
};
}
let handle = null;
try { handle = makeHandle(); } catch (e) { log('no drag handle', String(e)); }
// once (when shown): the page writes the panels' UV slots
const layoutHandle = () => { if (handle) { try { G.forceLayoutUpdate(); } catch { /* next layout */ } } };
// ---- world interface ----
const pose = (path) => {
try {
const [p] = G.VRHTML.GetPose(path, 1); // standing space
return p?.bPoseIsValid ? p : null;
} catch { return null; }
};
const toPose = (xf) => ({ p: [xf.translation.x, xf.translation.y, xf.translation.z], q: [xf.rotation.w, xf.rotation.x, xf.rotation.y, xf.rotation.z] });
let handPoses = { left: null, right: null }; // { p, q } per hand, this tick
// The hands for petting and startling: position and velocity only.
const readHands = () => {
handPoses = { left: null, right: null };
const hand = (side) => {
const p = pose(`/user/hand/${side}`);
if (!p) return null;
handPoses[side] = toPose(p.xfDeviceToAbsoluteTracking);
const t = p.xfDeviceToAbsoluteTracking.translation, v = p.vVelocity ?? { x: 0, y: 0, z: 0 };
return { x: t.x, y: t.y, z: t.z, vx: v.x, vy: v.y, vz: v.z };
};
return { left: hand('left'), right: hand('right') };
};
let hands = { left: null, right: null }, held = null; // sampled once per tick (run below); held: the laser drag
let shown = null, lastT = '', lastR = '', dirty = false;
// Send gating: head pose once per tick; the head's turn rate; the cat in view.
const TURN_DEG_S = 60, TURN_DEFER_MS = 250, VIEW_DEFER_MS = 1000;
let headPose = null, headPrev = null, turnRate = 0, lastSend = 0;
const deferred = { turn: 0, view: 0 };
const readHead = (dt) => {
const p = pose('/user/head');
headPrev = headPose;
headPose = p ? toPose(p.xfDeviceToAbsoluteTracking) : null;
if (headPose && headPrev && dt > 0) {
const d = Math.abs(headPose.q.reduce((s, v, i) => s + v * headPrev.q[i], 0));
turnRate = 2 * Math.acos(Math.min(1, d)) * 180 / Math.PI / dt;
} else turnRate = 0;
};
const inView = (at) => !headPose || UI.inView(headPose.p, M.qrot(headPose.q, [0, 0, -1]), at);
const fmt = (v, d) => v.map((x) => +x.toFixed(d)).join(' ');
const heading = (q) => { const f = M.qrot(q, [0, 0, -1]); return Math.hypot(f[0], f[2]) > 0.05 ? Math.atan2(f[0], f[2]) : null; };
// The carried frame F (the root while dragged: parent-path the hand, its
// pose rel = hand⁻¹ · F0, as SteamVR's floating windows): F0 at the bar's
// origin (the grab point) when pressed, its -Z along the line from the
// controller (the push's direction), level. vrcompositor draws it rigidly
// in the controller's frame, so the grab point stays put on the laser.
const lookFrom = (from, to) => {
const d = to.map((v, k) => v - from[k]), l = Math.hypot(...d) || 1;
const pitch = Math.asin(Math.max(-1, Math.min(1, d[1] / l)));
return M.qnorm(M.qmul(M.qyaw(Math.atan2(-d[0], -d[2])), [Math.cos(pitch / 2), Math.sin(pitch / 2), 0, 0]));
};
const carried = (h, d = drag) => M.compose(M.compose(h, d.rel), { p: [0, 0, -d.push], q: [1, 0, 0, 0] });
// The dragged cat's body under F: upright, its orientation at the grab
// turned by the laser hand's heading change since; its root lift straight
// below the grab point (the bar's origin stays at F's). From this tick's
// hand pose (vrcompositor turns F with the controller every frame; this
// undoes it once per tick). -> { q, p (in F), F, want } or null (no pose)
const dragBody = () => {
const h = handPoses[drag.hand];
if (!h) return null;
drag.lastHand = h;
const psi = heading(h.q);
if (psi != null && drag.psi0 == null) drag.psi0 = psi;
if (psi != null) drag.want = M.qmul(M.qyaw(psi - drag.psi0), drag.w0.q);
const want = drag.want ?? drag.w0.q, F = carried(h), c = M.qconj(F.q);
return { q: M.qnorm(M.qmul(c, want)), p: M.qrot(c, [0, -handle.lift(), 0]), F, want };
};
// Back in the world: no parent, the push inactive and reset (next drag from 0).
const endCarry = () => {
root.removeAttribute('parent-path');
const pp = push.sg.properties;
pp['is-active'] = false; pp['reset-generation'] = ++pushGen; delete pp['event-panel-sgid']; delete pp['base-distance'];
setBody([1, 0, 0, 0], [0, 0, 0]);
};
const world = {
prefetch(clips) {
want = all ? new Set([...clips, ...Object.keys(frames)]) : new Set([...clips].filter((c) => frames[c]));
},
ready(clip) {
const m = mounted.get(clip);
return !!m && m.n === frames[clip]?.length && performance.now() - m.last >= SETTLE_MS;
},
head() {
if (!headPose) return null;
const [x, y, z] = headPose.p, f = M.qrot(headPose.q, [0, 0, -1]); // forward (-Z) on the floor plane
const fl = Math.hypot(f[0], f[2]) || 1;
return { x, y, z, fx: f[0] / fl, fz: f[2] / fl };
},
hands: () => hands,
held: () => held,
heldPose() {
if (!drag?.rel) return null;
// where it is drawn: the carried frame (this tick's hand, the last
// push; on release the push asked then), the root lift below it
const h = handPoses[drag.hand] ?? drag.lastHand, F = carried(h);
return { x: F.p[0], y: F.p[1] - handle.lift(), z: F.p[2], q: drag.want ?? drag.w0.q };
},
show(clip, i, at) {
if (S.hidden) return;
const now = performance.now();
const mounts = pump(now) | evict(now);
let changed = dirty || !!mounts;
dirty = false;
const xf = world.ready(clip) ? frames[clip]?.[i] : null; // else: keep the last frame
if (xf && xf !== shown) {
if (shown) setShown(shown, false);
setShown(xf, true);
shown = xf; changed = true;
}
// The handle just above the cat: over the scruff of a dangling cat (the dangle frames hang from it).
if (handle?.setLift(frames.dangle?.includes(shown) ? HANDLE.liftDangle : liftOf())) changed = true;
// World pose; dragged: the root carried by the hand (rel, constant).
const W = { p: [at.x, at.y, at.z], q: at.q ?? M.qyaw(at.yaw) };
let dragChanged = false;
if (drag && !drag.w0) {
drag.w0 = W; dragChanged = true;
const h = handPoses[drag.hand];
if (h) { // the grab point (the bar's origin) rigid in the controller's frame from here
const g = M.compose(W, { p: [0, handle.lift(), 0], q: [1, 0, 0, 0] }).p;
drag.rel = M.compose(M.invert(h), { p: g, q: lookFrom(h.p, g) });
drag.lastHand = h;
root.setAttribute('parent-path', `/user/hand/${drag.hand}`);
Object.assign(push.sg.properties, { 'is-active': true, 'event-panel-sgid': handle.sgid, 'base-distance': +Math.hypot(...drag.rel.p).toFixed(4) });
log('carried', drag.hand, `${Math.hypot(...drag.rel.p).toFixed(3)} m from the controller`);
} else { drag.phase = 'done'; log('drag: no hand pose'); }
} else if (drag?.phase === 'ending') { // ask how far it was pushed; carried until the answer (let go there)
drag.phase = 'asking';
const d = drag;
if (d.rel) queueMicrotask(() => pushOf().then((z) => { d.push = z; }, (e) => log('drag: push?', String(e))).finally(() => { d.phase = 'done'; }));
else d.phase = 'done';
} else if (drag?.phase === 'done' && !held) { // released (the core falls from heldPose()): back in the world, there
if (drag.rel) endCarry();
log('dragged to', fmt(W.p, 3), `push ${drag.push.toFixed(3)}`);
drag = null; dragChanged = true; barActive(false);
}
const carry = drag?.rel;
const place = { pose: carry ? drag.rel : W, changed: dragChanged };
// Carried: the body upright below the grab point, turned with the controller's heading.
if (carry) { const b = dragBody(); if (b && setBody(b.q, b.p)) changed = true; }
const T = fmt(place.pose.p, 4), R = fmt(place.pose.q, 5); // w x y z
if (T !== lastT || R !== lastR) {
root.setAttribute('translation', T); root.setAttribute('rotation', R);
lastT = T; lastR = R; changed = true;
}
if (!root.isConnected) { document.body.appendChild(root); changed = true; }
if (!changed) return;
// Defer (keep it dirty) while the head turns fast or the cat is out of view (not while dragged).
if (!place.changed && !drag) {
if (turnRate > TURN_DEG_S && now - lastSend < TURN_DEFER_MS) { deferred.turn++; dirty = true; return; }
if (!mounts && now - lastSend < VIEW_DEFER_MS && !inView(at)) { deferred.view++; dirty = true; return; }
}
lastSend = now;
update();
},
load: () => { try { return G.__sfuiStore?.get?.(KEY); } catch { return undefined; } },
save: (v) => store(v),
log,
};
// ---- run ----
S.root = root;
S.frames = frames;
S.cat = VrPetCore.create(world, opts.cat, baked);
if (unknownModel) log('unknown stored model', unknownModel, '->', modelId);
S.drag = (side) => { if (side) startDrag(side); else endDrag(); return !!drag; }; // debug
S.where = (id) => where(id); // debug
// While dragged: the push (thumbstick), asked once per tick (heldPose());
// also a trace (debug, 4 Hz): [ms, the grab point as computed (hand · rel ·
// push), as vrcompositor has it (the pushed origin), laser hand pose, push].
S.dragTrace = [];
let traceAt = 0;
const askPush = (now) => {
if (drag?.phase !== 'drag' || !drag.rel || drag.asking) return;
const d = drag, h = handPoses[d.hand], r = (v) => v.map((x) => +x.toFixed(3));
d.asking = true;
const trace = now - traceAt >= 250 && h;
if (trace) traceAt = now;
Promise.all([pushOf().then((z) => { if (drag === d) d.push = z; }, () => {}), trace ? where(PUSHED_ID).catch(() => null) : null]).then(([, w]) => {
if (!trace) return;
S.dragTrace.push([Math.round(now), r(carried(h, d).p), w ? r(w.p) : null, r(h.p), r(h.q), +d.push.toFixed(3)]);
if (S.dragTrace.length > 80) S.dragTrace.shift();
}).finally(() => { d.asking = false; });
};
let last = performance.now();
const run = () => {
const now = performance.now();
try {
readHead((now - last) / 1000);
hands = readHands();
held = drag && drag.phase !== 'done' ? { source: 'laser', hand: drag.hand } : null;
S.cat.tick((now - last) / 1000);
askPush(now);
} catch (e) { log('tick', String(e)); }
last = now;
};
const start = () => { last = performance.now(); S.timer = setInterval(run, 1000 / opts.fps); };
// ---- hide / show (the X; the "+" menu's Pet entry via cli.mjs) ----
// hide(): the cat leaves the scene at once: a drag is let go, the
// core stands where it is (a dragged cat: below where it was carried), every frame
// is unmounted, one scene graph send without it; no timers, no sends until
// shown again. Persisted (hidden: true), so a SteamVR restart keeps it hidden.
// Every frame out of the scene graph (sent with the next update()).
const unmountAll = () => {
for (const [name, m] of mounted) for (let i = 0; i < m.n; i++) frames[name][i].remove();
mounted.clear(); nMounted = 0;
if (shown) { setShown(shown, false); shown = null; }
};
// Let go of a drag and of anything that holds the pet: the core stands it where it is.
const letGo = () => {
if (drag) { if (drag.rel) endCarry(); drag = null; barActive(false); }
disarm(); upArmed = null; held = null;
try { S.cat.settle(); } catch (e) { log('settle', String(e)); }
};
const hide = () => {
if (S.hidden) return { hidden: true, was: 'hidden' };
S.hidden = true;
clearInterval(S.timer); S.timer = null;
letGo();
handle?.closeMenu({ send: false }); // (the send below goes without it)
unmountAll();
want = new Set();
root.remove();
update();
store({ hidden: true });
log('hidden');
return { hidden: true, was: 'shown' };
};
// show({ summon }): back at its spot while that is near and in view, else
// 1 m in front of the user (core reveal(); summon: always). Idempotent: a
// shown cat only gets the same rule (not while held or falling).
const show = ({ summon = false } = {}) => {
const reveal = () => {
const st = S.cat.state(), h = world.head();
const r = VrPetCore.reveal(st, h, { inView: inView({ x: st.x, y: 0, z: st.z }), force: !!summon });
if (r === 'summon' && h) { S.cat.command('summon'); return true; }
return false;
};
if (!S.hidden) {
const st = S.cat.state();
const summoned = !st.held && !st.falling && !drag && reveal();
return { hidden: false, was: 'shown', summoned };
}
S.hidden = false;
held = null; drag = null;
endCarry(); // (also a fresh push)
lastT = ''; lastR = ''; dirty = true;
readHead(0);
const summoned = reveal();
store({ hidden: false });
document.body.appendChild(root);
layoutHandle();
start();
run(); // frames mount gradually from here (LOADS_PER_TICK per tick)
log('shown', summoned ? 'summoned' : 'at its spot');
return { hidden: false, was: 'hidden', summoned };
};
const status = () => {
const st = S.cat.state(), h = world.head();
return { hidden: !!S.hidden, pose: st.pose, activity: st.activity, x: +st.x.toFixed(3), z: +st.z.toFixed(3),
distance: h ? +Math.hypot(st.x - h.x, st.z - h.z).toFixed(2) : null, held: st.held, falling: st.falling,
dragging: !!drag, frames: nMounted, sends, version: VERSION, model: modelId };
};
// ---- models (coats and animals) ----
const listModels = () => ({ current: modelId, default: catalog.default,
models: (catalog.order ?? Object.keys(models)).map((id) => ({ id, name: models[id].name })) });
const setModel = ({ id } = {}) => {
const m = models[id];
if (!m) return { error: `unknown model ${JSON.stringify(id)}`, model: modelId, models: listModels().models.map((x) => x.id) };
const was = modelId;
if (id === was) return { model: id, was, name: m.name };
const other = catalog.frameSets[m.frames] !== baked; // another animal: its own frames and core
modelId = id;
if (!S.hidden) unmountAll(); // the old model out at once (sent below), then the new frames load gradually
if (other) {
letGo();
S.cat.save(); // its spot and heading (and pose, if the new one has it) go over to the new core
baked = catalog.frameSets[m.frames];
RARE = rareOf();
buildFrames(m.dir);
S.frames = frames;
want = new Set();
S.cat = VrPetCore.create(world, opts.cat, baked);
} else {
for (const fr of Object.values(frames)) for (const xf of fr) xf.rm.properties.source = `${m.dir}/${xf.file}`;
}
store({ model: id });
handle?.markModel();
log('model', id, other ? `(frames ${m.frames})` : '');
if (S.hidden) return { model: id, was, name: m.name, hidden: true };
update();
dirty = true;
return { model: id, was, name: m.name };
};
// Stable API for cli.mjs (bump API_VERSION on incompatible changes).
const API_VERSION = 2;
S.api = { version: API_VERSION, show, hide, status, models: listModels, setModel };
S.pause = () => { clearInterval(S.timer); S.timer = null; }; // debug
S.resume = () => { if (!S.timer && !S.hidden) start(); };
S.dispose = () => {
clearInterval(S.timer); root.remove(); handle?.dispose();
if (proto && hooks) hooks.remove(proto, 'SendMessage', 'vr-pet');
if (cap.sock?.send?.__sfuiPetOrig) cap.sock.send = cap.sock.send.__sfuiPetOrig;
delete S.onSceneGraph;
slowSend ? slowSend() : G.forceLayoutUpdate();
};
S.stats = () => ({ sender, sends, fast, deferred: { ...deferred }, captured: !!cap.str, hookCalls: cap.seen, wrapped: !!cap.sock?.send?.__sfuiPetOrig,
mounted: [...mounted].map(([k, m]) => (m.n === frames[k].length ? k : `${k} ${m.n}/${frames[k].length}`)), frames: nMounted, loads, evictions,
turnRate: Math.round(turnRate), rootSent: cap.node && { ...cap.node.properties },
handle: !!handle, menuOpen: !!handle?.isOpen(), drag: drag && { hand: drag.hand, phase: drag.phase, push: drag.push },
carry: root.getAttribute('parent-path'), pushSent: { ...push.sg.properties }, pushSeen: S.pushSeen ?? null, hidden: !!S.hidden });
// Hidden (stored): everything is built, nothing is in the page, no timers, no sends.
if (startHidden) { S.hidden = true; return 'hidden'; }
document.body.appendChild(root);
layoutHandle();
start();
readHead(0);
S.cat.tick(0);
return `patched (${modelId}, ${Object.values(baked.clips).reduce((s, c) => s + c.frames, 0)} frames, mount ${opts.mount}, via ${sender})`;
}