From 9a801d95572d449ca99245091c6e1d86a82e756a Mon Sep 17 00:00:00 2001 From: Denis Ranneft Date: Sun, 5 Apr 2026 13:09:25 +0300 Subject: [PATCH] latest changes --- admin-web/Dockerfile | 19 +- admin-web/index.html | 1 + admin-web/nginx.conf | 8 +- admin-web/town-editor/index.html | 38 + admin-web/town-editor/package.json | 17 + admin-web/town-editor/src/main.ts | 879 ++++++++++++++++++ admin-web/town-editor/src/style.css | 125 +++ admin-web/town-editor/tsconfig.json | 22 + admin-web/town-editor/vite.config.ts | 32 + .../internal/changelog/data/changelog.json | 8 + backend/internal/handler/admin.go | 296 +++++- backend/internal/model/town_object.go | 11 + backend/internal/router/router.go | 2 + backend/internal/storage/quest_store.go | 188 +++- backend/internal/version/version.go | 2 +- backend/migrations/000035_town_objects.sql | 26 + docker-compose.yml | 4 +- frontend/src/game/assets/spriteMapping.ts | 61 +- frontend/src/game/renderer.ts | 112 ++- frontend/src/game/types.ts | 10 + frontend/src/game/ws-handler.ts | 4 +- 21 files changed, 1834 insertions(+), 31 deletions(-) create mode 100644 admin-web/town-editor/index.html create mode 100644 admin-web/town-editor/package.json create mode 100644 admin-web/town-editor/src/main.ts create mode 100644 admin-web/town-editor/src/style.css create mode 100644 admin-web/town-editor/tsconfig.json create mode 100644 admin-web/town-editor/vite.config.ts create mode 100644 backend/internal/model/town_object.go create mode 100644 backend/migrations/000035_town_objects.sql 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 @@

Towns & NPCs (world)

Browse towns, NPCs, and which templates an NPC offers. To accept a quest for a hero, use Heroes → Give quest from world.

+ Open town editor

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,