zen: UB is now the Queen angelfish (real model) + fix admin lockout

- Admin lockout: /zen checked blockedForViewer() before auth loaded, so a hard-refresh/
  direct-link bounced admins to /play. Now revalidate auth (await refresh if !ready)
  BEFORE the gate check.
- UB swap: retired the two-tail koi (ub.glb/ub-split.glb) for the vetted Queen angelfish.
  Trimmed the 75.67s baked Take down to just the Idle loop (tools/glb-split/trim-idle.mjs
  → 16MB → 6.9MB) → static/models/ub-angelfish.glb. aquarium.js reworked for the pack's
  ONE-mesh/TWO-material layout (…_body opaque single-sided; …_fins opaque alpha-tested,
  tunable); animation is the trimmed Idle. Debug tuner (/zen?debug=1) updated: yaw/pitch/
  scale + one fins&tail section. Still devgate IN_DEV={'zen'} — admin-only.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
jay
2026-07-01 18:10:38 -04:00
parent e64c5ced3c
commit ce69b8cd18
11 changed files with 1075 additions and 56 deletions
+30 -34
View File
@@ -16,22 +16,20 @@
import * as THREE from 'three';
import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
const MODEL_URL = '/models/ub-split.glb';
const MODEL_URL = '/models/ub-angelfish.glb';
// Converged Phase-A starting point (Claude + Codex): a hair off strict profile so
// the mouth/chin reads naturally; tail OPAQUE alpha-tested (one coherent tail, no
// blend bleed); fins translucent + single-sided (kills the double-sided ghosting
// that read as an off-center dorsal). All live-tunable at /zen?debug=1.
// UB is now a marine angelfish (Queen): ONE mesh, TWO materials (…_body, …_fins — fins
// covers the caudal tail too), and a single trimmed Idle loop. Body renders OPAQUE +
// single-sided; fins default to OPAQUE alpha-tested (clean silhouette, no blend bleed —
// the koi's lesson) but can go translucent. All live-tunable at /zen?debug=1.
export const DEFAULTS = {
yaw: Math.PI / 2 + 0.10, // ub.rotation.y — slight 3/4 view
yaw: Math.PI / 2, // ub.rotation.y — side-on-ish (tune live; head faces ±Z)
pitch: 0, // ub.rotation.x — nose up/down
tailTranslucent: false, // false = opaque alpha-tested (coherent); true = blended
tailSide: 'front', // front | back | double
tailAlphaTest: 0.035,
tailOpacity: 1.0, // only when translucent
finSide: 'front', // front | back | double
finOpacity: 0.75,
finAlphaTest: 0.02,
scale: 1, // multiplier on the auto-fit size
finTranslucent: false, // false = opaque alpha-tested (coherent); true = blended
finSide: 'double', // front | back | double (fins are thin → double reads fuller)
finOpacity: 0.9, // only when translucent
finAlphaTest: 0.5, // clip the fin-edge alpha
paused: false,
frame: 0, // 0..1 scrub position when paused
};
@@ -76,43 +74,41 @@ export async function createAquarium(canvas, initial = {}) {
const center = box.getCenter(new THREE.Vector3());
ub.position.sub(center);
const maxDim = Math.max(size.x, size.y, size.z) || 1;
ub.scale.setScalar(2.6 / maxDim);
const baseScale = 2.6 / maxDim; // auto-fit; params.scale fine-tunes it live
// Collect the three named materials/meshes so applyMaterials can retarget them
// live without re-traversing semantics.
// Classify the two meshes by material-name substring (…_body / …_fins) so
// applyMaterials can retarget them live regardless of the species prefix.
const part = {};
ub.traverse((o) => { if (o.isMesh && o.material?.name) part[o.material.name] = o; });
ub.traverse((o) => {
if (!o.isMesh || !o.material?.name) return;
const n = o.material.name.toLowerCase();
if (n.includes('body')) part.body = o; else if (n.includes('fin')) part.fins = o;
});
function applyMaterials() {
const body = part.UB_Body, fins = part.UB_Fins, tail = part.UB_Tail;
if (body) {
const body = part.body, fins = part.fins;
if (body) { // body: always OPAQUE, single-sided
const m = body.material;
m.transparent = false; m.opacity = 1; m.alphaTest = 0;
m.depthWrite = true; m.depthTest = true; m.side = THREE.FrontSide;
body.renderOrder = 1; m.needsUpdate = true;
}
if (fins) {
if (fins) { // fins (+ tail): opaque alpha-tested by default
const m = fins.material;
m.transparent = true; m.opacity = params.finOpacity; m.alphaTest = params.finAlphaTest;
m.alphaToCoverage = true; m.depthWrite = false; m.depthTest = true;
m.side = SIDE[params.finSide] ?? THREE.FrontSide;
fins.renderOrder = 3; m.needsUpdate = true;
}
if (tail) {
const m = tail.material;
m.alphaTest = params.tailAlphaTest; m.alphaToCoverage = true; m.depthTest = true;
m.side = SIDE[params.tailSide] ?? THREE.FrontSide;
if (params.tailTranslucent) {
m.transparent = true; m.opacity = params.tailOpacity; m.depthWrite = false;
m.alphaTest = params.finAlphaTest; m.alphaToCoverage = true; m.depthTest = true;
m.side = SIDE[params.finSide] ?? THREE.DoubleSide;
if (params.finTranslucent) {
m.transparent = true; m.opacity = params.finOpacity; m.depthWrite = false;
} else {
m.transparent = false; m.opacity = 1; m.depthWrite = true; // opaque, coherent
m.transparent = false; m.opacity = 1; m.depthWrite = true; // clean, coherent
}
tail.renderOrder = 2; m.needsUpdate = true;
fins.renderOrder = 2; m.needsUpdate = true;
}
}
function applyTransform() {
ub.rotation.set(params.pitch, params.yaw, 0);
ub.scale.setScalar(baseScale * (params.scale ?? 1));
}
applyMaterials();
@@ -124,7 +120,7 @@ export async function createAquarium(canvas, initial = {}) {
const mixer = new THREE.AnimationMixer(ub);
const actions = {};
for (const clip of gltf.animations) actions[clip.name] = mixer.clipAction(clip);
const baseClip = (actions.Idle_swim ? actions.Idle_swim : mixer.clipAction(gltf.animations[0]));
const baseClip = mixer.clipAction(gltf.animations[0]); // the trimmed Idle loop
baseClip.play();
const baseDuration = baseClip.getClip().duration || 1;
mixer.timeScale = reduced ? 0.6 : 1; // calmer when reduced-motion
+18 -20
View File
@@ -2,7 +2,7 @@
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import { auth } from '$lib/auth.svelte.js';
import { auth, refresh as refreshAuth } from '$lib/auth.svelte.js';
import { isDevGated, blockedForViewer } from '$lib/devgate.js';
import Footer from '$lib/components/Footer.svelte';
@@ -25,12 +25,17 @@
}
onMount(() => {
// Dev-gated while UB is being ironed out: non-admins (no preview token) bounce.
if (blockedForViewer('zen', auth.user, $page.url)) { goto('/play'); return; }
debug = $page.url.searchParams.get('debug') === '1';
let h;
let cancelled = false; // guard the async load against an early unmount
(async () => {
// Don't decide the gate until we actually KNOW who's here. A cold load / hard
// refresh / direct link mounts before auth has revalidated, so auth.user is still
// null and an admin would be wrongly bounced. Revalidate first, THEN gate.
if (!auth.ready) { try { await refreshAuth(); } catch { /* offline → treat as gated */ } }
if (cancelled) return;
// Dev-gated while UB is being ironed out: non-admins (no preview token) bounce.
if (blockedForViewer('zen', auth.user, $page.url)) { goto('/play'); return; }
debug = $page.url.searchParams.get('debug') === '1';
try {
// WebGL guard — fall back to a warm card rather than a blank canvas.
const t = document.createElement('canvas');
@@ -88,27 +93,20 @@
<input type="range" min="-3.15" max="3.15" step="0.01" bind:value={dbg.yaw} oninput={apply} /></label>
<label>pitch <span>{dbg.pitch.toFixed(2)}</span>
<input type="range" min="-0.6" max="0.6" step="0.01" bind:value={dbg.pitch} oninput={apply} /></label>
<label>scale <span>{dbg.scale.toFixed(2)}</span>
<input type="range" min="0.3" max="2.5" step="0.05" bind:value={dbg.scale} oninput={apply} /></label>
<hr />
<div class="ph">Tail</div>
<label class="chk"><input type="checkbox" bind:checked={dbg.tailTranslucent} onchange={apply} /> translucent (off = opaque, coherent)</label>
<label>side
<select bind:value={dbg.tailSide} onchange={apply}><option>front</option><option>back</option><option>double</option></select></label>
<label>alphaTest <span>{dbg.tailAlphaTest.toFixed(3)}</span>
<input type="range" min="0" max="0.2" step="0.005" bind:value={dbg.tailAlphaTest} oninput={apply} /></label>
{#if dbg.tailTranslucent}
<label>opacity <span>{dbg.tailOpacity.toFixed(2)}</span>
<input type="range" min="0" max="1" step="0.05" bind:value={dbg.tailOpacity} oninput={apply} /></label>
{/if}
<hr />
<div class="ph">Fins</div>
<div class="ph">Fins &amp; tail</div>
<label class="chk"><input type="checkbox" bind:checked={dbg.finTranslucent} onchange={apply} /> translucent (off = opaque, coherent)</label>
<label>side
<select bind:value={dbg.finSide} onchange={apply}><option>front</option><option>back</option><option>double</option></select></label>
<label>opacity <span>{dbg.finOpacity.toFixed(2)}</span>
<input type="range" min="0" max="1" step="0.05" bind:value={dbg.finOpacity} oninput={apply} /></label>
<label>alphaTest <span>{dbg.finAlphaTest.toFixed(3)}</span>
<input type="range" min="0" max="0.2" step="0.005" bind:value={dbg.finAlphaTest} oninput={apply} /></label>
<input type="range" min="0" max="0.9" step="0.01" bind:value={dbg.finAlphaTest} oninput={apply} /></label>
{#if dbg.finTranslucent}
<label>opacity <span>{dbg.finOpacity.toFixed(2)}</span>
<input type="range" min="0" max="1" step="0.05" bind:value={dbg.finOpacity} oninput={apply} /></label>
{/if}
<hr />
<label class="chk"><input type="checkbox" bind:checked={dbg.paused} onchange={apply} /> freeze frame</label>
Binary file not shown.
Binary file not shown.
Binary file not shown.