latest changes
parent
4807a0186c
commit
9a801d9557
@ -0,0 +1,38 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>AutoHero Town Editor</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app">
|
||||||
|
<aside class="sidebar">
|
||||||
|
<div class="card">
|
||||||
|
<h3>Auth</h3>
|
||||||
|
<input id="auth-user" placeholder="Username" />
|
||||||
|
<input id="auth-pass" type="password" placeholder="Password" />
|
||||||
|
<button id="auth-save" class="btn">Save credentials</button>
|
||||||
|
<div id="auth-status" class="muted"></div>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<h3>Town</h3>
|
||||||
|
<button id="towns-reload" class="btn">Load towns</button>
|
||||||
|
<select id="town-select"></select>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<h3>Layout</h3>
|
||||||
|
<button id="layout-reload" class="btn">Reload layout</button>
|
||||||
|
<button id="layout-save" class="btn" disabled>Save</button>
|
||||||
|
<div id="selection-info" class="muted"></div>
|
||||||
|
<div id="layout-status" class="muted"></div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
<main class="canvas-area">
|
||||||
|
<div id="canvas-root" class="canvas-root"></div>
|
||||||
|
<div id="context-menu" class="context-menu hidden"></div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
<script type="module" src="/src/main.ts"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"name": "autohero-admin-town-editor",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"pixi.js": "^8.6.6"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"typescript": "~5.7.2",
|
||||||
|
"vite": "^6.2.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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();
|
||||||
@ -0,0 +1,125 @@
|
|||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
font-family: Arial, sans-serif;
|
||||||
|
background: #10141f;
|
||||||
|
color: #e8eef9;
|
||||||
|
}
|
||||||
|
|
||||||
|
#app {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 260px 1fr;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar {
|
||||||
|
background: #151b2a;
|
||||||
|
padding: 16px;
|
||||||
|
border-right: 1px solid #2a3551;
|
||||||
|
}
|
||||||
|
|
||||||
|
.canvas-area {
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.canvas-root {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
background: #151b2a;
|
||||||
|
border: 1px solid #2a3551;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 12px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card h3 {
|
||||||
|
margin: 0 0 8px 0;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
input,
|
||||||
|
select {
|
||||||
|
width: 100%;
|
||||||
|
padding: 8px;
|
||||||
|
margin: 4px 0;
|
||||||
|
background: #0f1522;
|
||||||
|
color: #fff;
|
||||||
|
border: 1px solid #2f3b5a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
padding: 8px 12px;
|
||||||
|
border: 1px solid #2f3b5a;
|
||||||
|
background: #223152;
|
||||||
|
color: #fff;
|
||||||
|
cursor: pointer;
|
||||||
|
margin-top: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.muted {
|
||||||
|
color: #9eb0d6;
|
||||||
|
font-size: 12px;
|
||||||
|
margin-top: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.context-menu {
|
||||||
|
position: absolute;
|
||||||
|
min-width: 180px;
|
||||||
|
background: #151b2a;
|
||||||
|
border: 1px solid #2a3551;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 6px 0;
|
||||||
|
z-index: 10;
|
||||||
|
box-shadow: 0 12px 24px rgba(0, 0, 0, 0.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
.context-menu.hidden {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.context-menu button {
|
||||||
|
width: 100%;
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
color: #e8eef9;
|
||||||
|
text-align: left;
|
||||||
|
padding: 8px 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.context-menu button:hover {
|
||||||
|
background: #223152;
|
||||||
|
}
|
||||||
|
|
||||||
|
.context-menu-item {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.context-menu-item > .submenu {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 100%;
|
||||||
|
min-width: 200px;
|
||||||
|
background: #151b2a;
|
||||||
|
border: 1px solid #2a3551;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 6px 0;
|
||||||
|
box-shadow: 0 12px 24px rgba(0, 0, 0, 0.35);
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.context-menu-item:hover > .submenu {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2020",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"moduleResolution": "Bundler",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"strict": true,
|
||||||
|
"jsx": "preserve",
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"@game/*": ["../../frontend/src/game/*"],
|
||||||
|
"@shared/*": ["../../frontend/src/shared/*"],
|
||||||
|
"@frontend/*": ["../../frontend/src/*"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
@ -0,0 +1,32 @@
|
|||||||
|
import { defineConfig } from 'vite';
|
||||||
|
import path from 'path';
|
||||||
|
|
||||||
|
const rootDir = __dirname;
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
root: rootDir,
|
||||||
|
base: '/town-editor/',
|
||||||
|
publicDir: path.resolve(rootDir, '../../frontend/public'),
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@game': path.resolve(rootDir, '../../frontend/src/game'),
|
||||||
|
'@shared': path.resolve(rootDir, '../../frontend/src/shared'),
|
||||||
|
'@frontend': path.resolve(rootDir, '../../frontend/src'),
|
||||||
|
'pixi.js': path.resolve(rootDir, 'node_modules/pixi.js'),
|
||||||
|
},
|
||||||
|
dedupe: ['pixi.js'],
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
port: 5175,
|
||||||
|
fs: {
|
||||||
|
allow: [
|
||||||
|
path.resolve(rootDir, '../../frontend'),
|
||||||
|
path.resolve(rootDir, '../../admin-web/town-editor'),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
build: {
|
||||||
|
outDir: 'dist',
|
||||||
|
emptyOutDir: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
@ -0,0 +1,11 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
// TownObject represents a decorative prop placed in a town.
|
||||||
|
type TownObject struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
TownID int64 `json:"townId"`
|
||||||
|
ObjectType string `json:"objectType"`
|
||||||
|
Variant int `json:"variant"`
|
||||||
|
OffsetX float64 `json:"offsetX"`
|
||||||
|
OffsetY float64 `json:"offsetY"`
|
||||||
|
}
|
||||||
@ -0,0 +1,26 @@
|
|||||||
|
-- Town objects for editor-placed props (barrel, stump, etc.)
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS public.town_objects (
|
||||||
|
id bigint PRIMARY KEY GENERATED BY DEFAULT AS IDENTITY,
|
||||||
|
town_id bigint NOT NULL REFERENCES public.towns(id) ON DELETE CASCADE,
|
||||||
|
object_type text NOT NULL,
|
||||||
|
variant int NOT NULL DEFAULT 0,
|
||||||
|
offset_x double precision NOT NULL DEFAULT 0,
|
||||||
|
offset_y double precision NOT NULL DEFAULT 0,
|
||||||
|
created_at timestamp with time zone NOT NULL DEFAULT now(),
|
||||||
|
CONSTRAINT town_objects_object_type_check CHECK (object_type = ANY (ARRAY[
|
||||||
|
'tree'::text,
|
||||||
|
'rock'::text,
|
||||||
|
'cart'::text,
|
||||||
|
'barrel'::text,
|
||||||
|
'bush'::text,
|
||||||
|
'mushroom'::text,
|
||||||
|
'leaves'::text,
|
||||||
|
'stump'::text,
|
||||||
|
'bones'::text,
|
||||||
|
'ruin'::text
|
||||||
|
])),
|
||||||
|
CONSTRAINT town_objects_variant_check CHECK (variant IN (0, 1))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_town_objects_town ON public.town_objects USING btree (town_id);
|
||||||
Loading…
Reference in New Issue