Compare commits
15 Commits
eec798e57b
...
9a801d9557
| Author | SHA1 | Date |
|---|---|---|
|
|
9a801d9557 | 1 month ago |
|
|
4807a0186c | 1 month ago |
|
|
7c0781bb29 | 1 month ago |
|
|
517334d18d | 1 month ago |
|
|
1ad235a68b | 1 month ago |
|
|
c770f886da | 1 month ago |
|
|
272d8a492c | 1 month ago |
|
|
a711a26b52 | 1 month ago |
|
|
5d5a7a4685 | 1 month ago |
|
|
0d6a82e0e1 | 1 month ago |
|
|
d9d8e72933 | 1 month ago |
|
|
c1dc5dc770 | 1 month ago |
|
|
80a18a24f6 | 1 month ago |
|
|
59db6dc43d | 1 month ago |
|
|
1758d581c6 | 1 month ago |
@ -0,0 +1,38 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>AutoHero Town Editor</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app">
|
||||
<aside class="sidebar">
|
||||
<div class="card">
|
||||
<h3>Auth</h3>
|
||||
<input id="auth-user" placeholder="Username" />
|
||||
<input id="auth-pass" type="password" placeholder="Password" />
|
||||
<button id="auth-save" class="btn">Save credentials</button>
|
||||
<div id="auth-status" class="muted"></div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h3>Town</h3>
|
||||
<button id="towns-reload" class="btn">Load towns</button>
|
||||
<select id="town-select"></select>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h3>Layout</h3>
|
||||
<button id="layout-reload" class="btn">Reload layout</button>
|
||||
<button id="layout-save" class="btn" disabled>Save</button>
|
||||
<div id="selection-info" class="muted"></div>
|
||||
<div id="layout-status" class="muted"></div>
|
||||
</div>
|
||||
</aside>
|
||||
<main class="canvas-area">
|
||||
<div id="canvas-root" class="canvas-root"></div>
|
||||
<div id="context-menu" class="context-menu hidden"></div>
|
||||
</main>
|
||||
</div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
@ -0,0 +1,17 @@
|
||||
{
|
||||
"name": "autohero-admin-town-editor",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"pixi.js": "^8.6.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "~5.7.2",
|
||||
"vite": "^6.2.0"
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,879 @@
|
||||
import './style.css';
|
||||
import { Camera } from '@game/camera';
|
||||
import { GameRenderer, screenToWorld, worldToScreen } from '@game/renderer';
|
||||
import { buildWorldTerrainContext, townsApiToInfluences } from '@game/procedural';
|
||||
import type { BuildingData, NPCData, TownData, TownObjectData } from '@game/types';
|
||||
|
||||
type AdminTown = {
|
||||
id: number;
|
||||
name: string;
|
||||
nameKey?: string;
|
||||
biome: string;
|
||||
worldX: number;
|
||||
worldY: number;
|
||||
radius: number;
|
||||
levelMin: number;
|
||||
levelMax: number;
|
||||
};
|
||||
|
||||
type AdminNPC = {
|
||||
id: number;
|
||||
townId: number;
|
||||
name: string;
|
||||
nameKey?: string;
|
||||
type: NPCData['type'];
|
||||
offsetX: number;
|
||||
offsetY: number;
|
||||
buildingId?: number | null;
|
||||
};
|
||||
|
||||
type AdminBuilding = {
|
||||
id: number;
|
||||
townId: number;
|
||||
buildingType: string;
|
||||
offsetX: number;
|
||||
offsetY: number;
|
||||
facing: BuildingData['facing'];
|
||||
footprintW: number;
|
||||
footprintH: number;
|
||||
};
|
||||
|
||||
type AdminTownObject = {
|
||||
id: number;
|
||||
townId: number;
|
||||
objectType: string;
|
||||
variant: number;
|
||||
offsetX: number;
|
||||
offsetY: number;
|
||||
};
|
||||
|
||||
type TownLayoutResponse = {
|
||||
town: AdminTown;
|
||||
npcs: AdminNPC[];
|
||||
buildings: AdminBuilding[];
|
||||
objects: AdminTownObject[];
|
||||
};
|
||||
|
||||
type DraftNPC = AdminNPC;
|
||||
type DraftBuilding = AdminBuilding;
|
||||
type DraftObject = AdminTownObject;
|
||||
|
||||
type Selection =
|
||||
| { type: 'npc'; id: number }
|
||||
| { type: 'building'; id: number }
|
||||
| { type: 'object'; id: number }
|
||||
| { type: 'none' };
|
||||
|
||||
type DragState =
|
||||
| {
|
||||
type: 'npc';
|
||||
id: number;
|
||||
startOffsetX: number;
|
||||
startOffsetY: number;
|
||||
}
|
||||
| {
|
||||
type: 'building';
|
||||
id: number;
|
||||
startOffsetX: number;
|
||||
startOffsetY: number;
|
||||
linkedNpcOffsets: Map<number, { offsetX: number; offsetY: number }>;
|
||||
}
|
||||
| {
|
||||
type: 'object';
|
||||
id: number;
|
||||
startOffsetX: number;
|
||||
startOffsetY: number;
|
||||
}
|
||||
| null;
|
||||
|
||||
type PanState = {
|
||||
startClientX: number;
|
||||
startClientY: number;
|
||||
startCameraX: number;
|
||||
startCameraY: number;
|
||||
} | null;
|
||||
|
||||
const state = {
|
||||
auth: {
|
||||
username: sessionStorage.getItem('admin_user') || '',
|
||||
password: sessionStorage.getItem('admin_pass') || '',
|
||||
},
|
||||
towns: [] as AdminTown[],
|
||||
selectedTownId: null as number | null,
|
||||
townData: null as TownData | null,
|
||||
npcData: [] as NPCData[],
|
||||
draftTown: null as AdminTown | null,
|
||||
draftNPCs: [] as DraftNPC[],
|
||||
draftBuildings: [] as DraftBuilding[],
|
||||
draftObjects: [] as DraftObject[],
|
||||
deletedNPCIds: [] as number[],
|
||||
deletedBuildingIds: [] as number[],
|
||||
deletedObjectIds: [] as number[],
|
||||
dirty: false,
|
||||
tempBuildingId: -1,
|
||||
tempNpcId: -1,
|
||||
tempObjectId: -1,
|
||||
selection: { type: 'none' } as Selection,
|
||||
drag: null as DragState,
|
||||
pan: null as PanState,
|
||||
contextWorld: null as { x: number; y: number } | null,
|
||||
};
|
||||
|
||||
const renderer = new GameRenderer();
|
||||
const camera = new Camera();
|
||||
|
||||
const authUserInput = document.getElementById('auth-user') as HTMLInputElement;
|
||||
const authPassInput = document.getElementById('auth-pass') as HTMLInputElement;
|
||||
const authSaveBtn = document.getElementById('auth-save') as HTMLButtonElement;
|
||||
const authStatus = document.getElementById('auth-status') as HTMLDivElement;
|
||||
|
||||
const townsReloadBtn = document.getElementById('towns-reload') as HTMLButtonElement;
|
||||
const townSelect = document.getElementById('town-select') as HTMLSelectElement;
|
||||
const layoutReloadBtn = document.getElementById('layout-reload') as HTMLButtonElement;
|
||||
const layoutSaveBtn = document.getElementById('layout-save') as HTMLButtonElement;
|
||||
const selectionInfo = document.getElementById('selection-info') as HTMLDivElement;
|
||||
const layoutStatus = document.getElementById('layout-status') as HTMLDivElement;
|
||||
const canvasRoot = document.getElementById('canvas-root') as HTMLDivElement;
|
||||
const contextMenu = document.getElementById('context-menu') as HTMLDivElement;
|
||||
|
||||
authUserInput.value = state.auth.username;
|
||||
authPassInput.value = state.auth.password;
|
||||
selectionInfo.textContent = 'No selection.';
|
||||
|
||||
function setStatus(el: HTMLElement, message: string, isError = false): void {
|
||||
el.textContent = message;
|
||||
el.style.color = isError ? '#ff8f8f' : '';
|
||||
}
|
||||
|
||||
function setDirty(value: boolean): void {
|
||||
state.dirty = value;
|
||||
layoutSaveBtn.disabled = !value;
|
||||
}
|
||||
|
||||
function setSelection(next: Selection): void {
|
||||
state.selection = next;
|
||||
if (next.type === 'none') {
|
||||
selectionInfo.textContent = 'No selection.';
|
||||
return;
|
||||
}
|
||||
selectionInfo.textContent = `${next.type.toUpperCase()} #${next.id}`;
|
||||
}
|
||||
|
||||
function saveAuth(): void {
|
||||
state.auth.username = authUserInput.value.trim();
|
||||
state.auth.password = authPassInput.value.trim();
|
||||
sessionStorage.setItem('admin_user', state.auth.username);
|
||||
sessionStorage.setItem('admin_pass', state.auth.password);
|
||||
setStatus(authStatus, 'Credentials saved.');
|
||||
}
|
||||
|
||||
function authHeader(): string {
|
||||
return `Basic ${btoa(`${state.auth.username}:${state.auth.password}`)}`;
|
||||
}
|
||||
|
||||
async function api(path: string, opts: RequestInit = {}): Promise<Response> {
|
||||
const headers = Object.assign(
|
||||
{ Authorization: authHeader(), 'Content-Type': 'application/json' },
|
||||
opts.headers || {},
|
||||
);
|
||||
return fetch(`/admin-api/${path}`, { cache: 'no-store', ...opts, headers });
|
||||
}
|
||||
|
||||
function townSize(radius: number): string {
|
||||
if (radius > 40) return 'XL';
|
||||
if (radius > 25) return 'M';
|
||||
if (radius > 15) return 'S';
|
||||
return 'XS';
|
||||
}
|
||||
|
||||
function buildTownData(town: AdminTown, npcs: AdminNPC[], buildings: AdminBuilding[]): TownData {
|
||||
const npcData: NPCData[] = npcs.map((n) => ({
|
||||
id: n.id,
|
||||
name: n.nameKey || n.name,
|
||||
nameKey: n.nameKey,
|
||||
type: n.type,
|
||||
worldX: town.worldX + n.offsetX,
|
||||
worldY: town.worldY + n.offsetY,
|
||||
buildingId: n.buildingId ?? undefined,
|
||||
townId: town.id,
|
||||
townLevelMin: town.levelMin,
|
||||
townLevelMax: town.levelMax,
|
||||
}));
|
||||
|
||||
const buildingData: BuildingData[] = buildings.map((b) => ({
|
||||
id: b.id,
|
||||
buildingType: b.buildingType,
|
||||
worldX: town.worldX + b.offsetX,
|
||||
worldY: town.worldY + b.offsetY,
|
||||
facing: b.facing,
|
||||
footprintW: b.footprintW,
|
||||
footprintH: b.footprintH,
|
||||
}));
|
||||
|
||||
const objectData: TownObjectData[] = state.draftObjects.map((o) => ({
|
||||
id: o.id,
|
||||
objectType: o.objectType,
|
||||
variant: o.variant,
|
||||
worldX: town.worldX + o.offsetX,
|
||||
worldY: town.worldY + o.offsetY,
|
||||
}));
|
||||
|
||||
return {
|
||||
id: town.id,
|
||||
name: town.nameKey || town.name,
|
||||
nameKey: town.nameKey,
|
||||
centerX: town.worldX,
|
||||
centerY: town.worldY,
|
||||
radius: town.radius,
|
||||
biome: town.biome,
|
||||
levelMin: town.levelMin,
|
||||
size: townSize(town.radius),
|
||||
npcs: npcData,
|
||||
buildings: buildingData,
|
||||
objects: objectData,
|
||||
};
|
||||
}
|
||||
|
||||
function refreshTownData(): void {
|
||||
if (!state.draftTown) return;
|
||||
state.townData = buildTownData(state.draftTown, state.draftNPCs, state.draftBuildings);
|
||||
state.npcData = state.townData.npcs || [];
|
||||
}
|
||||
|
||||
function applyLayout(data: TownLayoutResponse): void {
|
||||
state.draftTown = data.town;
|
||||
state.draftNPCs = data.npcs.map((n) => ({ ...n }));
|
||||
state.draftBuildings = data.buildings.map((b) => ({ ...b }));
|
||||
state.draftObjects = (data.objects || []).map((o) => ({ ...o }));
|
||||
state.deletedNPCIds = [];
|
||||
state.deletedBuildingIds = [];
|
||||
state.deletedObjectIds = [];
|
||||
refreshTownData();
|
||||
if (state.townData) {
|
||||
const center = worldToScreen(state.townData.centerX, state.townData.centerY);
|
||||
camera.setTarget(center.x, center.y);
|
||||
camera.snapToTarget();
|
||||
}
|
||||
setSelection({ type: 'none' });
|
||||
setDirty(false);
|
||||
}
|
||||
|
||||
async function loadTowns(): Promise<void> {
|
||||
if (!state.auth.username || !state.auth.password) {
|
||||
setStatus(authStatus, 'Set admin credentials first.', true);
|
||||
return;
|
||||
}
|
||||
setStatus(layoutStatus, 'Loading towns...');
|
||||
const res = await api('quests/towns');
|
||||
if (!res.ok) {
|
||||
setStatus(layoutStatus, `Failed to load towns (${res.status}).`, true);
|
||||
return;
|
||||
}
|
||||
const data = await res.json();
|
||||
const towns = (data.towns || []) as AdminTown[];
|
||||
state.towns = towns;
|
||||
townSelect.innerHTML = '';
|
||||
for (const t of towns) {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = String(t.id);
|
||||
opt.textContent = `${t.name} (Lv ${t.levelMin}-${t.levelMax})`;
|
||||
townSelect.appendChild(opt);
|
||||
}
|
||||
if (!state.selectedTownId && towns.length > 0) {
|
||||
state.selectedTownId = towns[0].id;
|
||||
}
|
||||
if (state.selectedTownId) {
|
||||
townSelect.value = String(state.selectedTownId);
|
||||
}
|
||||
const influences = townsApiToInfluences(towns);
|
||||
renderer.setWorldTerrainContext(buildWorldTerrainContext(influences, null));
|
||||
setStatus(layoutStatus, 'Towns loaded.');
|
||||
if (state.selectedTownId) {
|
||||
await loadLayout(state.selectedTownId);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadLayout(townId: number): Promise<void> {
|
||||
setStatus(layoutStatus, 'Loading town layout...');
|
||||
const res = await api(`quests/towns/${townId}/layout`);
|
||||
if (!res.ok) {
|
||||
setStatus(layoutStatus, `Failed to load layout (${res.status}).`, true);
|
||||
return;
|
||||
}
|
||||
const data = (await res.json()) as TownLayoutResponse;
|
||||
applyLayout(data);
|
||||
setStatus(layoutStatus, 'Layout loaded.');
|
||||
}
|
||||
|
||||
async function saveLayout(): Promise<void> {
|
||||
if (!state.draftTown) return;
|
||||
setStatus(layoutStatus, 'Saving layout...');
|
||||
layoutSaveBtn.disabled = true;
|
||||
const payload = {
|
||||
npcs: state.draftNPCs.map((n) => ({
|
||||
...(n.id > 0 ? { id: n.id } : {}),
|
||||
name: n.name,
|
||||
nameKey: n.nameKey || '',
|
||||
type: n.type,
|
||||
offsetX: n.offsetX,
|
||||
offsetY: n.offsetY,
|
||||
...(n.buildingId ? { buildingId: n.buildingId } : {}),
|
||||
})),
|
||||
buildings: state.draftBuildings.map((b) => ({
|
||||
...(b.id > 0 ? { id: b.id } : {}),
|
||||
buildingType: b.buildingType,
|
||||
offsetX: b.offsetX,
|
||||
offsetY: b.offsetY,
|
||||
facing: b.facing,
|
||||
footprintW: b.footprintW,
|
||||
footprintH: b.footprintH,
|
||||
})),
|
||||
objects: state.draftObjects.map((o) => ({
|
||||
...(o.id > 0 ? { id: o.id } : {}),
|
||||
objectType: o.objectType,
|
||||
variant: o.variant,
|
||||
offsetX: o.offsetX,
|
||||
offsetY: o.offsetY,
|
||||
})),
|
||||
deleteNpcIds: state.deletedNPCIds,
|
||||
deleteBuildingIds: state.deletedBuildingIds,
|
||||
deleteObjectIds: state.deletedObjectIds,
|
||||
};
|
||||
const res = await api(`quests/towns/${state.draftTown.id}/layout`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (!res.ok) {
|
||||
setStatus(layoutStatus, `Failed to save layout (${res.status}).`, true);
|
||||
layoutSaveBtn.disabled = false;
|
||||
return;
|
||||
}
|
||||
const data = (await res.json()) as TownLayoutResponse;
|
||||
applyLayout(data);
|
||||
setStatus(layoutStatus, 'Layout saved.');
|
||||
}
|
||||
|
||||
const buildingMenuItems = [
|
||||
{ label: 'House: Quest Giver', type: 'house.quest_giver', w: 2.5, h: 2.0 },
|
||||
{ label: 'House: Merchant', type: 'house.merchant', w: 2.5, h: 2.0 },
|
||||
{ label: 'House: Armorer', type: 'house.armorer', w: 2.5, h: 2.0 },
|
||||
{ label: 'House: Weapon Smith', type: 'house.weapon_smith', w: 2.5, h: 2.0 },
|
||||
{ label: 'House: Jeweler', type: 'house.jeweler', w: 2.5, h: 2.0 },
|
||||
{ label: 'House: Bounty Hunter', type: 'house.bounty_hunter', w: 2.5, h: 2.0 },
|
||||
{ label: 'House: Elder', type: 'house.elder', w: 2.5, h: 2.0 },
|
||||
{ label: 'House: Healer', type: 'house.healer', w: 2.5, h: 2.0 },
|
||||
{ label: 'Decoration: Well', type: 'decoration.well', w: 1.5, h: 1.5 },
|
||||
{ label: 'Decoration: Stall', type: 'decoration.stall', w: 1.5, h: 1.5 },
|
||||
{ label: 'Decoration: Signpost', type: 'decoration.signpost', w: 0.5, h: 0.5 },
|
||||
];
|
||||
|
||||
const npcTypeItems = [
|
||||
{ label: 'Quest Giver', type: 'quest_giver' as NPCData['type'] },
|
||||
{ label: 'Merchant', type: 'merchant' as NPCData['type'] },
|
||||
{ label: 'Armorer', type: 'armorer' as NPCData['type'] },
|
||||
{ label: 'Weapon', type: 'weapon' as NPCData['type'] },
|
||||
{ label: 'Jeweler', type: 'jeweler' as NPCData['type'] },
|
||||
{ label: 'Bounty Hunter', type: 'bounty_hunter' as NPCData['type'] },
|
||||
{ label: 'Elder', type: 'elder' as NPCData['type'] },
|
||||
{ label: 'Healer', type: 'healer' as NPCData['type'] },
|
||||
];
|
||||
|
||||
const objectTypeItems = [
|
||||
{ label: 'Tree', type: 'tree' },
|
||||
{ label: 'Rock', type: 'rock' },
|
||||
{ label: 'Cart', type: 'cart' },
|
||||
{ label: 'Barrel', type: 'barrel' },
|
||||
{ label: 'Bush', type: 'bush' },
|
||||
{ label: 'Mushroom', type: 'mushroom' },
|
||||
{ label: 'Leaves', type: 'leaves' },
|
||||
{ label: 'Stump', type: 'stump' },
|
||||
{ label: 'Bones', type: 'bones' },
|
||||
{ label: 'Ruin', type: 'ruin' },
|
||||
];
|
||||
|
||||
function hideContextMenu(): void {
|
||||
contextMenu.classList.add('hidden');
|
||||
}
|
||||
|
||||
function addContextHeader(label: string): void {
|
||||
const header = document.createElement('div');
|
||||
header.textContent = label;
|
||||
header.style.padding = '6px 12px 4px 12px';
|
||||
header.style.fontSize = '11px';
|
||||
header.style.color = '#9eb0d6';
|
||||
contextMenu.appendChild(header);
|
||||
}
|
||||
|
||||
function addContextButton(label: string, onClick: () => void): void {
|
||||
const btn = document.createElement('button');
|
||||
btn.textContent = label;
|
||||
btn.addEventListener('click', onClick);
|
||||
contextMenu.appendChild(btn);
|
||||
}
|
||||
|
||||
function addContextSubmenu(label: string, build: (submenu: HTMLDivElement) => void): void {
|
||||
const wrapper = document.createElement('div');
|
||||
wrapper.className = 'context-menu-item';
|
||||
const button = document.createElement('button');
|
||||
button.textContent = `${label} ▶`;
|
||||
const submenu = document.createElement('div');
|
||||
submenu.className = 'submenu';
|
||||
build(submenu);
|
||||
wrapper.appendChild(button);
|
||||
wrapper.appendChild(submenu);
|
||||
contextMenu.appendChild(wrapper);
|
||||
}
|
||||
|
||||
function showContextMenu(clientX: number, clientY: number, render: () => void): void {
|
||||
contextMenu.innerHTML = '';
|
||||
render();
|
||||
const area = canvasRoot.parentElement?.getBoundingClientRect();
|
||||
if (!area) return;
|
||||
contextMenu.style.left = `${clientX - area.left}px`;
|
||||
contextMenu.style.top = `${clientY - area.top}px`;
|
||||
contextMenu.classList.remove('hidden');
|
||||
}
|
||||
|
||||
function removeNpcById(id: number): void {
|
||||
state.draftNPCs = state.draftNPCs.filter((n) => n.id !== id);
|
||||
if (id > 0) state.deletedNPCIds.push(id);
|
||||
setSelection({ type: 'none' });
|
||||
refreshTownData();
|
||||
setDirty(true);
|
||||
}
|
||||
|
||||
function removeBuildingById(id: number): void {
|
||||
state.draftBuildings = state.draftBuildings.filter((b) => b.id !== id);
|
||||
if (id > 0) state.deletedBuildingIds.push(id);
|
||||
setSelection({ type: 'none' });
|
||||
refreshTownData();
|
||||
setDirty(true);
|
||||
}
|
||||
|
||||
function removeObjectById(id: number): void {
|
||||
state.draftObjects = state.draftObjects.filter((o) => o.id !== id);
|
||||
if (id > 0) state.deletedObjectIds.push(id);
|
||||
setSelection({ type: 'none' });
|
||||
refreshTownData();
|
||||
setDirty(true);
|
||||
}
|
||||
|
||||
function showContextMenuForSelection(clientX: number, clientY: number, selection: Selection): void {
|
||||
showContextMenu(clientX, clientY, () => {
|
||||
if (!state.draftTown) return;
|
||||
if (selection.type === 'npc') {
|
||||
addContextHeader('NPC');
|
||||
addContextSubmenu('Change type', (submenu) => {
|
||||
for (const item of npcTypeItems) {
|
||||
const btn = document.createElement('button');
|
||||
btn.textContent = item.label;
|
||||
btn.addEventListener('click', () => {
|
||||
const npc = state.draftNPCs.find((n) => n.id === selection.id);
|
||||
if (!npc) return;
|
||||
npc.type = item.type;
|
||||
if (!npc.name) npc.name = `New ${item.label}`;
|
||||
setDirty(true);
|
||||
refreshTownData();
|
||||
hideContextMenu();
|
||||
});
|
||||
submenu.appendChild(btn);
|
||||
}
|
||||
});
|
||||
addContextHeader('Actions');
|
||||
addContextButton('Delete NPC', () => {
|
||||
removeNpcById(selection.id);
|
||||
hideContextMenu();
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (selection.type === 'building') {
|
||||
addContextHeader('Building');
|
||||
addContextSubmenu('Change type', (submenu) => {
|
||||
for (const item of buildingMenuItems) {
|
||||
const btn = document.createElement('button');
|
||||
btn.textContent = item.label;
|
||||
btn.addEventListener('click', () => {
|
||||
const building = state.draftBuildings.find((b) => b.id === selection.id);
|
||||
if (!building) return;
|
||||
building.buildingType = item.type;
|
||||
building.footprintW = item.w;
|
||||
building.footprintH = item.h;
|
||||
setDirty(true);
|
||||
refreshTownData();
|
||||
hideContextMenu();
|
||||
});
|
||||
submenu.appendChild(btn);
|
||||
}
|
||||
});
|
||||
addContextHeader('Actions');
|
||||
addContextButton('Delete building', () => {
|
||||
removeBuildingById(selection.id);
|
||||
hideContextMenu();
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (selection.type === 'object') {
|
||||
addContextHeader('Object');
|
||||
addContextSubmenu('Change type', (submenu) => {
|
||||
for (const item of objectTypeItems) {
|
||||
const btn = document.createElement('button');
|
||||
btn.textContent = item.label;
|
||||
btn.addEventListener('click', () => {
|
||||
const obj = state.draftObjects.find((o) => o.id === selection.id);
|
||||
if (!obj) return;
|
||||
obj.objectType = item.type;
|
||||
setDirty(true);
|
||||
refreshTownData();
|
||||
hideContextMenu();
|
||||
});
|
||||
submenu.appendChild(btn);
|
||||
}
|
||||
});
|
||||
addContextSubmenu('Variant', (submenu) => {
|
||||
const btn0 = document.createElement('button');
|
||||
btn0.textContent = 'Variant v0';
|
||||
btn0.addEventListener('click', () => {
|
||||
const obj = state.draftObjects.find((o) => o.id === selection.id);
|
||||
if (!obj) return;
|
||||
obj.variant = 0;
|
||||
setDirty(true);
|
||||
refreshTownData();
|
||||
hideContextMenu();
|
||||
});
|
||||
const btn1 = document.createElement('button');
|
||||
btn1.textContent = 'Variant v1';
|
||||
btn1.addEventListener('click', () => {
|
||||
const obj = state.draftObjects.find((o) => o.id === selection.id);
|
||||
if (!obj) return;
|
||||
obj.variant = 1;
|
||||
setDirty(true);
|
||||
refreshTownData();
|
||||
hideContextMenu();
|
||||
});
|
||||
submenu.appendChild(btn0);
|
||||
submenu.appendChild(btn1);
|
||||
});
|
||||
addContextHeader('Actions');
|
||||
addContextButton('Delete object', () => {
|
||||
removeObjectById(selection.id);
|
||||
hideContextMenu();
|
||||
});
|
||||
return;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function showContextMenuForEmpty(clientX: number, clientY: number): void {
|
||||
showContextMenu(clientX, clientY, () => {
|
||||
if (!state.draftTown || !state.contextWorld) return;
|
||||
const world = state.contextWorld;
|
||||
addContextSubmenu('Add NPC', (submenu) => {
|
||||
for (const item of npcTypeItems) {
|
||||
const btn = document.createElement('button');
|
||||
btn.textContent = item.label;
|
||||
btn.addEventListener('click', () => {
|
||||
const offsetX = world.x - state.draftTown!.worldX;
|
||||
const offsetY = world.y - state.draftTown!.worldY;
|
||||
const newId = state.tempNpcId--;
|
||||
state.draftNPCs.push({
|
||||
id: newId,
|
||||
townId: state.draftTown!.id,
|
||||
name: `New ${item.label}`,
|
||||
nameKey: '',
|
||||
type: item.type,
|
||||
offsetX,
|
||||
offsetY,
|
||||
buildingId: null,
|
||||
});
|
||||
refreshTownData();
|
||||
setSelection({ type: 'npc', id: newId });
|
||||
setDirty(true);
|
||||
hideContextMenu();
|
||||
});
|
||||
submenu.appendChild(btn);
|
||||
}
|
||||
});
|
||||
addContextSubmenu('Add Building', (submenu) => {
|
||||
for (const item of buildingMenuItems) {
|
||||
const btn = document.createElement('button');
|
||||
btn.textContent = item.label;
|
||||
btn.addEventListener('click', () => {
|
||||
const offsetX = world.x - state.draftTown!.worldX;
|
||||
const offsetY = world.y - state.draftTown!.worldY;
|
||||
const newId = state.tempBuildingId--;
|
||||
state.draftBuildings.push({
|
||||
id: newId,
|
||||
townId: state.draftTown!.id,
|
||||
buildingType: item.type,
|
||||
offsetX,
|
||||
offsetY,
|
||||
facing: 'south',
|
||||
footprintW: item.w,
|
||||
footprintH: item.h,
|
||||
});
|
||||
refreshTownData();
|
||||
setSelection({ type: 'building', id: newId });
|
||||
setDirty(true);
|
||||
hideContextMenu();
|
||||
});
|
||||
submenu.appendChild(btn);
|
||||
}
|
||||
});
|
||||
addContextSubmenu('Add Object', (submenu) => {
|
||||
for (const item of objectTypeItems) {
|
||||
const btn = document.createElement('button');
|
||||
btn.textContent = item.label;
|
||||
btn.addEventListener('click', () => {
|
||||
const offsetX = world.x - state.draftTown!.worldX;
|
||||
const offsetY = world.y - state.draftTown!.worldY;
|
||||
const newId = state.tempObjectId--;
|
||||
state.draftObjects.push({
|
||||
id: newId,
|
||||
townId: state.draftTown!.id,
|
||||
objectType: item.type,
|
||||
variant: 0,
|
||||
offsetX,
|
||||
offsetY,
|
||||
});
|
||||
refreshTownData();
|
||||
setSelection({ type: 'object', id: newId });
|
||||
setDirty(true);
|
||||
hideContextMenu();
|
||||
});
|
||||
submenu.appendChild(btn);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
function eventToWorld(evt: MouseEvent): { x: number; y: number } | null {
|
||||
if (!renderer.initialized) return null;
|
||||
const canvas = renderer.app.canvas as HTMLCanvasElement;
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
if (evt.clientX < rect.left || evt.clientX > rect.right || evt.clientY < rect.top || evt.clientY > rect.bottom) {
|
||||
return null;
|
||||
}
|
||||
const localX = evt.clientX - rect.left;
|
||||
const localY = evt.clientY - rect.top;
|
||||
const screenW = renderer.app.renderer.width;
|
||||
const screenH = renderer.app.renderer.height;
|
||||
const worldScreenX = localX - screenW / 2 + camera.finalX;
|
||||
const worldScreenY = localY - screenH / 2 + camera.finalY;
|
||||
return screenToWorld(worldScreenX, worldScreenY);
|
||||
}
|
||||
|
||||
function findHit(world: { x: number; y: number }): Selection {
|
||||
if (!state.draftTown) return { type: 'none' };
|
||||
const npcHitRadius = 1.8;
|
||||
const objectHitRadius = 1.8;
|
||||
const buildingPadding = 1.0;
|
||||
for (const npc of state.draftNPCs) {
|
||||
const nx = state.draftTown.worldX + npc.offsetX;
|
||||
const ny = state.draftTown.worldY + npc.offsetY;
|
||||
const dx = world.x - nx;
|
||||
const dy = world.y - ny;
|
||||
if (Math.hypot(dx, dy) <= npcHitRadius) {
|
||||
return { type: 'npc', id: npc.id };
|
||||
}
|
||||
}
|
||||
for (const obj of state.draftObjects) {
|
||||
const ox = state.draftTown.worldX + obj.offsetX;
|
||||
const oy = state.draftTown.worldY + obj.offsetY;
|
||||
if (Math.hypot(world.x - ox, world.y - oy) <= objectHitRadius) {
|
||||
return { type: 'object', id: obj.id };
|
||||
}
|
||||
}
|
||||
for (const building of state.draftBuildings) {
|
||||
const bx = state.draftTown.worldX + building.offsetX;
|
||||
const by = state.draftTown.worldY + building.offsetY;
|
||||
const dx = Math.abs(world.x - bx);
|
||||
const dy = Math.abs(world.y - by);
|
||||
if (dx <= building.footprintW / 2 + buildingPadding && dy <= building.footprintH / 2 + buildingPadding) {
|
||||
return { type: 'building', id: building.id };
|
||||
}
|
||||
}
|
||||
return { type: 'none' };
|
||||
}
|
||||
|
||||
function startDrag(selection: Selection): void {
|
||||
if (selection.type === 'npc') {
|
||||
const npc = state.draftNPCs.find((n) => n.id === selection.id);
|
||||
if (!npc) return;
|
||||
state.drag = {
|
||||
type: 'npc',
|
||||
id: npc.id,
|
||||
startOffsetX: npc.offsetX,
|
||||
startOffsetY: npc.offsetY,
|
||||
};
|
||||
return;
|
||||
}
|
||||
if (selection.type === 'building') {
|
||||
const building = state.draftBuildings.find((b) => b.id === selection.id);
|
||||
if (!building) return;
|
||||
const linked = new Map<number, { offsetX: number; offsetY: number }>();
|
||||
for (const npc of state.draftNPCs) {
|
||||
if (npc.buildingId === building.id) {
|
||||
linked.set(npc.id, { offsetX: npc.offsetX, offsetY: npc.offsetY });
|
||||
}
|
||||
}
|
||||
state.drag = {
|
||||
type: 'building',
|
||||
id: building.id,
|
||||
startOffsetX: building.offsetX,
|
||||
startOffsetY: building.offsetY,
|
||||
linkedNpcOffsets: linked,
|
||||
};
|
||||
return;
|
||||
}
|
||||
if (selection.type === 'object') {
|
||||
const obj = state.draftObjects.find((o) => o.id === selection.id);
|
||||
if (!obj) return;
|
||||
state.drag = {
|
||||
type: 'object',
|
||||
id: obj.id,
|
||||
startOffsetX: obj.offsetX,
|
||||
startOffsetY: obj.offsetY,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function updateDrag(world: { x: number; y: number }): void {
|
||||
if (!state.draftTown || !state.drag) return;
|
||||
const offsetX = world.x - state.draftTown.worldX;
|
||||
const offsetY = world.y - state.draftTown.worldY;
|
||||
if (state.drag.type === 'npc') {
|
||||
const npc = state.draftNPCs.find((n) => n.id === state.drag?.id);
|
||||
if (!npc) return;
|
||||
npc.offsetX = offsetX;
|
||||
npc.offsetY = offsetY;
|
||||
}
|
||||
if (state.drag.type === 'building') {
|
||||
const building = state.draftBuildings.find((b) => b.id === state.drag?.id);
|
||||
if (!building) return;
|
||||
const deltaX = offsetX - state.drag.startOffsetX;
|
||||
const deltaY = offsetY - state.drag.startOffsetY;
|
||||
building.offsetX = offsetX;
|
||||
building.offsetY = offsetY;
|
||||
for (const npc of state.draftNPCs) {
|
||||
const start = state.drag.linkedNpcOffsets.get(npc.id);
|
||||
if (!start) continue;
|
||||
npc.offsetX = start.offsetX + deltaX;
|
||||
npc.offsetY = start.offsetY + deltaY;
|
||||
}
|
||||
}
|
||||
if (state.drag.type === 'object') {
|
||||
const obj = state.draftObjects.find((o) => o.id === state.drag?.id);
|
||||
if (!obj) return;
|
||||
obj.offsetX = offsetX;
|
||||
obj.offsetY = offsetY;
|
||||
}
|
||||
refreshTownData();
|
||||
setDirty(true);
|
||||
}
|
||||
|
||||
function startPan(evt: MouseEvent): void {
|
||||
state.pan = {
|
||||
startClientX: evt.clientX,
|
||||
startClientY: evt.clientY,
|
||||
startCameraX: camera.x,
|
||||
startCameraY: camera.y,
|
||||
};
|
||||
}
|
||||
|
||||
function updatePan(evt: MouseEvent): void {
|
||||
if (!state.pan) return;
|
||||
const dx = evt.clientX - state.pan.startClientX;
|
||||
const dy = evt.clientY - state.pan.startClientY;
|
||||
const nextX = state.pan.startCameraX - dx;
|
||||
const nextY = state.pan.startCameraY - dy;
|
||||
camera.setTarget(nextX, nextY);
|
||||
camera.snapToTarget();
|
||||
}
|
||||
|
||||
function renderLoop(): void {
|
||||
requestAnimationFrame(renderLoop);
|
||||
if (!renderer.initialized) return;
|
||||
if (!state.townData) return;
|
||||
|
||||
const screenW = renderer.app.renderer.width;
|
||||
const screenH = renderer.app.renderer.height;
|
||||
camera.applyTo(renderer.worldContainer, screenW, screenH);
|
||||
renderer.drawGround(camera, screenW, screenH);
|
||||
renderer.drawTowns([state.townData], camera, screenW, screenH);
|
||||
renderer.drawTownObjects(state.townData.objects ?? [], camera, screenW, screenH);
|
||||
if (state.npcData.length > 0) {
|
||||
renderer.drawNPCs(state.npcData, camera, screenW, screenH, performance.now());
|
||||
}
|
||||
}
|
||||
|
||||
async function bootstrap(): Promise<void> {
|
||||
await renderer.init(canvasRoot);
|
||||
window.addEventListener('resize', () => renderer.resize());
|
||||
renderLoop();
|
||||
const canvas = renderer.app.canvas as HTMLCanvasElement;
|
||||
canvas.addEventListener('contextmenu', (evt) => {
|
||||
evt.preventDefault();
|
||||
const world = eventToWorld(evt);
|
||||
if (!world) return;
|
||||
state.contextWorld = world;
|
||||
const hit = findHit(world);
|
||||
if (hit.type === 'none') {
|
||||
showContextMenuForEmpty(evt.clientX, evt.clientY);
|
||||
return;
|
||||
}
|
||||
setSelection(hit);
|
||||
showContextMenuForSelection(evt.clientX, evt.clientY, hit);
|
||||
});
|
||||
canvas.addEventListener('mousedown', (evt) => {
|
||||
hideContextMenu();
|
||||
const world = eventToWorld(evt);
|
||||
if (!world) return;
|
||||
if (evt.button === 1) {
|
||||
startPan(evt);
|
||||
return;
|
||||
}
|
||||
if (evt.button !== 0) return;
|
||||
const hit = findHit(world);
|
||||
if (hit.type === 'none') {
|
||||
setSelection(hit);
|
||||
startPan(evt);
|
||||
return;
|
||||
}
|
||||
setSelection(hit);
|
||||
startDrag(hit);
|
||||
});
|
||||
window.addEventListener('mousemove', (evt) => {
|
||||
if (state.drag) {
|
||||
const world = eventToWorld(evt);
|
||||
if (!world) return;
|
||||
updateDrag(world);
|
||||
return;
|
||||
}
|
||||
if (state.pan) {
|
||||
updatePan(evt);
|
||||
}
|
||||
});
|
||||
window.addEventListener('mouseup', () => {
|
||||
state.drag = null;
|
||||
state.pan = null;
|
||||
});
|
||||
window.addEventListener('click', () => hideContextMenu());
|
||||
if (state.auth.username && state.auth.password) {
|
||||
void loadTowns();
|
||||
}
|
||||
}
|
||||
|
||||
authSaveBtn.addEventListener('click', saveAuth);
|
||||
townsReloadBtn.addEventListener('click', () => void loadTowns());
|
||||
layoutReloadBtn.addEventListener('click', () => {
|
||||
if (!state.selectedTownId) return;
|
||||
void loadLayout(state.selectedTownId);
|
||||
});
|
||||
layoutSaveBtn.addEventListener('click', () => void saveLayout());
|
||||
townSelect.addEventListener('change', () => {
|
||||
const id = Number(townSelect.value);
|
||||
if (!id) return;
|
||||
state.selectedTownId = id;
|
||||
void loadLayout(id);
|
||||
});
|
||||
|
||||
void bootstrap();
|
||||
@ -0,0 +1,125 @@
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: Arial, sans-serif;
|
||||
background: #10141f;
|
||||
color: #e8eef9;
|
||||
}
|
||||
|
||||
#app {
|
||||
display: grid;
|
||||
grid-template-columns: 260px 1fr;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
background: #151b2a;
|
||||
padding: 16px;
|
||||
border-right: 1px solid #2a3551;
|
||||
}
|
||||
|
||||
.canvas-area {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.canvas-root {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: #151b2a;
|
||||
border: 1px solid #2a3551;
|
||||
border-radius: 8px;
|
||||
padding: 12px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.card h3 {
|
||||
margin: 0 0 8px 0;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
input,
|
||||
select {
|
||||
width: 100%;
|
||||
padding: 8px;
|
||||
margin: 4px 0;
|
||||
background: #0f1522;
|
||||
color: #fff;
|
||||
border: 1px solid #2f3b5a;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 8px 12px;
|
||||
border: 1px solid #2f3b5a;
|
||||
background: #223152;
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.muted {
|
||||
color: #9eb0d6;
|
||||
font-size: 12px;
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.context-menu {
|
||||
position: absolute;
|
||||
min-width: 180px;
|
||||
background: #151b2a;
|
||||
border: 1px solid #2a3551;
|
||||
border-radius: 6px;
|
||||
padding: 6px 0;
|
||||
z-index: 10;
|
||||
box-shadow: 0 12px 24px rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
|
||||
.context-menu.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.context-menu button {
|
||||
width: 100%;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: #e8eef9;
|
||||
text-align: left;
|
||||
padding: 8px 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.context-menu button:hover {
|
||||
background: #223152;
|
||||
}
|
||||
|
||||
.context-menu-item {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.context-menu-item > .submenu {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 100%;
|
||||
min-width: 200px;
|
||||
background: #151b2a;
|
||||
border: 1px solid #2a3551;
|
||||
border-radius: 6px;
|
||||
padding: 6px 0;
|
||||
box-shadow: 0 12px 24px rgba(0, 0, 0, 0.35);
|
||||
display: none;
|
||||
}
|
||||
|
||||
.context-menu-item:hover > .submenu {
|
||||
display: block;
|
||||
}
|
||||
@ -0,0 +1,22 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "Bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"strict": true,
|
||||
"jsx": "preserve",
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@game/*": ["../../frontend/src/game/*"],
|
||||
"@shared/*": ["../../frontend/src/shared/*"],
|
||||
"@frontend/*": ["../../frontend/src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@ -0,0 +1,32 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import path from 'path';
|
||||
|
||||
const rootDir = __dirname;
|
||||
|
||||
export default defineConfig({
|
||||
root: rootDir,
|
||||
base: '/town-editor/',
|
||||
publicDir: path.resolve(rootDir, '../../frontend/public'),
|
||||
resolve: {
|
||||
alias: {
|
||||
'@game': path.resolve(rootDir, '../../frontend/src/game'),
|
||||
'@shared': path.resolve(rootDir, '../../frontend/src/shared'),
|
||||
'@frontend': path.resolve(rootDir, '../../frontend/src'),
|
||||
'pixi.js': path.resolve(rootDir, 'node_modules/pixi.js'),
|
||||
},
|
||||
dedupe: ['pixi.js'],
|
||||
},
|
||||
server: {
|
||||
port: 5175,
|
||||
fs: {
|
||||
allow: [
|
||||
path.resolve(rootDir, '../../frontend'),
|
||||
path.resolve(rootDir, '../../admin-web/town-editor'),
|
||||
],
|
||||
},
|
||||
},
|
||||
build: {
|
||||
outDir: 'dist',
|
||||
emptyOutDir: true,
|
||||
},
|
||||
});
|
||||
@ -0,0 +1,10 @@
|
||||
package model
|
||||
|
||||
const (
|
||||
HeroModelVariantMin = 0
|
||||
HeroModelVariantMax = 2
|
||||
)
|
||||
|
||||
func IsValidHeroModelVariant(v int) bool {
|
||||
return v >= HeroModelVariantMin && v <= HeroModelVariantMax
|
||||
}
|
||||
@ -0,0 +1,11 @@
|
||||
package model
|
||||
|
||||
// TownObject represents a decorative prop placed in a town.
|
||||
type TownObject struct {
|
||||
ID int64 `json:"id"`
|
||||
TownID int64 `json:"townId"`
|
||||
ObjectType string `json:"objectType"`
|
||||
Variant int `json:"variant"`
|
||||
OffsetX float64 `json:"offsetX"`
|
||||
OffsetY float64 `json:"offsetY"`
|
||||
}
|
||||
@ -0,0 +1,16 @@
|
||||
ALTER TABLE heroes
|
||||
ADD COLUMN hero_model_variant integer;
|
||||
|
||||
UPDATE heroes
|
||||
SET hero_model_variant = floor(random() * 3)::int
|
||||
WHERE hero_model_variant IS NULL;
|
||||
|
||||
ALTER TABLE heroes
|
||||
ALTER COLUMN hero_model_variant SET NOT NULL;
|
||||
|
||||
ALTER TABLE heroes
|
||||
ALTER COLUMN hero_model_variant SET DEFAULT floor(random() * 3)::int;
|
||||
|
||||
ALTER TABLE heroes
|
||||
ADD CONSTRAINT heroes_hero_model_variant_check
|
||||
CHECK (hero_model_variant BETWEEN 0 AND 2);
|
||||
@ -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);
|
||||
@ -0,0 +1 @@
|
||||
PIXELLAB_API_TOKEN=b87d979a-8027-405d-8e07-105ac494cc0b
|
||||
|
After Width: | Height: | Size: 4.8 KiB |
|
After Width: | Height: | Size: 40 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 6.2 KiB |
|
After Width: | Height: | Size: 8.3 KiB |
|
After Width: | Height: | Size: 7.1 KiB |
|
After Width: | Height: | Size: 3.7 KiB |
|
After Width: | Height: | Size: 3.7 KiB |
|
After Width: | Height: | Size: 1.8 KiB |
|
After Width: | Height: | Size: 1.8 KiB |
|
After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 2.7 KiB |
|
After Width: | Height: | Size: 1.7 KiB |
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 2.2 KiB |
|
After Width: | Height: | Size: 2.2 KiB |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 2.2 KiB |
|
After Width: | Height: | Size: 5.5 KiB |
|
After Width: | Height: | Size: 5.6 KiB |
|
After Width: | Height: | Size: 4.7 KiB |
|
After Width: | Height: | Size: 7.8 KiB |
|
After Width: | Height: | Size: 6.5 KiB |
|
After Width: | Height: | Size: 5.8 KiB |
|
After Width: | Height: | Size: 5.5 KiB |
|
After Width: | Height: | Size: 4.2 KiB |
|
After Width: | Height: | Size: 5.4 KiB |
|
After Width: | Height: | Size: 4.1 KiB |
|
After Width: | Height: | Size: 5.1 KiB |
|
After Width: | Height: | Size: 4.6 KiB |
|
After Width: | Height: | Size: 8.8 KiB |
|
After Width: | Height: | Size: 5.9 KiB |
|
After Width: | Height: | Size: 6.9 KiB |
|
After Width: | Height: | Size: 7.3 KiB |
|
After Width: | Height: | Size: 7.9 KiB |
|
After Width: | Height: | Size: 8.8 KiB |
|
After Width: | Height: | Size: 6.3 KiB |
|
After Width: | Height: | Size: 6.2 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 6.0 KiB |
|
After Width: | Height: | Size: 5.8 KiB |
|
After Width: | Height: | Size: 6.5 KiB |
|
After Width: | Height: | Size: 7.4 KiB |
|
After Width: | Height: | Size: 7.1 KiB |
|
After Width: | Height: | Size: 4.0 KiB |
|
After Width: | Height: | Size: 5.0 KiB |