You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

880 lines
28 KiB
TypeScript

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<number, { offsetX: number; offsetY: number }>;
}
| {
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<Response> {
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<void> {
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<void> {
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<void> {
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<number, { offsetX: number; offsetY: number }>();
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<void> {
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();