graphics3

master
Denis Ranneft 1 month ago
parent 80a18a24f6
commit c1dc5dc770

1
.gitignore vendored

@ -9,6 +9,7 @@ backend/vendor/
frontend/node_modules/
frontend/dist/
frontend/.vite/
frontend/scripts/.enemy-south-pixellab-state.json
# IDE
.idea/

@ -57,3 +57,13 @@ Less central to AutoHeros **diamond isometric** ground plane but valid for ex
## MCP configuration (local)
Cursor `mcp.json` should use HTTP transport and Bearer token (see PixelLab setup). **Never commit API tokens** to the game repository.
## Batch: 220 enemy templates (south, transparent)
For all `enemies.type` slugs, use the repo script (API v2, same backend as MCP `create_map_object`):
1. Ensure archetype reference PNGs exist under `frontend/assets/enemies/` (`enemy.wolf.png`, … per `manifest.json`).
2. Set `PIXELLAB_API_TOKEN` (same token as MCP Bearer).
3. From `frontend/`: `npm run gen:enemy-south:pixellab -- --limit 3` (smoke), then without `--limit` for the full run. State file: `frontend/scripts/.enemy-south-pixellab-state.json` (resume-safe). Updates `enemy.<slug>.south` in `manifest.json` and writes `enemies/enemy.<slug>.south.png`.
Implementation: `frontend/scripts/pixellab-enemy-south-batch.mjs` (uses `POST /v2/map-objects`, polls `https://api.pixellab.ai/mcp/map-objects/{id}/download`).

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1018 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

@ -18,6 +18,7 @@
"@types/react-dom": "^19.0.0",
"@vitejs/plugin-react": "^4.3.4",
"eslint": "^9.17.0",
"pngjs": "^7.0.0",
"typescript": "~5.7.2",
"vite": "^6.2.0"
}
@ -2561,6 +2562,16 @@
"url": "https://opencollective.com/pixijs"
}
},
"node_modules/pngjs": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/pngjs/-/pngjs-7.0.0.tgz",
"integrity": "sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=14.19.0"
}
},
"node_modules/postcss": {
"version": "8.5.8",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz",

@ -7,7 +7,8 @@
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview",
"lint": "eslint src --ext .ts,.tsx --max-warnings 0"
"lint": "eslint src --ext .ts,.tsx --max-warnings 0",
"gen:enemy-south:pixellab": "node scripts/pixellab-enemy-south-batch.mjs"
},
"dependencies": {
"pixi.js": "^8.6.6",
@ -20,6 +21,7 @@
"@types/react-dom": "^19.0.0",
"@vitejs/plugin-react": "^4.3.4",
"eslint": "^9.17.0",
"pngjs": "^7.0.0",
"typescript": "~5.7.2",
"vite": "^6.2.0"
}

@ -1,7 +1,7 @@
{
"version": 7,
"version": 11,
"assetsRoot": "frontend/assets",
"note": "file paths relative to frontend/assets. hero.player.v0 Sellsword, v1 Duelist, v2 Spellblade — 8 dirs each (PixelLab create_character). Legacy hero.player.png kept. NPC npc.* south-only.",
"note": "file paths relative to frontend/assets. Rest camp: prop.camp_tent/fire/bag.v0 (wild rest). Other props + heroes + NPC.",
"textures": {
"terrain.grass.v0": {
"file": "tiles/terrain.grass.v0.png",
@ -208,6 +208,81 @@
"kind": "map_object",
"pixellabObjectId": "3aa0dcd6-0868-432f-b36e-5574e226b58c"
},
"prop.bush.v0": {
"file": "prop/prop.bush.v0.png",
"kind": "map_object",
"pixellabObjectId": "f6d16006-9600-484b-b3d2-218bb66b9e66"
},
"prop.bush.v1": {
"file": "prop/prop.bush.v1.png",
"kind": "map_object",
"pixellabObjectId": "14defee7-aa08-4ab9-bf8b-b7b2c1221b1d"
},
"prop.mushroom.v0": {
"file": "prop/prop.mushroom.v0.png",
"kind": "map_object",
"pixellabObjectId": "fa1ab040-56b9-48f2-b0cb-67adb67a0b33"
},
"prop.mushroom.v1": {
"file": "prop/prop.mushroom.v1.png",
"kind": "map_object",
"pixellabObjectId": "7a2bbb95-d084-4529-b39d-a53a9ba197e2"
},
"prop.leaves.v0": {
"file": "prop/prop.leaves.v0.png",
"kind": "map_object",
"pixellabObjectId": "50220bb9-0fdc-4220-b600-9ad4ddc12272"
},
"prop.leaves.v1": {
"file": "prop/prop.leaves.v1.png",
"kind": "map_object",
"pixellabObjectId": "f1e44471-7bc0-43a2-bedc-195a4fd7caeb"
},
"prop.stump.v0": {
"file": "prop/prop.stump.v0.png",
"kind": "map_object",
"pixellabObjectId": "7a52d31f-716a-4c77-bb28-78e08322be3f"
},
"prop.stump.v1": {
"file": "prop/prop.stump.v1.png",
"kind": "map_object",
"pixellabObjectId": "bffe7173-b0ba-4801-bc76-e78a40f8378d"
},
"prop.bones.v0": {
"file": "prop/prop.bones.v0.png",
"kind": "map_object",
"pixellabObjectId": "0ad79d67-863a-4260-99e7-84a243cfd751"
},
"prop.bones.v1": {
"file": "prop/prop.bones.v1.png",
"kind": "map_object",
"pixellabObjectId": "8ceb4caf-4c1f-4268-b260-9dc45c1079f7"
},
"prop.ruin.v0": {
"file": "prop/prop.ruin.v0.png",
"kind": "map_object",
"pixellabObjectId": "b2363bd6-2a6f-4010-9f83-3ac1a65c9220"
},
"prop.ruin.v1": {
"file": "prop/prop.ruin.v1.png",
"kind": "map_object",
"pixellabObjectId": "e789e08a-cdde-4a51-8ecd-c5090614a6c5"
},
"prop.camp_tent.v0": {
"file": "prop/prop.camp_tent.v0.png",
"kind": "map_object",
"pixellabObjectId": "2427b47e-da33-4463-9ea3-49ade72a9a60"
},
"prop.camp_fire.v0": {
"file": "prop/prop.camp_fire.v0.png",
"kind": "map_object",
"pixellabObjectId": "0362257f-c2fe-4d67-8d58-2951f1c9622f"
},
"prop.camp_bag.v0": {
"file": "prop/prop.camp_bag.v0.png",
"kind": "map_object",
"pixellabObjectId": "4c507361-376f-4462-bb5a-49237b82d760"
},
"building.house.v0": {
"file": "building/building.house.v0.png",
"kind": "map_object",
@ -283,6 +358,102 @@
"kind": "map_object",
"pixellabObjectId": "72db0b9e-c3ec-4e17-baa6-a08e30065ab2"
},
"enemy.wolf_l1_1_meadow.south": {
"file": "enemies/enemy.wolf_l1_1_meadow.south.png",
"kind": "map_object",
"pixellabObjectId": "4bf33db2-712c-424a-b4ad-7e425e2e7e9b",
"rotation": "south"
},
"enemy.wolf_l1_1_forest.south": {
"file": "enemies/enemy.wolf_l1_1_forest.south.png",
"kind": "map_object",
"pixellabObjectId": "d1f2dc86-520e-4360-9b07-0b61faf87e6d",
"rotation": "south"
},
"enemy.wolf_l2_2_forest.south": {
"file": "enemies/enemy.wolf_l2_2_forest.south.png",
"kind": "map_object",
"pixellabObjectId": "7d140a0e-ff83-4e99-af73-5f20439b5f56",
"rotation": "south"
},
"enemy.wolf_l2_2_ruins.south": {
"file": "enemies/enemy.wolf_l2_2_ruins.south.png",
"kind": "map_object",
"pixellabObjectId": "5ce86bef-f686-4a11-bd72-f49e18067a94",
"rotation": "south"
},
"enemy.wolf_l3_3_ruins.south": {
"file": "enemies/enemy.wolf_l3_3_ruins.south.png",
"kind": "map_object",
"pixellabObjectId": "13159934-d604-4de7-a2ba-a648f7aed9f2",
"rotation": "south"
},
"enemy.wolf_l3_3_canyon.south": {
"file": "enemies/enemy.wolf_l3_3_canyon.south.png",
"kind": "map_object",
"pixellabObjectId": "18a2b9e4-0321-4051-af42-a780179ead28",
"rotation": "south"
},
"enemy.wolf_l4_4_canyon.south": {
"file": "enemies/enemy.wolf_l4_4_canyon.south.png",
"kind": "map_object",
"pixellabObjectId": "540c7767-775a-41e6-80fc-24399c4c8037",
"rotation": "south"
},
"enemy.wolf_l4_4_swamp.south": {
"file": "enemies/enemy.wolf_l4_4_swamp.south.png",
"kind": "map_object",
"pixellabObjectId": "64022324-5ce7-435c-bd9b-13f1f37cac5f",
"rotation": "south"
},
"enemy.wolf_l5_5_volcanic.south": {
"file": "enemies/enemy.wolf_l5_5_volcanic.south.png",
"kind": "map_object",
"pixellabObjectId": "2ffa9c9d-4459-4ec5-8ed0-8db90d1f91ff",
"rotation": "south"
},
"enemy.wolf_l5_5_astral.south": {
"file": "enemies/enemy.wolf_l5_5_astral.south.png",
"kind": "map_object",
"pixellabObjectId": "e96d9819-fc57-4568-abc6-f9722cd9f163",
"rotation": "south"
},
"enemy.boar_l2_2_meadow.south": {
"file": "enemies/enemy.boar_l2_2_meadow.south.png",
"kind": "map_object",
"pixellabObjectId": "99e1a473-34fe-4c58-bc1e-9192902e4709",
"rotation": "south"
},
"enemy.boar_l2_2_forest.south": {
"file": "enemies/enemy.boar_l2_2_forest.south.png",
"kind": "map_object",
"pixellabObjectId": "771d2d9c-5d35-4a77-869d-70c86abc9d0b",
"rotation": "south"
},
"enemy.boar_l3_3_forest.south": {
"file": "enemies/enemy.boar_l3_3_forest.south.png",
"kind": "map_object",
"pixellabObjectId": "11b8358c-d4ed-49df-8da4-603845cb23c8",
"rotation": "south"
},
"enemy.boar_l3_3_ruins.south": {
"file": "enemies/enemy.boar_l3_3_ruins.south.png",
"kind": "map_object",
"pixellabObjectId": "3767a671-8c08-4ae3-b18a-fdcfe4d7c4ea",
"rotation": "south"
},
"enemy.boar_l4_4_ruins.south": {
"file": "enemies/enemy.boar_l4_4_ruins.south.png",
"kind": "map_object",
"pixellabObjectId": "b401afd4-f349-4827-ab59-57fa1c2d1fdb",
"rotation": "south"
},
"enemy.boar_l4_4_canyon.south": {
"file": "enemies/enemy.boar_l4_4_canyon.south.png",
"kind": "map_object",
"pixellabObjectId": "abfd83f3-1ede-4fe2-a70e-4bd5746dce78",
"rotation": "south"
},
"hero.player": {
"file": "characters/hero.player.png",
"kind": "map_object",

@ -0,0 +1,347 @@
/**
* Batch-generate enemy.<slug>.south map objects via PixelLab API v2, using archetype PNGs as init_image.
*
* Prerequisites:
* - PIXELLAB_API_TOKEN in env (https://pixellab.ai/account or MCP setup).
* - Reference sprites at frontend/assets/enemies/enemy.<archetype>.png (from manifest archetypes).
*
* Usage:
* set PIXELLAB_API_TOKEN=... # PowerShell: $env:PIXELLAB_API_TOKEN="..."
* npm run gen:enemy-south:pixellab -- --limit 3 # smoke test
* npm run gen:enemy-south:pixellab -- --slugs "wolf_l1_1_meadow,boar_l2_2_meadow" # explicit list
* npm run gen:enemy-south:pixellab # all ~220 (long run; resumable)
*
* Resume: state in scripts/.enemy-south-pixellab-state.json
* Output: assets/enemies/enemy.<slug>.south.png + manifest entries (pixellabObjectId, rotation south).
*/
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const ROOT = path.join(__dirname, '..');
const ASSETS = path.join(ROOT, 'assets');
const ENEMIES_DIR = path.join(ASSETS, 'enemies');
const MANIFEST_PATH = path.join(ROOT, 'public/assets/game/manifest.json');
const SLUGS_FILE = path.join(ROOT, 'src/game/enemyTemplateSlugs.ts');
const STATE_PATH = path.join(__dirname, '.enemy-south-pixellab-state.json');
const API_BASE = process.env.PIXELLAB_API_BASE ?? 'https://api.pixellab.ai/v2';
const TOKEN = process.env.PIXELLAB_API_TOKEN ?? '';
const PREFIX = `Dark fantasy, brutal mood, stylized painterly look inspired by Arcane series,
cinematic lighting, strong readable silhouette, muted desaturated colors with rare accent highlights,
single game asset, transparent background, no text, no watermark, no border.
Isometric three-quarter view toward camera, consistent with tile-based RPG ground plane.`;
const SLUG_RE = /^(.+)_l(\d+)_(\d+)_(meadow|forest|ruins|canyon|swamp|volcanic|astral)$/;
const ICE_ELEMENT_SLUGS = new Set([
'element_l12_14_forest',
'element_l15_16_ruins',
'element_l17_18_canyon',
'element_l19_20_swamp',
'element_l21_22_astral',
]);
/** Manifest texture key → filename stem under enemies/ (enemy.wolf.png) */
const ARCH_TO_REF = {
wolf: 'enemy.wolf',
boar: 'enemy.boar',
zombie: 'enemy.zombie',
spider: 'enemy.spider',
orc: 'enemy.orc',
skeleton: 'enemy.skeleton_archer',
battle_lizard: 'enemy.battle_lizard',
demon: 'enemy.fire_demon',
skeleton_king: 'enemy.skeleton_king',
forest_warden: 'enemy.forest_warden',
titan: 'enemy.lightning_titan',
element_ice: 'enemy.ice_guardian',
element_water: 'enemy.water_element',
golem: 'enemy.fire_demon',
wraith: 'enemy.water_element',
bandit: 'enemy.orc',
cultist: 'enemy.skeleton_archer',
treant: 'enemy.forest_warden',
basilisk: 'enemy.battle_lizard',
wyvern: 'enemy.battle_lizard',
harpy: 'enemy.spider',
manticore: 'enemy.boar',
shade: 'enemy.water_element',
};
const BIOME_HINT = {
meadow: 'open meadow, soft daylight, grassy hints',
forest: 'dense forest floor, cool greens and bark',
ruins: 'crumbling stone ruins, dust and cold gray',
canyon: 'dry canyon, warm rock and dust',
swamp: 'murky swamp, violet-brown rot and mist',
volcanic: 'volcanic ash, ember accents, scorched rock',
astral: 'ethereal astral void, faint cyan-violet glow',
};
function parseSlugs() {
const text = fs.readFileSync(SLUGS_FILE, 'utf8');
const lb = text.indexOf('[', text.indexOf('ENEMY_TEMPLATE_SLUGS'));
const rb = text.indexOf('] as const', lb);
const body = text.slice(lb + 1, rb);
const slugs = [];
for (const m of body.matchAll(/'([^']+)'/g)) {
slugs.push(m[1]);
}
return slugs;
}
function refKeyForSlug(slug, arch) {
if (arch === 'element') {
return ICE_ELEMENT_SLUGS.has(slug) ? 'element_ice' : 'element_water';
}
return arch in ARCH_TO_REF ? arch : 'wolf';
}
function buildDescription(slug, arch, low, high, biome) {
const refK = refKeyForSlug(slug, arch);
const archetypeHint =
arch === 'element'
? ICE_ELEMENT_SLUGS.has(slug)
? 'elemental ice creature'
: 'elemental water creature'
: `${arch.replace(/_/g, ' ')} monster`;
const biomeHint = BIOME_HINT[biome] ?? biome;
return `${PREFIX}
Game unit: ${archetypeHint}, template "${slug}", levels ~${low}${high}.
Biome flavor: ${biomeHint}.
Match the silhouette and style family of the reference init image, but make this a clearly distinct individual (markings, gear, mutations, wear).
Facing south (toward the camera), full body, feet near bottom center, pixel art, RPG enemy sprite.`;
}
function loadState() {
try {
return JSON.parse(fs.readFileSync(STATE_PATH, 'utf8'));
} catch {
return { jobs: {} };
}
}
function saveState(state) {
fs.writeFileSync(STATE_PATH, JSON.stringify(state, null, 2));
}
function extractObjectId(json) {
if (!json || typeof json !== 'object') return null;
if (json.object_id) return json.object_id;
if (json.data?.object_id) return json.data.object_id;
if (json.id) return json.id;
if (json.data?.id) return json.data.id;
return null;
}
async function postMapObject(body) {
const res = await fetch(`${API_BASE}/map-objects`, {
method: 'POST',
headers: {
Authorization: `Bearer ${TOKEN}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(body),
});
const text = await res.text();
let json;
try {
json = JSON.parse(text);
} catch {
json = { raw: text };
}
if (!res.ok) {
throw new Error(`POST map-objects ${res.status}: ${text.slice(0, 500)}`);
}
return json;
}
async function waitForDownload(objectId, timeoutMs = 600_000, pollMs = 4000) {
const url = `https://api.pixellab.ai/mcp/map-objects/${objectId}/download`;
const t0 = Date.now();
while (Date.now() - t0 < timeoutMs) {
const res = await fetch(url);
const ct = res.headers.get('content-type') ?? '';
if (res.ok && (ct.includes('png') || ct.includes('octet-stream'))) {
return Buffer.from(await res.arrayBuffer());
}
if (res.status === 404 || res.status === 425) {
await sleep(pollMs);
continue;
}
if (!res.ok) {
const errText = await res.text();
if (res.status >= 500) {
await sleep(pollMs);
continue;
}
throw new Error(`download ${res.status}: ${errText.slice(0, 200)}`);
}
await sleep(pollMs);
}
throw new Error(`timeout waiting for object ${objectId}`);
}
function sleep(ms) {
return new Promise((r) => setTimeout(r, ms));
}
function readRefInitImage(refStem) {
const p = path.join(ENEMIES_DIR, `${refStem}.png`);
if (!fs.existsSync(p)) return null;
const buf = fs.readFileSync(p);
return {
type: 'base64',
base64: buf.toString('base64'),
format: 'png',
};
}
function parseArgs() {
const a = process.argv.slice(2);
let limit = Infinity;
let dry = false;
/** If set, only these slugs (comma-separated on CLI). */
let slugList = null;
for (let i = 0; i < a.length; i++) {
if (a[i] === '--limit' && a[i + 1]) {
limit = parseInt(a[i + 1], 10);
i++;
}
if (a[i] === '--slugs' && a[i + 1]) {
slugList = a[i + 1]
.split(',')
.map((s) => s.trim())
.filter(Boolean);
i++;
}
if (a[i] === '--dry-run') dry = true;
}
return { limit, dry, slugList };
}
function setManifestEntry(slug, objectId) {
const manifest = JSON.parse(fs.readFileSync(MANIFEST_PATH, 'utf8'));
const key = `enemy.${slug}.south`;
const file = `enemies/enemy.${slug}.south.png`;
manifest.textures[key] = {
file,
kind: 'map_object',
pixellabObjectId: objectId,
rotation: 'south',
};
fs.writeFileSync(MANIFEST_PATH, `${JSON.stringify(manifest, null, 2)}\n`);
}
function bumpManifestVersion() {
const manifest = JSON.parse(fs.readFileSync(MANIFEST_PATH, 'utf8'));
manifest.version = (manifest.version | 0) + 1;
fs.writeFileSync(MANIFEST_PATH, `${JSON.stringify(manifest, null, 2)}\n`);
}
async function main() {
const { limit, dry, slugList } = parseArgs();
if (!TOKEN && !dry) {
console.error('Set PIXELLAB_API_TOKEN (Bearer token from PixelLab account / MCP).');
process.exit(1);
}
let slugs = slugList?.length ? slugList : parseSlugs();
if (!slugList?.length && Number.isFinite(limit)) slugs = slugs.slice(0, limit);
fs.mkdirSync(ENEMIES_DIR, { recursive: true });
const state = loadState();
let updatedManifest = false;
for (const slug of slugs) {
const m = slug.match(SLUG_RE);
if (!m) {
console.warn('skip bad slug', slug);
continue;
}
const arch = m[1];
const low = m[2];
const high = m[3];
const biome = m[4];
const outPath = path.join(ENEMIES_DIR, `enemy.${slug}.south.png`);
if (fs.existsSync(outPath) && state.jobs[slug]?.done) {
console.log('skip existing', slug);
continue;
}
const description = buildDescription(slug, arch, low, high, biome);
const refStem = ARCH_TO_REF[refKeyForSlug(slug, arch)];
const initImage = readRefInitImage(refStem);
if (dry) {
console.log('---', slug, refStem, initImage ? 'has ref' : 'no ref');
console.log(description.slice(0, 200) + '…');
continue;
}
let objectId = state.jobs[slug]?.objectId;
if (!objectId) {
const body = {
description,
image_size: { width: 96, height: 112 },
view: 'low top-down',
outline: 'single color outline',
shading: 'medium shading',
detail: 'medium detail',
text_guidance_scale: 8,
};
if (initImage) {
body.init_image = initImage;
body.init_image_strength = 420;
}
console.log('create', slug, initImage ? `+ref ${refStem}` : 'text only');
let created;
try {
created = await postMapObject(body);
} catch (e) {
if (initImage && String(e.message).includes('422')) {
console.warn('retry without init_image', slug);
delete body.init_image;
delete body.init_image_strength;
created = await postMapObject(body);
} else {
throw e;
}
}
objectId = extractObjectId(created);
if (!objectId) {
console.error('No object_id in response:', JSON.stringify(created).slice(0, 400));
process.exit(1);
}
state.jobs[slug] = { objectId, startedAt: Date.now() };
saveState(state);
await sleep(6000);
}
if (!fs.existsSync(outPath)) {
console.log('poll', slug, objectId);
const png = await waitForDownload(objectId);
fs.writeFileSync(outPath, png);
}
state.jobs[slug] = { objectId, done: true, finishedAt: Date.now() };
saveState(state);
setManifestEntry(slug, objectId);
updatedManifest = true;
console.log('ok', slug);
await sleep(1500);
}
if (!dry && updatedManifest) bumpManifestVersion();
console.log('Done.');
}
main().catch((e) => {
console.error(e);
process.exit(1);
});

@ -0,0 +1,74 @@
import { Assets, Texture } from 'pixi.js';
import {
fetchGameTextureManifest,
tryResolveGameAssetFile,
type GameTextureManifest,
} from './resolveGameAssetUrl';
import { getRequiredSpriteKeys } from './spriteMapping';
export class GameSpriteRegistry {
private _manifest: GameTextureManifest | null = null;
private _textures = new Map<string, Texture>();
private _ready = false;
private _buildFallbackManifest(keys: string[]): GameTextureManifest {
const textures: Record<string, { file: string; kind: string }> = {};
for (const key of keys) {
let file: string | null = null;
if (key.startsWith('terrain.')) file = `tiles/${key}.png`;
else if (key.startsWith('prop.')) file = `prop/${key}.png`;
else if (key.startsWith('building.')) file = `building/${key}.png`;
else if (key.startsWith('enemy.')) file = `enemies/${key}.png`;
else if (key.startsWith('npc.') || key.startsWith('hero.')) file = `characters/${key}.png`;
if (!file) continue;
textures[key] = { file, kind: 'fallback' };
}
return {
version: 0,
note: 'Fallback manifest (generated at runtime).',
textures,
};
}
get ready(): boolean {
return this._ready;
}
get manifest(): GameTextureManifest | null {
return this._manifest;
}
async loadAll(): Promise<void> {
const requiredKeys = getRequiredSpriteKeys();
try {
this._manifest = await fetchGameTextureManifest();
} catch (error) {
console.warn('[Assets] Manifest load failed, using fallback manifest.', error);
this._manifest = this._buildFallbackManifest(requiredKeys);
}
for (const key of requiredKeys) {
if (!this._manifest.textures[key]) {
console.warn(`[Assets] Missing manifest entry for sprite key: ${key}`);
}
}
const entries = Object.entries(this._manifest.textures);
await Promise.all(
entries.map(async ([key, entry]) => {
const url = tryResolveGameAssetFile(entry.file);
if (!url) {
console.warn(`[Assets] Missing sprite file in bundle: ${entry.file}`);
return;
}
const texture = await Assets.load<Texture>(url);
// Pixel-art tiles/props: nearest filtering avoids subpixel seam artifacts on tile borders.
texture.source.scaleMode = 'nearest';
this._textures.set(key, texture);
}),
);
this._ready = true;
}
getTexture(key: string): Texture | null {
return this._textures.get(key) ?? null;
}
}

@ -3,7 +3,7 @@
* Keep in sync with [public/assets/game/manifest.json](../../../../public/assets/game/manifest.json).
*/
const raw = import.meta.glob<string>('../../assets/**/*.png', { eager: true, as: 'url' });
const raw = import.meta.glob<string>('../../../assets/**/*.png', { eager: true, as: 'url' });
function toManifestRelativePath(globModulePath: string): string {
const n = globModulePath.replace(/\\/g, '/');
@ -52,8 +52,25 @@ export interface GameTextureManifest {
textures: Record<string, GameTextureManifestEntry>;
}
export async function fetchGameTextureManifest(url = '/assets/game/manifest.json'): Promise<GameTextureManifest> {
const res = await fetch(url);
async function fetchManifestJson(resolvedUrl: string): Promise<GameTextureManifest> {
const res = await fetch(resolvedUrl);
if (!res.ok) throw new Error(`Game manifest fetch failed: ${res.status}`);
return res.json() as Promise<GameTextureManifest>;
const manifest = (await res.json()) as GameTextureManifest;
if (!manifest || !manifest.textures) {
throw new Error('Game manifest missing textures');
}
return manifest;
}
export async function fetchGameTextureManifest(url?: string): Promise<GameTextureManifest> {
const baseUrl = import.meta.env.BASE_URL ?? '/';
const resolvedUrl = url ?? `${baseUrl}assets/game/manifest.json`;
try {
return await fetchManifestJson(resolvedUrl);
} catch (error) {
if (resolvedUrl !== '/assets/game/manifest.json') {
return fetchManifestJson('/assets/game/manifest.json');
}
throw error;
}
}

@ -0,0 +1,116 @@
export const ROAD_SPRITE_KEY = 'terrain.road.v1';
const DEFAULT_TERRAIN_KEY = 'terrain.grass.v1';
const HERO_SPRITE_KEY = 'hero.player.v1.south';
const MEET_PARTNER_SPRITE_KEY = 'hero.meet_partner';
/** Wilderness rest: separate props (transparent PNG), tent ~30px art height. */
export const CAMP_TENT_TEXTURE_KEY = 'prop.camp_tent.v0';
export const CAMP_FIRE_TEXTURE_KEY = 'prop.camp_fire.v0';
export const CAMP_BAG_TEXTURE_KEY = 'prop.camp_bag.v0';
const TERRAIN_TEXTURE_BY_KEY: Record<string, string> = {
plaza: 'terrain.plaza.v1',
road: ROAD_SPRITE_KEY,
dirt: 'terrain.dirt.v1',
stone: 'terrain.stone.v1',
forest_floor: 'terrain.forest_floor.v1',
ruins_floor: 'terrain.ruins_floor.v1',
canyon_floor: 'terrain.canyon_floor.v1',
swamp_floor: 'terrain.swamp_floor.v1',
volcanic_floor: 'terrain.volcanic_floor.v1',
astral_floor: 'terrain.astral_floor.v1',
grass: DEFAULT_TERRAIN_KEY,
};
const OBJECT_TEXTURE_BY_KEY: Record<string, { v0: string; v1: string }> = {
tree: { v0: 'prop.tree.v0', v1: 'prop.tree.v1' },
rock: { v0: 'prop.rock.v0', v1: 'prop.rock.v1' },
cart: { v0: 'prop.cart.v0', v1: 'prop.cart.v1' },
barrel: { v0: 'prop.barrel.v0', v1: 'prop.barrel.v1' },
bush: { v0: 'prop.bush.v0', v1: 'prop.bush.v1' },
mushroom: { v0: 'prop.mushroom.v0', v1: 'prop.mushroom.v1' },
leaves: { v0: 'prop.leaves.v0', v1: 'prop.leaves.v1' },
stump: { v0: 'prop.stump.v0', v1: 'prop.stump.v1' },
bones: { v0: 'prop.bones.v0', v1: 'prop.bones.v1' },
ruin: { v0: 'prop.ruin.v0', v1: 'prop.ruin.v1' },
};
const NPC_TEXTURE_BY_TYPE: Record<string, string> = {
merchant: 'npc.merchant',
armorer: 'npc.armorer',
weapon: 'npc.weapon',
jeweler: 'npc.jeweler',
healer: 'npc.healer',
bounty_hunter: 'npc.bounty_hunter',
elder: 'npc.elder',
quest_giver: 'npc.quest_giver',
};
const BUILDING_TEXTURE_BY_TYPE: Record<string, string> = {
'house.quest_giver': 'building.house.v1',
'house.merchant': 'building.house.v1',
'house.armorer': 'building.house.v1',
'house.weapon_smith': 'building.house.v1',
'house.jeweler': 'building.house.v1',
'house.bounty_hunter': 'building.house.v1',
'house.elder': 'building.house.v1',
'house.healer': 'building.house.v1',
};
export function terrainToTextureKey(terrain: string): string {
return TERRAIN_TEXTURE_BY_KEY[terrain] ?? DEFAULT_TERRAIN_KEY;
}
export function objectToTextureKey(obj: string, variant: number): string | null {
const entry = OBJECT_TEXTURE_BY_KEY[obj];
if (!entry) return null;
return variant > 0.5 ? entry.v1 : entry.v0;
}
export function npcTypeToTextureKey(npcType: string): string | null {
return NPC_TEXTURE_BY_TYPE[npcType] ?? null;
}
export function buildingTypeToTextureKey(buildingType: string): string | null {
return BUILDING_TEXTURE_BY_TYPE[buildingType] ?? null;
}
export function heroTextureKey(): string {
return HERO_SPRITE_KEY;
}
export function meetPartnerTextureKey(): string {
return MEET_PARTNER_SPRITE_KEY;
}
export function restCampTextureKeys(): [string, string, string] {
return [CAMP_TENT_TEXTURE_KEY, CAMP_FIRE_TEXTURE_KEY, CAMP_BAG_TEXTURE_KEY];
}
/** South-facing sprite per DB template (`enemies.type`); optional until listed in manifest + assets. */
export function enemySouthTextureKey(slug: string): string {
return `enemy.${slug}.south`;
}
export function getRequiredSpriteKeys(): string[] {
const terrainKeys = Object.values(TERRAIN_TEXTURE_BY_KEY);
const objectKeys = Object.values(OBJECT_TEXTURE_BY_KEY).flatMap((entry) => [
entry.v0,
entry.v1,
]);
const npcKeys = Object.values(NPC_TEXTURE_BY_TYPE);
const buildingKeys = Object.values(BUILDING_TEXTURE_BY_TYPE);
return [
...new Set([
...terrainKeys,
...objectKeys,
...npcKeys,
...buildingKeys,
HERO_SPRITE_KEY,
MEET_PARTNER_SPRITE_KEY,
CAMP_TENT_TEXTURE_KEY,
CAMP_FIRE_TEXTURE_KEY,
CAMP_BAG_TEXTURE_KEY,
]),
];
}

@ -89,7 +89,7 @@ export class Camera {
* The container is shifted so the camera target appears at screen center.
*/
applyTo(container: { x: number; y: number }, screenWidth: number, screenHeight: number): void {
container.x = screenWidth / 2 - this.finalX;
container.y = screenHeight / 2 - this.finalY;
container.x = Math.round(screenWidth / 2 - this.finalX);
container.y = Math.round(screenHeight / 2 - this.finalY);
}
}

@ -664,6 +664,54 @@ export function resolveEnemyVisual(slug: string, archetype?: string): EnemyVisua
return tweakVisualForSlug(base, slug);
}
// ---------------------------------------------------------------------------
// HP bar only (body drawn as texture)
// ---------------------------------------------------------------------------
export function drawEnemyHpBarOnly(
gfx: Graphics,
enemySlug: string,
enemyArchetype: string | undefined,
cx: number,
cy: number,
hp: number,
maxHp: number,
): void {
gfx.clear();
const config = resolveEnemyVisual(enemySlug, enemyArchetype);
const { size } = config;
const bodyTop = getBodyTopY(cy, size, config.bodyShape);
const barWidth = 30;
const barHeight = 4;
const headHeight =
config.headShape === 'none'
? config.isElite
? 12
: 6
: config.headShape === 'crown'
? 22
: config.headShape === 'horns'
? 18
: config.headShape === 'helmet'
? 16
: 14;
const hpBarY = bodyTop - headHeight - 4;
gfx.rect(cx - barWidth / 2, hpBarY, barWidth, barHeight);
gfx.fill({ color: 0x000000, alpha: 0.6 });
const hpRatio = maxHp > 0 ? Math.max(0, hp / maxHp) : 0;
if (hpRatio > 0) {
gfx.rect(cx - barWidth / 2, hpBarY, barWidth * hpRatio, barHeight);
const hpColor = hpRatio > 0.5 ? 0xcc3333 : hpRatio > 0.25 ? 0xccaa22 : 0xff2222;
gfx.fill({ color: hpColor });
}
if (config.isElite) {
gfx.rect(cx - barWidth / 2, hpBarY, barWidth, barHeight);
gfx.stroke({ color: config.glowColor ?? 0xffaa00, width: 1, alpha: 0.8 });
}
gfx.zIndex = cy + 101;
}
// ---------------------------------------------------------------------------
// Main draw function — replaces the generic red-diamond drawEnemy
// ---------------------------------------------------------------------------

@ -972,13 +972,6 @@ export class GameEngine {
? 'fight'
: 'idle';
this.renderer.drawHero(
this._heroDisplayX,
this._heroDisplayY,
animPhase,
now,
);
const rk = state.hero.restKind?.toLowerCase() ?? '';
const excPhase = state.hero.excursionPhase?.toLowerCase() ?? '';
// Camp only during the stationary wild phase; hide as soon as rest ends and return leg starts.
@ -992,6 +985,13 @@ export class GameEngine {
this.renderer.clearRestCamp();
}
this.renderer.drawHero(
this._heroDisplayX,
this._heroDisplayY,
animPhase,
now,
);
// Thought bubble during rest/town pauses
if (this._thoughtText) {
this.renderer.drawThoughtBubble(
@ -1083,6 +1083,8 @@ export class GameEngine {
state.enemy.enemyArchetype,
now,
);
} else {
this.renderer.clearEnemyCombat();
}
// Sort entities for isometric depth

@ -1,9 +1,20 @@
import { Application, Container, Graphics, Text, TextStyle } from 'pixi.js';
import { Application, Container, Graphics, Sprite, Text, TextStyle, Texture } from 'pixi.js';
import { TILE_WIDTH, TILE_HEIGHT, MAP_ZOOM } from '../shared/constants';
import { getViewport } from '../shared/telegram';
import type { Camera } from './camera';
import type { TownData, NPCData, BuildingData } from './types';
import { drawEnemyBySlug } from './enemyVisuals';
import { drawEnemyBySlug, drawEnemyHpBarOnly } from './enemyVisuals';
import { GameSpriteRegistry } from './assets/gameSpriteRegistry';
import {
buildingTypeToTextureKey,
heroTextureKey,
meetPartnerTextureKey,
npcTypeToTextureKey,
objectToTextureKey,
restCampTextureKeys,
enemySouthTextureKey,
terrainToTextureKey,
} from './assets/spriteMapping';
/**
* Isometric coordinate conversion utilities.
@ -23,6 +34,13 @@ export interface WorldPoint {
y: number;
}
type SpritePoolEntry = {
sprite: Sprite;
textureKey: string;
};
const TERRAIN_SEAM_BLEED_SCALE = 1.002;
/** Convert world (tile) coordinates to screen (pixel) coordinates */
export function worldToScreen(wx: number, wy: number): ScreenPoint {
return {
@ -73,6 +91,30 @@ export class GameRenderer {
/** Town ring + active route for procedural ground (null until towns loaded). */
private _worldTerrainContext: WorldTerrainContext | null = null;
// Sprite rendering
private _spriteRegistry = new GameSpriteRegistry();
private _spritesReady = false;
private _groundSpriteLayer: Container;
private _objectSpriteLayer: Container;
private _buildingSpriteLayer: Container;
private _tileSpritePool = new Map<string, SpritePoolEntry>();
private _objectSpritePool = new Map<string, SpritePoolEntry>();
private _tileSpriteFreeList: Sprite[] = [];
private _objectSpriteFreeList: Sprite[] = [];
private _buildingSpritePool = new Map<string, SpritePoolEntry>();
private _characterSpritePool = new Map<string, SpritePoolEntry>();
private _npcSpritePool = new Map<string, SpritePoolEntry>();
private _groundDirty = true;
private _lastGroundBounds:
| {
startX: number;
endX: number;
startY: number;
endY: number;
}
| null = null;
private _lastSpritesReady = false;
// Reusable Graphics objects (avoid GC in hot path)
private _groundGfx: Graphics | null = null;
private _heroGfx: Graphics | null = null;
@ -93,6 +135,7 @@ export class GameRenderer {
// Town rendering
private _townGfx: Graphics | null = null;
private _townIconGfx: Graphics | null = null;
private _townLabels: Text[] = [];
private _townLabelPool: Text[] = [];
@ -106,6 +149,9 @@ export class GameRenderer {
private _nearbyHeroLabels: Text[] = [];
private _nearbyHeroLabelPool: Text[] = [];
private _lastEntitySortMs = 0;
private _entitySortIntervalMs = 120;
private _drawBush(gfx: Graphics, x: number, y: number, variant: number): void {
const s = (0.9 + variant * 0.25) * 3.5;
gfx.ellipse(x - 4 * s, y - 7 * s, 7 * s, 4.5 * s);
@ -162,9 +208,9 @@ export class GameRenderer {
private _drawBones(gfx: Graphics, x: number, y: number, variant: number): void {
const s = (0.8 + variant * 0.3) * 2.8;
gfx.roundRect(x - 8 * s, y - 2 * s, 16 * s, 3 * s, 1 * s);
gfx.roundRect(x - 8 * s, y - 2 * s, 16 * s, 3 * s, s);
gfx.fill({ color: 0xddd5c8, alpha: 0.9 });
gfx.circle(x - 5 * s, y - 1 * s, 2 * s);
gfx.circle(x - 5 * s, y - s, 2 * s);
gfx.fill({ color: 0xc9c2b6, alpha: 0.9 });
gfx.circle(x + 6 * s, y, 1.5 * s);
gfx.fill({ color: 0xb0a898, alpha: 0.85 });
@ -226,9 +272,9 @@ export class GameRenderer {
gfx.fill({ color: 0x6b4a30, alpha: 0.92 });
gfx.ellipse(x, y - 7 * s, 5 * s, 3 * s);
gfx.fill({ color: 0x7a5a3a, alpha: 0.9 });
gfx.rect(x - 5 * s, y - 5 * s, 10 * s, 1 * s);
gfx.rect(x - 5 * s, y - 5 * s, 10 * s, s);
gfx.fill({ color: 0x4a3218, alpha: 0.6 });
gfx.rect(x - 5 * s, y - 2 * s, 10 * s, 1 * s);
gfx.rect(x - 5 * s, y - 2 * s, 10 * s, s);
gfx.fill({ color: 0x4a3218, alpha: 0.6 });
}
@ -269,9 +315,71 @@ export class GameRenderer {
return 0x1a4a12;
}
private _ensureSprite(
pool: Map<string, SpritePoolEntry>,
poolKey: string,
textureKey: string,
texture: SpritePoolEntry['sprite']['texture'],
layer: Container,
freeList?: Sprite[],
): SpritePoolEntry {
let entry = pool.get(poolKey);
if (!entry) {
const sprite = freeList && freeList.length > 0
? (freeList.pop() as Sprite)
: new Sprite(texture);
sprite.texture = texture;
sprite.anchor.set(0.5, 1);
sprite.roundPixels = true;
if (!sprite.parent) layer.addChild(sprite);
entry = { sprite, textureKey };
pool.set(poolKey, entry);
return entry;
}
if (entry.textureKey !== textureKey) {
entry.sprite.texture = texture;
entry.textureKey = textureKey;
}
return entry;
}
private _hideUnusedSprites(pool: Map<string, SpritePoolEntry>, used: Set<string>): void {
for (const [key, entry] of pool) {
entry.sprite.visible = used.has(key);
}
}
private _evictSpritesOutsideTileBounds(
pool: Map<string, SpritePoolEntry>,
minX: number,
maxX: number,
minY: number,
maxY: number,
freeList: Sprite[],
maxFreeListSize: number,
): void {
for (const [key, entry] of pool) {
const sep = key.indexOf(',');
if (sep < 0) continue;
const wx = Number(key.slice(0, sep));
const wy = Number(key.slice(sep + 1));
if (!Number.isFinite(wx) || !Number.isFinite(wy)) continue;
if (wx >= minX && wx <= maxX && wy >= minY && wy <= maxY) continue;
pool.delete(key);
const sprite = entry.sprite;
sprite.visible = false;
if (freeList.length < maxFreeListSize) {
freeList.push(sprite);
} else {
sprite.destroy();
}
}
}
/** Called from GameEngine when towns or route waypoints change. */
setWorldTerrainContext(ctx: WorldTerrainContext | null): void {
this._worldTerrainContext = ctx;
this._groundDirty = true;
}
constructor() {
@ -281,6 +389,9 @@ export class GameRenderer {
this.entityLayer = new Container();
this.effectLayer = new Container();
this.uiContainer = new Container();
this._groundSpriteLayer = new Container();
this._objectSpriteLayer = new Container();
this._buildingSpriteLayer = new Container();
}
get initialized(): boolean {
@ -305,6 +416,14 @@ export class GameRenderer {
canvasContainer.appendChild(this.app.canvas as HTMLCanvasElement);
try {
await this._spriteRegistry.loadAll();
this._spritesReady = this._spriteRegistry.ready;
} catch (error) {
this._spritesReady = false;
console.warn('[Renderer] Sprite preload failed, using fallback graphics.', error);
}
// Build scene graph
this.worldContainer.addChild(this.groundLayer);
this.worldContainer.addChild(this.entityLayer);
@ -318,9 +437,20 @@ export class GameRenderer {
this.worldContainer.scale.set(MAP_ZOOM);
// Create reusable graphics objects
this.groundLayer.sortableChildren = true;
this.groundLayer.addChild(this._groundSpriteLayer);
this._groundSpriteLayer.zIndex = 0;
this._groundGfx = new Graphics();
this._groundGfx.zIndex = 1;
this.groundLayer.addChild(this._groundGfx);
this.groundLayer.addChild(this._objectSpriteLayer);
this._objectSpriteLayer.zIndex = 2;
this.groundLayer.addChild(this._buildingSpriteLayer);
this._buildingSpriteLayer.zIndex = 3;
this._heroGfx = new Graphics();
this.entityLayer.addChild(this._heroGfx);
@ -401,8 +531,13 @@ export class GameRenderer {
// Town graphics (drawn between ground and entity layers)
this._townGfx = new Graphics();
this._townGfx.zIndex = 4;
this.groundLayer.addChild(this._townGfx);
this._townIconGfx = new Graphics();
this._townIconGfx.zIndex = 5;
this.groundLayer.addChild(this._townIconGfx);
// NPC graphics (drawn in entity layer for depth sorting)
this._npcGfx = new Graphics();
this.entityLayer.addChild(this._npcGfx);
@ -432,7 +567,6 @@ export class GameRenderer {
drawGround(camera: Camera, screenWidth: number, screenHeight: number): void {
const gfx = this._groundGfx;
if (!gfx) return;
gfx.clear();
const cx = camera.finalX;
const cy = camera.finalY;
@ -469,16 +603,63 @@ export class GameRenderer {
const startY = Math.floor(minWY);
const endY = Math.ceil(maxWY);
const spritesReady = this._spritesReady;
const last = this._lastGroundBounds;
const sameBounds =
last &&
last.startX === startX &&
last.endX === endX &&
last.startY === startY &&
last.endY === endY;
if (!this._groundDirty && sameBounds && this._lastSpritesReady === spritesReady) {
return;
}
this._groundDirty = false;
this._lastGroundBounds = { startX, endX, startY, endY };
this._lastSpritesReady = spritesReady;
gfx.clear();
const hw = TILE_WIDTH / 2;
const hh = TILE_HEIGHT / 2;
const terrainCtx = this._worldTerrainContext;
const usedTileSprites = new Set<string>();
const usedObjectSprites = new Set<string>();
// Pass 1: tiles
for (let wx = startX; wx <= endX; wx++) {
for (let wy = startY; wy <= endY; wy++) {
const terrain = proceduralTerrain(wx, wy, terrainCtx);
const iso = worldToScreen(wx, wy);
if (
Math.abs(iso.x - cx) > halfW + hw ||
Math.abs(iso.y - cy) > halfH + hh
) {
continue;
}
const terrain = proceduralTerrain(wx, wy, terrainCtx);
const dark = (wx + wy) % 2 === 0;
const textureKey = spritesReady ? terrainToTextureKey(terrain) : null;
const texture = textureKey ? this._spriteRegistry.getTexture(textureKey) : null;
if (textureKey && texture) {
const poolKey = `${wx},${wy}`;
usedTileSprites.add(poolKey);
const entry = this._ensureSprite(
this._tileSpritePool,
poolKey,
textureKey,
texture,
this._groundSpriteLayer,
this._tileSpriteFreeList,
);
entry.sprite.x = iso.x;
entry.sprite.y = iso.y + hh;
const texW = entry.sprite.texture.orig.width || entry.sprite.texture.width || TILE_WIDTH;
const scale = (TILE_WIDTH / texW) * TERRAIN_SEAM_BLEED_SCALE;
entry.sprite.scale.set(scale);
entry.sprite.visible = true;
} else {
const color = this._terrainColors(terrain, dark);
gfx.poly([
@ -502,6 +683,7 @@ export class GameRenderer {
});
}
}
}
// Pass 2: objects (drawn after tiles so they layer on top)
// Slightly expanded object bounds prevent enlarged props from edge clipping.
@ -512,11 +694,34 @@ export class GameRenderer {
const objectEndY = endY + objectPaddingTiles;
for (let wx = objectStartX; wx <= objectEndX; wx++) {
for (let wy = objectStartY; wy <= objectEndY; wy++) {
const iso = worldToScreen(wx, wy);
if (
Math.abs(iso.x - cx) > halfW + TILE_WIDTH * 1.5 ||
Math.abs(iso.y - cy) > halfH + TILE_HEIGHT * 2
) {
continue;
}
const terrainHere = proceduralTerrain(wx, wy, terrainCtx);
const obj = proceduralObject(wx, wy, terrainHere, terrainCtx);
if (!obj) continue;
const iso = worldToScreen(wx, wy);
const variant = tileHash(wx, wy, 999);
const objTextureKey = spritesReady ? objectToTextureKey(obj, variant) : null;
const objTexture = objTextureKey ? this._spriteRegistry.getTexture(objTextureKey) : null;
if (objTextureKey && objTexture) {
const poolKey = `${wx},${wy}`;
usedObjectSprites.add(poolKey);
const entry = this._ensureSprite(
this._objectSpritePool,
poolKey,
objTextureKey,
objTexture,
this._objectSpriteLayer,
this._objectSpriteFreeList,
);
entry.sprite.x = iso.x;
entry.sprite.y = iso.y;
entry.sprite.visible = true;
} else {
if (obj === 'tree') this._drawTree(gfx, iso.x, iso.y, variant);
else if (obj === 'bush') this._drawBush(gfx, iso.x, iso.y, variant);
else if (obj === 'rock') this._drawRock(gfx, iso.x, iso.y, variant);
@ -534,6 +739,34 @@ export class GameRenderer {
}
}
if (spritesReady) {
this._hideUnusedSprites(this._tileSpritePool, usedTileSprites);
this._hideUnusedSprites(this._objectSpritePool, usedObjectSprites);
const evictPaddingTiles = 8;
this._evictSpritesOutsideTileBounds(
this._tileSpritePool,
startX - evictPaddingTiles,
endX + evictPaddingTiles,
startY - evictPaddingTiles,
endY + evictPaddingTiles,
this._tileSpriteFreeList,
2800,
);
this._evictSpritesOutsideTileBounds(
this._objectSpritePool,
objectStartX - evictPaddingTiles,
objectEndX + evictPaddingTiles,
objectStartY - evictPaddingTiles,
objectEndY + evictPaddingTiles,
this._objectSpriteFreeList,
1800,
);
} else {
this._hideUnusedSprites(this._tileSpritePool, new Set());
this._hideUnusedSprites(this._objectSpritePool, new Set());
}
}
/**
* Shared adventurer figure for local hero and meet opponent (tint differs for readability).
*/
@ -602,13 +835,38 @@ export class GameRenderer {
drawHero(wx: number, wy: number, phase: 'walk' | 'fight' | 'idle', now: number): void {
const gfx = this._heroGfx;
if (!gfx) return;
const { cy, iso } = this.paintHeroSilhouette(gfx, wx, wy, phase, now, 'self');
const iso = worldToScreen(wx, wy);
const textureKey = this._spritesReady ? heroTextureKey() : null;
const texture = textureKey ? this._spriteRegistry.getTexture(textureKey) : null;
let cy = iso.y;
if (textureKey && texture) {
gfx.clear();
const entry = this._ensureSprite(
this._characterSpritePool,
'hero',
textureKey,
texture,
this.entityLayer,
);
entry.sprite.x = iso.x;
entry.sprite.y = iso.y;
entry.sprite.scale.set(0.80);
entry.sprite.zIndex = iso.y + 100;
entry.sprite.visible = true;
cy = iso.y;
} else {
const painted = this.paintHeroSilhouette(gfx, wx, wy, phase, now, 'self');
cy = painted.cy;
const entry = this._characterSpritePool.get('hero');
if (entry) entry.sprite.visible = false;
}
const nameTxt = this._heroNameText;
if (nameTxt && this._heroName) {
nameTxt.text = this._heroName;
nameTxt.x = iso.x;
nameTxt.y = iso.y - 42;
nameTxt.y = iso.y - 92;
nameTxt.visible = true;
nameTxt.zIndex = cy + 199;
}
@ -621,7 +879,32 @@ export class GameRenderer {
const gfx = this._meetPartnerGfx;
const lbl = this._meetPartnerLabel;
if (!gfx || !lbl) return;
const { cy, iso } = this.paintHeroSilhouette(gfx, wx, wy, 'idle', now, 'meet_partner');
const iso = worldToScreen(wx, wy);
const textureKey = this._spritesReady ? meetPartnerTextureKey() : null;
const texture = textureKey ? this._spriteRegistry.getTexture(textureKey) : null;
let cy = iso.y;
if (textureKey && texture) {
gfx.clear();
const entry = this._ensureSprite(
this._characterSpritePool,
'meet_partner',
textureKey,
texture,
this.entityLayer,
);
entry.sprite.x = iso.x;
entry.sprite.y = iso.y;
entry.sprite.scale.set(0.80);
entry.sprite.zIndex = iso.y + 100;
entry.sprite.visible = true;
cy = iso.y;
} else {
const painted = this.paintHeroSilhouette(gfx, wx, wy, 'idle', now, 'meet_partner');
cy = painted.cy;
const entry = this._characterSpritePool.get('meet_partner');
if (entry) entry.sprite.visible = false;
}
lbl.text = `${name} Lv.${level}`;
lbl.x = iso.x;
lbl.y = iso.y - 42;
@ -631,52 +914,100 @@ export class GameRenderer {
clearMeetPartner(): void {
this._meetPartnerGfx?.clear();
const entry = this._characterSpritePool.get('meet_partner');
if (entry) entry.sprite.visible = false;
if (this._meetPartnerLabel) {
this._meetPartnerLabel.visible = false;
}
}
/**
* Draw a small camp (A-frame tent + campfire) near the hero during wilderness rest (wild phase).
* Placed slightly behind the hero in screen space for a bivouac read.
* Wilderness rest: tent + fire + bag as separate transparent sprites around the hero (wild phase).
*/
drawRestCamp(wx: number, wy: number, now: number): void {
const gfx = this._restCampGfx;
if (!gfx) return;
gfx.clear();
const iso = worldToScreen(wx, wy);
const bob = Math.sin(now * 0.004) * 1.0;
const z = iso.y + 92;
const hideRestCampSprites = (): void => {
for (const key of ['rest_camp_tent', 'rest_camp_fire', 'rest_camp_bag'] as const) {
const e = this._characterSpritePool.get(key);
if (e) e.sprite.visible = false;
}
};
if (this._spritesReady) {
const [tentKey, fireKey, bagKey] = restCampTextureKeys();
const tentTex = this._spriteRegistry.getTexture(tentKey);
const fireTex = this._spriteRegistry.getTexture(fireKey);
const bagTex = this._spriteRegistry.getTexture(bagKey);
if (tentTex && fireTex && bagTex) {
gfx.clear();
const b = bob * 0.35;
const place = (
poolKey: string,
textureKey: string,
texture: Texture,
x: number,
y: number,
scale: number,
): void => {
const entry = this._ensureSprite(
this._characterSpritePool,
poolKey,
textureKey,
texture,
this.entityLayer,
);
entry.sprite.anchor.set(0.5, 1);
entry.sprite.x = x;
entry.sprite.y = y + b;
entry.sprite.scale.set(scale);
entry.sprite.zIndex = z;
entry.sprite.visible = true;
};
place('rest_camp_tent', tentKey, tentTex, iso.x - 56, iso.y - 6, 2.1);
place('rest_camp_fire', fireKey, fireTex, iso.x + 48, iso.y + 6, 1.5);
place('rest_camp_bag', bagKey, bagTex, iso.x - 42, iso.y + 26, 1.1);
return;
}
}
// --- Tent (screen-left of hero, reads “behind” in iso) ---
const tx = iso.x - 26;
const ty = iso.y - 4 + bob * 0.4;
hideRestCampSprites();
gfx.clear();
// --- Tent (screen-left of hero, reads “behind” in iso); ~2× size vs old fallback ---
const tx = iso.x - 54;
const ty = iso.y - 6 + bob * 0.4;
// Ground shadow under tent
gfx.ellipse(tx, ty + 14, 22, 7);
gfx.ellipse(tx, ty + 28, 44, 14);
gfx.fill({ color: 0x000000, alpha: 0.18 });
// Tent body (trapezoid wall + triangle roof)
gfx.poly([tx - 18, ty + 12, tx + 18, ty + 12, tx + 14, ty - 8, tx - 14, ty - 8]);
gfx.poly([tx - 36, ty + 24, tx + 36, ty + 24, tx + 28, ty - 16, tx - 28, ty - 16]);
gfx.fill({ color: 0x8b6914, alpha: 0.92 });
gfx.poly([tx - 14, ty - 8, tx, ty - 22, tx + 14, ty - 8]);
gfx.poly([tx - 28, ty - 16, tx, ty - 44, tx + 28, ty - 16]);
gfx.fill({ color: 0xc4a574, alpha: 0.96 });
gfx.poly([tx - 14, ty - 8, tx, ty - 22, tx + 14, ty - 8]);
gfx.poly([tx - 28, ty - 16, tx, ty - 44, tx + 28, ty - 16]);
gfx.stroke({ color: 0x5c4030, width: 1.2, alpha: 0.85 });
gfx.rect(tx - 5, ty + 2, 10, 10);
gfx.rect(tx - 10, ty + 4, 20, 20);
gfx.fill({ color: 0x1a1510, alpha: 0.55 });
// Guy lines / pegs (tiny)
gfx.moveTo(tx - 18, ty + 12);
gfx.lineTo(tx - 26, ty + 16);
gfx.moveTo(tx - 36, ty + 24);
gfx.lineTo(tx - 52, ty + 32);
gfx.stroke({ color: 0x4a3a2a, width: 1, alpha: 0.5 });
gfx.moveTo(tx + 18, ty + 12);
gfx.lineTo(tx + 26, ty + 16);
gfx.moveTo(tx + 36, ty + 24);
gfx.lineTo(tx + 52, ty + 32);
gfx.stroke({ color: 0x4a3a2a, width: 1, alpha: 0.5 });
// --- Campfire (near tent / hero) ---
const cx = iso.x + 16;
const cy = iso.y + 10 + bob;
// --- Campfire (pushed right; center clear for hero) ---
const cx = iso.x + 42;
const cy = iso.y + 8 + bob;
gfx.ellipse(cx, cy + 6, 12, 4);
gfx.fill({ color: 0x000000, alpha: 0.22 });
@ -701,6 +1032,10 @@ export class GameRenderer {
clearRestCamp(): void {
if (this._restCampGfx) this._restCampGfx.clear();
for (const key of ['rest_camp_tent', 'rest_camp_fire', 'rest_camp_bag'] as const) {
const e = this._characterSpritePool.get(key);
if (e) e.sprite.visible = false;
}
}
/**
@ -717,9 +1052,45 @@ export class GameRenderer {
): void {
const gfx = this._enemyGfx;
if (!gfx) return;
const iso = worldToScreen(wx, wy);
const sway = Math.sin(now * 0.004) * 2;
const cx = iso.x;
const cy = iso.y + sway;
const southKey = enemySouthTextureKey(enemySlug);
const tex = this._spritesReady ? this._spriteRegistry.getTexture(southKey) : null;
if (tex) {
const entry = this._ensureSprite(
this._characterSpritePool,
'enemy_combat',
southKey,
tex,
this.entityLayer,
);
entry.sprite.anchor.set(0.5, 1);
entry.sprite.x = cx;
entry.sprite.y = cy;
const th = tex.height || 48;
const targetH = 52;
entry.sprite.scale.set(targetH / th);
entry.sprite.zIndex = cy + 100;
entry.sprite.visible = true;
drawEnemyHpBarOnly(gfx, enemySlug, enemyArchetype, cx, cy, hp, maxHp);
return;
}
const pooled = this._characterSpritePool.get('enemy_combat');
if (pooled) pooled.sprite.visible = false;
drawEnemyBySlug(gfx, wx, wy, hp, maxHp, enemySlug, enemyArchetype, now, worldToScreen);
}
clearEnemyCombat(): void {
if (this._enemyGfx) this._enemyGfx.clear();
const pooled = this._characterSpritePool.get('enemy_combat');
if (pooled) pooled.sprite.visible = false;
}
/**
* Draw a white rounded-rect thought bubble above the hero with a small
* downward-pointing triangle. Fades in over 300ms and fades out when the
@ -733,8 +1104,7 @@ export class GameRenderer {
const elapsed = now - startMs;
// Fade in over 300ms
const fadeIn = Math.min(1, elapsed / 300);
const alpha = fadeIn;
const alpha = Math.min(1, elapsed / 300);
if (alpha <= 0) {
txt.visible = false;
return;
@ -920,6 +1290,8 @@ export class GameRenderer {
*/
private _drawServerBuildings(
gfx: Graphics,
iconGfx: Graphics,
usedBuildingSprites: Set<string>,
buildings: BuildingData[],
_townScreenX: number,
_townScreenY: number,
@ -936,34 +1308,48 @@ export class GameRenderer {
const rh = 32 * scale;
const bt = b.buildingType;
const spriteKey = this._spritesReady ? buildingTypeToTextureKey(bt) : null;
const spriteTexture = spriteKey ? this._spriteRegistry.getTexture(spriteKey) : null;
if (spriteKey && spriteTexture) {
const poolKey = `building:${b.id}`;
usedBuildingSprites.add(poolKey);
const entry = this._ensureSprite(
this._buildingSpritePool,
poolKey,
spriteKey,
spriteTexture,
this._buildingSpriteLayer,
);
entry.sprite.x = bx;
entry.sprite.y = by;
const texW = entry.sprite.texture.width || w;
const scaleFactor = texW > 0 ? w / texW : 1;
entry.sprite.scale.set(scaleFactor);
entry.sprite.zIndex = by;
entry.sprite.visible = true;
}
const hasSprite = Boolean(spriteKey && spriteTexture);
if (!hasSprite) {
if (bt === 'house.quest_giver') {
this._drawHouse(gfx, bx, by, w, h, rh, 0xb89040, 0x6a3a22, 0);
this._drawFence(gfx, bx, by, w, 'left');
this._drawBuildingIcon(gfx, bx, by - h - rh * 0.5, '!', 0xffd700, scale);
} else if (bt === 'house.merchant') {
this._drawHouse(gfx, bx, by, w * 1.1, h, rh * 0.8, 0x44aa55, 0x2a5a30, 1);
this._drawTownStall(gfx, bx + w * 0.7, by + 4, scale * 0.6);
this._drawBuildingIcon(gfx, bx, by - h - rh * 0.3, '$', 0x88dd88, scale);
} else if (bt === 'house.armorer') {
this._drawHouse(gfx, bx, by, w * 1.05, h, rh * 0.85, 0x5a6e8a, 0x2a3548, 1);
this._drawBuildingIcon(gfx, bx, by - h - rh * 0.4, 'A', 0xaaccff, scale);
} else if (bt === 'house.weapon_smith') {
this._drawHouse(gfx, bx, by, w * 1.05, h, rh * 0.85, 0x8a5a3a, 0x4a3020, 1);
this._drawBuildingIcon(gfx, bx, by - h - rh * 0.35, 'W', 0xffaa66, scale);
} else if (bt === 'house.jeweler') {
this._drawHouse(gfx, bx, by, w, h * 0.95, rh * 0.9, 0x7a4a9a, 0x3a2050, 2);
this._drawBuildingIcon(gfx, bx, by - h - rh * 0.45, 'J', 0xdd88ff, scale);
} else if (bt === 'house.bounty_hunter') {
this._drawHouse(gfx, bx, by, w, h, rh, 0x906040, 0x4a2818, 0);
this._drawFence(gfx, bx, by, w, 'right');
this._drawBuildingIcon(gfx, bx, by - h - rh * 0.5, 'B', 0xffcc44, scale);
} else if (bt === 'house.elder') {
this._drawHouse(gfx, bx, by, w * 0.98, h, rh * 1.05, 0x9a8860, 0x5a4830, 0);
this._drawBuildingIcon(gfx, bx, by - h - rh * 0.5, 'E', 0xeeddaa, scale);
} else if (bt === 'house.healer') {
this._drawHouse(gfx, bx, by, w, h, rh, 0xccccdd, 0x5555aa, 2);
this._drawBuildingIcon(gfx, bx, by - h - rh * 0.5, '+', 0xff6666, scale);
} else if (bt === 'decoration.well') {
this._drawTownWell(gfx, bx, by, scale);
} else if (bt === 'decoration.stall') {
@ -972,6 +1358,25 @@ export class GameRenderer {
this._drawSignpost(gfx, bx, by, scale);
}
}
if (bt === 'house.quest_giver') {
this._drawBuildingIcon(iconGfx, bx, by - h - rh * 0.5, '!', 0xffd700, scale);
} else if (bt === 'house.merchant') {
this._drawBuildingIcon(iconGfx, bx, by - h - rh * 0.3, '$', 0x88dd88, scale);
} else if (bt === 'house.armorer') {
this._drawBuildingIcon(iconGfx, bx, by - h - rh * 0.4, 'A', 0xaaccff, scale);
} else if (bt === 'house.weapon_smith') {
this._drawBuildingIcon(iconGfx, bx, by - h - rh * 0.35, 'W', 0xffaa66, scale);
} else if (bt === 'house.jeweler') {
this._drawBuildingIcon(iconGfx, bx, by - h - rh * 0.45, 'J', 0xdd88ff, scale);
} else if (bt === 'house.bounty_hunter') {
this._drawBuildingIcon(iconGfx, bx, by - h - rh * 0.5, 'B', 0xffcc44, scale);
} else if (bt === 'house.elder') {
this._drawBuildingIcon(iconGfx, bx, by - h - rh * 0.5, 'E', 0xeeddaa, scale);
} else if (bt === 'house.healer') {
this._drawBuildingIcon(iconGfx, bx, by - h - rh * 0.5, '+', 0xff6666, scale);
}
}
}
/** Draw a small icon circle above a building to indicate its purpose. */
@ -989,7 +1394,7 @@ export class GameRenderer {
gfx.ellipse(cx, cy, 10 * s, 5 * s);
gfx.fill({ color: 0x6a6a7a, alpha: 0.8 });
gfx.stroke({ color: 0x4a4a5a, width: 1.5, alpha: 0.6 });
gfx.rect(cx - 1 * s, cy - 12 * s, 2 * s, 12 * s);
gfx.rect(cx - s, cy - 12 * s, 2 * s, 12 * s);
gfx.fill({ color: 0x5a4a3a, alpha: 0.9 });
gfx.rect(cx - 6 * s, cy - 13 * s, 12 * s, 2 * s);
gfx.fill({ color: 0x5a4a3a, alpha: 0.9 });
@ -1064,7 +1469,7 @@ export class GameRenderer {
/** Draw a signpost decoration. */
private _drawSignpost(gfx: Graphics, cx: number, cy: number, s: number): void {
gfx.rect(cx - 1 * s, cy - 16 * s, 2 * s, 16 * s);
gfx.rect(cx - s, cy - 16 * s, 2 * s, 16 * s);
gfx.fill({ color: 0x6a5a3a, alpha: 0.9 });
gfx.poly([
cx + 2 * s, cy - 14 * s,
@ -1140,8 +1545,10 @@ export class GameRenderer {
*/
drawTowns(towns: TownData[], camera: Camera, screenWidth: number, screenHeight: number): void {
const gfx = this._townGfx;
if (!gfx) return;
const iconGfx = this._townIconGfx;
if (!gfx || !iconGfx) return;
gfx.clear();
iconGfx.clear();
// Hide all existing labels first; we'll show visible ones below
for (const lbl of this._townLabels) {
@ -1155,6 +1562,7 @@ export class GameRenderer {
let labelIdx = 0;
const usedBuildingSprites = new Set<string>();
for (const town of towns) {
// Convert town world position to screen space
const townScreen = worldToScreen(town.centerX, town.centerY);
@ -1208,7 +1616,7 @@ export class GameRenderer {
// --- Buildings: server-driven if available, fallback procedural ---
if (town.buildings && town.buildings.length > 0) {
this._drawServerBuildings(gfx, town.buildings, tx, ty, s);
this._drawServerBuildings(gfx, iconGfx, usedBuildingSprites, town.buildings, tx, ty, s);
this._drawCivicBuilding(gfx, civicScreen.x, civicScreen.y, s);
} else {
this._drawProceduralBuildings(gfx, tx, ty, s, spread, town.size, townSeed);
@ -1252,6 +1660,8 @@ export class GameRenderer {
label.zIndex = ty - 500;
labelIdx++;
}
this._hideUnusedSprites(this._buildingSpritePool, usedBuildingSprites);
}
/**
@ -1279,6 +1689,7 @@ export class GameRenderer {
const halfH = screenHeight / (2 * MAP_ZOOM) + TILE_HEIGHT * 3;
let labelIdx = 0;
const usedNpcSprites = new Set<string>();
for (const npc of npcs) {
const iso = worldToScreen(npc.worldX, npc.worldY);
@ -1298,6 +1709,26 @@ export class GameRenderer {
gfx.ellipse(cx, cy + 8, 10, 3.5);
gfx.fill({ color: 0x000000, alpha: 0.22 });
const npcTextureKey = this._spritesReady ? npcTypeToTextureKey(npc.type) : null;
const npcTexture = npcTextureKey ? this._spriteRegistry.getTexture(npcTextureKey) : null;
const hasSprite = Boolean(npcTextureKey && npcTexture);
if (npcTextureKey && npcTexture) {
const poolKey = `npc:${npc.id}`;
usedNpcSprites.add(poolKey);
const entry = this._ensureSprite(
this._npcSpritePool,
poolKey,
npcTextureKey,
npcTexture,
this.entityLayer,
);
entry.sprite.x = cx;
entry.sprite.y = cy;
entry.sprite.scale.set(1);
entry.sprite.zIndex = cy + 90;
entry.sprite.visible = true;
} else {
// NPC body diamond (type-specific color)
let bodyColor: number;
let bodyStroke: number;
@ -1446,6 +1877,42 @@ export class GameRenderer {
gfx.zIndex = cy + 100;
}
// NPC name label below for sprite case
if (hasSprite) {
let nameLabel: Text;
if (labelIdx < this._npcLabels.length) {
nameLabel = this._npcLabels[labelIdx]!;
} else {
if (this._npcLabelPool.length > 0) {
nameLabel = this._npcLabelPool.pop()!;
} else {
nameLabel = new Text({
text: '',
style: new TextStyle({
fontSize: 10,
fontFamily: 'system-ui, sans-serif',
fill: 0xcccccc,
stroke: { color: 0x000000, width: 2 },
align: 'center',
}),
});
nameLabel.anchor.set(0.5, 0.5);
}
this.entityLayer.addChild(nameLabel);
this._npcLabels.push(nameLabel);
}
nameLabel.text = npc.name;
nameLabel.x = cx;
nameLabel.y = cy + 6;
nameLabel.visible = true;
nameLabel.zIndex = cy + 101;
labelIdx++;
}
}
this._hideUnusedSprites(this._npcSpritePool, usedNpcSprites);
}
/** Clear NPC visuals when there are none to render */
@ -1454,6 +1921,7 @@ export class GameRenderer {
for (const lbl of this._npcLabels) {
lbl.visible = false;
}
this._hideUnusedSprites(this._npcSpritePool, new Set());
}
/**
@ -1563,8 +2031,7 @@ export class GameRenderer {
continue;
}
const elapsed = now - item.startMs;
const fadeIn = Math.min(1, elapsed / 200);
const alpha = fadeIn;
const alpha = Math.min(1, elapsed / 200);;
if (alpha <= 0) {
txt.visible = false;
gfx.clear();
@ -1615,6 +2082,9 @@ export class GameRenderer {
/** Sort entity layer by y-position for correct isometric depth */
sortEntities(): void {
const now = performance.now();
if (now - this._lastEntitySortMs < this._entitySortIntervalMs) return;
this._lastEntitySortMs = now;
this.entityLayer.sortableChildren = true;
this.entityLayer.children.sort((a, b) => (a.zIndex ?? a.y) - (b.zIndex ?? b.y));
}

Loading…
Cancel
Save