latest changes

master
Denis Ranneft 1 month ago
parent 4807a0186c
commit 9a801d9557

@ -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 FROM nginx:alpine
RUN rm /etc/nginx/conf.d/default.conf RUN rm /etc/nginx/conf.d/default.conf
COPY nginx.conf /etc/nginx/conf.d/default.conf COPY admin-web/nginx.conf /etc/nginx/conf.d/default.conf
COPY index.html /usr/share/nginx/html/index.html 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 EXPOSE 80

@ -2718,6 +2718,7 @@
<h3>Towns &amp; NPCs (world)</h3> <h3>Towns &amp; NPCs (world)</h3>
<p class="muted">Browse towns, NPCs, and which templates an NPC offers. To accept a quest for a hero, use Heroes → Give quest from world.</p> <p class="muted">Browse towns, NPCs, and which templates an NPC offers. To accept a quest for a hero, use Heroes → Give quest from world.</p>
<button class="btn" onclick="withAction(loadQuestTowns)">Load towns</button> <button class="btn" onclick="withAction(loadQuestTowns)">Load towns</button>
<a class="btn" href="/town-editor/" target="_blank" rel="noopener">Open town editor</a>
</div> </div>
<div class="row-2"> <div class="row-2">
<div class="card"><h4>Towns</h4><div class="list">${towns || `<div class="list-row"><span class="muted">No towns loaded</span><span></span><span></span><span></span></div>`}</div>${pagerHtml("towns", townsPage.page, townsPage.total)}</div> <div class="card"><h4>Towns</h4><div class="list">${towns || `<div class="list-row"><span class="muted">No towns loaded</span><span></span><span></span><span></span></div>`}</div>${pagerHtml("towns", townsPage.page, townsPage.total)}</div>

@ -2,10 +2,16 @@ server {
listen 80; listen 80;
server_name _; server_name _;
location /town-editor/ {
root /usr/share/nginx/html;
index index.html;
try_files $uri $uri/ /town-editor/index.html;
}
location / { location / {
root /usr/share/nginx/html; root /usr/share/nginx/html;
index index.html; index index.html;
try_files $uri /index.html; try_files $uri $uri/ /index.html;
} }
location /admin-api/ { location /admin-api/ {

@ -0,0 +1,38 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>AutoHero Town Editor</title>
</head>
<body>
<div id="app">
<aside class="sidebar">
<div class="card">
<h3>Auth</h3>
<input id="auth-user" placeholder="Username" />
<input id="auth-pass" type="password" placeholder="Password" />
<button id="auth-save" class="btn">Save credentials</button>
<div id="auth-status" class="muted"></div>
</div>
<div class="card">
<h3>Town</h3>
<button id="towns-reload" class="btn">Load towns</button>
<select id="town-select"></select>
</div>
<div class="card">
<h3>Layout</h3>
<button id="layout-reload" class="btn">Reload layout</button>
<button id="layout-save" class="btn" disabled>Save</button>
<div id="selection-info" class="muted"></div>
<div id="layout-status" class="muted"></div>
</div>
</aside>
<main class="canvas-area">
<div id="canvas-root" class="canvas-root"></div>
<div id="context-menu" class="context-menu hidden"></div>
</main>
</div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

@ -0,0 +1,17 @@
{
"name": "autohero-admin-town-editor",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"pixi.js": "^8.6.6"
},
"devDependencies": {
"typescript": "~5.7.2",
"vite": "^6.2.0"
}
}

@ -0,0 +1,879 @@
import './style.css';
import { Camera } from '@game/camera';
import { GameRenderer, screenToWorld, worldToScreen } from '@game/renderer';
import { buildWorldTerrainContext, townsApiToInfluences } from '@game/procedural';
import type { BuildingData, NPCData, TownData, TownObjectData } from '@game/types';
type AdminTown = {
id: number;
name: string;
nameKey?: string;
biome: string;
worldX: number;
worldY: number;
radius: number;
levelMin: number;
levelMax: number;
};
type AdminNPC = {
id: number;
townId: number;
name: string;
nameKey?: string;
type: NPCData['type'];
offsetX: number;
offsetY: number;
buildingId?: number | null;
};
type AdminBuilding = {
id: number;
townId: number;
buildingType: string;
offsetX: number;
offsetY: number;
facing: BuildingData['facing'];
footprintW: number;
footprintH: number;
};
type AdminTownObject = {
id: number;
townId: number;
objectType: string;
variant: number;
offsetX: number;
offsetY: number;
};
type TownLayoutResponse = {
town: AdminTown;
npcs: AdminNPC[];
buildings: AdminBuilding[];
objects: AdminTownObject[];
};
type DraftNPC = AdminNPC;
type DraftBuilding = AdminBuilding;
type DraftObject = AdminTownObject;
type Selection =
| { type: 'npc'; id: number }
| { type: 'building'; id: number }
| { type: 'object'; id: number }
| { type: 'none' };
type DragState =
| {
type: 'npc';
id: number;
startOffsetX: number;
startOffsetY: number;
}
| {
type: 'building';
id: number;
startOffsetX: number;
startOffsetY: number;
linkedNpcOffsets: Map<number, { offsetX: number; offsetY: number }>;
}
| {
type: 'object';
id: number;
startOffsetX: number;
startOffsetY: number;
}
| null;
type PanState = {
startClientX: number;
startClientY: number;
startCameraX: number;
startCameraY: number;
} | null;
const state = {
auth: {
username: sessionStorage.getItem('admin_user') || '',
password: sessionStorage.getItem('admin_pass') || '',
},
towns: [] as AdminTown[],
selectedTownId: null as number | null,
townData: null as TownData | null,
npcData: [] as NPCData[],
draftTown: null as AdminTown | null,
draftNPCs: [] as DraftNPC[],
draftBuildings: [] as DraftBuilding[],
draftObjects: [] as DraftObject[],
deletedNPCIds: [] as number[],
deletedBuildingIds: [] as number[],
deletedObjectIds: [] as number[],
dirty: false,
tempBuildingId: -1,
tempNpcId: -1,
tempObjectId: -1,
selection: { type: 'none' } as Selection,
drag: null as DragState,
pan: null as PanState,
contextWorld: null as { x: number; y: number } | null,
};
const renderer = new GameRenderer();
const camera = new Camera();
const authUserInput = document.getElementById('auth-user') as HTMLInputElement;
const authPassInput = document.getElementById('auth-pass') as HTMLInputElement;
const authSaveBtn = document.getElementById('auth-save') as HTMLButtonElement;
const authStatus = document.getElementById('auth-status') as HTMLDivElement;
const townsReloadBtn = document.getElementById('towns-reload') as HTMLButtonElement;
const townSelect = document.getElementById('town-select') as HTMLSelectElement;
const layoutReloadBtn = document.getElementById('layout-reload') as HTMLButtonElement;
const layoutSaveBtn = document.getElementById('layout-save') as HTMLButtonElement;
const selectionInfo = document.getElementById('selection-info') as HTMLDivElement;
const layoutStatus = document.getElementById('layout-status') as HTMLDivElement;
const canvasRoot = document.getElementById('canvas-root') as HTMLDivElement;
const contextMenu = document.getElementById('context-menu') as HTMLDivElement;
authUserInput.value = state.auth.username;
authPassInput.value = state.auth.password;
selectionInfo.textContent = 'No selection.';
function setStatus(el: HTMLElement, message: string, isError = false): void {
el.textContent = message;
el.style.color = isError ? '#ff8f8f' : '';
}
function setDirty(value: boolean): void {
state.dirty = value;
layoutSaveBtn.disabled = !value;
}
function setSelection(next: Selection): void {
state.selection = next;
if (next.type === 'none') {
selectionInfo.textContent = 'No selection.';
return;
}
selectionInfo.textContent = `${next.type.toUpperCase()} #${next.id}`;
}
function saveAuth(): void {
state.auth.username = authUserInput.value.trim();
state.auth.password = authPassInput.value.trim();
sessionStorage.setItem('admin_user', state.auth.username);
sessionStorage.setItem('admin_pass', state.auth.password);
setStatus(authStatus, 'Credentials saved.');
}
function authHeader(): string {
return `Basic ${btoa(`${state.auth.username}:${state.auth.password}`)}`;
}
async function api(path: string, opts: RequestInit = {}): Promise<Response> {
const headers = Object.assign(
{ Authorization: authHeader(), 'Content-Type': 'application/json' },
opts.headers || {},
);
return fetch(`/admin-api/${path}`, { cache: 'no-store', ...opts, headers });
}
function townSize(radius: number): string {
if (radius > 40) return 'XL';
if (radius > 25) return 'M';
if (radius > 15) return 'S';
return 'XS';
}
function buildTownData(town: AdminTown, npcs: AdminNPC[], buildings: AdminBuilding[]): TownData {
const npcData: NPCData[] = npcs.map((n) => ({
id: n.id,
name: n.nameKey || n.name,
nameKey: n.nameKey,
type: n.type,
worldX: town.worldX + n.offsetX,
worldY: town.worldY + n.offsetY,
buildingId: n.buildingId ?? undefined,
townId: town.id,
townLevelMin: town.levelMin,
townLevelMax: town.levelMax,
}));
const buildingData: BuildingData[] = buildings.map((b) => ({
id: b.id,
buildingType: b.buildingType,
worldX: town.worldX + b.offsetX,
worldY: town.worldY + b.offsetY,
facing: b.facing,
footprintW: b.footprintW,
footprintH: b.footprintH,
}));
const objectData: TownObjectData[] = state.draftObjects.map((o) => ({
id: o.id,
objectType: o.objectType,
variant: o.variant,
worldX: town.worldX + o.offsetX,
worldY: town.worldY + o.offsetY,
}));
return {
id: town.id,
name: town.nameKey || town.name,
nameKey: town.nameKey,
centerX: town.worldX,
centerY: town.worldY,
radius: town.radius,
biome: town.biome,
levelMin: town.levelMin,
size: townSize(town.radius),
npcs: npcData,
buildings: buildingData,
objects: objectData,
};
}
function refreshTownData(): void {
if (!state.draftTown) return;
state.townData = buildTownData(state.draftTown, state.draftNPCs, state.draftBuildings);
state.npcData = state.townData.npcs || [];
}
function applyLayout(data: TownLayoutResponse): void {
state.draftTown = data.town;
state.draftNPCs = data.npcs.map((n) => ({ ...n }));
state.draftBuildings = data.buildings.map((b) => ({ ...b }));
state.draftObjects = (data.objects || []).map((o) => ({ ...o }));
state.deletedNPCIds = [];
state.deletedBuildingIds = [];
state.deletedObjectIds = [];
refreshTownData();
if (state.townData) {
const center = worldToScreen(state.townData.centerX, state.townData.centerY);
camera.setTarget(center.x, center.y);
camera.snapToTarget();
}
setSelection({ type: 'none' });
setDirty(false);
}
async function loadTowns(): Promise<void> {
if (!state.auth.username || !state.auth.password) {
setStatus(authStatus, 'Set admin credentials first.', true);
return;
}
setStatus(layoutStatus, 'Loading towns...');
const res = await api('quests/towns');
if (!res.ok) {
setStatus(layoutStatus, `Failed to load towns (${res.status}).`, true);
return;
}
const data = await res.json();
const towns = (data.towns || []) as AdminTown[];
state.towns = towns;
townSelect.innerHTML = '';
for (const t of towns) {
const opt = document.createElement('option');
opt.value = String(t.id);
opt.textContent = `${t.name} (Lv ${t.levelMin}-${t.levelMax})`;
townSelect.appendChild(opt);
}
if (!state.selectedTownId && towns.length > 0) {
state.selectedTownId = towns[0].id;
}
if (state.selectedTownId) {
townSelect.value = String(state.selectedTownId);
}
const influences = townsApiToInfluences(towns);
renderer.setWorldTerrainContext(buildWorldTerrainContext(influences, null));
setStatus(layoutStatus, 'Towns loaded.');
if (state.selectedTownId) {
await loadLayout(state.selectedTownId);
}
}
async function loadLayout(townId: number): Promise<void> {
setStatus(layoutStatus, 'Loading town layout...');
const res = await api(`quests/towns/${townId}/layout`);
if (!res.ok) {
setStatus(layoutStatus, `Failed to load layout (${res.status}).`, true);
return;
}
const data = (await res.json()) as TownLayoutResponse;
applyLayout(data);
setStatus(layoutStatus, 'Layout loaded.');
}
async function saveLayout(): Promise<void> {
if (!state.draftTown) return;
setStatus(layoutStatus, 'Saving layout...');
layoutSaveBtn.disabled = true;
const payload = {
npcs: state.draftNPCs.map((n) => ({
...(n.id > 0 ? { id: n.id } : {}),
name: n.name,
nameKey: n.nameKey || '',
type: n.type,
offsetX: n.offsetX,
offsetY: n.offsetY,
...(n.buildingId ? { buildingId: n.buildingId } : {}),
})),
buildings: state.draftBuildings.map((b) => ({
...(b.id > 0 ? { id: b.id } : {}),
buildingType: b.buildingType,
offsetX: b.offsetX,
offsetY: b.offsetY,
facing: b.facing,
footprintW: b.footprintW,
footprintH: b.footprintH,
})),
objects: state.draftObjects.map((o) => ({
...(o.id > 0 ? { id: o.id } : {}),
objectType: o.objectType,
variant: o.variant,
offsetX: o.offsetX,
offsetY: o.offsetY,
})),
deleteNpcIds: state.deletedNPCIds,
deleteBuildingIds: state.deletedBuildingIds,
deleteObjectIds: state.deletedObjectIds,
};
const res = await api(`quests/towns/${state.draftTown.id}/layout`, {
method: 'PUT',
body: JSON.stringify(payload),
});
if (!res.ok) {
setStatus(layoutStatus, `Failed to save layout (${res.status}).`, true);
layoutSaveBtn.disabled = false;
return;
}
const data = (await res.json()) as TownLayoutResponse;
applyLayout(data);
setStatus(layoutStatus, 'Layout saved.');
}
const buildingMenuItems = [
{ label: 'House: Quest Giver', type: 'house.quest_giver', w: 2.5, h: 2.0 },
{ label: 'House: Merchant', type: 'house.merchant', w: 2.5, h: 2.0 },
{ label: 'House: Armorer', type: 'house.armorer', w: 2.5, h: 2.0 },
{ label: 'House: Weapon Smith', type: 'house.weapon_smith', w: 2.5, h: 2.0 },
{ label: 'House: Jeweler', type: 'house.jeweler', w: 2.5, h: 2.0 },
{ label: 'House: Bounty Hunter', type: 'house.bounty_hunter', w: 2.5, h: 2.0 },
{ label: 'House: Elder', type: 'house.elder', w: 2.5, h: 2.0 },
{ label: 'House: Healer', type: 'house.healer', w: 2.5, h: 2.0 },
{ label: 'Decoration: Well', type: 'decoration.well', w: 1.5, h: 1.5 },
{ label: 'Decoration: Stall', type: 'decoration.stall', w: 1.5, h: 1.5 },
{ label: 'Decoration: Signpost', type: 'decoration.signpost', w: 0.5, h: 0.5 },
];
const npcTypeItems = [
{ label: 'Quest Giver', type: 'quest_giver' as NPCData['type'] },
{ label: 'Merchant', type: 'merchant' as NPCData['type'] },
{ label: 'Armorer', type: 'armorer' as NPCData['type'] },
{ label: 'Weapon', type: 'weapon' as NPCData['type'] },
{ label: 'Jeweler', type: 'jeweler' as NPCData['type'] },
{ label: 'Bounty Hunter', type: 'bounty_hunter' as NPCData['type'] },
{ label: 'Elder', type: 'elder' as NPCData['type'] },
{ label: 'Healer', type: 'healer' as NPCData['type'] },
];
const objectTypeItems = [
{ label: 'Tree', type: 'tree' },
{ label: 'Rock', type: 'rock' },
{ label: 'Cart', type: 'cart' },
{ label: 'Barrel', type: 'barrel' },
{ label: 'Bush', type: 'bush' },
{ label: 'Mushroom', type: 'mushroom' },
{ label: 'Leaves', type: 'leaves' },
{ label: 'Stump', type: 'stump' },
{ label: 'Bones', type: 'bones' },
{ label: 'Ruin', type: 'ruin' },
];
function hideContextMenu(): void {
contextMenu.classList.add('hidden');
}
function addContextHeader(label: string): void {
const header = document.createElement('div');
header.textContent = label;
header.style.padding = '6px 12px 4px 12px';
header.style.fontSize = '11px';
header.style.color = '#9eb0d6';
contextMenu.appendChild(header);
}
function addContextButton(label: string, onClick: () => void): void {
const btn = document.createElement('button');
btn.textContent = label;
btn.addEventListener('click', onClick);
contextMenu.appendChild(btn);
}
function addContextSubmenu(label: string, build: (submenu: HTMLDivElement) => void): void {
const wrapper = document.createElement('div');
wrapper.className = 'context-menu-item';
const button = document.createElement('button');
button.textContent = `${label}`;
const submenu = document.createElement('div');
submenu.className = 'submenu';
build(submenu);
wrapper.appendChild(button);
wrapper.appendChild(submenu);
contextMenu.appendChild(wrapper);
}
function showContextMenu(clientX: number, clientY: number, render: () => void): void {
contextMenu.innerHTML = '';
render();
const area = canvasRoot.parentElement?.getBoundingClientRect();
if (!area) return;
contextMenu.style.left = `${clientX - area.left}px`;
contextMenu.style.top = `${clientY - area.top}px`;
contextMenu.classList.remove('hidden');
}
function removeNpcById(id: number): void {
state.draftNPCs = state.draftNPCs.filter((n) => n.id !== id);
if (id > 0) state.deletedNPCIds.push(id);
setSelection({ type: 'none' });
refreshTownData();
setDirty(true);
}
function removeBuildingById(id: number): void {
state.draftBuildings = state.draftBuildings.filter((b) => b.id !== id);
if (id > 0) state.deletedBuildingIds.push(id);
setSelection({ type: 'none' });
refreshTownData();
setDirty(true);
}
function removeObjectById(id: number): void {
state.draftObjects = state.draftObjects.filter((o) => o.id !== id);
if (id > 0) state.deletedObjectIds.push(id);
setSelection({ type: 'none' });
refreshTownData();
setDirty(true);
}
function showContextMenuForSelection(clientX: number, clientY: number, selection: Selection): void {
showContextMenu(clientX, clientY, () => {
if (!state.draftTown) return;
if (selection.type === 'npc') {
addContextHeader('NPC');
addContextSubmenu('Change type', (submenu) => {
for (const item of npcTypeItems) {
const btn = document.createElement('button');
btn.textContent = item.label;
btn.addEventListener('click', () => {
const npc = state.draftNPCs.find((n) => n.id === selection.id);
if (!npc) return;
npc.type = item.type;
if (!npc.name) npc.name = `New ${item.label}`;
setDirty(true);
refreshTownData();
hideContextMenu();
});
submenu.appendChild(btn);
}
});
addContextHeader('Actions');
addContextButton('Delete NPC', () => {
removeNpcById(selection.id);
hideContextMenu();
});
return;
}
if (selection.type === 'building') {
addContextHeader('Building');
addContextSubmenu('Change type', (submenu) => {
for (const item of buildingMenuItems) {
const btn = document.createElement('button');
btn.textContent = item.label;
btn.addEventListener('click', () => {
const building = state.draftBuildings.find((b) => b.id === selection.id);
if (!building) return;
building.buildingType = item.type;
building.footprintW = item.w;
building.footprintH = item.h;
setDirty(true);
refreshTownData();
hideContextMenu();
});
submenu.appendChild(btn);
}
});
addContextHeader('Actions');
addContextButton('Delete building', () => {
removeBuildingById(selection.id);
hideContextMenu();
});
return;
}
if (selection.type === 'object') {
addContextHeader('Object');
addContextSubmenu('Change type', (submenu) => {
for (const item of objectTypeItems) {
const btn = document.createElement('button');
btn.textContent = item.label;
btn.addEventListener('click', () => {
const obj = state.draftObjects.find((o) => o.id === selection.id);
if (!obj) return;
obj.objectType = item.type;
setDirty(true);
refreshTownData();
hideContextMenu();
});
submenu.appendChild(btn);
}
});
addContextSubmenu('Variant', (submenu) => {
const btn0 = document.createElement('button');
btn0.textContent = 'Variant v0';
btn0.addEventListener('click', () => {
const obj = state.draftObjects.find((o) => o.id === selection.id);
if (!obj) return;
obj.variant = 0;
setDirty(true);
refreshTownData();
hideContextMenu();
});
const btn1 = document.createElement('button');
btn1.textContent = 'Variant v1';
btn1.addEventListener('click', () => {
const obj = state.draftObjects.find((o) => o.id === selection.id);
if (!obj) return;
obj.variant = 1;
setDirty(true);
refreshTownData();
hideContextMenu();
});
submenu.appendChild(btn0);
submenu.appendChild(btn1);
});
addContextHeader('Actions');
addContextButton('Delete object', () => {
removeObjectById(selection.id);
hideContextMenu();
});
return;
}
});
}
function showContextMenuForEmpty(clientX: number, clientY: number): void {
showContextMenu(clientX, clientY, () => {
if (!state.draftTown || !state.contextWorld) return;
const world = state.contextWorld;
addContextSubmenu('Add NPC', (submenu) => {
for (const item of npcTypeItems) {
const btn = document.createElement('button');
btn.textContent = item.label;
btn.addEventListener('click', () => {
const offsetX = world.x - state.draftTown!.worldX;
const offsetY = world.y - state.draftTown!.worldY;
const newId = state.tempNpcId--;
state.draftNPCs.push({
id: newId,
townId: state.draftTown!.id,
name: `New ${item.label}`,
nameKey: '',
type: item.type,
offsetX,
offsetY,
buildingId: null,
});
refreshTownData();
setSelection({ type: 'npc', id: newId });
setDirty(true);
hideContextMenu();
});
submenu.appendChild(btn);
}
});
addContextSubmenu('Add Building', (submenu) => {
for (const item of buildingMenuItems) {
const btn = document.createElement('button');
btn.textContent = item.label;
btn.addEventListener('click', () => {
const offsetX = world.x - state.draftTown!.worldX;
const offsetY = world.y - state.draftTown!.worldY;
const newId = state.tempBuildingId--;
state.draftBuildings.push({
id: newId,
townId: state.draftTown!.id,
buildingType: item.type,
offsetX,
offsetY,
facing: 'south',
footprintW: item.w,
footprintH: item.h,
});
refreshTownData();
setSelection({ type: 'building', id: newId });
setDirty(true);
hideContextMenu();
});
submenu.appendChild(btn);
}
});
addContextSubmenu('Add Object', (submenu) => {
for (const item of objectTypeItems) {
const btn = document.createElement('button');
btn.textContent = item.label;
btn.addEventListener('click', () => {
const offsetX = world.x - state.draftTown!.worldX;
const offsetY = world.y - state.draftTown!.worldY;
const newId = state.tempObjectId--;
state.draftObjects.push({
id: newId,
townId: state.draftTown!.id,
objectType: item.type,
variant: 0,
offsetX,
offsetY,
});
refreshTownData();
setSelection({ type: 'object', id: newId });
setDirty(true);
hideContextMenu();
});
submenu.appendChild(btn);
}
});
});
}
function eventToWorld(evt: MouseEvent): { x: number; y: number } | null {
if (!renderer.initialized) return null;
const canvas = renderer.app.canvas as HTMLCanvasElement;
const rect = canvas.getBoundingClientRect();
if (evt.clientX < rect.left || evt.clientX > rect.right || evt.clientY < rect.top || evt.clientY > rect.bottom) {
return null;
}
const localX = evt.clientX - rect.left;
const localY = evt.clientY - rect.top;
const screenW = renderer.app.renderer.width;
const screenH = renderer.app.renderer.height;
const worldScreenX = localX - screenW / 2 + camera.finalX;
const worldScreenY = localY - screenH / 2 + camera.finalY;
return screenToWorld(worldScreenX, worldScreenY);
}
function findHit(world: { x: number; y: number }): Selection {
if (!state.draftTown) return { type: 'none' };
const npcHitRadius = 1.8;
const objectHitRadius = 1.8;
const buildingPadding = 1.0;
for (const npc of state.draftNPCs) {
const nx = state.draftTown.worldX + npc.offsetX;
const ny = state.draftTown.worldY + npc.offsetY;
const dx = world.x - nx;
const dy = world.y - ny;
if (Math.hypot(dx, dy) <= npcHitRadius) {
return { type: 'npc', id: npc.id };
}
}
for (const obj of state.draftObjects) {
const ox = state.draftTown.worldX + obj.offsetX;
const oy = state.draftTown.worldY + obj.offsetY;
if (Math.hypot(world.x - ox, world.y - oy) <= objectHitRadius) {
return { type: 'object', id: obj.id };
}
}
for (const building of state.draftBuildings) {
const bx = state.draftTown.worldX + building.offsetX;
const by = state.draftTown.worldY + building.offsetY;
const dx = Math.abs(world.x - bx);
const dy = Math.abs(world.y - by);
if (dx <= building.footprintW / 2 + buildingPadding && dy <= building.footprintH / 2 + buildingPadding) {
return { type: 'building', id: building.id };
}
}
return { type: 'none' };
}
function startDrag(selection: Selection): void {
if (selection.type === 'npc') {
const npc = state.draftNPCs.find((n) => n.id === selection.id);
if (!npc) return;
state.drag = {
type: 'npc',
id: npc.id,
startOffsetX: npc.offsetX,
startOffsetY: npc.offsetY,
};
return;
}
if (selection.type === 'building') {
const building = state.draftBuildings.find((b) => b.id === selection.id);
if (!building) return;
const linked = new Map<number, { offsetX: number; offsetY: number }>();
for (const npc of state.draftNPCs) {
if (npc.buildingId === building.id) {
linked.set(npc.id, { offsetX: npc.offsetX, offsetY: npc.offsetY });
}
}
state.drag = {
type: 'building',
id: building.id,
startOffsetX: building.offsetX,
startOffsetY: building.offsetY,
linkedNpcOffsets: linked,
};
return;
}
if (selection.type === 'object') {
const obj = state.draftObjects.find((o) => o.id === selection.id);
if (!obj) return;
state.drag = {
type: 'object',
id: obj.id,
startOffsetX: obj.offsetX,
startOffsetY: obj.offsetY,
};
}
}
function updateDrag(world: { x: number; y: number }): void {
if (!state.draftTown || !state.drag) return;
const offsetX = world.x - state.draftTown.worldX;
const offsetY = world.y - state.draftTown.worldY;
if (state.drag.type === 'npc') {
const npc = state.draftNPCs.find((n) => n.id === state.drag?.id);
if (!npc) return;
npc.offsetX = offsetX;
npc.offsetY = offsetY;
}
if (state.drag.type === 'building') {
const building = state.draftBuildings.find((b) => b.id === state.drag?.id);
if (!building) return;
const deltaX = offsetX - state.drag.startOffsetX;
const deltaY = offsetY - state.drag.startOffsetY;
building.offsetX = offsetX;
building.offsetY = offsetY;
for (const npc of state.draftNPCs) {
const start = state.drag.linkedNpcOffsets.get(npc.id);
if (!start) continue;
npc.offsetX = start.offsetX + deltaX;
npc.offsetY = start.offsetY + deltaY;
}
}
if (state.drag.type === 'object') {
const obj = state.draftObjects.find((o) => o.id === state.drag?.id);
if (!obj) return;
obj.offsetX = offsetX;
obj.offsetY = offsetY;
}
refreshTownData();
setDirty(true);
}
function startPan(evt: MouseEvent): void {
state.pan = {
startClientX: evt.clientX,
startClientY: evt.clientY,
startCameraX: camera.x,
startCameraY: camera.y,
};
}
function updatePan(evt: MouseEvent): void {
if (!state.pan) return;
const dx = evt.clientX - state.pan.startClientX;
const dy = evt.clientY - state.pan.startClientY;
const nextX = state.pan.startCameraX - dx;
const nextY = state.pan.startCameraY - dy;
camera.setTarget(nextX, nextY);
camera.snapToTarget();
}
function renderLoop(): void {
requestAnimationFrame(renderLoop);
if (!renderer.initialized) return;
if (!state.townData) return;
const screenW = renderer.app.renderer.width;
const screenH = renderer.app.renderer.height;
camera.applyTo(renderer.worldContainer, screenW, screenH);
renderer.drawGround(camera, screenW, screenH);
renderer.drawTowns([state.townData], camera, screenW, screenH);
renderer.drawTownObjects(state.townData.objects ?? [], camera, screenW, screenH);
if (state.npcData.length > 0) {
renderer.drawNPCs(state.npcData, camera, screenW, screenH, performance.now());
}
}
async function bootstrap(): Promise<void> {
await renderer.init(canvasRoot);
window.addEventListener('resize', () => renderer.resize());
renderLoop();
const canvas = renderer.app.canvas as HTMLCanvasElement;
canvas.addEventListener('contextmenu', (evt) => {
evt.preventDefault();
const world = eventToWorld(evt);
if (!world) return;
state.contextWorld = world;
const hit = findHit(world);
if (hit.type === 'none') {
showContextMenuForEmpty(evt.clientX, evt.clientY);
return;
}
setSelection(hit);
showContextMenuForSelection(evt.clientX, evt.clientY, hit);
});
canvas.addEventListener('mousedown', (evt) => {
hideContextMenu();
const world = eventToWorld(evt);
if (!world) return;
if (evt.button === 1) {
startPan(evt);
return;
}
if (evt.button !== 0) return;
const hit = findHit(world);
if (hit.type === 'none') {
setSelection(hit);
startPan(evt);
return;
}
setSelection(hit);
startDrag(hit);
});
window.addEventListener('mousemove', (evt) => {
if (state.drag) {
const world = eventToWorld(evt);
if (!world) return;
updateDrag(world);
return;
}
if (state.pan) {
updatePan(evt);
}
});
window.addEventListener('mouseup', () => {
state.drag = null;
state.pan = null;
});
window.addEventListener('click', () => hideContextMenu());
if (state.auth.username && state.auth.password) {
void loadTowns();
}
}
authSaveBtn.addEventListener('click', saveAuth);
townsReloadBtn.addEventListener('click', () => void loadTowns());
layoutReloadBtn.addEventListener('click', () => {
if (!state.selectedTownId) return;
void loadLayout(state.selectedTownId);
});
layoutSaveBtn.addEventListener('click', () => void saveLayout());
townSelect.addEventListener('change', () => {
const id = Number(townSelect.value);
if (!id) return;
state.selectedTownId = id;
void loadLayout(id);
});
void bootstrap();

@ -0,0 +1,125 @@
* {
box-sizing: border-box;
}
body {
margin: 0;
font-family: Arial, sans-serif;
background: #10141f;
color: #e8eef9;
}
#app {
display: grid;
grid-template-columns: 260px 1fr;
min-height: 100vh;
}
.sidebar {
background: #151b2a;
padding: 16px;
border-right: 1px solid #2a3551;
}
.canvas-area {
position: relative;
overflow: hidden;
}
.canvas-root {
width: 100%;
height: 100%;
}
.card {
background: #151b2a;
border: 1px solid #2a3551;
border-radius: 8px;
padding: 12px;
margin-bottom: 12px;
}
.card h3 {
margin: 0 0 8px 0;
font-size: 14px;
}
input,
select {
width: 100%;
padding: 8px;
margin: 4px 0;
background: #0f1522;
color: #fff;
border: 1px solid #2f3b5a;
}
.btn {
padding: 8px 12px;
border: 1px solid #2f3b5a;
background: #223152;
color: #fff;
cursor: pointer;
margin-top: 6px;
}
.btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.muted {
color: #9eb0d6;
font-size: 12px;
margin-top: 6px;
}
.context-menu {
position: absolute;
min-width: 180px;
background: #151b2a;
border: 1px solid #2a3551;
border-radius: 6px;
padding: 6px 0;
z-index: 10;
box-shadow: 0 12px 24px rgba(0, 0, 0, 0.35);
}
.context-menu.hidden {
display: none;
}
.context-menu button {
width: 100%;
border: 0;
background: transparent;
color: #e8eef9;
text-align: left;
padding: 8px 12px;
cursor: pointer;
}
.context-menu button:hover {
background: #223152;
}
.context-menu-item {
position: relative;
}
.context-menu-item > .submenu {
position: absolute;
top: 0;
left: 100%;
min-width: 200px;
background: #151b2a;
border: 1px solid #2a3551;
border-radius: 6px;
padding: 6px 0;
box-shadow: 0 12px 24px rgba(0, 0, 0, 0.35);
display: none;
}
.context-menu-item:hover > .submenu {
display: block;
}

@ -0,0 +1,22 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "Bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"strict": true,
"jsx": "preserve",
"baseUrl": ".",
"paths": {
"@game/*": ["../../frontend/src/game/*"],
"@shared/*": ["../../frontend/src/shared/*"],
"@frontend/*": ["../../frontend/src/*"]
}
},
"include": ["src"]
}

@ -0,0 +1,32 @@
import { defineConfig } from 'vite';
import path from 'path';
const rootDir = __dirname;
export default defineConfig({
root: rootDir,
base: '/town-editor/',
publicDir: path.resolve(rootDir, '../../frontend/public'),
resolve: {
alias: {
'@game': path.resolve(rootDir, '../../frontend/src/game'),
'@shared': path.resolve(rootDir, '../../frontend/src/shared'),
'@frontend': path.resolve(rootDir, '../../frontend/src'),
'pixi.js': path.resolve(rootDir, 'node_modules/pixi.js'),
},
dedupe: ['pixi.js'],
},
server: {
port: 5175,
fs: {
allow: [
path.resolve(rootDir, '../../frontend'),
path.resolve(rootDir, '../../admin-web/town-editor'),
],
},
},
build: {
outDir: 'dist',
emptyOutDir: true,
},
});

@ -1,5 +1,13 @@
{ {
"releases": [ "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", "version": "0.2.0-dev",
"title": "AutoHero — 0.2.0", "title": "AutoHero — 0.2.0",

@ -8,6 +8,7 @@ import (
"fmt" "fmt"
"io" "io"
"log/slog" "log/slog"
"math"
"net/http" "net/http"
"runtime" "runtime"
"strconv" "strconv"
@ -732,6 +733,299 @@ func (h *AdminHandler) ListTownNPCsForQuests(w http.ResponseWriter, r *http.Requ
writeJSON(w, http.StatusOK, map[string]any{"npcs": npcs}) 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). // ContentAllQuests returns all quest template rows (global content).
// GET /admin/content/quests // GET /admin/content/quests
func (h *AdminHandler) ContentAllQuests(w http.ResponseWriter, r *http.Request) { func (h *AdminHandler) ContentAllQuests(w http.ResponseWriter, r *http.Request) {

@ -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"`
}

@ -120,6 +120,8 @@ func New(deps Deps) *chi.Mux {
r.Put("/content/gear/{gearId}", adminH.ContentUpdateGear) r.Put("/content/gear/{gearId}", adminH.ContentUpdateGear)
r.Get("/quests/towns", adminH.ListTownsForQuests) r.Get("/quests/towns", adminH.ListTownsForQuests)
r.Get("/quests/towns/{townId}/npcs", adminH.ListTownNPCsForQuests) 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.Get("/quests/npcs/{npcId}", adminH.ListNPCQuestsForAdmin)
r.Delete("/heroes/{heroId}", adminH.DeleteHero) r.Delete("/heroes/{heroId}", adminH.DeleteHero)
r.Get("/towns", adminH.ListTowns) r.Get("/towns", adminH.ListTowns)

@ -6,6 +6,7 @@ import (
"fmt" "fmt"
"math/rand" "math/rand"
"strings" "strings"
"time"
"github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool" "github.com/jackc/pgx/v5/pgxpool"
@ -19,6 +20,36 @@ type QuestStore struct {
pool *pgxpool.Pool 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. // NewQuestStore creates a new QuestStore backed by the given connection pool.
func NewQuestStore(pool *pgxpool.Pool) *QuestStore { func NewQuestStore(pool *pgxpool.Pool) *QuestStore {
return &QuestStore{pool: pool} return &QuestStore{pool: pool}
@ -174,6 +205,155 @@ func (s *QuestStore) ListBuildingsByTown(ctx context.Context, townID int64) ([]m
return buildings, nil 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). // ListAllBuildings returns every building across all towns (for road_graph preload).
func (s *QuestStore) ListAllBuildings(ctx context.Context) ([]model.TownBuilding, error) { func (s *QuestStore) ListAllBuildings(ctx context.Context) ([]model.TownBuilding, error) {
rows, err := s.pool.Query(ctx, ` rows, err := s.pool.Query(ctx, `

@ -3,4 +3,4 @@
package version package version
// Version is the active server build id (shown in /hero/init and admin /info). // 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"

@ -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);

@ -81,8 +81,8 @@ services:
admin-web: admin-web:
image: ${DOCKER_REGISTRY:-static.ranneft.ru:25000}/autohero/admin-web:${IMAGE_TAG:-latest} image: ${DOCKER_REGISTRY:-static.ranneft.ru:25000}/autohero/admin-web:${IMAGE_TAG:-latest}
build: build:
context: ./admin-web context: .
dockerfile: Dockerfile dockerfile: admin-web/Dockerfile
ports: ports:
- "3002:80" - "3002:80"
depends_on: depends_on:

@ -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 PLAZA_FOUNTAIN_TEXTURE_KEY = 'prop.fountain.plaza.v0';
export const MARKET_STALL_TEXTURE_KEY = 'prop.market.stall.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<string, string> = { const BUILDING_TEXTURE_BY_TYPE: Record<string, string> = {
'house.quest_giver': 'building.house.v0', 'house.quest_giver': 'building.house.v0',
'house.merchant': 'building.tavern.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]; 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.<slug>.south`). */
export function enemySouthTextureKey(slug: string): string { 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[] { export function getRequiredSpriteKeys(): string[] {

@ -2,7 +2,7 @@ import { Application, Container, Graphics, Sprite, Text, TextStyle, Texture } fr
import { TILE_WIDTH, TILE_HEIGHT, MAP_ZOOM } from '../shared/constants'; import { TILE_WIDTH, TILE_HEIGHT, MAP_ZOOM } from '../shared/constants';
import { getViewport } from '../shared/telegram'; import { getViewport } from '../shared/telegram';
import type { Camera } from './camera'; 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 { drawEnemyBySlug, drawEnemyHpBarOnly } from './enemyVisuals';
import { GameSpriteRegistry } from './assets/gameSpriteRegistry'; import { GameSpriteRegistry } from './assets/gameSpriteRegistry';
import { import {
@ -12,8 +12,9 @@ import {
npcTypeToTextureKey, npcTypeToTextureKey,
objectToTextureKey, objectToTextureKey,
PLAZA_FOUNTAIN_TEXTURE_KEY, PLAZA_FOUNTAIN_TEXTURE_KEY,
PROCEDURAL_TOWN_HOUSE_FACADE_KEYS,
restCampTextureKeys, restCampTextureKeys,
enemySouthTextureKey, resolveEnemySouthTextureKey,
terrainToTextureKey, terrainToTextureKey,
TOWN_HALL_TEXTURE_KEY, TOWN_HALL_TEXTURE_KEY,
} from './assets/spriteMapping'; } from './assets/spriteMapping';
@ -101,12 +102,16 @@ export class GameRenderer {
private _groundSpriteLayer: Container; private _groundSpriteLayer: Container;
private _objectSpriteLayer: Container; private _objectSpriteLayer: Container;
private _buildingSpriteLayer: Container; private _buildingSpriteLayer: Container;
private _townObjectSpriteLayer: Container;
private _tileSpritePool = new Map<string, SpritePoolEntry>(); private _tileSpritePool = new Map<string, SpritePoolEntry>();
private _objectSpritePool = new Map<string, SpritePoolEntry>(); private _objectSpritePool = new Map<string, SpritePoolEntry>();
private _tileSpriteFreeList: Sprite[] = []; private _tileSpriteFreeList: Sprite[] = [];
private _objectSpriteFreeList: Sprite[] = []; private _objectSpriteFreeList: Sprite[] = [];
private _usedTileSprites = new Set<string>(); private _usedTileSprites = new Set<string>();
private _usedObjectSprites = new Set<string>(); private _usedObjectSprites = new Set<string>();
private _townObjectSpritePool = new Map<string, SpritePoolEntry>();
private _townObjectSpriteFreeList: Sprite[] = [];
private _usedTownObjectSprites = new Set<string>();
private _emptySpriteSet = new Set<string>(); private _emptySpriteSet = new Set<string>();
private _buildingSpritePool = new Map<string, SpritePoolEntry>(); private _buildingSpritePool = new Map<string, SpritePoolEntry>();
private _characterSpritePool = new Map<string, SpritePoolEntry>(); private _characterSpritePool = new Map<string, SpritePoolEntry>();
@ -414,6 +419,7 @@ export class GameRenderer {
this._groundSpriteLayer = new Container(); this._groundSpriteLayer = new Container();
this._objectSpriteLayer = new Container(); this._objectSpriteLayer = new Container();
this._buildingSpriteLayer = new Container(); this._buildingSpriteLayer = new Container();
this._townObjectSpriteLayer = new Container();
} }
get initialized(): boolean { get initialized(): boolean {
@ -472,6 +478,10 @@ export class GameRenderer {
this._objectSpriteLayer.zIndex = 2; this._objectSpriteLayer.zIndex = 2;
this._objectSpriteLayer.sortableChildren = true; this._objectSpriteLayer.sortableChildren = true;
this.groundLayer.addChild(this._townObjectSpriteLayer);
this._townObjectSpriteLayer.zIndex = 2;
this._townObjectSpriteLayer.sortableChildren = true;
this.groundLayer.addChild(this._buildingSpriteLayer); this.groundLayer.addChild(this._buildingSpriteLayer);
this._buildingSpriteLayer.zIndex = 3; this._buildingSpriteLayer.zIndex = 3;
this._buildingSpriteLayer.sortableChildren = true; this._buildingSpriteLayer.sortableChildren = true;
@ -1122,9 +1132,11 @@ export class GameRenderer {
const cx = iso.x; const cx = iso.x;
const cy = iso.y + sway; const cy = iso.y + sway;
const southKey = enemySouthTextureKey(enemySlug); const southKey = this._spritesReady
const tex = this._spritesReady ? this._spriteRegistry.getTexture(southKey) : null; ? resolveEnemySouthTextureKey(enemySlug, (k) => this._spriteRegistry.getTexture(k))
if (tex) { : null;
const tex = southKey ? this._spriteRegistry.getTexture(southKey) : null;
if (tex && southKey) {
const entry = this._ensureSprite( const entry = this._ensureSprite(
this._characterSpritePool, this._characterSpritePool,
'enemy_combat', 'enemy_combat',
@ -1135,7 +1147,8 @@ export class GameRenderer {
entry.sprite.anchor.set(0.5, 1); entry.sprite.anchor.set(0.5, 1);
entry.sprite.x = cx; entry.sprite.x = cx;
entry.sprite.y = cy; 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; const targetH = 52;
entry.sprite.scale.set(targetH / th); entry.sprite.scale.set(targetH / th);
entry.sprite.zIndex = cy + 100; entry.sprite.zIndex = cy + 100;
@ -1416,8 +1429,9 @@ export class GameRenderer {
: bt === 'decoration.stall' : bt === 'decoration.stall'
? Math.max(w, 44 * scale) ? Math.max(w, 44 * scale)
: w; : w;
const spriteKey = this._spritesReady ? buildingTypeToTextureKey(bt) : null; const spriteKey = buildingTypeToTextureKey(bt);
const spriteTexture = spriteKey ? this._spriteRegistry.getTexture(spriteKey) : null; const spriteTexture =
this._spritesReady && spriteKey ? this._spriteRegistry.getTexture(spriteKey) : null;
const hasUsableSprite = spriteKey !== null && spriteTexture !== null; const hasUsableSprite = spriteKey !== null && spriteTexture !== null;
if (spriteKey !== null && spriteTexture !== null) { if (spriteKey !== null && spriteTexture !== null) {
const poolKey = `building:${b.id}`; 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. */ /** Single civic building (hall / notice board) facing the plaza — not an NPC home. */
private _drawCivicBuilding(gfx: Graphics, cx: number, cy: number, s: number): void { private _drawCivicBuilding(gfx: Graphics, cx: number, cy: number, s: number): void {
const w = 52 * s; const w = 52 * s * 3;
const h = 38 * s; const h = 38 * s * 3;
const rh = 26 * s; const rh = 26 * s * 3;
gfx.rect(cx - w / 2, cy - h, w, h); gfx.rect(cx - w / 2, cy - h, w, h);
gfx.fill({ color: 0x8a9098, alpha: 0.95 }); gfx.fill({ color: 0x8a9098, alpha: 0.95 });
gfx.stroke({ color: 0x4a5058, width: 1.2, alpha: 0.55 }); gfx.stroke({ color: 0x4a5058, width: 1.2, alpha: 0.55 });
@ -1630,14 +1644,29 @@ export class GameRenderer {
const rh = baseRH * s * sizeVar; const rh = baseRH * s * sizeVar;
const roofStyle = i % 5 === 0 ? 1 : i % 3 === 0 ? 2 : 0; const roofStyle = i % 5 === 0 ? 1 : i % 3 === 0 ? 2 : 0;
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( this._drawHouse(
gfx, tx + dx, ty + dy, w, h, rh, gfx, hx, hy, w, h, rh,
wallColors[i % wallColors.length]!, wallColors[i % wallColors.length]!,
roofColors[i % roofColors.length]!, roofColors[i % roofColors.length]!,
roofStyle, roofStyle,
); );
}
if (i % 4 === 1) { 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); 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 */ /** Clear NPC visuals when there are none to render */
clearNPCs(): void { clearNPCs(): void {
if (this._npcGfx) this._npcGfx.clear(); if (this._npcGfx) this._npcGfx.clear();

@ -420,6 +420,15 @@ export interface BuildingData {
footprintH: number; 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 */ /** Alias: engine-facing town data for map rendering */
export interface TownData { export interface TownData {
id: number; id: number;
@ -434,6 +443,7 @@ export interface TownData {
size: string; size: string;
npcs?: NPCData[]; npcs?: NPCData[];
buildings?: BuildingData[]; buildings?: BuildingData[];
objects?: TownObjectData[];
} }
/** NPC encounter event returned instead of an enemy */ /** NPC encounter event returned instead of an enemy */

@ -37,6 +37,7 @@ import type {
NearbyHeroMovePayload, NearbyHeroMovePayload,
} from './types'; } from './types';
import { DebuffType, Rarity } from './types'; import { DebuffType, Rarity } from './types';
import { normalizeEnemyTemplateSlug } from './assets/spriteMapping';
// ---- Callback types for UI layer (App.tsx) ---- // ---- Callback types for UI layer (App.tsx) ----
export interface WSHandlerCallbacks { export interface WSHandlerCallbacks {
@ -113,7 +114,8 @@ export function wireWSHandler(
ws.on('combat_start', (msg: ServerMessage) => { ws.on('combat_start', (msg: ServerMessage) => {
const p = msg.payload as CombatStartPayload; 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 = { const enemy: EnemyState = {
id: Date.now(), id: Date.now(),
name: p.enemy.name, name: p.enemy.name,

Loading…
Cancel
Save