|
|
|
|
@ -0,0 +1,879 @@
|
|
|
|
|
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();
|