SOURCE / PINNED RELEASE
Made of little things.
Soybert 3D
- Release
- 4bf80e758af1…
- Author-recorded commit
- 298fa2d0e8b1…
- License
- LICENSE
- Author’s source reference
- nostr://npub1n8ga89w8h6tvwamxusfyzexw8gjy84yxu9rxgnmk955cxtml4ujswzxydd/wss%3A%2F%2Fgit.napplet.soy%2F/n-9021e1b6a96
Archive hash verified: 7083223a53bbd320…. The source-to-build association is the author’s claim; it has not been independently rebuilt.
import { config, themeGet, themeOnChanged, type Subscription, type Theme } from '@napplet/sdk';
import { runtimeHasDomain } from './domain-availability.js';
import { subscribeSettings } from './napplet-settings.js';
import { Stage } from './stage.js';
import { Sound } from './audio.js';
import { celebrate, jump, shoot } from './simulation.js';
import './styles.css';
const get = <T extends HTMLElement>(id: string) => document.getElementById(id) as T;
const app = get<HTMLElement>('app');
const canvas = get<HTMLCanvasElement>('stage');
const status = get<HTMLOutputElement>('status');
const enter = get<HTMLButtonElement>('enter');
const pauseOverlay = get<HTMLElement>('pause');
const help = get<HTMLElement>('help');
const soundButton = get<HTMLButtonElement>('sound');
const motionButton = get<HTMLButtonElement>('motion');
const sound = new Sound();
const events = new AbortController();
const options = { signal: events.signal };
let stage: Stage;
let themeSubscription: Subscription | undefined;
let settingsClose: (() => void) | undefined;
let frame = 0, previous = 0, statusTimer = 0;
let disposed = false;
const pressed = new Set<string>();
let stickX = 0, stickY = 0;
const stick = get<HTMLElement>('stick');
let stickPointer: number | undefined;
let orbitPointer: number | undefined;
let lastX = 0, lastY = 0;
function message(text: string) {
status.textContent = text; window.clearTimeout(statusTimer);
if (text) statusTimer = window.setTimeout(() => { status.textContent = ''; }, 2000);
}
function clearInput() {
pressed.clear(); stickX = 0; stickY = 0; stickPointer = undefined; orbitPointer = undefined;
stick.style.transform = '';
}
function pause() {
clearInput();
if (stage?.mode !== 'stage' || stage.paused) return;
stage.paused = true; pauseOverlay.hidden = false; app.dataset.paused = 'true';
}
function resume() {
if (!stage) return;
stage.paused = false; pauseOverlay.hidden = true; app.dataset.paused = 'false'; previous = performance.now();
app.focus({ preventScroll: true }); sound.unlock();
}
function reflectToggles() {
soundButton.setAttribute('aria-pressed', String(sound.enabled));
soundButton.title = sound.enabled ? 'Mute sound' : 'Enable sound';
motionButton.setAttribute('aria-pressed', String(stage.reducedMotion));
motionButton.title = stage.reducedMotion ? 'Enable automatic motion' : 'Reduce automatic motion';
}
function action(kind: 'jump' | 'shoot' | 'celebrate') {
if (stage.mode !== 'stage' || stage.paused) return;
sound.unlock();
const ok = kind === 'jump' ? jump(stage.actor) : kind === 'shoot' ? shoot(stage.actor) : celebrate(stage.actor);
if (!ok) return;
if (kind === 'shoot') stage.fire();
if (kind === 'celebrate') stage.celebrate();
sound.play(kind); app.dataset.action = kind;
message(kind === 'jump' ? 'Jump!' : kind === 'shoot' ? 'Pump. Ready.' : 'A little victory dance.');
}
function applyTheme(theme: Theme) {
const root = document.documentElement.style;
root.setProperty('--bg', theme.colors.background); root.setProperty('--fg', theme.colors.text); root.setProperty('--primary', theme.colors.primary);
stage?.theme(theme.colors.background, theme.colors.primary);
}
try {
stage = new Stage(canvas);
stage.reducedMotion = matchMedia('(prefers-reduced-motion: reduce)').matches;
app.dataset.ready = 'true';
reflectToggles(); enter.disabled = false; status.textContent = '';
const observer = new ResizeObserver(entries => { const r = entries[0].contentRect; stage.resize(r.width, r.height); });
observer.observe(app);
if (runtimeHasDomain('theme')) {
void themeGet().then(theme => { if (!disposed) applyTheme(theme); }).catch(() => undefined);
try { themeSubscription = themeOnChanged(applyTheme); } catch { /* Keep fallback palette. */ }
}
settingsClose = subscribeSettings(values => {
sound.enabled = values.sound; stage.reducedMotion = values.reducedMotion; stage.movementSpeed = values.movementSpeed; reflectToggles();
});
const settingsButton = get<HTMLButtonElement>('settings');
settingsButton.hidden = !runtimeHasDomain('config');
settingsButton.addEventListener('click', () => { try { config.openSettings(); } catch { message('Settings are unavailable in this host.'); } }, options);
enter.addEventListener('click', () => {
stage.enter(); app.dataset.mode = 'stage'; get('mode-label').textContent = 'Free roam'; resume(); message('WASD to walk · Space to jump');
}, options);
get('return').addEventListener('click', () => {
clearInput(); stage.intro(); app.dataset.mode = 'intro'; get('mode-label').textContent = 'Turntable'; pauseOverlay.hidden = true; help.hidden = true; status.textContent = '';
enter.focus({ preventScroll: true });
}, options);
get('reset').addEventListener('click', () => { clearInput(); stage.reset(); resume(); message('Back to center.'); }, options);
get('resume').addEventListener('click', resume, options);
soundButton.addEventListener('click', () => { sound.enabled = !sound.enabled; sound.unlock(); reflectToggles(); }, options);
motionButton.addEventListener('click', () => { stage.reducedMotion = !stage.reducedMotion; reflectToggles(); }, options);
get('help-toggle').addEventListener('click', () => { help.hidden = !help.hidden; get('help-toggle').setAttribute('aria-expanded', String(!help.hidden)); }, options);
for (const kind of ['jump', 'shoot', 'celebrate'] as const) get(kind).addEventListener('click', () => action(kind), options);
const movementKeys = new Set(['KeyW', 'KeyA', 'KeyS', 'KeyD', 'ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight']);
// Focus-local game input only. No host shortcuts or document-wide key interception.
app.addEventListener('keydown', event => {
if (event.code === 'Escape') { if (stage.paused) resume(); else pause(); return; }
if (stage.mode !== 'stage' || stage.paused) return;
if (movementKeys.has(event.code)) { event.preventDefault(); pressed.add(event.code); }
if (event.repeat) return;
if (event.code === 'Space') { event.preventDefault(); action('jump'); }
if (event.code === 'KeyF') { event.preventDefault(); action('shoot'); }
if (event.code === 'KeyC') { event.preventDefault(); action('celebrate'); }
if (event.code === 'KeyR') { clearInput(); stage.reset(); message('Back to center.'); }
}, options);
app.addEventListener('keyup', event => { pressed.delete(event.code); }, options);
window.addEventListener('blur', pause, options);
document.addEventListener('visibilitychange', () => { if (document.hidden) pause(); }, options);
app.addEventListener('focusout', event => { if (!event.relatedTarget || !app.contains(event.relatedTarget as Node)) pause(); }, options);
canvas.addEventListener('pointerdown', event => {
if (stage.paused || orbitPointer !== undefined) return;
app.focus({ preventScroll: true }); orbitPointer = event.pointerId; lastX = event.clientX; lastY = event.clientY; canvas.setPointerCapture(event.pointerId);
}, options);
canvas.addEventListener('pointermove', event => {
if (event.pointerId !== orbitPointer) return;
stage.yaw -= (event.clientX - lastX) * .007;
stage.pitch = Math.max(.10, Math.min(1.08, stage.pitch + (event.clientY - lastY) * .005));
lastX = event.clientX; lastY = event.clientY;
}, options);
const releaseOrbit = () => { orbitPointer = undefined; };
canvas.addEventListener('pointerup', releaseOrbit, options); canvas.addEventListener('pointercancel', releaseOrbit, options); canvas.addEventListener('lostpointercapture', releaseOrbit, options);
canvas.addEventListener('wheel', event => { event.preventDefault(); stage.zoom = Math.max(.65, Math.min(1.65, stage.zoom + event.deltaY * .0007)); }, { ...options, passive: false });
const joystick = get('joystick');
function moveStick(event: PointerEvent) {
if (event.pointerId !== stickPointer) return;
const rect = joystick.getBoundingClientRect(); const radius = rect.width * .32;
const dx = event.clientX - rect.left - rect.width / 2, dy = event.clientY - rect.top - rect.height / 2;
const length = Math.hypot(dx, dy), scale = length > radius ? radius / length : 1;
stickX = dx * scale / radius; stickY = dy * scale / radius;
stick.style.transform = `translate(${dx * scale}px, ${dy * scale}px)`;
}
joystick.addEventListener('pointerdown', event => {
if (stage.paused || stickPointer !== undefined) return;
event.preventDefault(); app.focus({ preventScroll: true }); stickPointer = event.pointerId; joystick.setPointerCapture(event.pointerId); moveStick(event);
}, options);
joystick.addEventListener('pointermove', moveStick, options);
const releaseStick = (event: PointerEvent) => { if (event.pointerId !== stickPointer) return; stickPointer = undefined; stickX = 0; stickY = 0; stick.style.transform = ''; };
joystick.addEventListener('pointerup', releaseStick, options); joystick.addEventListener('pointercancel', releaseStick, options); joystick.addEventListener('lostpointercapture', releaseStick, options);
canvas.addEventListener('webglcontextlost', event => { event.preventDefault(); pause(); message('Graphics paused. Waiting for the browser…'); }, options);
canvas.addEventListener('webglcontextrestored', () => { message('Graphics restored.'); }, options);
function tick(now: number) {
const dt = previous ? Math.min(.05, (now - previous) / 1000) : 1 / 60; previous = now;
const x = stickX + Number(pressed.has('KeyD') || pressed.has('ArrowRight')) - Number(pressed.has('KeyA') || pressed.has('ArrowLeft'));
const z = stickY + Number(pressed.has('KeyS') || pressed.has('ArrowDown')) - Number(pressed.has('KeyW') || pressed.has('ArrowUp'));
if (stage.update(dt, x, z)) sound.play('land');
app.dataset.airborne = String(stage.actor.y > .001);
app.dataset.moving = String(Math.hypot(stage.actor.vx, stage.actor.vz) > .1);
frame = requestAnimationFrame(tick);
}
frame = requestAnimationFrame(tick);
window.addEventListener('pagehide', () => {
disposed = true; cancelAnimationFrame(frame); window.clearTimeout(statusTimer); observer.disconnect(); events.abort(); settingsClose?.(); themeSubscription?.close(); sound.dispose(); stage.dispose();
}, { once: true });
} catch (error) {
console.error('Unable to initialize Soybert:', error); get('error').hidden = false; status.textContent = ''; enter.disabled = true;
}
