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.
import { cameraAt, DEFAULT_RULES, distance, doorArrival, move, visibleLine, type Stage, type Room, type Point, type Camera, type LevelRules } from './world.js';
import type { GameSound } from './sound-cues.js';
export type Zombie = Point & { id: string; room: string; hp: number; yaw: number; stagger: number; bite: number; groan?: number; headGone?: boolean; deathAt?: number; attackAt?: number; hitAt?: number; moving?: boolean };
export type Controls = { forward: number; turn: number; run: boolean; aim: boolean; aimUp?: boolean };
export type Herb = Point & { id: string; room: string; collected: boolean };
export type Burst = Point & { id: number; room: string; at: number };
export class Game {
stage: Stage;
rules: LevelRules;
room: Room;
player = { x: -1.8, z: 2.2, yaw: Math.PI, hp: 100 };
zombies: Zombie[] = [];
herbs: Herb[] = [];bursts: Burst[] = [];
time=0;shotSerial=0;shotAt=-10;shotUp=false;aiming=false;aimUp=false;moving=false;running=false;deathAt=-1;
camera: Camera;
ammo = 6;
reserve = 24;
cooldown = 0;
reloadTime = 0;
flash = 0;
hurt = 0;
paused = true;
won = false;
message = 'Clear the precinct. Keep your distance.';
private sounds: GameSound[] = [];
private footstepDistance = 0;
private reloadShellPlayed = false;
drainSounds(): GameSound[] { return this.sounds.splice(0); }
private sound(id: GameSound['id'], position?: Point, delaySeconds = 0): void {
if (this.sounds.length >= 64) this.sounds.shift();
this.sounds.push({ id, ...(position ? { position: { x: position.x, z: position.z }, room: this.room.id } : {}), ...(delaySeconds ? { delaySeconds } : {}) });
}
constructor(stage: Stage) {
this.rules = { ...DEFAULT_RULES, ...stage.rules }; this.player.hp = this.rules.playerHealth; this.reserve = this.rules.reserve;
this.stage = stage; this.room = stage.rooms[0];
this.player.x = this.room.spawn.x; this.player.z = this.room.spawn.z;
this.camera = cameraAt(this.room, this.player);
this.zombies = stage.rooms.flatMap(r => r.zombies.map((p, i) => ({ ...p, id: `${r.id}-${i}`, room: r.id, hp: this.rules.zombieHealth, yaw: 0, stagger: 0, bite: 0, groan: 3 + i * 1.7 })));
this.herbs=stage.rooms.flatMap(r=>(r.herbs??[]).map(h=>({...h,id:`${r.id}/${h.id}`,room:r.id,collected:false})));
}
get alive(): number { return this.zombies.filter(z => z.hp > 0).length; }
get nearbyDoor() { return this.room.doors.find(d => distance(d, this.player) < 1.25); }
get nearbyHerb() { return this.herbs.find(h=>!h.collected&&h.room===this.room.id&&distance(h,this.player)<.95); }
shoot(aimUp=false): void {
if (this.paused || this.player.hp <= 0 || this.won || this.cooldown > 0 || this.reloadTime > 0) return;
if (this.ammo === 0) { this.message = 'Empty. Press R to reload.'; return; }
this.ammo--; this.cooldown = .75; this.flash = .13;
this.shotSerial++;this.shotAt=this.time;this.shotUp=aimUp;
this.sound('shotgun_fire');
this.sound('shotgun_pump', undefined, .25);
let hits = 0;
for (const z of this.zombies) {
if (z.room !== this.room.id || z.hp <= 0) continue;
const d = distance(z, this.player);
const dot = d<.05?1:((z.x - this.player.x) * Math.sin(this.player.yaw) + (z.z - this.player.z) * Math.cos(this.player.yaw)) / d;
if (d < 7 && dot > Math.cos(.34) && visibleLine(this.room, this.player, z)) {
if(aimUp&&d<=2.1) { z.hp=0;z.headGone=true;this.bursts.push({id:this.shotSerial*1000+this.zombies.indexOf(z),room:z.room,at:this.time,x:z.x,z:z.z});this.bursts=this.bursts.slice(-24);this.sound('zombie_head_burst',z); }
else z.hp -= d < 3.2 ? 2 : 1;
z.stagger = .8;z.hitAt=this.time;hits++;
if(z.hp<=0){z.deathAt=this.time;z.moving=false;this.sound('zombie_fall',z);}
move(this.room, z, Math.sin(this.player.yaw) * .35, Math.cos(this.player.yaw) * .35);
}
}
this.message = this.bursts.some(b=>b.id>=this.shotSerial*1000)?'Headshot.':hits ? 'Direct hit.' : 'Shot missed. Aim toward the target.';
if (this.alive === 0) { this.won = true; this.message = 'Precinct secured. Soybert survived.'; }
}
reload(): void {
if (this.paused || this.won || this.player.hp <= 0 || this.reloadTime > 0 || this.ammo === 6 || this.reserve === 0) return;
this.reloadTime = 1.5; this.message = 'Reloading…';
this.reloadShellPlayed = false; this.sound('reload_start');
}
interact(): boolean {
if (this.paused || this.player.hp <= 0 || this.won) return false;
const d = this.nearbyDoor;
const herb=this.nearbyHerb;
if(!d&&herb) {
if(this.player.hp>=this.rules.playerHealth){this.message='Health is full. Leave the herb for later.';return false;}
const heal=Math.min(35,this.rules.playerHealth-this.player.hp);this.player.hp+=heal;herb.collected=true;this.message=`Green herb · +${heal} health.`;return true;
}
const r = this.stage.rooms.find(r => r.id === d?.to);
const destination = r?.doors.find(t => t.id === d?.targetDoor);
if (!r || !destination) { this.message = 'Move close to a door to enter.'; return false; }
this.room = r;
this.footstepDistance = 0;
const offsets = { west: [1, 0], east: [-1, 0], north: [0, 1], south: [0, -1] };
const o = offsets[destination.side];
Object.assign(this.player, doorArrival(destination));
this.player.yaw = Math.atan2(o[0], o[1]);
this.camera = cameraAt(r, this.player); this.message = r.name;
return true;
}
update(dt: number, input: Controls): void {
if (this.paused) return;
dt = Math.min(dt, .05);this.time+=dt;this.bursts=this.bursts.filter(b=>this.time-b.at<1.8);
this.cooldown = Math.max(0, this.cooldown - dt); this.flash = Math.max(0, this.flash - dt); this.hurt = Math.max(0, this.hurt - dt);
if(this.won||this.player.hp<=0){this.moving=false;return;}
if (this.alive === 0) { this.won = true; this.message = 'Precinct secured. Soybert survived.'; return; }
this.aiming=input.aim;this.aimUp=Boolean(input.aimUp&&input.aim);this.running=input.run;
if (this.reloadTime > 0) {
this.reloadTime -= dt;
if (this.reloadTime <= .75 && !this.reloadShellPlayed) { this.reloadShellPlayed = true; this.sound('reload_shell'); }
if (this.reloadTime <= 0) { const n = Math.min(6 - this.ammo, this.reserve); this.ammo += n; this.reserve -= n; this.message = 'Shotgun ready.'; this.sound('reload_finish'); }
}
this.player.yaw += input.turn * dt * (input.aim ? 1.6 : 2.3);
const speed = input.forward * (input.aim ? .55 : input.run ? 3 : 1.75) * dt;
const before = { x: this.player.x, z: this.player.z };
move(this.room, this.player, Math.sin(this.player.yaw) * speed, Math.cos(this.player.yaw) * speed);
const travelled=distance(before,this.player);this.moving=travelled>.001;this.footstepDistance += travelled;
if (this.footstepDistance >= .72) { this.footstepDistance %= .72; this.sound(['forensics', 'maintenance'].includes(this.room.id) ? 'footstep_concrete' : 'footstep_stone'); }
this.camera = cameraAt(this.room, this.player, this.camera.id);
for (const z of this.zombies) {
z.moving=false;
if (z.room !== this.room.id || z.hp <= 0) continue;
z.bite = Math.max(0, z.bite - dt); z.stagger = Math.max(0, z.stagger - dt);
const d = distance(z, this.player);
z.groan = (z.groan ?? 3) - dt;
z.yaw = Math.atan2(this.player.x - z.x, this.player.z - z.z);
if (z.stagger > 0) continue;
if (z.groan <= 0 && d < 8) { this.sound('zombie_idle_groan', z); z.groan = 5 + Math.random() * 3; }
if (d > .65) {
const before={x:z.x,z:z.z};
move(this.room, z, Math.sin(z.yaw) * dt * this.rules.zombieSpeed, Math.cos(z.yaw) * dt * this.rules.zombieSpeed, .25);
z.moving=distance(before,z)>.001;
} else if (z.bite === 0) {
z.attackAt=this.time;
this.sound('zombie_attack', z);
this.player.hp = Math.max(0, this.player.hp - this.rules.biteDamage); z.bite = 1.5; this.hurt = .3;
if(this.player.hp===0){this.deathAt=this.time;this.sound('player_death');this.message='Soybert fell. Restart to try again.';break;}
this.message = this.player.hp === 0 ? 'Soybert fell. Restart to try again.' : 'Bitten. Create some distance.';
}
}
}
}
