import './style.css'; import { Camera } from '@game/camera'; import { GameRenderer, screenToWorld, worldToScreen } from '@game/renderer'; import { buildWorldTerrainContext, townsApiToInfluences } from '@game/procedural'; import type { BuildingData, NPCData, TownData, TownObjectData } from '@game/types'; type AdminTown = { id: number; name: string; nameKey?: string; biome: string; worldX: number; worldY: number; radius: number; levelMin: number; levelMax: number; }; type AdminNPC = { id: number; townId: number; name: string; nameKey?: string; type: NPCData['type']; offsetX: number; offsetY: number; buildingId?: number | null; }; type AdminBuilding = { id: number; townId: number; buildingType: string; offsetX: number; offsetY: number; facing: BuildingData['facing']; footprintW: number; footprintH: number; }; type AdminTownObject = { id: number; townId: number; objectType: string; variant: number; offsetX: number; offsetY: number; }; type TownLayoutResponse = { town: AdminTown; npcs: AdminNPC[]; buildings: AdminBuilding[]; objects: AdminTownObject[]; }; type DraftNPC = AdminNPC; type DraftBuilding = AdminBuilding; type DraftObject = AdminTownObject; type Selection = | { type: 'npc'; id: number } | { type: 'building'; id: number } | { type: 'object'; id: number } | { type: 'none' }; type DragState = | { type: 'npc'; id: number; startOffsetX: number; startOffsetY: number; } | { type: 'building'; id: number; startOffsetX: number; startOffsetY: number; linkedNpcOffsets: Map; } | { type: 'object'; id: number; startOffsetX: number; startOffsetY: number; } | null; type PanState = { startClientX: number; startClientY: number; startCameraX: number; startCameraY: number; } | null; const state = { auth: { username: sessionStorage.getItem('admin_user') || '', password: sessionStorage.getItem('admin_pass') || '', }, towns: [] as AdminTown[], selectedTownId: null as number | null, townData: null as TownData | null, npcData: [] as NPCData[], draftTown: null as AdminTown | null, draftNPCs: [] as DraftNPC[], draftBuildings: [] as DraftBuilding[], draftObjects: [] as DraftObject[], deletedNPCIds: [] as number[], deletedBuildingIds: [] as number[], deletedObjectIds: [] as number[], dirty: false, tempBuildingId: -1, tempNpcId: -1, tempObjectId: -1, selection: { type: 'none' } as Selection, drag: null as DragState, pan: null as PanState, contextWorld: null as { x: number; y: number } | null, }; const renderer = new GameRenderer(); const camera = new Camera(); const authUserInput = document.getElementById('auth-user') as HTMLInputElement; const authPassInput = document.getElementById('auth-pass') as HTMLInputElement; const authSaveBtn = document.getElementById('auth-save') as HTMLButtonElement; const authStatus = document.getElementById('auth-status') as HTMLDivElement; const townsReloadBtn = document.getElementById('towns-reload') as HTMLButtonElement; const townSelect = document.getElementById('town-select') as HTMLSelectElement; const layoutReloadBtn = document.getElementById('layout-reload') as HTMLButtonElement; const layoutSaveBtn = document.getElementById('layout-save') as HTMLButtonElement; const selectionInfo = document.getElementById('selection-info') as HTMLDivElement; const layoutStatus = document.getElementById('layout-status') as HTMLDivElement; const canvasRoot = document.getElementById('canvas-root') as HTMLDivElement; const contextMenu = document.getElementById('context-menu') as HTMLDivElement; authUserInput.value = state.auth.username; authPassInput.value = state.auth.password; selectionInfo.textContent = 'No selection.'; function setStatus(el: HTMLElement, message: string, isError = false): void { el.textContent = message; el.style.color = isError ? '#ff8f8f' : ''; } function setDirty(value: boolean): void { state.dirty = value; layoutSaveBtn.disabled = !value; } function setSelection(next: Selection): void { state.selection = next; if (next.type === 'none') { selectionInfo.textContent = 'No selection.'; return; } selectionInfo.textContent = `${next.type.toUpperCase()} #${next.id}`; } function saveAuth(): void { state.auth.username = authUserInput.value.trim(); state.auth.password = authPassInput.value.trim(); sessionStorage.setItem('admin_user', state.auth.username); sessionStorage.setItem('admin_pass', state.auth.password); setStatus(authStatus, 'Credentials saved.'); } function authHeader(): string { return `Basic ${btoa(`${state.auth.username}:${state.auth.password}`)}`; } async function api(path: string, opts: RequestInit = {}): Promise { const headers = Object.assign( { Authorization: authHeader(), 'Content-Type': 'application/json' }, opts.headers || {}, ); return fetch(`/admin-api/${path}`, { cache: 'no-store', ...opts, headers }); } function townSize(radius: number): string { if (radius > 40) return 'XL'; if (radius > 25) return 'M'; if (radius > 15) return 'S'; return 'XS'; } function buildTownData(town: AdminTown, npcs: AdminNPC[], buildings: AdminBuilding[]): TownData { const npcData: NPCData[] = npcs.map((n) => ({ id: n.id, name: n.nameKey || n.name, nameKey: n.nameKey, type: n.type, worldX: town.worldX + n.offsetX, worldY: town.worldY + n.offsetY, buildingId: n.buildingId ?? undefined, townId: town.id, townLevelMin: town.levelMin, townLevelMax: town.levelMax, })); const buildingData: BuildingData[] = buildings.map((b) => ({ id: b.id, buildingType: b.buildingType, worldX: town.worldX + b.offsetX, worldY: town.worldY + b.offsetY, facing: b.facing, footprintW: b.footprintW, footprintH: b.footprintH, })); const objectData: TownObjectData[] = state.draftObjects.map((o) => ({ id: o.id, objectType: o.objectType, variant: o.variant, worldX: town.worldX + o.offsetX, worldY: town.worldY + o.offsetY, })); return { id: town.id, name: town.nameKey || town.name, nameKey: town.nameKey, centerX: town.worldX, centerY: town.worldY, radius: town.radius, biome: town.biome, levelMin: town.levelMin, size: townSize(town.radius), npcs: npcData, buildings: buildingData, objects: objectData, }; } function refreshTownData(): void { if (!state.draftTown) return; state.townData = buildTownData(state.draftTown, state.draftNPCs, state.draftBuildings); state.npcData = state.townData.npcs || []; } function applyLayout(data: TownLayoutResponse): void { state.draftTown = data.town; state.draftNPCs = data.npcs.map((n) => ({ ...n })); state.draftBuildings = data.buildings.map((b) => ({ ...b })); state.draftObjects = (data.objects || []).map((o) => ({ ...o })); state.deletedNPCIds = []; state.deletedBuildingIds = []; state.deletedObjectIds = []; refreshTownData(); if (state.townData) { const center = worldToScreen(state.townData.centerX, state.townData.centerY); camera.setTarget(center.x, center.y); camera.snapToTarget(); } setSelection({ type: 'none' }); setDirty(false); } async function loadTowns(): Promise { if (!state.auth.username || !state.auth.password) { setStatus(authStatus, 'Set admin credentials first.', true); return; } setStatus(layoutStatus, 'Loading towns...'); const res = await api('quests/towns'); if (!res.ok) { setStatus(layoutStatus, `Failed to load towns (${res.status}).`, true); return; } const data = await res.json(); const towns = (data.towns || []) as AdminTown[]; state.towns = towns; townSelect.innerHTML = ''; for (const t of towns) { const opt = document.createElement('option'); opt.value = String(t.id); opt.textContent = `${t.name} (Lv ${t.levelMin}-${t.levelMax})`; townSelect.appendChild(opt); } if (!state.selectedTownId && towns.length > 0) { state.selectedTownId = towns[0].id; } if (state.selectedTownId) { townSelect.value = String(state.selectedTownId); } const influences = townsApiToInfluences(towns); renderer.setWorldTerrainContext(buildWorldTerrainContext(influences, null)); setStatus(layoutStatus, 'Towns loaded.'); if (state.selectedTownId) { await loadLayout(state.selectedTownId); } } async function loadLayout(townId: number): Promise { setStatus(layoutStatus, 'Loading town layout...'); const res = await api(`quests/towns/${townId}/layout`); if (!res.ok) { setStatus(layoutStatus, `Failed to load layout (${res.status}).`, true); return; } const data = (await res.json()) as TownLayoutResponse; applyLayout(data); setStatus(layoutStatus, 'Layout loaded.'); } async function saveLayout(): Promise { if (!state.draftTown) return; setStatus(layoutStatus, 'Saving layout...'); layoutSaveBtn.disabled = true; const payload = { npcs: state.draftNPCs.map((n) => ({ ...(n.id > 0 ? { id: n.id } : {}), name: n.name, nameKey: n.nameKey || '', type: n.type, offsetX: n.offsetX, offsetY: n.offsetY, ...(n.buildingId ? { buildingId: n.buildingId } : {}), })), buildings: state.draftBuildings.map((b) => ({ ...(b.id > 0 ? { id: b.id } : {}), buildingType: b.buildingType, offsetX: b.offsetX, offsetY: b.offsetY, facing: b.facing, footprintW: b.footprintW, footprintH: b.footprintH, })), objects: state.draftObjects.map((o) => ({ ...(o.id > 0 ? { id: o.id } : {}), objectType: o.objectType, variant: o.variant, offsetX: o.offsetX, offsetY: o.offsetY, })), deleteNpcIds: state.deletedNPCIds, deleteBuildingIds: state.deletedBuildingIds, deleteObjectIds: state.deletedObjectIds, }; const res = await api(`quests/towns/${state.draftTown.id}/layout`, { method: 'PUT', body: JSON.stringify(payload), }); if (!res.ok) { setStatus(layoutStatus, `Failed to save layout (${res.status}).`, true); layoutSaveBtn.disabled = false; return; } const data = (await res.json()) as TownLayoutResponse; applyLayout(data); setStatus(layoutStatus, 'Layout saved.'); } const buildingMenuItems = [ { label: 'House: Quest Giver', type: 'house.quest_giver', w: 2.5, h: 2.0 }, { label: 'House: Merchant', type: 'house.merchant', w: 2.5, h: 2.0 }, { label: 'House: Armorer', type: 'house.armorer', w: 2.5, h: 2.0 }, { label: 'House: Weapon Smith', type: 'house.weapon_smith', w: 2.5, h: 2.0 }, { label: 'House: Jeweler', type: 'house.jeweler', w: 2.5, h: 2.0 }, { label: 'House: Bounty Hunter', type: 'house.bounty_hunter', w: 2.5, h: 2.0 }, { label: 'House: Elder', type: 'house.elder', w: 2.5, h: 2.0 }, { label: 'House: Healer', type: 'house.healer', w: 2.5, h: 2.0 }, { label: 'Decoration: Well', type: 'decoration.well', w: 1.5, h: 1.5 }, { label: 'Decoration: Stall', type: 'decoration.stall', w: 1.5, h: 1.5 }, { label: 'Decoration: Signpost', type: 'decoration.signpost', w: 0.5, h: 0.5 }, ]; const npcTypeItems = [ { label: 'Quest Giver', type: 'quest_giver' as NPCData['type'] }, { label: 'Merchant', type: 'merchant' as NPCData['type'] }, { label: 'Armorer', type: 'armorer' as NPCData['type'] }, { label: 'Weapon', type: 'weapon' as NPCData['type'] }, { label: 'Jeweler', type: 'jeweler' as NPCData['type'] }, { label: 'Bounty Hunter', type: 'bounty_hunter' as NPCData['type'] }, { label: 'Elder', type: 'elder' as NPCData['type'] }, { label: 'Healer', type: 'healer' as NPCData['type'] }, ]; const objectTypeItems = [ { label: 'Tree', type: 'tree' }, { label: 'Rock', type: 'rock' }, { label: 'Cart', type: 'cart' }, { label: 'Barrel', type: 'barrel' }, { label: 'Bush', type: 'bush' }, { label: 'Mushroom', type: 'mushroom' }, { label: 'Leaves', type: 'leaves' }, { label: 'Stump', type: 'stump' }, { label: 'Bones', type: 'bones' }, { label: 'Ruin', type: 'ruin' }, ]; function hideContextMenu(): void { contextMenu.classList.add('hidden'); } function addContextHeader(label: string): void { const header = document.createElement('div'); header.textContent = label; header.style.padding = '6px 12px 4px 12px'; header.style.fontSize = '11px'; header.style.color = '#9eb0d6'; contextMenu.appendChild(header); } function addContextButton(label: string, onClick: () => void): void { const btn = document.createElement('button'); btn.textContent = label; btn.addEventListener('click', onClick); contextMenu.appendChild(btn); } function addContextSubmenu(label: string, build: (submenu: HTMLDivElement) => void): void { const wrapper = document.createElement('div'); wrapper.className = 'context-menu-item'; const button = document.createElement('button'); button.textContent = `${label} ▶`; const submenu = document.createElement('div'); submenu.className = 'submenu'; build(submenu); wrapper.appendChild(button); wrapper.appendChild(submenu); contextMenu.appendChild(wrapper); } function showContextMenu(clientX: number, clientY: number, render: () => void): void { contextMenu.innerHTML = ''; render(); const area = canvasRoot.parentElement?.getBoundingClientRect(); if (!area) return; contextMenu.style.left = `${clientX - area.left}px`; contextMenu.style.top = `${clientY - area.top}px`; contextMenu.classList.remove('hidden'); } function removeNpcById(id: number): void { state.draftNPCs = state.draftNPCs.filter((n) => n.id !== id); if (id > 0) state.deletedNPCIds.push(id); setSelection({ type: 'none' }); refreshTownData(); setDirty(true); } function removeBuildingById(id: number): void { state.draftBuildings = state.draftBuildings.filter((b) => b.id !== id); if (id > 0) state.deletedBuildingIds.push(id); setSelection({ type: 'none' }); refreshTownData(); setDirty(true); } function removeObjectById(id: number): void { state.draftObjects = state.draftObjects.filter((o) => o.id !== id); if (id > 0) state.deletedObjectIds.push(id); setSelection({ type: 'none' }); refreshTownData(); setDirty(true); } function showContextMenuForSelection(clientX: number, clientY: number, selection: Selection): void { showContextMenu(clientX, clientY, () => { if (!state.draftTown) return; if (selection.type === 'npc') { addContextHeader('NPC'); addContextSubmenu('Change type', (submenu) => { for (const item of npcTypeItems) { const btn = document.createElement('button'); btn.textContent = item.label; btn.addEventListener('click', () => { const npc = state.draftNPCs.find((n) => n.id === selection.id); if (!npc) return; npc.type = item.type; if (!npc.name) npc.name = `New ${item.label}`; setDirty(true); refreshTownData(); hideContextMenu(); }); submenu.appendChild(btn); } }); addContextHeader('Actions'); addContextButton('Delete NPC', () => { removeNpcById(selection.id); hideContextMenu(); }); return; } if (selection.type === 'building') { addContextHeader('Building'); addContextSubmenu('Change type', (submenu) => { for (const item of buildingMenuItems) { const btn = document.createElement('button'); btn.textContent = item.label; btn.addEventListener('click', () => { const building = state.draftBuildings.find((b) => b.id === selection.id); if (!building) return; building.buildingType = item.type; building.footprintW = item.w; building.footprintH = item.h; setDirty(true); refreshTownData(); hideContextMenu(); }); submenu.appendChild(btn); } }); addContextHeader('Actions'); addContextButton('Delete building', () => { removeBuildingById(selection.id); hideContextMenu(); }); return; } if (selection.type === 'object') { addContextHeader('Object'); addContextSubmenu('Change type', (submenu) => { for (const item of objectTypeItems) { const btn = document.createElement('button'); btn.textContent = item.label; btn.addEventListener('click', () => { const obj = state.draftObjects.find((o) => o.id === selection.id); if (!obj) return; obj.objectType = item.type; setDirty(true); refreshTownData(); hideContextMenu(); }); submenu.appendChild(btn); } }); addContextSubmenu('Variant', (submenu) => { const btn0 = document.createElement('button'); btn0.textContent = 'Variant v0'; btn0.addEventListener('click', () => { const obj = state.draftObjects.find((o) => o.id === selection.id); if (!obj) return; obj.variant = 0; setDirty(true); refreshTownData(); hideContextMenu(); }); const btn1 = document.createElement('button'); btn1.textContent = 'Variant v1'; btn1.addEventListener('click', () => { const obj = state.draftObjects.find((o) => o.id === selection.id); if (!obj) return; obj.variant = 1; setDirty(true); refreshTownData(); hideContextMenu(); }); submenu.appendChild(btn0); submenu.appendChild(btn1); }); addContextHeader('Actions'); addContextButton('Delete object', () => { removeObjectById(selection.id); hideContextMenu(); }); return; } }); } function showContextMenuForEmpty(clientX: number, clientY: number): void { showContextMenu(clientX, clientY, () => { if (!state.draftTown || !state.contextWorld) return; const world = state.contextWorld; addContextSubmenu('Add NPC', (submenu) => { for (const item of npcTypeItems) { const btn = document.createElement('button'); btn.textContent = item.label; btn.addEventListener('click', () => { const offsetX = world.x - state.draftTown!.worldX; const offsetY = world.y - state.draftTown!.worldY; const newId = state.tempNpcId--; state.draftNPCs.push({ id: newId, townId: state.draftTown!.id, name: `New ${item.label}`, nameKey: '', type: item.type, offsetX, offsetY, buildingId: null, }); refreshTownData(); setSelection({ type: 'npc', id: newId }); setDirty(true); hideContextMenu(); }); submenu.appendChild(btn); } }); addContextSubmenu('Add Building', (submenu) => { for (const item of buildingMenuItems) { const btn = document.createElement('button'); btn.textContent = item.label; btn.addEventListener('click', () => { const offsetX = world.x - state.draftTown!.worldX; const offsetY = world.y - state.draftTown!.worldY; const newId = state.tempBuildingId--; state.draftBuildings.push({ id: newId, townId: state.draftTown!.id, buildingType: item.type, offsetX, offsetY, facing: 'south', footprintW: item.w, footprintH: item.h, }); refreshTownData(); setSelection({ type: 'building', id: newId }); setDirty(true); hideContextMenu(); }); submenu.appendChild(btn); } }); addContextSubmenu('Add Object', (submenu) => { for (const item of objectTypeItems) { const btn = document.createElement('button'); btn.textContent = item.label; btn.addEventListener('click', () => { const offsetX = world.x - state.draftTown!.worldX; const offsetY = world.y - state.draftTown!.worldY; const newId = state.tempObjectId--; state.draftObjects.push({ id: newId, townId: state.draftTown!.id, objectType: item.type, variant: 0, offsetX, offsetY, }); refreshTownData(); setSelection({ type: 'object', id: newId }); setDirty(true); hideContextMenu(); }); submenu.appendChild(btn); } }); }); } function eventToWorld(evt: MouseEvent): { x: number; y: number } | null { if (!renderer.initialized) return null; const canvas = renderer.app.canvas as HTMLCanvasElement; const rect = canvas.getBoundingClientRect(); if (evt.clientX < rect.left || evt.clientX > rect.right || evt.clientY < rect.top || evt.clientY > rect.bottom) { return null; } const localX = evt.clientX - rect.left; const localY = evt.clientY - rect.top; const screenW = renderer.app.renderer.width; const screenH = renderer.app.renderer.height; const worldScreenX = localX - screenW / 2 + camera.finalX; const worldScreenY = localY - screenH / 2 + camera.finalY; return screenToWorld(worldScreenX, worldScreenY); } function findHit(world: { x: number; y: number }): Selection { if (!state.draftTown) return { type: 'none' }; const npcHitRadius = 1.8; const objectHitRadius = 1.8; const buildingPadding = 1.0; for (const npc of state.draftNPCs) { const nx = state.draftTown.worldX + npc.offsetX; const ny = state.draftTown.worldY + npc.offsetY; const dx = world.x - nx; const dy = world.y - ny; if (Math.hypot(dx, dy) <= npcHitRadius) { return { type: 'npc', id: npc.id }; } } for (const obj of state.draftObjects) { const ox = state.draftTown.worldX + obj.offsetX; const oy = state.draftTown.worldY + obj.offsetY; if (Math.hypot(world.x - ox, world.y - oy) <= objectHitRadius) { return { type: 'object', id: obj.id }; } } for (const building of state.draftBuildings) { const bx = state.draftTown.worldX + building.offsetX; const by = state.draftTown.worldY + building.offsetY; const dx = Math.abs(world.x - bx); const dy = Math.abs(world.y - by); if (dx <= building.footprintW / 2 + buildingPadding && dy <= building.footprintH / 2 + buildingPadding) { return { type: 'building', id: building.id }; } } return { type: 'none' }; } function startDrag(selection: Selection): void { if (selection.type === 'npc') { const npc = state.draftNPCs.find((n) => n.id === selection.id); if (!npc) return; state.drag = { type: 'npc', id: npc.id, startOffsetX: npc.offsetX, startOffsetY: npc.offsetY, }; return; } if (selection.type === 'building') { const building = state.draftBuildings.find((b) => b.id === selection.id); if (!building) return; const linked = new Map(); for (const npc of state.draftNPCs) { if (npc.buildingId === building.id) { linked.set(npc.id, { offsetX: npc.offsetX, offsetY: npc.offsetY }); } } state.drag = { type: 'building', id: building.id, startOffsetX: building.offsetX, startOffsetY: building.offsetY, linkedNpcOffsets: linked, }; return; } if (selection.type === 'object') { const obj = state.draftObjects.find((o) => o.id === selection.id); if (!obj) return; state.drag = { type: 'object', id: obj.id, startOffsetX: obj.offsetX, startOffsetY: obj.offsetY, }; } } function updateDrag(world: { x: number; y: number }): void { if (!state.draftTown || !state.drag) return; const offsetX = world.x - state.draftTown.worldX; const offsetY = world.y - state.draftTown.worldY; if (state.drag.type === 'npc') { const npc = state.draftNPCs.find((n) => n.id === state.drag?.id); if (!npc) return; npc.offsetX = offsetX; npc.offsetY = offsetY; } if (state.drag.type === 'building') { const building = state.draftBuildings.find((b) => b.id === state.drag?.id); if (!building) return; const deltaX = offsetX - state.drag.startOffsetX; const deltaY = offsetY - state.drag.startOffsetY; building.offsetX = offsetX; building.offsetY = offsetY; for (const npc of state.draftNPCs) { const start = state.drag.linkedNpcOffsets.get(npc.id); if (!start) continue; npc.offsetX = start.offsetX + deltaX; npc.offsetY = start.offsetY + deltaY; } } if (state.drag.type === 'object') { const obj = state.draftObjects.find((o) => o.id === state.drag?.id); if (!obj) return; obj.offsetX = offsetX; obj.offsetY = offsetY; } refreshTownData(); setDirty(true); } function startPan(evt: MouseEvent): void { state.pan = { startClientX: evt.clientX, startClientY: evt.clientY, startCameraX: camera.x, startCameraY: camera.y, }; } function updatePan(evt: MouseEvent): void { if (!state.pan) return; const dx = evt.clientX - state.pan.startClientX; const dy = evt.clientY - state.pan.startClientY; const nextX = state.pan.startCameraX - dx; const nextY = state.pan.startCameraY - dy; camera.setTarget(nextX, nextY); camera.snapToTarget(); } function renderLoop(): void { requestAnimationFrame(renderLoop); if (!renderer.initialized) return; if (!state.townData) return; const screenW = renderer.app.renderer.width; const screenH = renderer.app.renderer.height; camera.applyTo(renderer.worldContainer, screenW, screenH); renderer.drawGround(camera, screenW, screenH); renderer.drawTowns([state.townData], camera, screenW, screenH); renderer.drawTownObjects(state.townData.objects ?? [], camera, screenW, screenH); if (state.npcData.length > 0) { renderer.drawNPCs(state.npcData, camera, screenW, screenH, performance.now()); } } async function bootstrap(): Promise { await renderer.init(canvasRoot); window.addEventListener('resize', () => renderer.resize()); renderLoop(); const canvas = renderer.app.canvas as HTMLCanvasElement; canvas.addEventListener('contextmenu', (evt) => { evt.preventDefault(); const world = eventToWorld(evt); if (!world) return; state.contextWorld = world; const hit = findHit(world); if (hit.type === 'none') { showContextMenuForEmpty(evt.clientX, evt.clientY); return; } setSelection(hit); showContextMenuForSelection(evt.clientX, evt.clientY, hit); }); canvas.addEventListener('mousedown', (evt) => { hideContextMenu(); const world = eventToWorld(evt); if (!world) return; if (evt.button === 1) { startPan(evt); return; } if (evt.button !== 0) return; const hit = findHit(world); if (hit.type === 'none') { setSelection(hit); startPan(evt); return; } setSelection(hit); startDrag(hit); }); window.addEventListener('mousemove', (evt) => { if (state.drag) { const world = eventToWorld(evt); if (!world) return; updateDrag(world); return; } if (state.pan) { updatePan(evt); } }); window.addEventListener('mouseup', () => { state.drag = null; state.pan = null; }); window.addEventListener('click', () => hideContextMenu()); if (state.auth.username && state.auth.password) { void loadTowns(); } } authSaveBtn.addEventListener('click', saveAuth); townsReloadBtn.addEventListener('click', () => void loadTowns()); layoutReloadBtn.addEventListener('click', () => { if (!state.selectedTownId) return; void loadLayout(state.selectedTownId); }); layoutSaveBtn.addEventListener('click', () => void saveLayout()); townSelect.addEventListener('change', () => { const id = Number(townSelect.value); if (!id) return; state.selectedTownId = id; void loadLayout(id); }); void bootstrap();