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 demoLevel from '../levels/demo.json';
export type Point = { x: number; z: number };
export type Rect = Point & { w: number; d: number };
export type PolygonZone = { points: Point[] };
export type CameraZone = Rect | PolygonZone;
export type Prop = Rect & { id: string; kind: 'shelf' | 'desk' | 'crate' | 'wall' | 'bench'; h: number };
export const DRESSING_KINDS = ['pinboard', 'photo', 'poster', 'papers', 'cleaning', 'pipes'] as const;
export type Dressing = Point & { id: string; kind: typeof DRESSING_KINDS[number]; side?: Door['side'] };
export type Camera = { id: string; name: string; position: [number, number, number]; target: [number, number, number]; fov: number; zone: CameraZone; priority?: number; door?: string; fallback?: boolean; background?: { asset: string; signature: string }; backdrop?: string; artSignature?: string };
export type Door = Point & { id: string; side: 'north' | 'south' | 'east' | 'west'; to: string; targetDoor: string };
export type HerbPlacement = Point & { id: string; kind: 'green' };
export type Room = { id: string; name: string; description: string; w: number; d: number; origin: Point; props: Prop[]; dressing?: Dressing[]; cameras: Camera[]; zombies: Point[]; herbs?: HerbPlacement[]; doors: Door[]; spawn: Point };
export type LevelRules = { playerHealth: number; reserve: number; zombieHealth: number; zombieSpeed: number; biteDamage: number };
export type LevelAsset = { id: string; path?: string; url?: string; sha256: string; mime: 'image/png' | 'image/jpeg' | 'image/webp'; size: number };
export type Stage = { version: 1; id?: string; name: string; style: string; rules?: LevelRules; assets?: LevelAsset[]; rooms: Room[] };
export const BASE_STYLE = demoLevel.style;
export const DEFAULT_RULES: LevelRules = { playerHealth: 100, reserve: 24, zombieHealth: 2, zombieSpeed: .52, biteDamage: 16 };
export function createStage(): Stage { return parseStage(JSON.stringify(demoLevel)); }
export function doorPosition(room: Room, side: Door['side'], offset: number): Point {
return { x: side === 'east' ? room.w / 2 - .35 : side === 'west' ? -room.w / 2 + .35 : offset, z: side === 'south' ? room.d / 2 - .35 : side === 'north' ? -room.d / 2 + .35 : offset };
}
export const doorOffset = (door: Door): number => door.side === 'east' || door.side === 'west' ? door.z : door.x;
export function doorIssue(room: Room, door: Door, ignoreId = door.id): string | undefined {
const extent = door.side === 'east' || door.side === 'west' ? room.d : room.w;
if (Math.abs(doorOffset(door)) > extent / 2 - .65 + .001) return 'Keep door openings clear of room corners.';
if (room.doors.some(d => d.id !== ignoreId && d.side === door.side && Math.abs(doorOffset(d) - doorOffset(door)) < 1.4)) return 'Leave at least 1.4 m between doors on the same wall.';
if (blocked(room, doorArrival(door))) return `Clear the floor immediately inside the ${door.side} door in ${room.name}.`;
return undefined;
}
export const inside = (p: Point, r: Rect, padding = 0): boolean => Math.abs(p.x - r.x) <= r.w / 2 + padding && Math.abs(p.z - r.z) <= r.d / 2 + padding;
export const distance = (a: Point, b: Point): number => Math.hypot(a.x - b.x, a.z - b.z);
export function zonePoints(zone: CameraZone): Point[] {
return 'points' in zone ? zone.points : [{x:zone.x-zone.w/2,z:zone.z-zone.d/2},{x:zone.x+zone.w/2,z:zone.z-zone.d/2},{x:zone.x+zone.w/2,z:zone.z+zone.d/2},{x:zone.x-zone.w/2,z:zone.z+zone.d/2}];
}
export function zoneBounds(zone: CameraZone): Rect {
if (!('points' in zone)) return zone;
const xs=zone.points.map(p=>p.x),zs=zone.points.map(p=>p.z),minX=Math.min(...xs),maxX=Math.max(...xs),minZ=Math.min(...zs),maxZ=Math.max(...zs);
return {x:(minX+maxX)/2,z:(minZ+maxZ)/2,w:maxX-minX,d:maxZ-minZ};
}
const cross = (a: Point,b: Point,c: Point): number => (b.x-a.x)*(c.z-a.z)-(b.z-a.z)*(c.x-a.x);
function edgeDistance(p: Point,a: Point,b: Point): number {
const dx=b.x-a.x,dz=b.z-a.z,t=Math.max(0,Math.min(1,((p.x-a.x)*dx+(p.z-a.z)*dz)/(dx*dx+dz*dz||1)));
return Math.hypot(p.x-a.x-t*dx,p.z-a.z-t*dz);
}
export function inZone(p: Point, zone: CameraZone, padding=0): boolean {
if (!('points' in zone)) return inside(p,zone,padding);
let contained=false;const points=zone.points;
for(let i=0,j=points.length-1;i<points.length;j=i++) {
const a=points[j],b=points[i];if(edgeDistance(p,a,b)<=padding+1e-7)return true;
if((a.z>p.z)!==(b.z>p.z)&&p.x<(b.x-a.x)*(p.z-a.z)/(b.z-a.z)+a.x)contained=!contained;
}
return contained;
}
export function polygonIssue(points: Point[]): string | undefined {
if (!Array.isArray(points)||points.length<3||points.length>24||points.some(p=>!point(p))) return 'Use 3–24 finite polygon vertices.';
let area=0;
for(let i=0;i<points.length;i++) {
const a=points[i],b=points[(i+1)%points.length];if(distance(a,b)<.05)return 'Keep polygon vertices at least 5 cm apart.';
area+=a.x*b.z-b.x*a.z;
for(let j=i+1;j<points.length;j++) {
if(j===i+1||(i===0&&j===points.length-1))continue;
const c=points[j],d=points[(j+1)%points.length],abC=cross(a,b,c),abD=cross(a,b,d),cdA=cross(c,d,a),cdB=cross(c,d,b);
if((abC*abD<=0&&cdA*cdB<=0)&&(Math.max(Math.min(a.x,b.x),Math.min(c.x,d.x))<=Math.min(Math.max(a.x,b.x),Math.max(c.x,d.x))+1e-7)&&(Math.max(Math.min(a.z,b.z),Math.min(c.z,d.z))<=Math.min(Math.max(a.z,b.z),Math.max(c.z,d.z))+1e-7))return 'Polygon edges must not cross or touch.';
}
}
if(Math.abs(area)<.1)return 'Give the polygon at least 0.05 square meters.';
return undefined;
}
export function doorArrival(door: Door): Point {
const offsets = { west: [1, 0], east: [-1, 0], north: [0, 1], south: [0, -1] };
const offset = offsets[door.side];
return { x: door.x + offset[0] * .7, z: door.z + offset[1] * .7 };
}
export function blocked(room: Room, p: Point, radius = .28): boolean {
return Math.abs(p.x) > room.w / 2 - radius || Math.abs(p.z) > room.d / 2 - radius || room.props.some(r => inside(p, r, radius));
}
export function move(room: Room, p: Point, dx: number, dz: number, radius = .28): void {
if (!blocked(room, { x: p.x + dx, z: p.z }, radius)) p.x += dx;
if (!blocked(room, { x: p.x, z: p.z + dz }, radius)) p.z += dz;
}
export function cameraAt(room: Room, p: Point, current?: string): Camera {
const selected = room.cameras.find(c => c.id === current);
const priority=(c:Camera)=>(c.fallback?-1:c.priority??0);
const candidates=[...room.cameras].reverse().filter(c=>inZone(p,c.zone)&&cameraContains(c,p)).sort((a,b)=>priority(b)-priority(a));
const best=candidates[0];
if(selected&&cameraContains(selected,p)&&inZone(p,selected.zone,.07)&&priority(selected)>=(best?priority(best):-1)) {
const door=room.doors.find(d=>d.id===selected.door),other=room.doors.find(d=>d.id===best?.door);
if(!door||!other||distance(p,door)<=distance(p,other)+.2)return selected;
}
return best ?? room.cameras.find(c=>cameraContains(c,p)) ?? selected ?? room.cameras[0];
}
export function cameraContains(c: Camera, p: Point): boolean {
const [cx,cy,cz]=c.position,dx=c.target[0]-cx,dy=c.target[1]-cy,dz=c.target[2]-cz,length=Math.hypot(dx,dy,dz)||1;
const fx=dx/length,fy=dy/length,fz=dz/length,rightLength=Math.hypot(fx,fz),rx=rightLength?-fz/rightLength:1,rz=rightLength?fx/rightLength:0;
const ux=-rz*fy,uy=rz*fx-rx*fz,uz=rx*fy,tan=Math.tan(c.fov*Math.PI/360);
for(const height of [.08,1.9]) {
const x=p.x-cx,y=height-cy,z=p.z-cz,depth=x*fx+y*fy+z*fz;
if(depth<.1||depth>80||Math.abs((x*rx+z*rz)/(depth*tan*4/3))>.97||Math.abs((x*ux+y*uy+z*uz)/(depth*tan))>.97)return false;
}
return true;
}
export function cutWall(camera: Camera, side: Door['side']): boolean {
const [x,,z]=camera.position,[tx,,tz]=camera.target;
return side==='north' ? z<0&&tz>z : side==='south' ? z>0&&tz<z : side==='west' ? x<0&&tx>x : x>0&&tx<x;
}
export function visibleLine(room: Room, a: Point, b: Point): boolean {
const steps = Math.ceil(distance(a, b) / .1);
for (let i = 1; i < steps; i++) {
const p = { x: a.x + (b.x - a.x) * i / steps, z: a.z + (b.z - a.z) * i / steps };
if (room.props.some(r => inside(p, r))) return false;
}
return true;
}
export function signature(room: Room, camera: Camera): string {
return JSON.stringify([room.w, room.d, room.props, room.doors, room.dressing ?? [], camera.position, camera.target, camera.fov]);
}
export function shotPrompt(stage: Stage, room: Room, camera: Camera): string {
return `${stage.style}\n\n${stage.name} — ${room.name} — ${camera.name}.\n${room.description}\n\nUse the accompanying gray faces and dark edges schema as the exact perspective and layout reference. Frame: 4:3. Lens: ${camera.fov}° vertical field of view. Camera XYZ: ${camera.position.join(', ')} meters. Look target XYZ: ${camera.target.join(', ')}. Room footprint: ${room.w} × ${room.d} meters. ${camera.position[1] < 1 ? 'This is a frog perspective near the floor, looking slightly upward. Preserve the low horizon and visible ceiling.' : 'Preserve the authored camera height and horizon.'} Lighting fixtures are omitted from the schema; use dim ceiling fluorescent pools without inventing floor obstacles. Wall boards must contain varied pinned notes, photos and maps; framed photos should depict period police portraits or precinct scenes. Retain the schema broom, bucket and flat litter, adding believable surface detail. Preserve every floor boundary, doorway, furniture silhouette, vanishing point and camera crop. Dress existing surfaces; keep all doorway arrivals and central paths clear. No extra solid obstacles. Printed photographs may contain people; no live characters, creatures, weapon, interface, border or overlay. Produce one complete background image matching this schema.`;
}
const number = (n: unknown, min = -100, max = 100): n is number => typeof n === 'number' && Number.isFinite(n) && n >= min && n <= max;
const point = (p: Point): boolean => !!p && number(p.x) && number(p.z);
const rect = (r: Rect): boolean => point(r) && number(r.w, .1, 40) && number(r.d, .1, 40);
const zone = (z: CameraZone): boolean => !!z&&typeof z==='object'&&('points' in z ? !polygonIssue(z.points) : rect(z));
const label = (s: unknown): s is string => typeof s === 'string' && s.length > 0 && s.length <= 200;
export function parseStage(raw: string): Stage {
if (raw.length > 9_000_000) throw new Error('Map exceeds the 9 MB import limit.');
const s = JSON.parse(raw) as Stage;
if (!s || s.version !== 1 || !label(s.name) || typeof s.style !== 'string' || s.style.length > 8000 || !Array.isArray(s.rooms) || s.rooms.length < 1 || s.rooms.length > 16) throw new Error('Expected a version 1 map with 1–16 rooms.');
if (s.id !== undefined && (typeof s.id !== 'string' || !/^[a-zA-Z0-9_-]{1,64}$/.test(s.id))) throw new Error('Invalid level ID.');
if (s.rules !== undefined && (!s.rules || !number(s.rules.playerHealth, 25, 100) || !Number.isInteger(s.rules.reserve) || !number(s.rules.reserve, 0, 96) || !Number.isInteger(s.rules.zombieHealth) || !number(s.rules.zombieHealth, 1, 8) || !number(s.rules.zombieSpeed, .1, 2) || !number(s.rules.biteDamage, 1, 50))) throw new Error('Invalid level rules.');
const assetIds = new Set<string>();
if (s.assets !== undefined) {
if (!Array.isArray(s.assets) || s.assets.length > 96) throw new Error('Up to 96 linked backgrounds per level.');
for (const a of s.assets) {
if (!a || !label(a.id) || assetIds.has(a.id) || typeof a.sha256 !== 'string' || !/^[a-f0-9]{64}$/.test(a.sha256) || !['image/png', 'image/jpeg', 'image/webp'].includes(a.mime) || !Number.isInteger(a.size) || !number(a.size, 1, 2_000_000) || (a.path !== undefined && (typeof a.path !== 'string' || !/^[a-zA-Z0-9_./-]{1,200}$/.test(a.path) || a.path.startsWith('/') || a.path.split('/').includes('..'))) || (a.url !== undefined && !safeAssetUrl(a.url))) throw new Error('Invalid linked background asset.');
assetIds.add(a.id);
}
}
const ids = new Set<string>();
for (const r of s.rooms) {
if (!r || !label(r.id) || ids.has(r.id) || !label(r.name) || typeof r.description !== 'string' || r.description.length > 4000 || !number(r.w, 3, 30) || !number(r.d, 3, 30) || !point(r.origin) || !point(r.spawn)) throw new Error('Invalid room or duplicate room ID.');
ids.add(r.id);
if (!Array.isArray(r.props) || r.props.length > 80 || r.props.some(p => !p || !label(p.id) || !['shelf', 'desk', 'crate', 'wall', 'bench'].includes(p.kind) || !rect(p) || !number(p.h, .1, 3.2))) throw new Error('Invalid room props.');
if (!Array.isArray(r.zombies) || r.zombies.length > 20 || r.zombies.some(p => !point(p))) throw new Error('Invalid zombie spawns.');
if(r.herbs!==undefined) {
const herbIds=new Set<string>();
if(!Array.isArray(r.herbs)||r.herbs.length>20)throw new Error('Invalid herb placements.');
for(const h of r.herbs){if(!h||!label(h.id)||herbIds.has(h.id)||!point(h)||h.kind!=='green')throw new Error('Invalid herb placements.');herbIds.add(h.id);}
if(r.herbs.some(h=>blocked(r,h)))throw new Error(`A herb in ${r.name} is inside a wall or prop.`);
}
if (r.dressing !== undefined && (!Array.isArray(r.dressing) || r.dressing.length > 80 || r.dressing.some(d => !d || !label(d.id) || !point(d) || !DRESSING_KINDS.includes(d.kind) || (d.side !== undefined && !['north', 'south', 'east', 'west'].includes(d.side))))) throw new Error('Invalid room dressing.');
if (!Array.isArray(r.cameras) || !r.cameras.length || r.cameras.length > 12) throw new Error('Each room needs 1–12 cameras.');
const camIds = new Set<string>();
for (const c of r.cameras) {
if (!c || !label(c.id) || camIds.has(c.id) || !label(c.name) || !zone(c.zone) || (c.priority!==undefined&&(!Number.isInteger(c.priority)||!number(c.priority,0,100))) || (c.fallback!==undefined&&typeof c.fallback!=='boolean') || (c.door!==undefined&&(!label(c.door)||(!Array.isArray(r.doors)||!r.doors.some(d=>d.id===c.door)))) || !number(c.fov, 25, 90) || !Array.isArray(c.position) || c.position.length !== 3 || !c.position.every(v => number(v)) || !Array.isArray(c.target) || c.target.length !== 3 || !c.target.every(v => number(v)) || (c.backdrop !== undefined && (typeof c.backdrop !== 'string' || !/^data:image\/(png|jpeg|webp);base64,[A-Za-z0-9+/=]+$/.test(c.backdrop) || c.backdrop.length > 2_800_000)) || (c.artSignature !== undefined && typeof c.artSignature !== 'string')) throw new Error('Invalid camera or image.');
if (c.background && (!assetIds.has(c.background.asset) || typeof c.background.signature !== 'string' || c.background.signature.length > 100_000)) throw new Error('Camera background must reference a declared asset and geometry signature.');
camIds.add(c.id);
}
if (!Array.isArray(r.doors) || r.doors.length > 16 || r.doors.some(d => !point(d) || !label(d.id) || !label(d.to) || !label(d.targetDoor) || !['north', 'south', 'east', 'west'].includes(d.side))) throw new Error('Invalid door.');
const doorIds = new Set<string>();
for (const door of r.doors) {
if (doorIds.has(door.id)) throw new Error(`Duplicate door ID in ${r.name}.`);
doorIds.add(door.id);
const sidePosition = door.side === 'east' ? r.w / 2 - .35 : door.side === 'west' ? -r.w / 2 + .35 : door.side === 'south' ? r.d / 2 - .35 : -r.d / 2 + .35;
if (Math.abs((door.side === 'east' || door.side === 'west' ? door.x : door.z) - sidePosition) > .05) throw new Error(`A door in ${r.name} must lie on its named wall.`);
const issue = doorIssue(r, door); if (issue) throw new Error(issue);
}
if (blocked(r, r.spawn)) throw new Error(`The player spawn in ${r.name} is inside a wall or prop.`);
if (r.zombies.some(p => blocked(r, p))) throw new Error(`A zombie spawn in ${r.name} is inside a wall or prop.`);
}
for (const r of s.rooms) for (const d of r.doors) {
const target = s.rooms.find(r => r.id === d.to)?.doors.find(t => t.id === d.targetDoor);
if (!target || target.to !== r.id || target.targetDoor !== d.id) throw new Error('Door connections must have a matching return door.');
}
return s;
}
export function safeAssetUrl(value: unknown): value is string {
if (typeof value !== 'string' || value.length > 2048) return false;
try { const u = new URL(value); return !u.username && !u.password && !u.hash && (u.protocol === 'https:' || (u.protocol === 'http:' && ['127.0.0.1', '[::1]', 'localhost'].includes(u.hostname))); } catch { return false; }
}
