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 { connectRooms, connections, removeConnection, repositionDoor, wallEndpoint, wallPoint, type Endpoint } from './connections.js';
import { doorPosition, type Door, type Point, type Room, type Stage } from './world.js';
const el = <T extends HTMLElement>(id: string) => document.getElementById(id) as T;
const snap = (n: number) => Math.round(n * 2) / 2;
type Selection = { room: Room; door: Door };
type Drag = { kind: 'room' | 'door' | 'link' | 'pan'; start: Point; original: Point; room?: Room; door?: Door; endpoint?: Endpoint; moved: boolean };
export class GraphEditor {
canvas = el<HTMLCanvasElement>('mapCanvas'); ctx = this.canvas.getContext('2d')!;
scale = 12; center: Point = { x: 8, z: 0 }; tool = 'move'; selection: Selection | null = null;
pending: Endpoint | null = null; drag: Drag | null = null; pointer: Point | null = null;
observer: ResizeObserver; ready = false;
constructor(private state: () => { stage: Stage; room: Room }, private select: (id: string) => void, private changed: () => void, private notify: (message: string) => void) {
this.canvas.addEventListener('pointerdown', this.down); this.canvas.addEventListener('pointermove', this.moving); this.canvas.addEventListener('pointerup', this.up);
this.canvas.addEventListener('pointercancel', this.cancel);
this.canvas.addEventListener('wheel', this.wheel, { passive: false });
el('mapMove').addEventListener('click', () => this.setTool('move'));
el('mapConnect').addEventListener('click', () => this.setTool('connect'));
el('mapFit').addEventListener('click', () => this.fit());
el('mapZoomIn').addEventListener('click', () => this.zoom(1.3)); el('mapZoomOut').addEventListener('click', () => this.zoom(1 / 1.3));
el('removeLink').addEventListener('click', () => { if (!this.selection) return; removeConnection(this.state().stage, this.selection.room, this.selection.door); this.selection = null; this.changed(); this.notify('Both door endpoints removed.'); });
for (const axis of ['X', 'Z'] as const) el('roomMap' + axis).addEventListener('change', () => {
const n = Number(el<HTMLInputElement>('roomMap' + axis).value);
if (Number.isFinite(n)) { this.state().room.origin[axis === 'X' ? 'x' : 'z'] = Math.max(-100, Math.min(100, snap(n))); this.changed(); }
});
this.observer = new ResizeObserver(() => { if (!this.ready && this.canvas.parentElement!.clientWidth > 0) this.fit(); else this.draw(); });
this.observer.observe(this.canvas.parentElement!);
}
setTool(tool: string): void { this.tool = tool; this.pending = null; this.drag = null; el('mapMove').classList.toggle('active', tool === 'move'); el('mapConnect').classList.toggle('active', tool === 'connect'); this.draw(); }
fit(): void {
const { stage } = this.state(), b = this.canvas.parentElement!.getBoundingClientRect(); if (b.width < 10) return;
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));
this.center = { x: (minX + maxX) / 2, z: (minZ + maxZ) / 2 };
this.scale = Math.max(2, Math.min((b.width - 48) / (maxX - minX + 4), (b.height - 72) / (maxZ - minZ + 4)));
this.ready = true; this.draw();
}
zoom(factor: number): void { this.scale = Math.max(2, Math.min(60, this.scale * factor)); this.draw(); }
world(e: PointerEvent): Point { const b = this.canvas.getBoundingClientRect(); return { x: this.center.x + (e.clientX - b.left - b.width / 2) / this.scale, z: this.center.z + (e.clientY - b.top - b.height / 2) / this.scale }; }
screen(p: Point): Point { return { x: this.canvas.clientWidth / 2 + (p.x - this.center.x) * this.scale, z: this.canvas.clientHeight / 2 + (p.z - this.center.z) * this.scale }; }
hitRoom(p: Point): Room | undefined {
const rooms = this.state().stage.rooms;
return [...rooms].reverse().find(r => Math.abs(p.x - r.origin.x) <= r.w / 2 && Math.abs(p.z - r.origin.z) <= r.d / 2) ?? rooms.find(r => Math.abs(p.x - r.origin.x) <= r.w / 2 + 8 / this.scale && Math.abs(p.z - r.origin.z) <= r.d / 2 + 8 / this.scale);
}
endpoint(p: Point): Endpoint | null { const room = this.hitRoom(p); return room ? wallEndpoint(room, { x: p.x - room.origin.x, z: p.z - room.origin.z }) : null; }
hitDoor(p: Point): Selection | null {
for (const room of this.state().stage.rooms) for (const door of room.doors) { const at = wallPoint(room, door); if (Math.hypot(p.x - at.x, p.z - at.z) * this.scale < 12) return { room, door }; }
return null;
}
hitLink(p: Point): Selection | null {
for (const c of connections(this.state().stage)) {
const a = wallPoint(c.room, c.door), b = wallPoint(c.target, c.other), dx = b.x - a.x, dz = b.z - a.z;
const t = Math.max(0, Math.min(1, ((p.x - a.x) * dx + (p.z - a.z) * dz) / (dx * dx + dz * dz || 1)));
if (Math.hypot(p.x - a.x - t * dx, p.z - a.z - t * dz) * this.scale < 7) return { room: c.room, door: c.door };
}
return null;
}
down = (e: PointerEvent): void => {
e.preventDefault(); this.canvas.setPointerCapture(e.pointerId); const p = this.world(e); this.pointer = p;
if (this.tool === 'connect') {
const endpoint = this.endpoint(p); if (!endpoint) { this.pending = null; this.draw(); return; }
this.drag = { kind: 'link', start: p, original: p, endpoint, moved: false }; this.draw(); return;
}
const door = this.hitDoor(p);
if (door) { this.selection = door; this.select(door.room.id); this.drag = { kind: 'door', start: p, original: { x: door.door.x, z: door.door.z }, ...door, moved: false }; }
else {
const link = this.hitLink(p), room = this.hitRoom(p);
if (link && !room) { this.selection = link; this.select(link.room.id); }
else if (room) { this.select(room.id); this.selection = null; this.drag = { kind: 'room', start: p, original: { ...room.origin }, room, moved: false }; }
else { this.selection = null; this.drag = { kind: 'pan', start: p, original: { ...this.center }, moved: false }; }
}
this.draw();
};
moving = (e: PointerEvent): void => {
const p = this.world(e); this.pointer = p; const d = this.drag; if (!d) return;
d.moved ||= Math.hypot(p.x - d.start.x, p.z - d.start.z) * this.scale > 4;
if (d.kind === 'room' && d.room) d.room.origin = { x: Math.max(-100, Math.min(100, snap(d.original.x + p.x - d.start.x))), z: Math.max(-100, Math.min(100, snap(d.original.z + p.z - d.start.z))) };
if (d.kind === 'pan') { this.center.x -= p.x - d.start.x; this.center.z -= p.z - d.start.z; }
this.draw();
};
finishLink(a: Endpoint, b: Endpoint): void {
try { const [door] = connectRooms(a, b); this.pending = null; this.selection = { room: a.room, door }; this.changed(); this.notify(`Connected ${a.room.name} ↔ ${b.room.name}.`); }
catch (error) { this.pending = a; this.notify(error instanceof Error ? error.message : String(error)); }
}
up = (e: PointerEvent): void => {
const d = this.drag; this.drag = null; if (!d) return;
const p = this.world(e);
if (d.kind === 'link' && d.endpoint) {
const target = this.endpoint(p);
if (d.moved && target && target.room !== d.endpoint.room) this.finishLink(d.endpoint, target);
else if (!d.moved && this.pending && this.pending.room !== d.endpoint.room) this.finishLink(this.pending, d.endpoint);
else this.pending = d.endpoint;
}
if (d.kind === 'room' && d.moved) this.changed();
if (d.kind === 'door' && d.room && d.door && d.moved) {
try { repositionDoor(d.room, d.door, wallEndpoint(d.room, { x: p.x - d.room.origin.x, z: p.z - d.room.origin.z })); this.changed(); this.notify('Door endpoint moved. Regenerate affected camera backgrounds.'); }
catch (error) { this.notify(error instanceof Error ? error.message : String(error)); }
}
this.draw();
};
cancel = (): void => { if (this.drag?.kind === 'room' && this.drag.room) this.drag.room.origin = this.drag.original; this.drag = null; this.pointer = null; this.draw(); };
wheel = (e: WheelEvent): void => { e.preventDefault(); this.zoom(e.deltaY < 0 ? 1.08 : 1 / 1.08); };
reset(): void { this.cancel(); this.selection = null; this.pending = null; this.ready = false; this.fit(); }
draw(): void {
const b = this.canvas.parentElement!.getBoundingClientRect(); if (b.width < 10 || b.height < 10) return;
const w = b.width, h = b.height, x = this.ctx, { stage, room: selected } = this.state();
Object.assign(this.canvas.dataset, { scale: String(this.scale), centerX: String(this.center.x), centerZ: String(this.center.z) });
this.canvas.width = Math.round(w * devicePixelRatio); this.canvas.height = Math.round(h * devicePixelRatio); this.canvas.style.width = `${w}px`; this.canvas.style.height = `${h}px`;
x.setTransform(devicePixelRatio, 0, 0, devicePixelRatio, 0, 0); x.fillStyle = '#111b18'; x.fillRect(0, 0, w, h);
x.strokeStyle = '#24332b'; x.lineWidth = .5;
for (let n = Math.ceil(this.center.x - w / 2 / this.scale); n <= this.center.x + w / 2 / this.scale; n += 1) { const p = this.screen({ x: n, z: 0 }); x.beginPath(); x.moveTo(p.x, 0); x.lineTo(p.x, h); x.stroke(); }
for (let n = Math.ceil(this.center.z - h / 2 / this.scale); n <= this.center.z + h / 2 / this.scale; n += 1) { const p = this.screen({ x: 0, z: n }); x.beginPath(); x.moveTo(0, p.z); x.lineTo(w, p.z); x.stroke(); }
const links = connections(stage);
if (this.selection && !this.selection.room.doors.includes(this.selection.door)) this.selection = null;
for (const c of links) {
const a = this.screen(wallPoint(c.room, c.door)), b = this.screen(wallPoint(c.target, c.other));
const active = this.selection?.door === c.door || this.selection?.door === c.other;
x.strokeStyle = active ? '#f0d597' : '#b09b69'; x.lineWidth = active ? 3 : 1.5; x.setLineDash([5, 3]); x.beginPath(); x.moveTo(a.x, a.z); x.lineTo(b.x, b.z); x.stroke(); x.setLineDash([]);
}
for (const [i, room] of stage.rooms.entries()) {
const p = this.screen(room.origin), rw = room.w * this.scale, rh = room.d * this.scale;
x.fillStyle = room === selected ? '#455541' : '#25392f'; x.strokeStyle = room === selected ? '#d0c08c' : '#78937a'; x.lineWidth = room === selected ? 2 : 1;
x.fillRect(p.x - rw / 2, p.z - rh / 2, rw, rh); x.strokeRect(p.x - rw / 2, p.z - rh / 2, rw, rh);
x.save(); x.beginPath(); x.rect(p.x - rw / 2 + 3, p.z - rh / 2 + 3, rw - 6, rh - 6); x.clip();
x.textAlign = 'center'; x.fillStyle = '#e4dec0'; x.font = `${Math.min(12, Math.max(8, rw / 9))}px monospace`; x.fillText(`${String(i + 1).padStart(2, '0')}`, p.x, p.z - 10); x.fillText(room.name.replace(' Room', '').replace('Records ', ''), p.x, p.z + 5);
if (rh > 65) { x.fillStyle = '#a8b69c'; x.font = '9px monospace'; x.fillText(`${room.doors.length} doors`, p.x, p.z + 20); } x.restore();
for (const door of room.doors) { const d = this.screen(wallPoint(room, door)); x.fillStyle = this.selection?.door === door ? '#ffe3a0' : '#bcab7c'; x.strokeStyle = '#17251b'; x.lineWidth = 2; x.beginPath(); x.arc(d.x, d.z, 5.5, 0, Math.PI * 2); x.fill(); x.stroke(); }
}
const source = this.drag?.kind === 'link' ? this.drag.endpoint : this.pending;
if (source) {
const a = this.screen(wallPoint(source.room, { side: source.side, ...doorPosition(source.room, source.side, source.offset) }));
x.strokeStyle = '#f1d59a'; x.lineWidth = 2; x.beginPath(); x.arc(a.x, a.z, 10, 0, Math.PI * 2); x.stroke();
if (this.pointer) { const p = this.screen(this.pointer); x.setLineDash([4, 3]); x.beginPath(); x.moveTo(a.x, a.z); x.lineTo(p.x, p.z); x.stroke(); x.setLineDash([]); }
}
if (this.drag?.kind === 'door' && this.drag.room && this.pointer) { const r = this.drag.room, e = wallEndpoint(r, { x: this.pointer.x - r.origin.x, z: this.pointer.z - r.origin.z }), p = this.screen(wallPoint(r, { side: e.side, ...doorPosition(r, e.side, e.offset) })); x.strokeStyle = '#ffe3a0'; x.beginPath(); x.arc(p.x, p.z, 9, 0, Math.PI * 2); x.stroke(); }
x.fillStyle = '#9caf97'; x.font = '10px monospace'; x.textAlign = 'left'; x.fillText('N ↑ / FLOOR 01', 14, 21); x.textAlign = 'right'; x.fillText(`${stage.rooms.length} rooms · ${links.length} links`, w - 14, h - 14);
el<HTMLInputElement>('roomMapX').value = String(selected.origin.x); el<HTMLInputElement>('roomMapZ').value = String(selected.origin.z);
el<HTMLButtonElement>('removeLink').disabled = !this.selection;
const link = this.selection, target = stage.rooms.find(r => r.id === link?.door.to);
el('mapSelection').textContent = link ? `${link.room.name} (${link.door.side}) ↔ ${target?.name ?? '?'}` : this.pending ? `${this.pending.room.name} selected. Choose another room edge.` : `${selected.name} · drag its center to position it`;
}
dispose(): void { this.observer.disconnect(); this.canvas.removeEventListener('pointerdown', this.down); this.canvas.removeEventListener('pointermove', this.moving); this.canvas.removeEventListener('pointerup', this.up); this.canvas.removeEventListener('pointercancel', this.cancel); this.canvas.removeEventListener('wheel', this.wheel); }
}
