Back to Soybert 3D
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.

src/main.ts
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;
}