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 { blocked, inside, signature, shotPrompt, zonePoints, zoneBounds, polygonIssue, type Stage, type Room, type Camera, type Point, type Prop, type Door, type Dressing, type HerbPlacement } from './world.js';
import { StageRenderer } from './renderer.js';
import { base64, exportFile, importFile } from './files.js';
import { connectRooms, removeConnection, wallEndpoint } from './connections.js';
import { cameraWarnings, doorCamera } from './camera-coverage.js';
import { GraphEditor } from './graph-editor.js';
const el = <T extends HTMLElement>(id: string): T => document.getElementById(id) as T;
const escape = (v: string) => v.replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]!);
const snap = (n: number) => Math.round(n * 4) / 4;
export class Editor {
stage: Stage; room: Room; camera: Camera; renderer: StageRenderer;
canvas = el<HTMLCanvasElement>('plan'); ctx = this.canvas.getContext('2d')!;
polygonDraft: Point[] | null = null; vertexDrag: number | null = null; zoneOriginal: Point[] | null = null;
tool = 'select'; selected: Prop | Dressing | HerbPlacement | Point | null = null; drag: Point | null = null;
schema = true; dirty = true; scale = 30; offset = { x: 0, z: 0 };
onChange: () => void; notify: (message: string) => void;
observer: ResizeObserver;
graph: GraphEditor; mapMode = false; cameraDrag: 'position' | 'target' | null = null; cameraOriginal: Camera['position'] | null = null; dragOriginal: Point | null = null;
constructor(stage: Stage, renderer: StageRenderer, onChange: () => void, notify: (message: string) => void) {
this.stage = stage; this.room = stage.rooms[0]; this.camera = this.room.cameras[0]; this.renderer = renderer; this.onChange = onChange; this.notify = notify;
this.graph = new GraphEditor(() => ({ stage: this.stage, room: this.room }), id => this.selectRoom(id), () => this.changed(), notify);
el('roomPlanTab').addEventListener('click', () => this.setView(false)); el('precinctMapTab').addEventListener('click', () => this.setView(true));
this.canvas.addEventListener('pointerdown', this.down); this.canvas.addEventListener('pointermove', this.moving); this.canvas.addEventListener('pointerup', this.up); this.canvas.addEventListener('pointercancel', this.cancelDrag);
this.observer = new ResizeObserver(() => this.draw()); this.observer.observe(this.canvas.parentElement!);
document.querySelectorAll<HTMLButtonElement>('[data-tool]').forEach(b => b.addEventListener('click', () => {
this.cancelPolygon(); this.tool = b.dataset.tool!;
document.querySelectorAll('[data-tool]').forEach(e => e.classList.toggle('active', e === b));
const help: Record<string, string> = { select: 'Select and drag furniture, dressing or a zombie. Delete removes it.', camera: 'Drag ◎ camera and + look target. Height and lens update live in the camera panel.', dress: 'Choose a detail and tap to place it. Wall art snaps to the nearest wall.', prop: 'Drag a rectangle to place a solid prop. Choose its type above.', zone: 'Drag a rectangle or draw a polygon in the camera panel. Drag polygon vertices to refine its boundary.', herb: 'Tap clear floor to place a green herb.', zombie: 'Tap empty floor to place a zombie.', spawn: 'Tap empty floor to set Soybert’s starting position.' };
this.notify(help[this.tool] ?? '');this.draw();
}));
el('addRoom').addEventListener('click', () => this.addRoom());
el('addCamera').addEventListener('click', () => this.addCamera());
el('deleteCamera').addEventListener('click', () => { if (this.room.cameras.length === 1) return this.notify('Keep at least one camera in a room.'); this.cancelDrag();this.cancelPolygon();this.room.cameras = this.room.cameras.filter(c => c !== this.camera); this.camera = this.room.cameras[0]; this.changed(); });
el('deleteSelection').addEventListener('click', () => { if (!this.selected) return this.notify('Select furniture, dressing or a zombie first.'); this.room.props = this.room.props.filter(p => p !== this.selected); this.room.zombies = this.room.zombies.filter(p => p !== this.selected); this.room.herbs = this.room.herbs?.filter(p => p !== this.selected); this.room.dressing = this.room.dressing?.filter(p => p !== this.selected); this.selected = null; this.changed(); });
el('connectRooms').addEventListener('click', () => this.connect());
el('deleteRoom').addEventListener('click', () => { if (this.stage.rooms.length === 1) return this.notify('Keep at least one room.'); this.cancelDrag();this.cancelPolygon();for(const d of [...this.room.doors])removeConnection(this.stage,this.room,d); this.stage.rooms = this.stage.rooms.filter(r => r !== this.room); this.room = this.stage.rooms[0]; this.camera = this.room.cameras[0]; this.selected = null; this.changed(); });
el('previewMode').addEventListener('click', () => { this.schema = !this.schema; el('previewMode').textContent = this.schema ? 'Faces + edges' : 'Rendered scene'; this.dirty = true; });
for (const id of ['roomName', 'roomWidth', 'roomDepth', 'roomDescription', 'cameraName', 'camX', 'camY', 'camZ', 'targetX', 'targetY', 'targetZ', 'camFov', 'baseStyle', 'zoneX', 'zoneZ', 'zoneWidth', 'zoneDepth', 'cameraPriority', 'cameraFallback', 'cameraDoor']) el(id).addEventListener('change', () => this.applyFields());
el('zoneShape').addEventListener('change', () => { this.cancelPolygon(); this.camera.zone = el<HTMLSelectElement>('zoneShape').value === 'polygon' ? {points:zonePoints(this.camera.zone)} : zoneBounds(this.camera.zone); this.changed(); });
el('drawPolygon').addEventListener('click', () => { this.polygonDraft=[]; this.tool='zone'; document.querySelectorAll<HTMLElement>('[data-tool]').forEach(b=>b.classList.toggle('active',b.dataset.tool==='zone')); this.refresh(); this.notify('Tap 3–24 vertices in order. Finish closes the polygon; concave shapes are supported.'); });
el('finishPolygon').addEventListener('click', () => this.finishPolygon());
el('undoPolygon').addEventListener('click', () => {this.polygonDraft?.pop();this.refresh();});
el('cancelPolygon').addEventListener('click', () => {this.cancelPolygon();this.refresh();});
el('frameDoor').addEventListener('click', () => {
const d=this.room.doors.find(d=>d.id===el<HTMLSelectElement>('cameraDoor').value); if(!d)return this.notify('Choose a doorway to frame.');
const angle=doorCamera(this.room,d,this.stage.rooms.find(r=>r.id===d.to)?.name??'Door'); const existing=this.room.cameras.find(c=>c.door===d.id);
if(existing){Object.assign(existing,angle);delete existing.background;delete existing.backdrop;delete existing.artSignature;this.camera=existing;}
else {if(this.room.cameras.length>=12)return this.notify('Up to 12 cameras per room.');this.room.cameras.push(angle);this.camera=angle;}
this.cancelPolygon();this.changed();this.notify('Door approach framed with a priority polygon trigger.');
});
el('cameraHeight').addEventListener('input', () => { this.camera.position[1] = Number(el<HTMLInputElement>('cameraHeight').value); this.changed(); });
el('cameraLens').addEventListener('input', () => { this.camera.fov = Number(el<HTMLInputElement>('cameraLens').value); this.changed(); });
document.querySelectorAll<HTMLButtonElement>('[data-camera-preset]').forEach(b => b.addEventListener('click', () => {
const preset = b.dataset.cameraPreset;
this.camera.position[1] = preset === 'frog' ? .4 : preset === 'eye' ? 1.55 : 4.6;
this.camera.target[1] = preset === 'frog' ? 1.15 : preset === 'eye' ? 1.05 : .6;
this.camera.fov = preset === 'frog' ? 65 : preset === 'eye' ? 58 : 55;
this.changed(); this.notify(preset === 'frog' ? 'Frog perspective: 40 cm from the floor. Drag the camera and target to compose it.' : 'Camera preset applied. Drag the handles to frame your room.');
}));
el('exportSchema').addEventListener('click', () => this.action(async () => { const data = this.renderer.schemaShot(this.room, this.camera); this.dirty = true; await exportFile(`${this.room.id}-${this.camera.id}-schema.png`, data.split(',')[1], true); this.notify('Schema PNG exported. Download it from the host’s Files panel.'); }));
el('exportPrompt').addEventListener('click', () => this.action(async () => { await exportFile(`${this.room.id}-${this.camera.id}-prompt.txt`, shotPrompt(this.stage, this.room, this.camera)); this.notify('Prompt exported to the host’s Files panel.'); }));
el('exportCamera').addEventListener('click', () => this.action(async () => { await exportFile(`${this.room.id}-${this.camera.id}-camera.json`, JSON.stringify({ room: this.room.name, footprint: [this.room.w, this.room.d], camera: this.camera, geometrySignature: signature(this.room, this.camera) }, null, 2)); this.notify('Camera metadata exported.'); }));
el('importBackdrop').addEventListener('click', () => this.action(async () => {
const room = this.room, camera = this.camera, geometry = signature(room, camera);
const file = await importFile(['.png', '.jpg', '.jpeg', '.webp'], 2_000_000);
const type = file.data[0] === 137 && file.data[1] === 80 ? 'png' : file.data[0] === 255 && file.data[1] === 216 ? 'jpeg' : String.fromCharCode(...file.data.slice(8, 12)) === 'WEBP' ? 'webp' : null;
if (!type) throw new Error('Use a PNG, JPEG or WebP background.');
const uri = `data:image/${type};base64,${base64(file.data)}`; const image = new Image(); image.src = uri; await image.decode();
if (Math.abs(image.naturalWidth / image.naturalHeight - 4 / 3) > .025) throw new Error('Use a 4:3 image so the background matches the camera.');
if (!this.stage.rooms.includes(room) || !room.cameras.includes(camera) || signature(room, camera) !== geometry) throw new Error('The selected camera changed during import. Try again.');
camera.backdrop = uri; camera.artSignature = geometry; this.schema = false; el('previewMode').textContent = 'Rendered scene'; this.changed(); this.notify('Background assigned. Export the map to retain custom image bytes.');
}));
el('removeBackdrop').addEventListener('click', () => { delete this.camera.backdrop; delete this.camera.artSignature; this.changed(); });
this.refresh();
}
action(fn: () => Promise<void>): void { void (async () => { try { await fn(); } catch (e) { this.notify(e instanceof Error ? e.message : String(e)); } })(); }
setView(map: boolean): void {
this.mapMode = map; el('roomPlanPanel').hidden = map; el('precinctMapPanel').hidden = !map; el('editorView').classList.toggle('map-mode', map);
el('roomPlanTab').classList.toggle('active', !map); el('precinctMapTab').classList.toggle('active', map); el('planTitle').textContent = map ? 'Precinct map' : 'Floor plan';
if (map) this.graph.fit(); else this.draw();
}
changed(): void { this.onChange(); this.dirty = true; this.refresh(); }
cancelPolygon(): void { this.polygonDraft=null; }
finishPolygon(): void {
if(!this.polygonDraft)return; const issue=polygonIssue(this.polygonDraft);if(issue)return this.notify(issue);
this.camera.zone={points:structuredClone(this.polygonDraft)};this.cancelPolygon();this.changed();this.notify('Polygon trigger saved. Drag its vertices with the Zone tool to refine it.');
}
selectRoom(id: string): void { this.cancelDrag(); this.cancelPolygon(); this.room = this.stage.rooms.find(r => r.id === id)!; this.camera = this.room.cameras[0]; this.selected = null; this.dirty = true; this.refresh(); }
refresh(): void {
const list = el('roomList'); list.replaceChildren();
for (const [i, r] of this.stage.rooms.entries()) {
const b = document.createElement('button'); b.className = `room-item ${r === this.room ? 'active' : ''}`; b.innerHTML = `<span class="room-index">0${i + 1}</span><span>${escape(r.name)}<small>${r.w} × ${r.d} m · ${r.cameras.length} cameras</small></span>`; b.addEventListener('click', () => this.selectRoom(r.id)); list.append(b);
}
const cameras = el('cameraList'); cameras.replaceChildren();
for (const [i, c] of this.room.cameras.entries()) { const b = document.createElement('button'); b.textContent = `C${i + 1}`; b.className = c === this.camera ? 'active' : ''; b.title = c.name; b.addEventListener('click', () => { this.cancelDrag(); this.cancelPolygon(); this.camera = c; this.dirty = true; this.refresh(); }); cameras.append(b); }
const field = (id: string, v: string | number) => { el<HTMLInputElement>(id).value = String(v); };
field('roomName', this.room.name); field('roomWidth', this.room.w); field('roomDepth', this.room.d); field('roomDescription', this.room.description);
field('cameraName', this.camera.name); field('baseStyle', this.stage.style);
['camX', 'camY', 'camZ'].forEach((id, i) => field(id, this.camera.position[i])); ['targetX', 'targetY', 'targetZ'].forEach((id, i) => field(id, this.camera.target[i])); field('camFov', this.camera.fov);
field('cameraHeight', this.camera.position[1]); field('cameraLens', this.camera.fov); el('heightLabel').textContent = `${this.camera.position[1].toFixed(2)} m`; el('lensLabel').textContent = `${this.camera.fov}°`;
document.querySelectorAll<HTMLButtonElement>('[data-camera-preset]').forEach(b => b.classList.toggle('active', b.dataset.cameraPreset === (this.camera.position[1] < 1 ? 'frog' : this.camera.position[1] < 2.5 ? 'eye' : 'classic')));
const bounds=zoneBounds(this.camera.zone);field('zoneX', bounds.x); field('zoneZ', bounds.z); field('zoneWidth', bounds.w); field('zoneDepth', bounds.d);
field('zoneShape','points' in this.camera.zone?'polygon':'rectangle');el('zoneRectFields').hidden='points' in this.camera.zone;
field('cameraPriority',this.camera.priority??0);el<HTMLInputElement>('cameraFallback').checked=this.camera.fallback??false;
const doorSelect=el<HTMLSelectElement>('cameraDoor');doorSelect.replaceChildren(); const none=document.createElement('option');none.value='';none.textContent='No doorway';doorSelect.append(none);
for(const d of this.room.doors){const o=document.createElement('option');o.value=d.id;o.textContent=`${d.side} → ${this.stage.rooms.find(r=>r.id===d.to)?.name??'Door'}`;doorSelect.append(o);}doorSelect.value=this.camera.door??'';
el<HTMLButtonElement>('frameDoor').disabled=!doorSelect.value;
el<HTMLButtonElement>('finishPolygon').disabled=!this.polygonDraft||this.polygonDraft.length<3;
el<HTMLButtonElement>('undoPolygon').disabled=!this.polygonDraft?.length;el<HTMLButtonElement>('cancelPolygon').disabled=!this.polygonDraft;
el('zoneHelp').textContent=this.polygonDraft?`${this.polygonDraft.length}/24 vertices · Finish closes the shape.`:'points' in this.camera.zone?'Polygon: use the Zone tool to drag vertices.':'Rectangle: use the Zone tool to drag its bounds.';
const warnings=cameraWarnings(this.room);el('cameraCoverage').textContent=warnings.length?warnings.join(' '):'Door approaches and walkable floor are framed.';el('cameraCoverage').classList.toggle('warning',!!warnings.length);
el<HTMLTextAreaElement>('shotPrompt').value = shotPrompt(this.stage, this.room, this.camera);
el('selectedLabel').textContent = this.selected ? ('kind' in this.selected ? this.selected.kind === 'green' ? 'green herb' : this.selected.kind : 'zombie spawn') : 'Nothing selected';
el('artStatus').textContent = this.camera.backdrop ? this.camera.artSignature === signature(this.room, this.camera) ? 'Custom art assigned' : 'Geometry changed · reimport art' : 'Baked scene / bundled art';
const target = el<HTMLSelectElement>('connectTarget'); target.replaceChildren(...this.stage.rooms.filter(r => r !== this.room).map(r => { const o = document.createElement('option'); o.value = r.id; o.textContent = r.name; return o; }));
const doors = el('doorList'); doors.replaceChildren();
for (const d of this.room.doors) { const row = document.createElement('div'); row.className = 'connection'; const label = document.createElement('span'); label.textContent = `${d.side} → ${this.stage.rooms.find(r => r.id === d.to)?.name ?? '?'}`; const b = document.createElement('button'); b.textContent = '×'; b.setAttribute('aria-label', 'Remove connection'); b.addEventListener('click', () => { removeConnection(this.stage, this.room, d); this.changed(); }); row.append(label, b); doors.append(row); }
this.graph.draw();
this.draw();
}
applyFields(): void {
const value = (id: string) => el<HTMLInputElement>(id).value;
const num = (id: string, fallback: number, min: number, max: number) => { const n = Number(value(id)); return Number.isFinite(n) ? Math.max(min, Math.min(max, n)) : fallback; };
this.room.name = value('roomName').trim().slice(0, 100) || 'Untitled room'; this.room.description = value('roomDescription').slice(0, 4000);
const oldW = this.room.w, oldD = this.room.d;
this.room.w = num('roomWidth', this.room.w, 3, 30); this.room.d = num('roomDepth', this.room.d, 3, 30);
this.camera.name = value('cameraName').trim().slice(0, 100) || 'Camera';
['camX', 'camY', 'camZ'].forEach((id, i) => this.camera.position[i] = num(id, this.camera.position[i], i === 1 ? .25 : -40, 40));
['targetX', 'targetY', 'targetZ'].forEach((id, i) => this.camera.target[i] = num(id, this.camera.target[i], -40, 40)); this.camera.fov = num('camFov', this.camera.fov, 25, 90);
if(!('points' in this.camera.zone)) this.camera.zone = { x: num('zoneX', 0, -30, 30), z: num('zoneZ', 0, -30, 30), w: num('zoneWidth', this.room.w, .25, 40), d: num('zoneDepth', this.room.d, .25, 40) };
this.camera.priority=Math.round(num('cameraPriority',0,0,100));this.camera.fallback=el<HTMLInputElement>('cameraFallback').checked;this.camera.door=value('cameraDoor')||undefined;
this.stage.style = value('baseStyle').slice(0, 8000);
if (oldW !== this.room.w || oldD !== this.room.d) {
for (const d of this.room.doors) { if (d.side === 'west' || d.side === 'east') { d.x = (d.side === 'east' ? 1 : -1) * (this.room.w / 2 - .35); d.z = Math.max(-this.room.d / 2 + .7, Math.min(this.room.d / 2 - .7, d.z)); } else { d.z = (d.side === 'south' ? 1 : -1) * (this.room.d / 2 - .35); d.x = Math.max(-this.room.w / 2 + .7, Math.min(this.room.w / 2 - .7, d.x)); } }
for (const d of this.room.dressing ?? []) if (d.side) this.positionDressing(d, d);
}
this.changed();
}
addRoom(): void {
this.cancelDrag();this.cancelPolygon();
if (this.stage.rooms.length >= 16) return this.notify('This demo supports up to 16 rooms.');
const id = `room-${Date.now().toString(36)}`;
const slot = this.stage.rooms.length;
const r: Room = { id, name: 'New room', description: 'An abandoned police department room.', w: 8, d: 8, origin: { x: (slot % 4) * 12, z: 24 + Math.floor(slot / 4) * 12 }, props: [], cameras: [{ id: 'cam-1', name: 'Wide', position: [-4, 4.6, 5], target: [0, .5, 0], fov: 55, zone: { x: 0, z: 0, w: 8, d: 8 } }], zombies: [], doors: [], spawn: { x: 0, z: 2 } };
this.stage.rooms.push(r); this.room = r; this.camera = r.cameras[0]; this.selected = null; this.changed(); this.notify('Room created. Set its dimensions, props and connections.');
}
addCamera(): void {
this.cancelDrag();this.cancelPolygon();
if (this.room.cameras.length >= 12) return this.notify('Up to 12 cameras per room.');
const c: Camera = { ...structuredClone(this.camera), id: `cam-${Date.now().toString(36)}`, name: 'New angle' }; delete c.backdrop; delete c.artSignature; this.room.cameras.push(c); this.camera = c; this.changed(); this.tool = 'zone'; this.notify('Camera added. Draw its trigger area, then set position and target.');
}
connect(): void {
const target = this.stage.rooms.find(r => r.id === el<HTMLSelectElement>('connectTarget').value); if (!target) return this.notify('Add another room first.');
const side = el<HTMLSelectElement>('connectSide').value as Door['side']; const other = el<HTMLSelectElement>('targetSide').value as Door['side'];
try { connectRooms({ room: this.room, side, offset: Number(el<HTMLInputElement>('connectOffset').value) }, { room: target, side: other, offset: Number(el<HTMLInputElement>('targetOffset').value) }); this.changed(); this.notify('Rooms connected in both directions.'); }
catch (error) { this.notify(error instanceof Error ? error.message : String(error)); }
}
positionDressing(d: Dressing, p: Point): void {
if (['papers', 'cleaning'].includes(d.kind)) { d.x = Math.max(-this.room.w / 2 + .4, Math.min(this.room.w / 2 - .4, p.x)); d.z = Math.max(-this.room.d / 2 + .4, Math.min(this.room.d / 2 - .4, p.z)); return; }
const e = wallEndpoint(this.room, p); d.side = e.side;
d.x = e.side === 'east' ? this.room.w / 2 - .16 : e.side === 'west' ? -this.room.w / 2 + .16 : e.offset;
d.z = e.side === 'south' ? this.room.d / 2 - .16 : e.side === 'north' ? -this.room.d / 2 + .16 : e.offset;
}
point(e: PointerEvent): Point { const r = this.canvas.getBoundingClientRect(); return { x: snap((e.clientX - r.left - this.offset.x) / this.scale), z: snap((e.clientY - r.top - this.offset.z) / this.scale) }; }
down = (e: PointerEvent): void => {
e.preventDefault(); this.canvas.setPointerCapture(e.pointerId); const p = this.point(e);
const near = (at: number[]) => Math.hypot(p.x - at[0], p.z - at[2]) * this.scale < 14;
if (this.tool === 'camera' || (this.tool === 'select' && (near(this.camera.position) || near(this.camera.target)))) {
this.cameraDrag = near(this.camera.target) ? 'target' : near(this.camera.position) || this.tool === 'camera' ? 'position' : null;
this.cameraOriginal = [...this.camera[this.cameraDrag!]]; this.drag = p; this.selected = null; return;
}
if (Math.abs(p.x) > this.room.w / 2 || Math.abs(p.z) > this.room.d / 2) return;
if(this.tool==='zone') {
if(this.polygonDraft){if(this.polygonDraft.length>=3&&Math.hypot(p.x-this.polygonDraft[0].x,p.z-this.polygonDraft[0].z)*this.scale<12){this.finishPolygon();return;}if(this.polygonDraft.length>=24)return this.notify('Up to 24 vertices.');this.polygonDraft.push(p);this.refresh();return;}
if('points' in this.camera.zone){const index=this.camera.zone.points.findIndex(v=>Math.hypot(v.x-p.x,v.z-p.z)*this.scale<14);if(index<0)return this.notify('Drag a vertex, or draw a new polygon.');this.vertexDrag=index;this.zoneOriginal=structuredClone(this.camera.zone.points);this.drag=p;return;}
}
if(this.tool==='herb'){if(blocked(this.room,p))return this.notify('Place herbs on empty floor.');if((this.room.herbs?.length??0)>=20)return this.notify('Up to 20 herbs per room.');const h={id:`herb-${Date.now().toString(36)}`,kind:'green' as const,...p};(this.room.herbs??=[]).push(h);this.selected=h;this.changed();return;}
if (this.tool === 'dress') { if ((this.room.dressing?.length ?? 0) >= 80) return this.notify('Up to 80 dressing details per room.'); const d: Dressing = { id: `dress-${Date.now().toString(36)}`, kind: el<HTMLSelectElement>('dressingKind').value as Dressing['kind'], ...p }; this.positionDressing(d, p); (this.room.dressing ??= []).push(d); this.selected = d; this.changed(); return; }
if (this.tool === 'zombie' || this.tool === 'spawn') {
if (blocked(this.room, p)) return this.notify('Place spawns on empty floor.');
if (this.tool === 'zombie') { if (this.room.zombies.length >= 20) return this.notify('Up to 20 zombies per room.'); this.room.zombies.push(p); } else this.room.spawn = p;
this.changed(); return;
}
this.drag = p;
if (this.tool === 'select') { this.selected = this.room.herbs?.find(h=>Math.hypot(p.x-h.x,p.z-h.z)<.5) ?? this.room.dressing?.find(d => Math.hypot(p.x - d.x, p.z - d.z) < .5) ?? [...this.room.props].reverse().find(r => inside(p, r)) ?? this.room.zombies.find(z => Math.hypot(p.x - z.x, p.z - z.z) < .5) ?? null; this.dragOriginal = this.selected ? { ...this.selected } : null; this.refresh(); }
};
moving = (e: PointerEvent): void => {
if (!this.drag) return;
const p = this.point(e);
if(this.vertexDrag!==null&&'points' in this.camera.zone){this.camera.zone.points[this.vertexDrag]={x:Math.max(-this.room.w/2,Math.min(this.room.w/2,p.x)),z:Math.max(-this.room.d/2,Math.min(this.room.d/2,p.z))};this.draw();}
else if (this.cameraDrag) { const value = this.camera[this.cameraDrag]; value[0] = Math.max(-40, Math.min(40, p.x)); value[2] = Math.max(-40, Math.min(40, p.z)); ['camX', 'camZ'].forEach((id, i) => { if (this.cameraDrag === 'position') el<HTMLInputElement>(id).value = String(value[i * 2]); }); ['targetX', 'targetZ'].forEach((id, i) => { if (this.cameraDrag === 'target') el<HTMLInputElement>(id).value = String(value[i * 2]); }); this.dirty = true; this.draw(); }
else if (this.tool === 'select' && this.selected) { if (this.room.dressing?.includes(this.selected as Dressing)) this.positionDressing(this.selected as Dressing, p); else { this.selected.x = Math.max(-this.room.w / 2 + .5, Math.min(this.room.w / 2 - .5, p.x)); this.selected.z = Math.max(-this.room.d / 2 + .5, Math.min(this.room.d / 2 - .5, p.z)); } this.dirty = true; this.draw(); }
else this.draw(p);
};
up = (e: PointerEvent): void => {
if (!this.drag) return;
const p = this.point(e), a = this.drag; this.drag = null;
if(this.vertexDrag!==null&&'points' in this.camera.zone){const issue=polygonIssue(this.camera.zone.points);if(issue&&this.zoneOriginal){this.camera.zone.points=this.zoneOriginal;this.notify(issue);}this.vertexDrag=null;this.zoneOriginal=null;this.changed();return;}
if(this.tool==='select'&&this.selected&&this.room.herbs?.includes(this.selected as HerbPlacement)&&blocked(this.room,this.selected)&&this.dragOriginal){Object.assign(this.selected,this.dragOriginal);this.notify('Keep herbs on clear floor.');}
if (this.cameraDrag) { this.cameraDrag = null; this.cameraOriginal = null; this.changed(); return; }
this.dragOriginal = null;
if (this.tool === 'zone' || this.tool === 'prop') {
const minX = Math.max(-this.room.w / 2, Math.min(a.x, p.x)), maxX = Math.min(this.room.w / 2, Math.max(a.x, p.x));
const minZ = Math.max(-this.room.d / 2, Math.min(a.z, p.z)), maxZ = Math.min(this.room.d / 2, Math.max(a.z, p.z));
const rect = { x: (minX + maxX) / 2, z: (minZ + maxZ) / 2, w: Math.max(.25, maxX - minX), d: Math.max(.25, maxZ - minZ) };
if (this.tool === 'zone') this.camera.zone = rect;
else { if (this.room.props.length >= 80) return this.notify('Up to 80 props per room.'); const kind = el<HTMLSelectElement>('propKind').value as Prop['kind']; const heights = { wall: 2.8, shelf: 2.2, desk: .85, crate: .9, bench: .6 }; const prop = { ...rect, id: `prop-${Date.now().toString(36)}`, kind, h: heights[kind] }; if (inside(this.room.spawn, prop, .3) || this.room.zombies.some(p => inside(p, prop, .3)) || this.room.herbs?.some(p=>inside(p,prop,.3))) return this.notify('Keep the player and zombie spawns clear.'); this.room.props.push(prop); this.selected = prop; }
}
this.changed();
};
draw(end?: Point): void {
if (this.mapMode) { this.graph.draw(); return; }
const bounds = this.canvas.parentElement!.getBoundingClientRect(); const w = Math.max(200, bounds.width), h = Math.max(220, bounds.height);
this.canvas.width = w * devicePixelRatio; this.canvas.height = h * devicePixelRatio; this.canvas.style.width = `${w}px`; this.canvas.style.height = `${h}px`;
const x = this.ctx; x.setTransform(devicePixelRatio, 0, 0, devicePixelRatio, 0, 0); x.clearRect(0, 0, w, h); x.fillStyle = '#121c1b'; x.fillRect(0, 0, w, h);
if (!this.drag) {
const minX = Math.min(-this.room.w / 2, this.camera.position[0], this.camera.target[0]) - .7, maxX = Math.max(this.room.w / 2, this.camera.position[0], this.camera.target[0]) + .7;
const minZ = Math.min(-this.room.d / 2, this.camera.position[2], this.camera.target[2]) - .7, maxZ = Math.max(this.room.d / 2, this.camera.position[2], this.camera.target[2]) + .7;
this.scale = Math.min((w - 40) / (maxX - minX), (h - 55) / (maxZ - minZ)); this.offset = { x: w / 2 - (maxX + minX) / 2 * this.scale, z: h / 2 - (maxZ + minZ) / 2 * this.scale };
}
const px = (n: number) => this.offset.x + n * this.scale, pz = (n: number) => this.offset.z + n * this.scale;
Object.assign(this.canvas.dataset, { scale: String(this.scale), offsetX: String(this.offset.x), offsetZ: String(this.offset.z) });
x.strokeStyle = '#243331'; x.lineWidth = .5;
for (let n = Math.ceil(-this.room.w / 2); n <= this.room.w / 2; n++) { x.beginPath(); x.moveTo(px(n), pz(-this.room.d / 2)); x.lineTo(px(n), pz(this.room.d / 2)); x.stroke(); }
for (let n = Math.ceil(-this.room.d / 2); n <= this.room.d / 2; n++) { x.beginPath(); x.moveTo(px(-this.room.w / 2), pz(n)); x.lineTo(px(this.room.w / 2), pz(n)); x.stroke(); }
const rectangle = (r: { x: number; z: number; w: number; d: number }, fill: string, stroke: string) => { x.fillStyle = fill; x.strokeStyle = stroke; x.fillRect(px(r.x - r.w / 2), pz(r.z - r.d / 2), r.w * this.scale, r.d * this.scale); x.strokeRect(px(r.x - r.w / 2), pz(r.z - r.d / 2), r.w * this.scale, r.d * this.scale); };
const polygon=(points:Point[],fill:string,stroke:string)=>{if(!points.length)return;x.beginPath();points.forEach((p,i)=>i?x.lineTo(px(p.x),pz(p.z)):x.moveTo(px(p.x),pz(p.z)));x.closePath();x.fillStyle=fill;x.strokeStyle=stroke;x.fill();x.stroke();};
for (const c of this.room.cameras) { x.setLineDash([4, 4]); polygon(zonePoints(c.zone), c === this.camera ? '#76987b22' : '#54676710', c === this.camera ? '#83a88d' : '#405b51'); } x.setLineDash([]);
const vertices=this.polygonDraft??('points' in this.camera.zone?this.camera.zone.points:[]);
if(this.polygonDraft)polygon(vertices,'#e3c48822','#e3c488');
for (const p of this.room.props) { rectangle(p, p === this.selected ? '#917e4e' : '#3e4a44', p === this.selected ? '#e0c27c' : '#7d8976'); x.fillStyle = '#c4c6ab'; x.font = '10px monospace'; x.textAlign = 'center'; if (p.w * this.scale > 32 && p.d * this.scale > 17) x.fillText(p.kind, px(p.x), pz(p.z) + 3); }
for (const d of this.room.dressing ?? []) { x.fillStyle = d === this.selected ? '#efc681' : '#7f9c90'; x.beginPath(); x.arc(px(d.x), pz(d.z), 4, 0, Math.PI * 2); x.fill(); }
x.strokeStyle = '#b9b89b'; x.lineWidth = 3; x.strokeRect(px(-this.room.w / 2), pz(-this.room.d / 2), this.room.w * this.scale, this.room.d * this.scale);
for (const d of this.room.doors) { x.fillStyle = '#d1b679'; if (d.side === 'east' || d.side === 'west') x.fillRect(px(d.x) - 4, pz(d.z) - .6 * this.scale, 8, 1.2 * this.scale); else x.fillRect(px(d.x) - .6 * this.scale, pz(d.z) - 4, 1.2 * this.scale, 8); }
for (const z of this.room.zombies) { x.fillStyle = z === this.selected ? '#f0c784' : '#bd7763'; x.beginPath(); x.arc(px(z.x), pz(z.z), 5, 0, Math.PI * 2); x.fill(); }
for(const h of this.room.herbs??[]){x.fillStyle=h===this.selected?'#e3c488':'#88bf72';x.font='bold 16px monospace';x.fillText('✿',px(h.x),pz(h.z)+5);}
x.fillStyle = '#ece0b4'; x.fillRect(px(this.room.spawn.x) - 5, pz(this.room.spawn.z) - 5, 10, 10); x.font = '11px monospace'; x.fillText('S', px(this.room.spawn.x), pz(this.room.spawn.z) - 10);
const cp = this.camera.position, ct = this.camera.target; x.strokeStyle = '#bfa76c'; x.lineWidth = 1; x.setLineDash([3, 3]); x.beginPath(); x.moveTo(px(cp[0]), pz(cp[2])); x.lineTo(px(ct[0]), pz(ct[2])); x.stroke(); x.setLineDash([]);
x.fillStyle = '#18231b'; x.strokeStyle = '#f1cf88'; x.lineWidth = 2; x.beginPath(); x.arc(px(cp[0]), pz(cp[2]), 11, 0, Math.PI * 2); x.fill(); x.stroke(); x.fillStyle = '#f1cf88'; x.font = '14px monospace'; x.textAlign = 'center'; x.fillText('◎', px(cp[0]), pz(cp[2]) + 5);
x.strokeStyle = '#bed4bd'; x.beginPath(); x.arc(px(ct[0]), pz(ct[2]), 10, 0, Math.PI * 2); x.stroke(); x.beginPath(); x.moveTo(px(ct[0]) - 6, pz(ct[2])); x.lineTo(px(ct[0]) + 6, pz(ct[2])); x.moveTo(px(ct[0]), pz(ct[2]) - 6); x.lineTo(px(ct[0]), pz(ct[2]) + 6); x.stroke();
if(this.tool==='zone'||this.polygonDraft)for(const [i,p] of vertices.entries()){x.fillStyle=i===0&&this.polygonDraft?'#e3c488':'#c8dcc4';x.beginPath();x.arc(px(p.x),pz(p.z),6,0,Math.PI*2);x.fill();}
if (end && this.drag) rectangle({ x: (end.x + this.drag.x) / 2, z: (end.z + this.drag.z) / 2, w: Math.abs(end.x - this.drag.x), d: Math.abs(end.z - this.drag.z) }, '#bea56d33', '#d3b773');
x.fillStyle = '#91a499'; x.font = '11px monospace'; x.textAlign = 'left'; x.fillText('N ↑ / 1 square = 1 m', 16, 22); x.fillText(`${this.room.w} × ${this.room.d} m`, 16, h - 16); x.textAlign = 'right'; x.fillText('¼ m snap', w - 16, h - 16);
}
renderPreview(): void { if (this.dirty) { this.renderer.preview(this.room, this.camera, this.schema); this.dirty = false; } }
cancelDrag = (): void => { if(this.zoneOriginal&&'points' in this.camera.zone)this.camera.zone.points=this.zoneOriginal;this.vertexDrag=null;this.zoneOriginal=null; if (this.cameraDrag && this.cameraOriginal) this.camera[this.cameraDrag] = this.cameraOriginal; if (this.selected && this.dragOriginal) Object.assign(this.selected, this.dragOriginal); this.cameraDrag = null; this.cameraOriginal = null; this.dragOriginal = null; this.drag = null; this.dirty = true; this.refresh(); };
dispose(): void { this.observer.disconnect(); this.graph.dispose(); }
}
