diff --git a/admin-web/Dockerfile b/admin-web/Dockerfile
index 19aeab9..907aa67 100644
--- a/admin-web/Dockerfile
+++ b/admin-web/Dockerfile
@@ -1,8 +1,23 @@
+FROM node:20-alpine AS builder
+
+WORKDIR /app
+
+COPY admin-web/town-editor/package.json admin-web/town-editor/
+RUN cd admin-web/town-editor && npm install
+
+COPY admin-web/town-editor admin-web/town-editor
+COPY frontend/src frontend/src
+COPY frontend/assets frontend/assets
+COPY frontend/public frontend/public
+
+RUN cd admin-web/town-editor && npm run build
+
FROM nginx:alpine
RUN rm /etc/nginx/conf.d/default.conf
-COPY nginx.conf /etc/nginx/conf.d/default.conf
-COPY index.html /usr/share/nginx/html/index.html
+COPY admin-web/nginx.conf /etc/nginx/conf.d/default.conf
+COPY admin-web/index.html /usr/share/nginx/html/index.html
+COPY --from=builder /app/admin-web/town-editor/dist /usr/share/nginx/html/town-editor
EXPOSE 80
diff --git a/admin-web/index.html b/admin-web/index.html
index b20280f..6ab4f77 100644
--- a/admin-web/index.html
+++ b/admin-web/index.html
@@ -2718,6 +2718,7 @@
Browse towns, NPCs, and which templates an NPC offers. To accept a quest for a hero, use Heroes → Give quest from world.
Towns
${towns || `
No towns loaded
`}
${pagerHtml("towns", townsPage.page, townsPage.total)}
diff --git a/admin-web/nginx.conf b/admin-web/nginx.conf
index 41c9757..6605f2b 100644
--- a/admin-web/nginx.conf
+++ b/admin-web/nginx.conf
@@ -2,10 +2,16 @@ server {
listen 80;
server_name _;
+ location /town-editor/ {
+ root /usr/share/nginx/html;
+ index index.html;
+ try_files $uri $uri/ /town-editor/index.html;
+ }
+
location / {
root /usr/share/nginx/html;
index index.html;
- try_files $uri /index.html;
+ try_files $uri $uri/ /index.html;
}
location /admin-api/ {
diff --git a/admin-web/town-editor/index.html b/admin-web/town-editor/index.html
new file mode 100644
index 0000000..969a56e
--- /dev/null
+++ b/admin-web/town-editor/index.html
@@ -0,0 +1,38 @@
+
+
+
+
+
+
AutoHero Town Editor
+
+
+
+
+
+
diff --git a/admin-web/town-editor/package.json b/admin-web/town-editor/package.json
new file mode 100644
index 0000000..e39b9a5
--- /dev/null
+++ b/admin-web/town-editor/package.json
@@ -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"
+ }
+}
diff --git a/admin-web/town-editor/src/main.ts b/admin-web/town-editor/src/main.ts
new file mode 100644
index 0000000..0782a51
--- /dev/null
+++ b/admin-web/town-editor/src/main.ts
@@ -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
;
+ }
+ | {
+ type: 'object';
+ id: number;
+ startOffsetX: number;
+ startOffsetY: number;
+ }
+ | null;
+
+type PanState = {
+ startClientX: number;
+ startClientY: number;
+ startCameraX: number;
+ startCameraY: number;
+} | null;
+
+const state = {
+ auth: {
+ username: sessionStorage.getItem('admin_user') || '',
+ password: sessionStorage.getItem('admin_pass') || '',
+ },
+ towns: [] as AdminTown[],
+ selectedTownId: null as number | null,
+ townData: null as TownData | null,
+ npcData: [] as NPCData[],
+ draftTown: null as AdminTown | null,
+ draftNPCs: [] as DraftNPC[],
+ draftBuildings: [] as DraftBuilding[],
+ draftObjects: [] as DraftObject[],
+ deletedNPCIds: [] as number[],
+ deletedBuildingIds: [] as number[],
+ deletedObjectIds: [] as number[],
+ dirty: false,
+ tempBuildingId: -1,
+ tempNpcId: -1,
+ tempObjectId: -1,
+ selection: { type: 'none' } as Selection,
+ drag: null as DragState,
+ pan: null as PanState,
+ contextWorld: null as { x: number; y: number } | null,
+};
+
+const renderer = new GameRenderer();
+const camera = new Camera();
+
+const authUserInput = document.getElementById('auth-user') as HTMLInputElement;
+const authPassInput = document.getElementById('auth-pass') as HTMLInputElement;
+const authSaveBtn = document.getElementById('auth-save') as HTMLButtonElement;
+const authStatus = document.getElementById('auth-status') as HTMLDivElement;
+
+const townsReloadBtn = document.getElementById('towns-reload') as HTMLButtonElement;
+const townSelect = document.getElementById('town-select') as HTMLSelectElement;
+const layoutReloadBtn = document.getElementById('layout-reload') as HTMLButtonElement;
+const layoutSaveBtn = document.getElementById('layout-save') as HTMLButtonElement;
+const selectionInfo = document.getElementById('selection-info') as HTMLDivElement;
+const layoutStatus = document.getElementById('layout-status') as HTMLDivElement;
+const canvasRoot = document.getElementById('canvas-root') as HTMLDivElement;
+const contextMenu = document.getElementById('context-menu') as HTMLDivElement;
+
+authUserInput.value = state.auth.username;
+authPassInput.value = state.auth.password;
+selectionInfo.textContent = 'No selection.';
+
+function setStatus(el: HTMLElement, message: string, isError = false): void {
+ el.textContent = message;
+ el.style.color = isError ? '#ff8f8f' : '';
+}
+
+function setDirty(value: boolean): void {
+ state.dirty = value;
+ layoutSaveBtn.disabled = !value;
+}
+
+function setSelection(next: Selection): void {
+ state.selection = next;
+ if (next.type === 'none') {
+ selectionInfo.textContent = 'No selection.';
+ return;
+ }
+ selectionInfo.textContent = `${next.type.toUpperCase()} #${next.id}`;
+}
+
+function saveAuth(): void {
+ state.auth.username = authUserInput.value.trim();
+ state.auth.password = authPassInput.value.trim();
+ sessionStorage.setItem('admin_user', state.auth.username);
+ sessionStorage.setItem('admin_pass', state.auth.password);
+ setStatus(authStatus, 'Credentials saved.');
+}
+
+function authHeader(): string {
+ return `Basic ${btoa(`${state.auth.username}:${state.auth.password}`)}`;
+}
+
+async function api(path: string, opts: RequestInit = {}): Promise {
+ const headers = Object.assign(
+ { Authorization: authHeader(), 'Content-Type': 'application/json' },
+ opts.headers || {},
+ );
+ return fetch(`/admin-api/${path}`, { cache: 'no-store', ...opts, headers });
+}
+
+function townSize(radius: number): string {
+ if (radius > 40) return 'XL';
+ if (radius > 25) return 'M';
+ if (radius > 15) return 'S';
+ return 'XS';
+}
+
+function buildTownData(town: AdminTown, npcs: AdminNPC[], buildings: AdminBuilding[]): TownData {
+ const npcData: NPCData[] = npcs.map((n) => ({
+ id: n.id,
+ name: n.nameKey || n.name,
+ nameKey: n.nameKey,
+ type: n.type,
+ worldX: town.worldX + n.offsetX,
+ worldY: town.worldY + n.offsetY,
+ buildingId: n.buildingId ?? undefined,
+ townId: town.id,
+ townLevelMin: town.levelMin,
+ townLevelMax: town.levelMax,
+ }));
+
+ const buildingData: BuildingData[] = buildings.map((b) => ({
+ id: b.id,
+ buildingType: b.buildingType,
+ worldX: town.worldX + b.offsetX,
+ worldY: town.worldY + b.offsetY,
+ facing: b.facing,
+ footprintW: b.footprintW,
+ footprintH: b.footprintH,
+ }));
+
+ const objectData: TownObjectData[] = state.draftObjects.map((o) => ({
+ id: o.id,
+ objectType: o.objectType,
+ variant: o.variant,
+ worldX: town.worldX + o.offsetX,
+ worldY: town.worldY + o.offsetY,
+ }));
+
+ return {
+ id: town.id,
+ name: town.nameKey || town.name,
+ nameKey: town.nameKey,
+ centerX: town.worldX,
+ centerY: town.worldY,
+ radius: town.radius,
+ biome: town.biome,
+ levelMin: town.levelMin,
+ size: townSize(town.radius),
+ npcs: npcData,
+ buildings: buildingData,
+ objects: objectData,
+ };
+}
+
+function refreshTownData(): void {
+ if (!state.draftTown) return;
+ state.townData = buildTownData(state.draftTown, state.draftNPCs, state.draftBuildings);
+ state.npcData = state.townData.npcs || [];
+}
+
+function applyLayout(data: TownLayoutResponse): void {
+ state.draftTown = data.town;
+ state.draftNPCs = data.npcs.map((n) => ({ ...n }));
+ state.draftBuildings = data.buildings.map((b) => ({ ...b }));
+ state.draftObjects = (data.objects || []).map((o) => ({ ...o }));
+ state.deletedNPCIds = [];
+ state.deletedBuildingIds = [];
+ state.deletedObjectIds = [];
+ refreshTownData();
+ if (state.townData) {
+ const center = worldToScreen(state.townData.centerX, state.townData.centerY);
+ camera.setTarget(center.x, center.y);
+ camera.snapToTarget();
+ }
+ setSelection({ type: 'none' });
+ setDirty(false);
+}
+
+async function loadTowns(): Promise {
+ if (!state.auth.username || !state.auth.password) {
+ setStatus(authStatus, 'Set admin credentials first.', true);
+ return;
+ }
+ setStatus(layoutStatus, 'Loading towns...');
+ const res = await api('quests/towns');
+ if (!res.ok) {
+ setStatus(layoutStatus, `Failed to load towns (${res.status}).`, true);
+ return;
+ }
+ const data = await res.json();
+ const towns = (data.towns || []) as AdminTown[];
+ state.towns = towns;
+ townSelect.innerHTML = '';
+ for (const t of towns) {
+ const opt = document.createElement('option');
+ opt.value = String(t.id);
+ opt.textContent = `${t.name} (Lv ${t.levelMin}-${t.levelMax})`;
+ townSelect.appendChild(opt);
+ }
+ if (!state.selectedTownId && towns.length > 0) {
+ state.selectedTownId = towns[0].id;
+ }
+ if (state.selectedTownId) {
+ townSelect.value = String(state.selectedTownId);
+ }
+ const influences = townsApiToInfluences(towns);
+ renderer.setWorldTerrainContext(buildWorldTerrainContext(influences, null));
+ setStatus(layoutStatus, 'Towns loaded.');
+ if (state.selectedTownId) {
+ await loadLayout(state.selectedTownId);
+ }
+}
+
+async function loadLayout(townId: number): Promise {
+ setStatus(layoutStatus, 'Loading town layout...');
+ const res = await api(`quests/towns/${townId}/layout`);
+ if (!res.ok) {
+ setStatus(layoutStatus, `Failed to load layout (${res.status}).`, true);
+ return;
+ }
+ const data = (await res.json()) as TownLayoutResponse;
+ applyLayout(data);
+ setStatus(layoutStatus, 'Layout loaded.');
+}
+
+async function saveLayout(): Promise {
+ if (!state.draftTown) return;
+ setStatus(layoutStatus, 'Saving layout...');
+ layoutSaveBtn.disabled = true;
+ const payload = {
+ npcs: state.draftNPCs.map((n) => ({
+ ...(n.id > 0 ? { id: n.id } : {}),
+ name: n.name,
+ nameKey: n.nameKey || '',
+ type: n.type,
+ offsetX: n.offsetX,
+ offsetY: n.offsetY,
+ ...(n.buildingId ? { buildingId: n.buildingId } : {}),
+ })),
+ buildings: state.draftBuildings.map((b) => ({
+ ...(b.id > 0 ? { id: b.id } : {}),
+ buildingType: b.buildingType,
+ offsetX: b.offsetX,
+ offsetY: b.offsetY,
+ facing: b.facing,
+ footprintW: b.footprintW,
+ footprintH: b.footprintH,
+ })),
+ objects: state.draftObjects.map((o) => ({
+ ...(o.id > 0 ? { id: o.id } : {}),
+ objectType: o.objectType,
+ variant: o.variant,
+ offsetX: o.offsetX,
+ offsetY: o.offsetY,
+ })),
+ deleteNpcIds: state.deletedNPCIds,
+ deleteBuildingIds: state.deletedBuildingIds,
+ deleteObjectIds: state.deletedObjectIds,
+ };
+ const res = await api(`quests/towns/${state.draftTown.id}/layout`, {
+ method: 'PUT',
+ body: JSON.stringify(payload),
+ });
+ if (!res.ok) {
+ setStatus(layoutStatus, `Failed to save layout (${res.status}).`, true);
+ layoutSaveBtn.disabled = false;
+ return;
+ }
+ const data = (await res.json()) as TownLayoutResponse;
+ applyLayout(data);
+ setStatus(layoutStatus, 'Layout saved.');
+}
+
+const buildingMenuItems = [
+ { label: 'House: Quest Giver', type: 'house.quest_giver', w: 2.5, h: 2.0 },
+ { label: 'House: Merchant', type: 'house.merchant', w: 2.5, h: 2.0 },
+ { label: 'House: Armorer', type: 'house.armorer', w: 2.5, h: 2.0 },
+ { label: 'House: Weapon Smith', type: 'house.weapon_smith', w: 2.5, h: 2.0 },
+ { label: 'House: Jeweler', type: 'house.jeweler', w: 2.5, h: 2.0 },
+ { label: 'House: Bounty Hunter', type: 'house.bounty_hunter', w: 2.5, h: 2.0 },
+ { label: 'House: Elder', type: 'house.elder', w: 2.5, h: 2.0 },
+ { label: 'House: Healer', type: 'house.healer', w: 2.5, h: 2.0 },
+ { label: 'Decoration: Well', type: 'decoration.well', w: 1.5, h: 1.5 },
+ { label: 'Decoration: Stall', type: 'decoration.stall', w: 1.5, h: 1.5 },
+ { label: 'Decoration: Signpost', type: 'decoration.signpost', w: 0.5, h: 0.5 },
+];
+
+const npcTypeItems = [
+ { label: 'Quest Giver', type: 'quest_giver' as NPCData['type'] },
+ { label: 'Merchant', type: 'merchant' as NPCData['type'] },
+ { label: 'Armorer', type: 'armorer' as NPCData['type'] },
+ { label: 'Weapon', type: 'weapon' as NPCData['type'] },
+ { label: 'Jeweler', type: 'jeweler' as NPCData['type'] },
+ { label: 'Bounty Hunter', type: 'bounty_hunter' as NPCData['type'] },
+ { label: 'Elder', type: 'elder' as NPCData['type'] },
+ { label: 'Healer', type: 'healer' as NPCData['type'] },
+];
+
+const objectTypeItems = [
+ { label: 'Tree', type: 'tree' },
+ { label: 'Rock', type: 'rock' },
+ { label: 'Cart', type: 'cart' },
+ { label: 'Barrel', type: 'barrel' },
+ { label: 'Bush', type: 'bush' },
+ { label: 'Mushroom', type: 'mushroom' },
+ { label: 'Leaves', type: 'leaves' },
+ { label: 'Stump', type: 'stump' },
+ { label: 'Bones', type: 'bones' },
+ { label: 'Ruin', type: 'ruin' },
+];
+
+function hideContextMenu(): void {
+ contextMenu.classList.add('hidden');
+}
+
+function addContextHeader(label: string): void {
+ const header = document.createElement('div');
+ header.textContent = label;
+ header.style.padding = '6px 12px 4px 12px';
+ header.style.fontSize = '11px';
+ header.style.color = '#9eb0d6';
+ contextMenu.appendChild(header);
+}
+
+function addContextButton(label: string, onClick: () => void): void {
+ const btn = document.createElement('button');
+ btn.textContent = label;
+ btn.addEventListener('click', onClick);
+ contextMenu.appendChild(btn);
+}
+
+function addContextSubmenu(label: string, build: (submenu: HTMLDivElement) => void): void {
+ const wrapper = document.createElement('div');
+ wrapper.className = 'context-menu-item';
+ const button = document.createElement('button');
+ button.textContent = `${label} ▶`;
+ const submenu = document.createElement('div');
+ submenu.className = 'submenu';
+ build(submenu);
+ wrapper.appendChild(button);
+ wrapper.appendChild(submenu);
+ contextMenu.appendChild(wrapper);
+}
+
+function showContextMenu(clientX: number, clientY: number, render: () => void): void {
+ contextMenu.innerHTML = '';
+ render();
+ const area = canvasRoot.parentElement?.getBoundingClientRect();
+ if (!area) return;
+ contextMenu.style.left = `${clientX - area.left}px`;
+ contextMenu.style.top = `${clientY - area.top}px`;
+ contextMenu.classList.remove('hidden');
+}
+
+function removeNpcById(id: number): void {
+ state.draftNPCs = state.draftNPCs.filter((n) => n.id !== id);
+ if (id > 0) state.deletedNPCIds.push(id);
+ setSelection({ type: 'none' });
+ refreshTownData();
+ setDirty(true);
+}
+
+function removeBuildingById(id: number): void {
+ state.draftBuildings = state.draftBuildings.filter((b) => b.id !== id);
+ if (id > 0) state.deletedBuildingIds.push(id);
+ setSelection({ type: 'none' });
+ refreshTownData();
+ setDirty(true);
+}
+
+function removeObjectById(id: number): void {
+ state.draftObjects = state.draftObjects.filter((o) => o.id !== id);
+ if (id > 0) state.deletedObjectIds.push(id);
+ setSelection({ type: 'none' });
+ refreshTownData();
+ setDirty(true);
+}
+
+function showContextMenuForSelection(clientX: number, clientY: number, selection: Selection): void {
+ showContextMenu(clientX, clientY, () => {
+ if (!state.draftTown) return;
+ if (selection.type === 'npc') {
+ addContextHeader('NPC');
+ addContextSubmenu('Change type', (submenu) => {
+ for (const item of npcTypeItems) {
+ const btn = document.createElement('button');
+ btn.textContent = item.label;
+ btn.addEventListener('click', () => {
+ const npc = state.draftNPCs.find((n) => n.id === selection.id);
+ if (!npc) return;
+ npc.type = item.type;
+ if (!npc.name) npc.name = `New ${item.label}`;
+ setDirty(true);
+ refreshTownData();
+ hideContextMenu();
+ });
+ submenu.appendChild(btn);
+ }
+ });
+ addContextHeader('Actions');
+ addContextButton('Delete NPC', () => {
+ removeNpcById(selection.id);
+ hideContextMenu();
+ });
+ return;
+ }
+ if (selection.type === 'building') {
+ addContextHeader('Building');
+ addContextSubmenu('Change type', (submenu) => {
+ for (const item of buildingMenuItems) {
+ const btn = document.createElement('button');
+ btn.textContent = item.label;
+ btn.addEventListener('click', () => {
+ const building = state.draftBuildings.find((b) => b.id === selection.id);
+ if (!building) return;
+ building.buildingType = item.type;
+ building.footprintW = item.w;
+ building.footprintH = item.h;
+ setDirty(true);
+ refreshTownData();
+ hideContextMenu();
+ });
+ submenu.appendChild(btn);
+ }
+ });
+ addContextHeader('Actions');
+ addContextButton('Delete building', () => {
+ removeBuildingById(selection.id);
+ hideContextMenu();
+ });
+ return;
+ }
+ if (selection.type === 'object') {
+ addContextHeader('Object');
+ addContextSubmenu('Change type', (submenu) => {
+ for (const item of objectTypeItems) {
+ const btn = document.createElement('button');
+ btn.textContent = item.label;
+ btn.addEventListener('click', () => {
+ const obj = state.draftObjects.find((o) => o.id === selection.id);
+ if (!obj) return;
+ obj.objectType = item.type;
+ setDirty(true);
+ refreshTownData();
+ hideContextMenu();
+ });
+ submenu.appendChild(btn);
+ }
+ });
+ addContextSubmenu('Variant', (submenu) => {
+ const btn0 = document.createElement('button');
+ btn0.textContent = 'Variant v0';
+ btn0.addEventListener('click', () => {
+ const obj = state.draftObjects.find((o) => o.id === selection.id);
+ if (!obj) return;
+ obj.variant = 0;
+ setDirty(true);
+ refreshTownData();
+ hideContextMenu();
+ });
+ const btn1 = document.createElement('button');
+ btn1.textContent = 'Variant v1';
+ btn1.addEventListener('click', () => {
+ const obj = state.draftObjects.find((o) => o.id === selection.id);
+ if (!obj) return;
+ obj.variant = 1;
+ setDirty(true);
+ refreshTownData();
+ hideContextMenu();
+ });
+ submenu.appendChild(btn0);
+ submenu.appendChild(btn1);
+ });
+ addContextHeader('Actions');
+ addContextButton('Delete object', () => {
+ removeObjectById(selection.id);
+ hideContextMenu();
+ });
+ return;
+ }
+ });
+}
+
+function showContextMenuForEmpty(clientX: number, clientY: number): void {
+ showContextMenu(clientX, clientY, () => {
+ if (!state.draftTown || !state.contextWorld) return;
+ const world = state.contextWorld;
+ addContextSubmenu('Add NPC', (submenu) => {
+ for (const item of npcTypeItems) {
+ const btn = document.createElement('button');
+ btn.textContent = item.label;
+ btn.addEventListener('click', () => {
+ const offsetX = world.x - state.draftTown!.worldX;
+ const offsetY = world.y - state.draftTown!.worldY;
+ const newId = state.tempNpcId--;
+ state.draftNPCs.push({
+ id: newId,
+ townId: state.draftTown!.id,
+ name: `New ${item.label}`,
+ nameKey: '',
+ type: item.type,
+ offsetX,
+ offsetY,
+ buildingId: null,
+ });
+ refreshTownData();
+ setSelection({ type: 'npc', id: newId });
+ setDirty(true);
+ hideContextMenu();
+ });
+ submenu.appendChild(btn);
+ }
+ });
+ addContextSubmenu('Add Building', (submenu) => {
+ for (const item of buildingMenuItems) {
+ const btn = document.createElement('button');
+ btn.textContent = item.label;
+ btn.addEventListener('click', () => {
+ const offsetX = world.x - state.draftTown!.worldX;
+ const offsetY = world.y - state.draftTown!.worldY;
+ const newId = state.tempBuildingId--;
+ state.draftBuildings.push({
+ id: newId,
+ townId: state.draftTown!.id,
+ buildingType: item.type,
+ offsetX,
+ offsetY,
+ facing: 'south',
+ footprintW: item.w,
+ footprintH: item.h,
+ });
+ refreshTownData();
+ setSelection({ type: 'building', id: newId });
+ setDirty(true);
+ hideContextMenu();
+ });
+ submenu.appendChild(btn);
+ }
+ });
+ addContextSubmenu('Add Object', (submenu) => {
+ for (const item of objectTypeItems) {
+ const btn = document.createElement('button');
+ btn.textContent = item.label;
+ btn.addEventListener('click', () => {
+ const offsetX = world.x - state.draftTown!.worldX;
+ const offsetY = world.y - state.draftTown!.worldY;
+ const newId = state.tempObjectId--;
+ state.draftObjects.push({
+ id: newId,
+ townId: state.draftTown!.id,
+ objectType: item.type,
+ variant: 0,
+ offsetX,
+ offsetY,
+ });
+ refreshTownData();
+ setSelection({ type: 'object', id: newId });
+ setDirty(true);
+ hideContextMenu();
+ });
+ submenu.appendChild(btn);
+ }
+ });
+ });
+}
+function eventToWorld(evt: MouseEvent): { x: number; y: number } | null {
+ if (!renderer.initialized) return null;
+ const canvas = renderer.app.canvas as HTMLCanvasElement;
+ const rect = canvas.getBoundingClientRect();
+ if (evt.clientX < rect.left || evt.clientX > rect.right || evt.clientY < rect.top || evt.clientY > rect.bottom) {
+ return null;
+ }
+ const localX = evt.clientX - rect.left;
+ const localY = evt.clientY - rect.top;
+ const screenW = renderer.app.renderer.width;
+ const screenH = renderer.app.renderer.height;
+ const worldScreenX = localX - screenW / 2 + camera.finalX;
+ const worldScreenY = localY - screenH / 2 + camera.finalY;
+ return screenToWorld(worldScreenX, worldScreenY);
+}
+
+function findHit(world: { x: number; y: number }): Selection {
+ if (!state.draftTown) return { type: 'none' };
+ const npcHitRadius = 1.8;
+ const objectHitRadius = 1.8;
+ const buildingPadding = 1.0;
+ for (const npc of state.draftNPCs) {
+ const nx = state.draftTown.worldX + npc.offsetX;
+ const ny = state.draftTown.worldY + npc.offsetY;
+ const dx = world.x - nx;
+ const dy = world.y - ny;
+ if (Math.hypot(dx, dy) <= npcHitRadius) {
+ return { type: 'npc', id: npc.id };
+ }
+ }
+ for (const obj of state.draftObjects) {
+ const ox = state.draftTown.worldX + obj.offsetX;
+ const oy = state.draftTown.worldY + obj.offsetY;
+ if (Math.hypot(world.x - ox, world.y - oy) <= objectHitRadius) {
+ return { type: 'object', id: obj.id };
+ }
+ }
+ for (const building of state.draftBuildings) {
+ const bx = state.draftTown.worldX + building.offsetX;
+ const by = state.draftTown.worldY + building.offsetY;
+ const dx = Math.abs(world.x - bx);
+ const dy = Math.abs(world.y - by);
+ if (dx <= building.footprintW / 2 + buildingPadding && dy <= building.footprintH / 2 + buildingPadding) {
+ return { type: 'building', id: building.id };
+ }
+ }
+ return { type: 'none' };
+}
+
+function startDrag(selection: Selection): void {
+ if (selection.type === 'npc') {
+ const npc = state.draftNPCs.find((n) => n.id === selection.id);
+ if (!npc) return;
+ state.drag = {
+ type: 'npc',
+ id: npc.id,
+ startOffsetX: npc.offsetX,
+ startOffsetY: npc.offsetY,
+ };
+ return;
+ }
+ if (selection.type === 'building') {
+ const building = state.draftBuildings.find((b) => b.id === selection.id);
+ if (!building) return;
+ const linked = new Map();
+ for (const npc of state.draftNPCs) {
+ if (npc.buildingId === building.id) {
+ linked.set(npc.id, { offsetX: npc.offsetX, offsetY: npc.offsetY });
+ }
+ }
+ state.drag = {
+ type: 'building',
+ id: building.id,
+ startOffsetX: building.offsetX,
+ startOffsetY: building.offsetY,
+ linkedNpcOffsets: linked,
+ };
+ return;
+ }
+ if (selection.type === 'object') {
+ const obj = state.draftObjects.find((o) => o.id === selection.id);
+ if (!obj) return;
+ state.drag = {
+ type: 'object',
+ id: obj.id,
+ startOffsetX: obj.offsetX,
+ startOffsetY: obj.offsetY,
+ };
+ }
+}
+
+function updateDrag(world: { x: number; y: number }): void {
+ if (!state.draftTown || !state.drag) return;
+ const offsetX = world.x - state.draftTown.worldX;
+ const offsetY = world.y - state.draftTown.worldY;
+ if (state.drag.type === 'npc') {
+ const npc = state.draftNPCs.find((n) => n.id === state.drag?.id);
+ if (!npc) return;
+ npc.offsetX = offsetX;
+ npc.offsetY = offsetY;
+ }
+ if (state.drag.type === 'building') {
+ const building = state.draftBuildings.find((b) => b.id === state.drag?.id);
+ if (!building) return;
+ const deltaX = offsetX - state.drag.startOffsetX;
+ const deltaY = offsetY - state.drag.startOffsetY;
+ building.offsetX = offsetX;
+ building.offsetY = offsetY;
+ for (const npc of state.draftNPCs) {
+ const start = state.drag.linkedNpcOffsets.get(npc.id);
+ if (!start) continue;
+ npc.offsetX = start.offsetX + deltaX;
+ npc.offsetY = start.offsetY + deltaY;
+ }
+ }
+ if (state.drag.type === 'object') {
+ const obj = state.draftObjects.find((o) => o.id === state.drag?.id);
+ if (!obj) return;
+ obj.offsetX = offsetX;
+ obj.offsetY = offsetY;
+ }
+ refreshTownData();
+ setDirty(true);
+}
+
+function startPan(evt: MouseEvent): void {
+ state.pan = {
+ startClientX: evt.clientX,
+ startClientY: evt.clientY,
+ startCameraX: camera.x,
+ startCameraY: camera.y,
+ };
+}
+
+function updatePan(evt: MouseEvent): void {
+ if (!state.pan) return;
+ const dx = evt.clientX - state.pan.startClientX;
+ const dy = evt.clientY - state.pan.startClientY;
+ const nextX = state.pan.startCameraX - dx;
+ const nextY = state.pan.startCameraY - dy;
+ camera.setTarget(nextX, nextY);
+ camera.snapToTarget();
+}
+
+function renderLoop(): void {
+ requestAnimationFrame(renderLoop);
+ if (!renderer.initialized) return;
+ if (!state.townData) return;
+
+ const screenW = renderer.app.renderer.width;
+ const screenH = renderer.app.renderer.height;
+ camera.applyTo(renderer.worldContainer, screenW, screenH);
+ renderer.drawGround(camera, screenW, screenH);
+ renderer.drawTowns([state.townData], camera, screenW, screenH);
+ renderer.drawTownObjects(state.townData.objects ?? [], camera, screenW, screenH);
+ if (state.npcData.length > 0) {
+ renderer.drawNPCs(state.npcData, camera, screenW, screenH, performance.now());
+ }
+}
+
+async function bootstrap(): Promise {
+ await renderer.init(canvasRoot);
+ window.addEventListener('resize', () => renderer.resize());
+ renderLoop();
+ const canvas = renderer.app.canvas as HTMLCanvasElement;
+ canvas.addEventListener('contextmenu', (evt) => {
+ evt.preventDefault();
+ const world = eventToWorld(evt);
+ if (!world) return;
+ state.contextWorld = world;
+ const hit = findHit(world);
+ if (hit.type === 'none') {
+ showContextMenuForEmpty(evt.clientX, evt.clientY);
+ return;
+ }
+ setSelection(hit);
+ showContextMenuForSelection(evt.clientX, evt.clientY, hit);
+ });
+ canvas.addEventListener('mousedown', (evt) => {
+ hideContextMenu();
+ const world = eventToWorld(evt);
+ if (!world) return;
+ if (evt.button === 1) {
+ startPan(evt);
+ return;
+ }
+ if (evt.button !== 0) return;
+ const hit = findHit(world);
+ if (hit.type === 'none') {
+ setSelection(hit);
+ startPan(evt);
+ return;
+ }
+ setSelection(hit);
+ startDrag(hit);
+ });
+ window.addEventListener('mousemove', (evt) => {
+ if (state.drag) {
+ const world = eventToWorld(evt);
+ if (!world) return;
+ updateDrag(world);
+ return;
+ }
+ if (state.pan) {
+ updatePan(evt);
+ }
+ });
+ window.addEventListener('mouseup', () => {
+ state.drag = null;
+ state.pan = null;
+ });
+ window.addEventListener('click', () => hideContextMenu());
+ if (state.auth.username && state.auth.password) {
+ void loadTowns();
+ }
+}
+
+authSaveBtn.addEventListener('click', saveAuth);
+townsReloadBtn.addEventListener('click', () => void loadTowns());
+layoutReloadBtn.addEventListener('click', () => {
+ if (!state.selectedTownId) return;
+ void loadLayout(state.selectedTownId);
+});
+layoutSaveBtn.addEventListener('click', () => void saveLayout());
+townSelect.addEventListener('change', () => {
+ const id = Number(townSelect.value);
+ if (!id) return;
+ state.selectedTownId = id;
+ void loadLayout(id);
+});
+
+void bootstrap();
diff --git a/admin-web/town-editor/src/style.css b/admin-web/town-editor/src/style.css
new file mode 100644
index 0000000..5669e16
--- /dev/null
+++ b/admin-web/town-editor/src/style.css
@@ -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;
+}
diff --git a/admin-web/town-editor/tsconfig.json b/admin-web/town-editor/tsconfig.json
new file mode 100644
index 0000000..ebbc6cb
--- /dev/null
+++ b/admin-web/town-editor/tsconfig.json
@@ -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"]
+}
diff --git a/admin-web/town-editor/vite.config.ts b/admin-web/town-editor/vite.config.ts
new file mode 100644
index 0000000..a6af567
--- /dev/null
+++ b/admin-web/town-editor/vite.config.ts
@@ -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,
+ },
+});
diff --git a/backend/internal/changelog/data/changelog.json b/backend/internal/changelog/data/changelog.json
index e2b285c..48894b4 100644
--- a/backend/internal/changelog/data/changelog.json
+++ b/backend/internal/changelog/data/changelog.json
@@ -1,5 +1,13 @@
{
"releases": [
+ {
+ "version": "0.3.0-dev",
+ "title": "AutoHero — 0.3.0",
+ "items": [
+ "GRAPHICS! We are delighted to introduce graphics in the game. It's static but it's better than procedural.",
+ "New NPC types."
+ ]
+ },
{
"version": "0.2.0-dev",
"title": "AutoHero — 0.2.0",
diff --git a/backend/internal/handler/admin.go b/backend/internal/handler/admin.go
index 99c2838..55efc9d 100644
--- a/backend/internal/handler/admin.go
+++ b/backend/internal/handler/admin.go
@@ -8,6 +8,7 @@ import (
"fmt"
"io"
"log/slog"
+ "math"
"net/http"
"runtime"
"strconv"
@@ -162,7 +163,7 @@ func buildAdminLiveMovementSnap(hm *game.HeroMovement) *adminLiveMovementJSON {
return nil
}
s := &adminLiveMovementJSON{
- Online: true,
+ Online: true,
}
if !hm.RestUntil.IsZero() {
t := hm.RestUntil
@@ -732,6 +733,299 @@ func (h *AdminHandler) ListTownNPCsForQuests(w http.ResponseWriter, r *http.Requ
writeJSON(w, http.StatusOK, map[string]any{"npcs": npcs})
}
+type adminTownLayoutResponse struct {
+ Town *model.Town `json:"town"`
+ NPCs []model.NPC `json:"npcs"`
+ Buildings []model.TownBuilding `json:"buildings"`
+ Objects []model.TownObject `json:"objects"`
+}
+
+type adminTownLayoutNPCUpdate struct {
+ ID *int64 `json:"id,omitempty"`
+ Name string `json:"name"`
+ NameKey string `json:"nameKey"`
+ Type string `json:"type"`
+ OffsetX float64 `json:"offsetX"`
+ OffsetY float64 `json:"offsetY"`
+ BuildingID *int64 `json:"buildingId,omitempty"`
+}
+
+type adminTownLayoutBuildingUpdate struct {
+ ID *int64 `json:"id,omitempty"`
+ BuildingType string `json:"buildingType"`
+ OffsetX float64 `json:"offsetX"`
+ OffsetY float64 `json:"offsetY"`
+ Facing string `json:"facing"`
+ FootprintW float64 `json:"footprintW"`
+ FootprintH float64 `json:"footprintH"`
+}
+
+type adminTownLayoutObjectUpdate struct {
+ ID *int64 `json:"id,omitempty"`
+ ObjectType string `json:"objectType"`
+ Variant int `json:"variant"`
+ OffsetX float64 `json:"offsetX"`
+ OffsetY float64 `json:"offsetY"`
+}
+
+type adminTownLayoutRequest struct {
+ NPCs []adminTownLayoutNPCUpdate `json:"npcs"`
+ Buildings []adminTownLayoutBuildingUpdate `json:"buildings"`
+ Objects []adminTownLayoutObjectUpdate `json:"objects"`
+ DeleteNPCIDs []int64 `json:"deleteNpcIds"`
+ DeleteBuildingIDs []int64 `json:"deleteBuildingIds"`
+ DeleteObjectIDs []int64 `json:"deleteObjectIds"`
+}
+
+var adminTownNPCTypes = map[string]struct{}{
+ "quest_giver": {},
+ "merchant": {},
+ "armorer": {},
+ "weapon": {},
+ "jeweler": {},
+ "bounty_hunter": {},
+ "elder": {},
+ "healer": {},
+}
+
+var adminTownBuildingTypes = map[string]struct{}{
+ "house.quest_giver": {},
+ "house.merchant": {},
+ "house.armorer": {},
+ "house.weapon_smith": {},
+ "house.jeweler": {},
+ "house.bounty_hunter": {},
+ "house.elder": {},
+ "house.healer": {},
+ "decoration.well": {},
+ "decoration.stall": {},
+ "decoration.signpost": {},
+}
+
+var adminTownObjectTypes = map[string]struct{}{
+ "tree": {},
+ "rock": {},
+ "cart": {},
+ "barrel": {},
+ "bush": {},
+ "mushroom": {},
+ "leaves": {},
+ "stump": {},
+ "bones": {},
+ "ruin": {},
+}
+
+var adminTownBuildingFacings = map[string]struct{}{
+ "north": {},
+ "south": {},
+ "east": {},
+ "west": {},
+}
+
+func (h *AdminHandler) GetTownLayout(w http.ResponseWriter, r *http.Request) {
+ townID, err := strconv.ParseInt(chi.URLParam(r, "townId"), 10, 64)
+ if err != nil || townID <= 0 {
+ writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid townId"})
+ return
+ }
+
+ town, err := h.questStore.GetTown(r.Context(), townID)
+ if err != nil {
+ writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to load town"})
+ return
+ }
+ if town == nil {
+ writeJSON(w, http.StatusNotFound, map[string]string{"error": "town not found"})
+ return
+ }
+
+ npcs, err := h.questStore.ListNPCsByTown(r.Context(), townID)
+ if err != nil {
+ writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to list npcs"})
+ return
+ }
+
+ buildings, err := h.questStore.ListBuildingsByTown(r.Context(), townID)
+ if err != nil {
+ writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to list buildings"})
+ return
+ }
+ objects, err := h.questStore.ListTownObjectsByTown(r.Context(), townID)
+ if err != nil {
+ writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to list objects"})
+ return
+ }
+
+ writeJSON(w, http.StatusOK, adminTownLayoutResponse{
+ Town: town,
+ NPCs: npcs,
+ Buildings: buildings,
+ Objects: objects,
+ })
+}
+
+func (h *AdminHandler) UpdateTownLayout(w http.ResponseWriter, r *http.Request) {
+ townID, err := strconv.ParseInt(chi.URLParam(r, "townId"), 10, 64)
+ if err != nil || townID <= 0 {
+ writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid townId"})
+ return
+ }
+
+ town, err := h.questStore.GetTown(r.Context(), townID)
+ if err != nil {
+ writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to load town"})
+ return
+ }
+ if town == nil {
+ writeJSON(w, http.StatusNotFound, map[string]string{"error": "town not found"})
+ return
+ }
+
+ var req adminTownLayoutRequest
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+ writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid json body"})
+ return
+ }
+
+ npcUpdates := make([]storage.TownLayoutNPCUpdate, 0, len(req.NPCs))
+ for _, n := range req.NPCs {
+ if n.ID != nil && *n.ID <= 0 {
+ writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid npc id"})
+ return
+ }
+ if _, ok := adminTownNPCTypes[n.Type]; !ok {
+ writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid npc type"})
+ return
+ }
+ if math.IsNaN(n.OffsetX) || math.IsNaN(n.OffsetY) || math.IsInf(n.OffsetX, 0) || math.IsInf(n.OffsetY, 0) {
+ writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid npc offsets"})
+ return
+ }
+ if n.ID == nil && strings.TrimSpace(n.Name) == "" {
+ writeJSON(w, http.StatusBadRequest, map[string]string{"error": "npc name is required"})
+ return
+ }
+ npcUpdates = append(npcUpdates, storage.TownLayoutNPCUpdate{
+ ID: n.ID,
+ Name: n.Name,
+ NameKey: n.NameKey,
+ Type: n.Type,
+ OffsetX: n.OffsetX,
+ OffsetY: n.OffsetY,
+ BuildingID: n.BuildingID,
+ })
+ }
+
+ buildingUpdates := make([]storage.TownLayoutBuildingUpsert, 0, len(req.Buildings))
+ for _, b := range req.Buildings {
+ if b.ID != nil && *b.ID <= 0 {
+ writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid building id"})
+ return
+ }
+ if _, ok := adminTownBuildingTypes[b.BuildingType]; !ok {
+ writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid buildingType"})
+ return
+ }
+ if _, ok := adminTownBuildingFacings[b.Facing]; !ok {
+ writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid building facing"})
+ return
+ }
+ if math.IsNaN(b.OffsetX) || math.IsNaN(b.OffsetY) || math.IsInf(b.OffsetX, 0) || math.IsInf(b.OffsetY, 0) {
+ writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid building offsets"})
+ return
+ }
+ if b.FootprintW <= 0 || b.FootprintH <= 0 {
+ writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid building footprint"})
+ return
+ }
+ buildingUpdates = append(buildingUpdates, storage.TownLayoutBuildingUpsert{
+ ID: b.ID,
+ BuildingType: b.BuildingType,
+ OffsetX: b.OffsetX,
+ OffsetY: b.OffsetY,
+ Facing: b.Facing,
+ FootprintW: b.FootprintW,
+ FootprintH: b.FootprintH,
+ })
+ }
+
+ objectUpdates := make([]storage.TownLayoutObjectUpsert, 0, len(req.Objects))
+ for _, o := range req.Objects {
+ if o.ID != nil && *o.ID <= 0 {
+ writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid object id"})
+ return
+ }
+ if _, ok := adminTownObjectTypes[o.ObjectType]; !ok {
+ writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid object type"})
+ return
+ }
+ if o.Variant < 0 || o.Variant > 1 {
+ writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid object variant"})
+ return
+ }
+ if math.IsNaN(o.OffsetX) || math.IsNaN(o.OffsetY) || math.IsInf(o.OffsetX, 0) || math.IsInf(o.OffsetY, 0) {
+ writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid object offsets"})
+ return
+ }
+ objectUpdates = append(objectUpdates, storage.TownLayoutObjectUpsert{
+ ID: o.ID,
+ ObjectType: o.ObjectType,
+ Variant: o.Variant,
+ OffsetX: o.OffsetX,
+ OffsetY: o.OffsetY,
+ })
+ }
+
+ if err := h.questStore.UpdateTownLayout(
+ r.Context(),
+ townID,
+ npcUpdates,
+ buildingUpdates,
+ objectUpdates,
+ req.DeleteNPCIDs,
+ req.DeleteBuildingIDs,
+ req.DeleteObjectIDs,
+ ); err != nil {
+ if errors.Is(err, storage.ErrTownLayoutMissing) {
+ writeJSON(w, http.StatusBadRequest, map[string]string{"error": "npc, building, or object not found in town"})
+ return
+ }
+ writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to update town layout"})
+ return
+ }
+
+ rg, err := game.LoadRoadGraph(r.Context(), h.pool)
+ if err != nil {
+ h.logger.Error("admin: reload road graph after town layout update", "error", err)
+ writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to reload road graph"})
+ return
+ }
+ h.engine.SetRoadGraph(rg)
+
+ npcs, err := h.questStore.ListNPCsByTown(r.Context(), townID)
+ if err != nil {
+ writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to list npcs"})
+ return
+ }
+ buildings, err := h.questStore.ListBuildingsByTown(r.Context(), townID)
+ if err != nil {
+ writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to list buildings"})
+ return
+ }
+ objects, err := h.questStore.ListTownObjectsByTown(r.Context(), townID)
+ if err != nil {
+ writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to list objects"})
+ return
+ }
+
+ writeJSON(w, http.StatusOK, adminTownLayoutResponse{
+ Town: town,
+ NPCs: npcs,
+ Buildings: buildings,
+ Objects: objects,
+ })
+}
+
// ContentAllQuests returns all quest template rows (global content).
// GET /admin/content/quests
func (h *AdminHandler) ContentAllQuests(w http.ResponseWriter, r *http.Request) {
diff --git a/backend/internal/model/town_object.go b/backend/internal/model/town_object.go
new file mode 100644
index 0000000..fe8913a
--- /dev/null
+++ b/backend/internal/model/town_object.go
@@ -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"`
+}
diff --git a/backend/internal/router/router.go b/backend/internal/router/router.go
index 9bbfc00..ed31868 100644
--- a/backend/internal/router/router.go
+++ b/backend/internal/router/router.go
@@ -120,6 +120,8 @@ func New(deps Deps) *chi.Mux {
r.Put("/content/gear/{gearId}", adminH.ContentUpdateGear)
r.Get("/quests/towns", adminH.ListTownsForQuests)
r.Get("/quests/towns/{townId}/npcs", adminH.ListTownNPCsForQuests)
+ r.Get("/quests/towns/{townId}/layout", adminH.GetTownLayout)
+ r.Put("/quests/towns/{townId}/layout", adminH.UpdateTownLayout)
r.Get("/quests/npcs/{npcId}", adminH.ListNPCQuestsForAdmin)
r.Delete("/heroes/{heroId}", adminH.DeleteHero)
r.Get("/towns", adminH.ListTowns)
diff --git a/backend/internal/storage/quest_store.go b/backend/internal/storage/quest_store.go
index f93529f..37babcd 100644
--- a/backend/internal/storage/quest_store.go
+++ b/backend/internal/storage/quest_store.go
@@ -6,6 +6,7 @@ import (
"fmt"
"math/rand"
"strings"
+ "time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
@@ -19,6 +20,36 @@ type QuestStore struct {
pool *pgxpool.Pool
}
+var ErrTownLayoutMissing = errors.New("town layout: missing npc or building")
+
+type TownLayoutNPCUpdate struct {
+ ID *int64
+ Name string
+ NameKey string
+ Type string
+ OffsetX float64
+ OffsetY float64
+ BuildingID *int64
+}
+
+type TownLayoutBuildingUpsert struct {
+ ID *int64
+ BuildingType string
+ OffsetX float64
+ OffsetY float64
+ Facing string
+ FootprintW float64
+ FootprintH float64
+}
+
+type TownLayoutObjectUpsert struct {
+ ID *int64
+ ObjectType string
+ Variant int
+ OffsetX float64
+ OffsetY float64
+}
+
// NewQuestStore creates a new QuestStore backed by the given connection pool.
func NewQuestStore(pool *pgxpool.Pool) *QuestStore {
return &QuestStore{pool: pool}
@@ -174,6 +205,155 @@ func (s *QuestStore) ListBuildingsByTown(ctx context.Context, townID int64) ([]m
return buildings, nil
}
+// UpdateTownLayout updates NPC offsets and upserts town buildings in a single transaction.
+func (s *QuestStore) UpdateTownLayout(
+ ctx context.Context,
+ townID int64,
+ npcs []TownLayoutNPCUpdate,
+ buildings []TownLayoutBuildingUpsert,
+ objects []TownLayoutObjectUpsert,
+ deleteNPCIDs []int64,
+ deleteBuildingIDs []int64,
+ deleteObjectIDs []int64,
+) error {
+ tx, err := s.pool.Begin(ctx)
+ if err != nil {
+ return fmt.Errorf("update town layout begin: %w", err)
+ }
+ defer tx.Rollback(ctx)
+
+ if len(deleteNPCIDs) > 0 {
+ if _, err := tx.Exec(ctx, `
+ DELETE FROM npcs
+ WHERE town_id = $1 AND id = ANY($2)
+ `, townID, deleteNPCIDs); err != nil {
+ return fmt.Errorf("delete npcs: %w", err)
+ }
+ }
+ if len(deleteBuildingIDs) > 0 {
+ if _, err := tx.Exec(ctx, `
+ DELETE FROM town_buildings
+ WHERE town_id = $1 AND id = ANY($2)
+ `, townID, deleteBuildingIDs); err != nil {
+ return fmt.Errorf("delete town buildings: %w", err)
+ }
+ }
+ if len(deleteObjectIDs) > 0 {
+ if _, err := tx.Exec(ctx, `
+ DELETE FROM town_objects
+ WHERE town_id = $1 AND id = ANY($2)
+ `, townID, deleteObjectIDs); err != nil {
+ return fmt.Errorf("delete town objects: %w", err)
+ }
+ }
+
+ for _, n := range npcs {
+ if n.ID != nil {
+ tag, err := tx.Exec(ctx, `
+ UPDATE npcs
+ SET name = $1, name_key = $2, type = $3, offset_x = $4, offset_y = $5, building_id = $6
+ WHERE id = $7 AND town_id = $8
+ `, n.Name, n.NameKey, n.Type, n.OffsetX, n.OffsetY, n.BuildingID, *n.ID, townID)
+ if err != nil {
+ return fmt.Errorf("update npc: %w", err)
+ }
+ if tag.RowsAffected() == 0 {
+ return fmt.Errorf("update npc: %w", ErrTownLayoutMissing)
+ }
+ continue
+ }
+ if _, err := tx.Exec(ctx, `
+ INSERT INTO npcs (town_id, name, name_key, type, offset_x, offset_y, created_at, building_id)
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
+ `, townID, n.Name, n.NameKey, n.Type, n.OffsetX, n.OffsetY, time.Now().UTC(), n.BuildingID); err != nil {
+ return fmt.Errorf("insert npc: %w", err)
+ }
+ }
+
+ now := time.Now().UTC()
+ for _, b := range buildings {
+ if b.ID != nil {
+ tag, err := tx.Exec(ctx, `
+ UPDATE town_buildings
+ SET building_type = $1, offset_x = $2, offset_y = $3, facing = $4, footprint_w = $5, footprint_h = $6
+ WHERE id = $7 AND town_id = $8
+ `, b.BuildingType, b.OffsetX, b.OffsetY, b.Facing, b.FootprintW, b.FootprintH, *b.ID, townID)
+ if err != nil {
+ return fmt.Errorf("update town building: %w", err)
+ }
+ if tag.RowsAffected() == 0 {
+ return fmt.Errorf("update town building: %w", ErrTownLayoutMissing)
+ }
+ continue
+ }
+ if err := tx.QueryRow(ctx, `
+ INSERT INTO town_buildings (town_id, building_type, offset_x, offset_y, facing, footprint_w, footprint_h, created_at)
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
+ RETURNING id
+ `, townID, b.BuildingType, b.OffsetX, b.OffsetY, b.Facing, b.FootprintW, b.FootprintH, now).Scan(new(int64)); err != nil {
+ return fmt.Errorf("insert town building: %w", err)
+ }
+ }
+
+ for _, o := range objects {
+ if o.ID != nil {
+ tag, err := tx.Exec(ctx, `
+ UPDATE town_objects
+ SET object_type = $1, variant = $2, offset_x = $3, offset_y = $4
+ WHERE id = $5 AND town_id = $6
+ `, o.ObjectType, o.Variant, o.OffsetX, o.OffsetY, *o.ID, townID)
+ if err != nil {
+ return fmt.Errorf("update town object: %w", err)
+ }
+ if tag.RowsAffected() == 0 {
+ return fmt.Errorf("update town object: %w", ErrTownLayoutMissing)
+ }
+ continue
+ }
+ if _, err := tx.Exec(ctx, `
+ INSERT INTO town_objects (town_id, object_type, variant, offset_x, offset_y, created_at)
+ VALUES ($1, $2, $3, $4, $5, $6)
+ `, townID, o.ObjectType, o.Variant, o.OffsetX, o.OffsetY, now); err != nil {
+ return fmt.Errorf("insert town object: %w", err)
+ }
+ }
+
+ if err := tx.Commit(ctx); err != nil {
+ return fmt.Errorf("update town layout commit: %w", err)
+ }
+ return nil
+}
+
+// ListTownObjectsByTown returns all props in the given town.
+func (s *QuestStore) ListTownObjectsByTown(ctx context.Context, townID int64) ([]model.TownObject, error) {
+ rows, err := s.pool.Query(ctx, `
+ SELECT id, town_id, object_type, variant, offset_x, offset_y
+ FROM town_objects
+ WHERE town_id = $1
+ ORDER BY id ASC
+ `, townID)
+ if err != nil {
+ return nil, fmt.Errorf("list town objects: %w", err)
+ }
+ defer rows.Close()
+
+ var objects []model.TownObject
+ for rows.Next() {
+ var o model.TownObject
+ if err := rows.Scan(&o.ID, &o.TownID, &o.ObjectType, &o.Variant, &o.OffsetX, &o.OffsetY); err != nil {
+ return nil, fmt.Errorf("scan town object: %w", err)
+ }
+ objects = append(objects, o)
+ }
+ if err := rows.Err(); err != nil {
+ return nil, fmt.Errorf("list town objects rows: %w", err)
+ }
+ if objects == nil {
+ objects = []model.TownObject{}
+ }
+ return objects, nil
+}
+
// ListAllBuildings returns every building across all towns (for road_graph preload).
func (s *QuestStore) ListAllBuildings(ctx context.Context) ([]model.TownBuilding, error) {
rows, err := s.pool.Query(ctx, `
@@ -610,10 +790,10 @@ func (s *QuestStore) IncrementCollectItemProgress(ctx context.Context, heroID in
type collectQuest struct {
hqID int64
- targetCount int
- progress int
- dropChance float64
- targetEnemyType *string
+ targetCount int
+ progress int
+ dropChance float64
+ targetEnemyType *string
targetEnemyArchetype *string
}
var cqs []collectQuest
diff --git a/backend/internal/version/version.go b/backend/internal/version/version.go
index 94fa8b4..cf203fb 100644
--- a/backend/internal/version/version.go
+++ b/backend/internal/version/version.go
@@ -3,4 +3,4 @@
package version
// Version is the active server build id (shown in /hero/init and admin /info).
-const Version = "0.2.0-dev"
+const Version = "0.3.0-dev"
diff --git a/backend/migrations/000035_town_objects.sql b/backend/migrations/000035_town_objects.sql
new file mode 100644
index 0000000..0dcdaf1
--- /dev/null
+++ b/backend/migrations/000035_town_objects.sql
@@ -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);
diff --git a/docker-compose.yml b/docker-compose.yml
index 021a192..892a537 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -81,8 +81,8 @@ services:
admin-web:
image: ${DOCKER_REGISTRY:-static.ranneft.ru:25000}/autohero/admin-web:${IMAGE_TAG:-latest}
build:
- context: ./admin-web
- dockerfile: Dockerfile
+ context: .
+ dockerfile: admin-web/Dockerfile
ports:
- "3002:80"
depends_on:
diff --git a/frontend/src/game/assets/spriteMapping.ts b/frontend/src/game/assets/spriteMapping.ts
index bde10bf..4729abb 100644
--- a/frontend/src/game/assets/spriteMapping.ts
+++ b/frontend/src/game/assets/spriteMapping.ts
@@ -51,6 +51,13 @@ export const TOWN_HALL_TEXTURE_KEY = 'building.civic.townhall.v0';
export const PLAZA_FOUNTAIN_TEXTURE_KEY = 'prop.fountain.plaza.v0';
export const MARKET_STALL_TEXTURE_KEY = 'prop.market.stall.v0';
+/** Generic facades for procedural town rings (no `town_buildings` rows). */
+export const PROCEDURAL_TOWN_HOUSE_FACADE_KEYS = [
+ 'building.house.v0',
+ 'building.house.v1',
+ 'building.house.v2',
+] as const;
+
const BUILDING_TEXTURE_BY_TYPE: Record = {
'house.quest_giver': 'building.house.v0',
'house.merchant': 'building.tavern.v0',
@@ -94,9 +101,59 @@ export function restCampTextureKeys(): [string, string, string] {
return [CAMP_TENT_TEXTURE_KEY, CAMP_FIRE_TEXTURE_KEY, CAMP_BAG_TEXTURE_KEY];
}
-/** South-facing sprite per DB template (`enemies.type`); optional until listed in manifest + assets. */
+/**
+ * Normalizes `enemies.type` for texture keys: trim, lowercase, strip accidental `enemy.` / `.south`.
+ */
+export function normalizeEnemyTemplateSlug(slug: string): string {
+ let s = String(slug).trim().toLowerCase().replace(/\s+/g, '_');
+ if (s.startsWith('enemy.')) s = s.slice(6);
+ if (s.endsWith('.south')) s = s.slice(0, -6);
+ return s;
+}
+
+/** South-facing manifest key for a template slug (`enemy..south`). */
export function enemySouthTextureKey(slug: string): string {
- return `enemy.${slug}.south`;
+ return `enemy.${normalizeEnemyTemplateSlug(slug)}.south`;
+}
+
+/** If exact south PNG is missing from the bundle, try another bandit variant (same archetype art). */
+const BANDIT_SOUTH_FALLBACK_SLUGS: readonly string[] = [
+ 'bandit_l8_9_canyon',
+ 'bandit_l10_11_canyon',
+ 'bandit_l8_9_ruins',
+ 'bandit_l6_7_ruins',
+ 'bandit_l6_7_forest',
+ 'bandit_l4_5_forest',
+ 'bandit_l4_5_meadow',
+ 'bandit_l10_11_swamp',
+ 'bandit_l12_12_volcanic',
+ 'bandit_l12_12_astral',
+];
+
+/**
+ * Resolves a loaded south-facing enemy texture key, or null to use procedural fallback.
+ */
+export function resolveEnemySouthTextureKey(
+ slug: string,
+ getTexture: (manifestKey: string) => unknown | null,
+): string | null {
+ const norm = normalizeEnemyTemplateSlug(slug);
+ const trySlug = (templateSlug: string): string | null => {
+ const k = `enemy.${templateSlug}.south`;
+ return getTexture(k) != null ? k : null;
+ };
+
+ const primary = trySlug(norm);
+ if (primary) return primary;
+
+ if (!norm.includes('bandit')) return null;
+
+ for (const s of BANDIT_SOUTH_FALLBACK_SLUGS) {
+ if (s === norm) continue;
+ const k = trySlug(s);
+ if (k) return k;
+ }
+ return null;
}
export function getRequiredSpriteKeys(): string[] {
diff --git a/frontend/src/game/renderer.ts b/frontend/src/game/renderer.ts
index 633dd2b..25e0b6e 100644
--- a/frontend/src/game/renderer.ts
+++ b/frontend/src/game/renderer.ts
@@ -2,7 +2,7 @@ import { Application, Container, Graphics, Sprite, Text, TextStyle, Texture } fr
import { TILE_WIDTH, TILE_HEIGHT, MAP_ZOOM } from '../shared/constants';
import { getViewport } from '../shared/telegram';
import type { Camera } from './camera';
-import type { TownData, NPCData, BuildingData } from './types';
+import type { TownData, NPCData, BuildingData, TownObjectData } from './types';
import { drawEnemyBySlug, drawEnemyHpBarOnly } from './enemyVisuals';
import { GameSpriteRegistry } from './assets/gameSpriteRegistry';
import {
@@ -12,8 +12,9 @@ import {
npcTypeToTextureKey,
objectToTextureKey,
PLAZA_FOUNTAIN_TEXTURE_KEY,
+ PROCEDURAL_TOWN_HOUSE_FACADE_KEYS,
restCampTextureKeys,
- enemySouthTextureKey,
+ resolveEnemySouthTextureKey,
terrainToTextureKey,
TOWN_HALL_TEXTURE_KEY,
} from './assets/spriteMapping';
@@ -101,12 +102,16 @@ export class GameRenderer {
private _groundSpriteLayer: Container;
private _objectSpriteLayer: Container;
private _buildingSpriteLayer: Container;
+ private _townObjectSpriteLayer: Container;
private _tileSpritePool = new Map();
private _objectSpritePool = new Map();
private _tileSpriteFreeList: Sprite[] = [];
private _objectSpriteFreeList: Sprite[] = [];
private _usedTileSprites = new Set();
private _usedObjectSprites = new Set();
+ private _townObjectSpritePool = new Map();
+ private _townObjectSpriteFreeList: Sprite[] = [];
+ private _usedTownObjectSprites = new Set();
private _emptySpriteSet = new Set();
private _buildingSpritePool = new Map();
private _characterSpritePool = new Map();
@@ -414,6 +419,7 @@ export class GameRenderer {
this._groundSpriteLayer = new Container();
this._objectSpriteLayer = new Container();
this._buildingSpriteLayer = new Container();
+ this._townObjectSpriteLayer = new Container();
}
get initialized(): boolean {
@@ -472,6 +478,10 @@ export class GameRenderer {
this._objectSpriteLayer.zIndex = 2;
this._objectSpriteLayer.sortableChildren = true;
+ this.groundLayer.addChild(this._townObjectSpriteLayer);
+ this._townObjectSpriteLayer.zIndex = 2;
+ this._townObjectSpriteLayer.sortableChildren = true;
+
this.groundLayer.addChild(this._buildingSpriteLayer);
this._buildingSpriteLayer.zIndex = 3;
this._buildingSpriteLayer.sortableChildren = true;
@@ -1122,9 +1132,11 @@ export class GameRenderer {
const cx = iso.x;
const cy = iso.y + sway;
- const southKey = enemySouthTextureKey(enemySlug);
- const tex = this._spritesReady ? this._spriteRegistry.getTexture(southKey) : null;
- if (tex) {
+ const southKey = this._spritesReady
+ ? resolveEnemySouthTextureKey(enemySlug, (k) => this._spriteRegistry.getTexture(k))
+ : null;
+ const tex = southKey ? this._spriteRegistry.getTexture(southKey) : null;
+ if (tex && southKey) {
const entry = this._ensureSprite(
this._characterSpritePool,
'enemy_combat',
@@ -1135,7 +1147,8 @@ export class GameRenderer {
entry.sprite.anchor.set(0.5, 1);
entry.sprite.x = cx;
entry.sprite.y = cy;
- const th = tex.height || 48;
+ entry.sprite.roundPixels = true;
+ const th = Math.max(1, tex.height || tex.width || 48);
const targetH = 52;
entry.sprite.scale.set(targetH / th);
entry.sprite.zIndex = cy + 100;
@@ -1416,8 +1429,9 @@ export class GameRenderer {
: bt === 'decoration.stall'
? Math.max(w, 44 * scale)
: w;
- const spriteKey = this._spritesReady ? buildingTypeToTextureKey(bt) : null;
- const spriteTexture = spriteKey ? this._spriteRegistry.getTexture(spriteKey) : null;
+ const spriteKey = buildingTypeToTextureKey(bt);
+ const spriteTexture =
+ this._spritesReady && spriteKey ? this._spriteRegistry.getTexture(spriteKey) : null;
const hasUsableSprite = spriteKey !== null && spriteTexture !== null;
if (spriteKey !== null && spriteTexture !== null) {
const poolKey = `building:${b.id}`;
@@ -1561,9 +1575,9 @@ export class GameRenderer {
/** Single civic building (hall / notice board) facing the plaza — not an NPC home. */
private _drawCivicBuilding(gfx: Graphics, cx: number, cy: number, s: number): void {
- const w = 52 * s;
- const h = 38 * s;
- const rh = 26 * s;
+ const w = 52 * s * 3;
+ const h = 38 * s * 3;
+ const rh = 26 * s * 3;
gfx.rect(cx - w / 2, cy - h, w, h);
gfx.fill({ color: 0x8a9098, alpha: 0.95 });
gfx.stroke({ color: 0x4a5058, width: 1.2, alpha: 0.55 });
@@ -1630,14 +1644,29 @@ export class GameRenderer {
const rh = baseRH * s * sizeVar;
const roofStyle = i % 5 === 0 ? 1 : i % 3 === 0 ? 2 : 0;
- this._drawHouse(
- gfx, tx + dx, ty + dy, w, h, rh,
- wallColors[i % wallColors.length]!,
- roofColors[i % roofColors.length]!,
- roofStyle,
+ const hx = tx + dx;
+ const hy = ty + dy;
+ const facadeKey =
+ PROCEDURAL_TOWN_HOUSE_FACADE_KEYS[hash % PROCEDURAL_TOWN_HOUSE_FACADE_KEYS.length]!;
+ const poolKey = `town:${townId}:proc_house:${i}`;
+ const placedSprite = this._placeBuildingLayerSprite(
+ poolKey,
+ facadeKey,
+ hx,
+ hy,
+ w,
+ usedBuildingSprites,
);
+ if (!placedSprite) {
+ this._drawHouse(
+ gfx, hx, hy, w, h, rh,
+ wallColors[i % wallColors.length]!,
+ roofColors[i % roofColors.length]!,
+ roofStyle,
+ );
+ }
if (i % 4 === 1) {
- this._drawFence(gfx, tx + dx, ty + dy, w, i % 2 === 0 ? 'left' : 'right');
+ this._drawFence(gfx, hx, hy, w, i % 2 === 0 ? 'left' : 'right');
}
}
@@ -2081,6 +2110,55 @@ export class GameRenderer {
this._hideUnusedSprites(this._npcSpritePool, usedNpcSprites);
}
+ /**
+ * Draw editor-defined town objects (props) within the viewport.
+ */
+ drawTownObjects(
+ objects: TownObjectData[],
+ camera: Camera,
+ screenWidth: number,
+ screenHeight: number,
+ ): void {
+ const camX = camera.finalX;
+ const camY = camera.finalY;
+ const halfW = screenWidth / (2 * MAP_ZOOM) + TILE_WIDTH * 3;
+ const halfH = screenHeight / (2 * MAP_ZOOM) + TILE_HEIGHT * 3;
+
+ const usedTownObjects = this._usedTownObjectSprites;
+ usedTownObjects.clear();
+
+ for (const obj of objects) {
+ const iso = worldToScreen(obj.worldX, obj.worldY);
+ if (Math.abs(iso.x - camX) > halfW || Math.abs(iso.y - camY) > halfH) continue;
+
+ const variant = Number.isFinite(obj.variant) ? obj.variant : 0;
+ const objTextureKey = this._spritesReady
+ ? objectToTextureKey(obj.objectType, variant)
+ : null;
+ const objTexture = objTextureKey ? this._spriteRegistry.getTexture(objTextureKey) : null;
+ if (objTextureKey && objTexture) {
+ const poolKey = `town-object:${obj.id}`;
+ usedTownObjects.add(poolKey);
+ const entry = this._ensureSprite(
+ this._townObjectSpritePool,
+ poolKey,
+ objTextureKey,
+ objTexture,
+ this._townObjectSpriteLayer,
+ obj.worldX,
+ obj.worldY,
+ this._townObjectSpriteFreeList,
+ );
+ entry.sprite.x = iso.x;
+ entry.sprite.y = iso.y;
+ entry.sprite.zIndex = iso.y;
+ entry.sprite.visible = true;
+ }
+ }
+
+ this._hideUnusedSprites(this._townObjectSpritePool, usedTownObjects);
+ }
+
/** Clear NPC visuals when there are none to render */
clearNPCs(): void {
if (this._npcGfx) this._npcGfx.clear();
diff --git a/frontend/src/game/types.ts b/frontend/src/game/types.ts
index a10d148..bfc74ec 100644
--- a/frontend/src/game/types.ts
+++ b/frontend/src/game/types.ts
@@ -420,6 +420,15 @@ export interface BuildingData {
footprintH: number;
}
+/** Decorative prop placed in a town (editor-defined). */
+export interface TownObjectData {
+ id: number;
+ objectType: string;
+ variant: number;
+ worldX: number;
+ worldY: number;
+}
+
/** Alias: engine-facing town data for map rendering */
export interface TownData {
id: number;
@@ -434,6 +443,7 @@ export interface TownData {
size: string;
npcs?: NPCData[];
buildings?: BuildingData[];
+ objects?: TownObjectData[];
}
/** NPC encounter event returned instead of an enemy */
diff --git a/frontend/src/game/ws-handler.ts b/frontend/src/game/ws-handler.ts
index b62a6eb..8d18dc1 100644
--- a/frontend/src/game/ws-handler.ts
+++ b/frontend/src/game/ws-handler.ts
@@ -37,6 +37,7 @@ import type {
NearbyHeroMovePayload,
} from './types';
import { DebuffType, Rarity } from './types';
+import { normalizeEnemyTemplateSlug } from './assets/spriteMapping';
// ---- Callback types for UI layer (App.tsx) ----
export interface WSHandlerCallbacks {
@@ -113,7 +114,8 @@ export function wireWSHandler(
ws.on('combat_start', (msg: ServerMessage) => {
const p = msg.payload as CombatStartPayload;
- const slug = typeof p.enemy.type === 'string' && p.enemy.type !== '' ? p.enemy.type : 'unknown';
+ const rawType = typeof p.enemy.type === 'string' && p.enemy.type !== '' ? p.enemy.type : 'unknown';
+ const slug = normalizeEnemyTemplateSlug(rawType);
const enemy: EnemyState = {
id: Date.now(),
name: p.enemy.name,