Back to Outbreak: Soycoon City
SOURCE / PINNED RELEASE

Made of little things.

Outbreak: Soycoon City

Release
db7750b1d009…
Author-recorded commit
3ee3f5fd27a6…
License
LICENSE
Author’s source reference
nostr://npub182jczunncwe0jn6frpqwq3e0qjws7yqqnc3auccqv9nte2dnd63scjm4rf/wss%3A%2F%2Fgit.napplet.soy%2F/n-b87c04571a9

Archive hash verified: dbebdc772b142a1c…. The source-to-build association is the author’s claim; it has not been independently rebuilt.

src/main.ts
import { renderWidth } from './napplet-settings.js';
import { storage } from '@napplet/sdk';
import { runtimeHasDomain } from './domain-availability.js';
import { createStage, DEFAULT_RULES, parseStage, type Stage } from './world.js';
import { Game } from './game.js';
import { StageRenderer } from './renderer.js';
import { Editor } from './editor.js';
import { Input } from './input.js';
import { exportFile, importFile } from './files.js';
import mascot from './soy-mascot.png';
import './styles.css';
import { bundledImages } from './assets.js';
import { LevelFiles } from './level-files.js';
import { hostLevelFiles } from './sharing-host.js';
import { LevelsView } from './levels-view.js';
import { GameController, mergeControls } from './controller.js';
import { connections, wallPoint } from './connections.js';
import { GameAudio } from './audio.js';
import { audioClips } from './audio-assets.js';
const el = <T extends HTMLElement>(id: string): T => document.getElementById(id) as T;
// The game and evidence-terminal interface retain their authored PS1 palette.
const FOLLOW_HOST_THEME = false;
void FOLLOW_HOST_THEME;
let stage: Stage = createStage(), game = new Game(stage), mode: 'play' | 'editor' | 'levels' = 'play', dirty = false, needsGameReset = false;
let statusTimer = 0;
function notify(message: string): void { el('status').textContent = message; el('status').hidden = false; window.clearTimeout(statusTimer); statusTimer = window.setTimeout(() => el('status').hidden = true, 6500); }
const audio = new GameAudio(audioClips, soundStatus);
function soundStatus(): void { const button=el<HTMLButtonElement>('soundButton');button.textContent=audio.label;button.title=audio.detail;button.setAttribute('aria-pressed',String(audio.enabled));el('audioStatus').textContent=audio.detail; }
el('soundButton').addEventListener('click',()=>{audio.toggle();soundStatus();});soundStatus();
let renderer: StageRenderer;
try { renderer = new StageRenderer(el('scene')); } catch (e) { el('scene').textContent = 'WebGL 2 is needed for this demo. Try a browser with hardware acceleration enabled.'; throw e; }
const editor = new Editor(stage, renderer, () => { dirty = true; needsGameReset = true; }, notify);
const input = new Input(action);
const controller = new GameController();
const fileHost = hostLevelFiles(), levelFiles = new LevelFiles(bundledImages, fileHost);
levelFiles.bind(stage, renderer.art);
const library = new LevelsView(levelFiles, { stage:()=>stage, dirty:()=>dirty, open:(next,edit)=>{replaceStage(next);setMode(edit?'editor':'play');if(!edit)start();} });
const ruleFields = { ruleHealth:'playerHealth', ruleReserve:'reserve', ruleZombieHealth:'zombieHealth', ruleZombieSpeed:'zombieSpeed', ruleBite:'biteDamage' } as const;
function levelFields():void { el<HTMLInputElement>('stageName').value=stage.name;for(const [id,key] of Object.entries(ruleFields))el<HTMLInputElement>(id).value=String(stage.rules?.[key]??DEFAULT_RULES[key]); }
for(const id of ['stageName',...Object.keys(ruleFields)])el(id).addEventListener('change',()=>{
  try {const next=structuredClone(stage);next.name=el<HTMLInputElement>('stageName').value.trim();next.rules={...DEFAULT_RULES};for(const [id,key] of Object.entries(ruleFields))next.rules[key]=Number(el<HTMLInputElement>(id).value);parseStage(JSON.stringify(next));stage.name=next.name;stage.rules=next.rules;dirty=true;needsGameReset=true;}catch(error){notify(error instanceof Error?error.message:String(error));levelFields();}
});
levelFields();
const stageSizer = new ResizeObserver(entries => {
  const bounds = entries[0].contentRect;
  const width = Math.min(bounds.width, bounds.height * 4 / 3);
  el('gameStage').style.width = `${width}px`;
  el('gameStage').style.height = `${width * .75}px`;
});
stageSizer.observe(document.querySelector('.stage-surround')!);
el<HTMLImageElement>('mascotPortrait').src = mascot;
let hudKey = '';
function action(a: string): void {
  if (mode !== 'play') return;
  if (a === 'controllerLost') { game.paused=true;audio.stop();input.clear();controller.reset();return; }
  if (!el('helpPanel').hidden) { if(a==='pause'){el('helpPanel').hidden=true;input.enabled=true;controller.reset();}return; }
  if (a === 'shoot') { audio.activate();game.shoot(Boolean(mergeControls(input.controls,controller.controls).aimUp)); }
  if (a === 'reload') { audio.activate();game.reload(); }
  if (a === 'interact') game.interact();
  if (a === 'pause') { if(game.paused||game.won||game.player.hp<=0)start();else game.paused=true; input.clear();controller.reset(); }
  if(game.paused)audio.stop();
}
function setMode(next: 'play' | 'editor' | 'levels'): void {
  mode = next; input.clear(); controller.reset(); input.enabled = next === 'play';
  if(next!=='play')audio.stop();
  el('playView').hidden = next !== 'play'; el('editorView').hidden = next !== 'editor'; el('levelsView').hidden = next !== 'levels';
  el('playTab').classList.toggle('active', next === 'play'); el('editorTab').classList.toggle('active', next === 'editor'); el('levelsTab').classList.toggle('active',next==='levels');
  if (next === 'editor') { game.paused = true; el('cameraPreview').append(renderer.renderer.domElement); editor.dirty = true; editor.draw(); }
  else if(next==='play') { el('scene').append(renderer.renderer.domElement); renderer.backgroundKey = ''; renderer.actors.visible = true; hudKey = ''; }
  else { game.paused=true; library.enter(); }
}
function replaceStage(next: Stage): void {
  audio.stop();
  editor.cancelDrag();editor.cancelPolygon(); stage = next; editor.stage = next; editor.room = next.rooms[0]; editor.camera = editor.room.cameras[0]; editor.selected = null; editor.dirty = true; editor.graph.reset(); editor.refresh();
  levelFiles.bind(next, renderer.art); renderer.clearImages();levelFields();
  // JSON imports and host drafts can carry remote links from a shared level.
  // Keep geometry playable while optional resource loading verifies its art.
  if(next.assets?.some(a=>a.url))void levelFiles.resolve(next).then(()=>{
    if(stage===next){levelFiles.bind(next,renderer.art);renderer.clearImages();editor.dirty=true;}
  }).catch(error=>{if(stage===next)notify(`Map loaded; linked art unavailable: ${error instanceof Error?error.message:String(error)}`);});
  renderer.clearEnemies(); game = new Game(next); dirty = false; needsGameReset = false; hudKey = '';
}
function start(): void { audio.activate();if (game.won || game.player.hp <= 0) { audio.stop();renderer.clearEnemies(); game = new Game(stage); } game.paused = false; hudKey = ''; }
el('playTab').addEventListener('click', () => {
  if (needsGameReset) {
    try { const validated = parseStage(JSON.stringify(stage)); const unsaved = dirty; replaceStage(validated); dirty = unsaved; }
    catch (error) { notify(error instanceof Error ? error.message : String(error)); return; }
  }
  setMode('play');
});
el('editorTab').addEventListener('click', () => setMode('editor'));
el('levelsTab').addEventListener('click',()=>setMode('levels'));
el('startButton').addEventListener('click', start); el('pauseButton').addEventListener('click', () => action('pause'));
el('testMap').addEventListener('click', () => { try { const validated = parseStage(JSON.stringify(stage)); const unsaved = dirty; replaceStage(validated); dirty = unsaved; setMode('play'); start(); } catch (e) { notify(e instanceof Error ? e.message : String(e)); } });
document.querySelectorAll<HTMLButtonElement>('[data-action]').forEach(b => {
  b.addEventListener('pointerdown', e => { if(e.pointerType==='touch'||e.pointerType==='pen'){e.preventDefault();b.setPointerCapture(e.pointerId);action(b.dataset.action!);} });
  b.addEventListener('click', e => { if(e instanceof PointerEvent&&(e.pointerType==='touch'||e.pointerType==='pen'))return;action(b.dataset.action!); });
});
el('helpButton').addEventListener('click', () => { game.paused = true;audio.stop(); input.clear(); input.enabled = false; el('helpPanel').hidden = false; }); el('closeHelp').addEventListener('click', () => { el('helpPanel').hidden = true; input.enabled = mode === 'play'; });
window.addEventListener('blur', () => { game.paused = true;audio.stop(); }); document.addEventListener('visibilitychange', () => { if (document.hidden) {game.paused = true;audio.stop();} });
function task(fn: () => Promise<void>): void { void (async () => { try { await fn(); } catch (e) { notify(e instanceof Error ? e.message : String(e)); } })(); }
el('saveMap').addEventListener('click', () => task(async () => {
  if (!runtimeHasDomain('storage')) throw new Error('Storage is unavailable. Use Export map or copy the map JSON.');
  parseStage(JSON.stringify(stage));
  const draft = structuredClone(stage); let images = false;
  for (const r of draft.rooms) for (const c of r.cameras) { if (c.backdrop) images = true; delete c.backdrop; delete c.artSignature; }
  const revision = JSON.stringify(stage);
  await storage.setItem('soyindent-stage-v1', JSON.stringify(draft)); if (revision === JSON.stringify(stage)) dirty = false;
  notify(images ? 'Geometry and prompts saved. Export map also preserves custom background images.' : 'Draft saved in this host. Use Load draft to reopen it.');
}));
el('loadMap').addEventListener('click', () => task(async () => {
  if (!runtimeHasDomain('storage')) throw new Error('Storage is unavailable. Import a map or paste JSON.');
  if (dirty) throw new Error('Save or export your current edits before loading another draft.');
  const raw = await storage.getItem('soyindent-stage-v1'); if (!raw) throw new Error('No saved draft in this host.'); replaceStage(parseStage(raw)); notify('Draft loaded.');
}));
el('exportMap').addEventListener('click', () => task(async () => { const raw = JSON.stringify(stage, null, 2); parseStage(raw); el<HTMLTextAreaElement>('mapJson').value = raw; await exportFile('soyindent-stage.json', raw); if (raw === JSON.stringify(stage, null, 2)) dirty = false; notify('Map exported with custom art. Download it from the host’s Files panel.'); }));
el('importMap').addEventListener('click', () => task(async () => { if (dirty) throw new Error('Save or export your current edits before replacing the map.'); const file = await importFile(['.json']); replaceStage(parseStage(new TextDecoder().decode(file.data))); notify('Map imported.'); }));
el('showJson').addEventListener('click', () => { el<HTMLTextAreaElement>('mapJson').value = JSON.stringify(stage, null, 2); });
el('applyJson').addEventListener('click', () => { try { replaceStage(parseStage(el<HTMLTextAreaElement>('mapJson').value)); notify('Map JSON applied.'); } catch (e) { notify(e instanceof Error ? e.message : String(e)); } });
for (const domain of ['storage', 'fs', 'config', 'identity', 'outbox', 'upload', 'resource']) { const span = document.createElement('span'); span.textContent = `${runtimeHasDomain(domain) ? '●' : '○'} ${domain} / ${runtimeHasDomain(domain) ? 'injected' : 'unavailable'}`; el('hostDomains').append(span); }
function minimap(): void {
  const canvas = el<HTMLCanvasElement>('miniMap'), x = canvas.getContext('2d')!; canvas.width = 410; canvas.height = 268; x.clearRect(0, 0, 410, 268);
  const minX = Math.min(...stage.rooms.map(r => r.origin.x - r.w / 2)), maxX = Math.max(...stage.rooms.map(r => r.origin.x + r.w / 2));
  const minZ = Math.min(...stage.rooms.map(r => r.origin.z - r.d / 2)), maxZ = Math.max(...stage.rooms.map(r => r.origin.z + r.d / 2));
  const scale = Math.min(350 / (maxX - minX), 190 / (maxZ - minZ)), px = (v: number) => 30 + (v - minX) * scale, pz = (v: number) => 35 + (v - minZ) * scale;
  x.strokeStyle = '#6e785e'; x.lineWidth = 2; x.setLineDash([4, 4]);
  for (const c of connections(stage)) { const a = wallPoint(c.room, c.door), b = wallPoint(c.target, c.other); x.beginPath(); x.moveTo(px(a.x), pz(a.z)); x.lineTo(px(b.x), pz(b.z)); x.stroke(); } x.setLineDash([]);
  for (const [i, r] of stage.rooms.entries()) { const current = r.id === game.room.id; x.fillStyle = current ? '#637456' : '#263529'; x.strokeStyle = current ? '#c8c493' : '#6c7b60'; x.fillRect(px(r.origin.x - r.w / 2), pz(r.origin.z - r.d / 2), r.w * scale, r.d * scale); x.strokeRect(px(r.origin.x - r.w / 2), pz(r.origin.z - r.d / 2), r.w * scale, r.d * scale); x.fillStyle = '#c6c7a6'; x.font = '17px monospace'; x.textAlign = 'center'; x.fillText(`0${i + 1}`, px(r.origin.x), pz(r.origin.z) + 6); }
  x.fillStyle = '#f2df9e'; x.beginPath(); x.arc(px(game.room.origin.x + game.player.x), pz(game.room.origin.z + game.player.z), 4, 0, Math.PI * 2); x.fill();
}
function hud(): void {
  const state = game.won ? 'won' : game.player.hp <= 0 ? 'dead' : game.paused ? 'paused' : 'running';
  const key = [game.room.id, game.camera.id, game.player.hp, game.ammo, game.reserve, game.alive, state, game.message, Boolean(game.nearbyDoor),Boolean(game.nearbyHerb), Math.ceil(game.reloadTime)].join('|');
  if (key === hudKey) return; hudKey = key;
  el('roomTitle').textContent = game.room.name; el('cameraLabel').textContent = `CAMERA ${String(game.room.cameras.indexOf(game.camera) + 1).padStart(2, '0')}`;
  const health = game.player.hp > game.rules.playerHealth * .65 ? 'Fine' : game.player.hp > game.rules.playerHealth * .3 ? 'Caution' : game.player.hp > 0 ? 'Danger' : 'Dead';
  el('healthLabel').textContent = health; document.querySelector('.health')!.className = `health ${health.toLowerCase()}`;
  el('ammoCount').textContent = String(game.ammo).padStart(2, '0'); el('reserveCount').textContent = String(game.reserve).padStart(2, '0'); el('shells').textContent = Array.from({ length: 6 }, (_, i) => i < game.ammo ? '●' : '○').join(' ');
  el('gameMessage').textContent = game.reloadTime > 0 ? 'Reloading…' : game.message;
  const total = game.zombies.length; el('killProgress').textContent = `${total - game.alive} / ${total} threats neutralized`; el('progressBar').style.width = `${total ? (total - game.alive) / total * 100 : 0}%`;
  el('mapLocation').textContent = `${String(stage.rooms.indexOf(game.room) + 1).padStart(2, '0')} / ${game.room.name}`;
  el('sceneState').hidden = state === 'running'; el('doorHint').hidden = (!game.nearbyDoor&&!game.nearbyHerb) || state !== 'running';el('doorHint').textContent=game.nearbyDoor?'E / Interact · enter door':'E / Interact · use green herb';
  el('stateCaption').textContent = state === 'won' ? 'INCIDENT CLOSED' : state === 'dead' ? 'END OF PATROL' : 'SEPTEMBER 29, 1998 · 02:14 AM';
  el('stateTitle').textContent = state === 'won' ? 'Precinct secured.' : state === 'dead' ? 'You were the last hope.' : 'Something is still moving.';
  el('startButton').textContent = state === 'won' || state === 'dead' ? 'Restart patrol →' : 'Begin / resume patrol →';
  el('pauseButton').textContent = game.paused ? '▷' : 'Ⅱ';
}
let last = 0, frame = 0, animation = 0;
function loop(ms: number): void {
  const dt = last ? (ms - last) / 1000 : 0; last = ms;
  const sizeOk = innerWidth >= 280 && innerHeight >= 300;
  if (renderer.resolution !== renderWidth) { renderer.resolution = renderWidth; renderer.backgroundKey = ''; editor.dirty = true; }
  controller.poll(mode==='play'&&sizeOk,action);
  if(frame%12===0){el('controllerStatus').textContent=controller.status;el('controllerHelpStatus').textContent=controller.status;}
  if (mode === 'play') { const controls = mergeControls(input.controls,controller.controls); if (sizeOk && el('helpPanel').hidden) game.update(dt, controls); renderer.render(game); el('hitFlash').style.opacity = game.hurt > 0 ? '.65' : '0'; hud(); if (frame % 12 === 0) minimap(); }
  else if(mode==='editor') editor.renderPreview();
  audio.sync(mode==='play'&&sizeOk&&!game.paused&&!document.hidden&&el('helpPanel').hidden,game.room.id,game.won||game.player.hp<=0);
  for(const cue of game.drainSounds())audio.play(cue,{...game.player,room:game.room.id});
  frame++; animation=requestAnimationFrame(loop);
}
animation=requestAnimationFrame(loop);
window.addEventListener('pagehide', () => { cancelAnimationFrame(animation);audio.dispose(); controller.dispose(); library.dispose(); levelFiles.dispose(); fileHost.dispose();input.dispose(); editor.dispose(); stageSizer.disconnect(); renderer.dispose(); });