diff --git a/.gitignore b/.gitignore index f4fc41e..93bc933 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,7 @@ backend/vendor/ frontend/node_modules/ frontend/dist/ frontend/.vite/ +frontend/scripts/.enemy-south-pixellab-state.json # IDE .idea/ diff --git a/docs/pixellab-mcp-schema.md b/docs/pixellab-mcp-schema.md index 119f13f..f47baf7 100644 --- a/docs/pixellab-mcp-schema.md +++ b/docs/pixellab-mcp-schema.md @@ -57,3 +57,13 @@ Less central to AutoHero’s **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..south` in `manifest.json` and writes `enemies/enemy..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`). diff --git a/frontend/assets/enemies/enemy.boar_l2_2_forest.south.png b/frontend/assets/enemies/enemy.boar_l2_2_forest.south.png new file mode 100644 index 0000000..9ca3056 Binary files /dev/null and b/frontend/assets/enemies/enemy.boar_l2_2_forest.south.png differ diff --git a/frontend/assets/enemies/enemy.boar_l2_2_meadow.south.png b/frontend/assets/enemies/enemy.boar_l2_2_meadow.south.png new file mode 100644 index 0000000..bfc4dc7 Binary files /dev/null and b/frontend/assets/enemies/enemy.boar_l2_2_meadow.south.png differ diff --git a/frontend/assets/enemies/enemy.boar_l3_3_forest.south.png b/frontend/assets/enemies/enemy.boar_l3_3_forest.south.png new file mode 100644 index 0000000..6def0d5 Binary files /dev/null and b/frontend/assets/enemies/enemy.boar_l3_3_forest.south.png differ diff --git a/frontend/assets/enemies/enemy.boar_l3_3_ruins.south.png b/frontend/assets/enemies/enemy.boar_l3_3_ruins.south.png new file mode 100644 index 0000000..afe9797 Binary files /dev/null and b/frontend/assets/enemies/enemy.boar_l3_3_ruins.south.png differ diff --git a/frontend/assets/enemies/enemy.boar_l4_4_canyon.south.png b/frontend/assets/enemies/enemy.boar_l4_4_canyon.south.png new file mode 100644 index 0000000..0476061 Binary files /dev/null and b/frontend/assets/enemies/enemy.boar_l4_4_canyon.south.png differ diff --git a/frontend/assets/enemies/enemy.boar_l4_4_ruins.south.png b/frontend/assets/enemies/enemy.boar_l4_4_ruins.south.png new file mode 100644 index 0000000..e7f8bd0 Binary files /dev/null and b/frontend/assets/enemies/enemy.boar_l4_4_ruins.south.png differ diff --git a/frontend/assets/enemies/enemy.wolf_l1_1_forest.south.png b/frontend/assets/enemies/enemy.wolf_l1_1_forest.south.png new file mode 100644 index 0000000..8dc816c Binary files /dev/null and b/frontend/assets/enemies/enemy.wolf_l1_1_forest.south.png differ diff --git a/frontend/assets/enemies/enemy.wolf_l1_1_meadow.south.png b/frontend/assets/enemies/enemy.wolf_l1_1_meadow.south.png new file mode 100644 index 0000000..82121eb Binary files /dev/null and b/frontend/assets/enemies/enemy.wolf_l1_1_meadow.south.png differ diff --git a/frontend/assets/enemies/enemy.wolf_l2_2_forest.south.png b/frontend/assets/enemies/enemy.wolf_l2_2_forest.south.png new file mode 100644 index 0000000..5dab946 Binary files /dev/null and b/frontend/assets/enemies/enemy.wolf_l2_2_forest.south.png differ diff --git a/frontend/assets/enemies/enemy.wolf_l2_2_ruins.south.png b/frontend/assets/enemies/enemy.wolf_l2_2_ruins.south.png new file mode 100644 index 0000000..f80282e Binary files /dev/null and b/frontend/assets/enemies/enemy.wolf_l2_2_ruins.south.png differ diff --git a/frontend/assets/enemies/enemy.wolf_l3_3_canyon.south.png b/frontend/assets/enemies/enemy.wolf_l3_3_canyon.south.png new file mode 100644 index 0000000..0926bb2 Binary files /dev/null and b/frontend/assets/enemies/enemy.wolf_l3_3_canyon.south.png differ diff --git a/frontend/assets/enemies/enemy.wolf_l3_3_ruins.south.png b/frontend/assets/enemies/enemy.wolf_l3_3_ruins.south.png new file mode 100644 index 0000000..23f6f0f Binary files /dev/null and b/frontend/assets/enemies/enemy.wolf_l3_3_ruins.south.png differ diff --git a/frontend/assets/enemies/enemy.wolf_l4_4_canyon.south.png b/frontend/assets/enemies/enemy.wolf_l4_4_canyon.south.png new file mode 100644 index 0000000..1e0b9c5 Binary files /dev/null and b/frontend/assets/enemies/enemy.wolf_l4_4_canyon.south.png differ diff --git a/frontend/assets/enemies/enemy.wolf_l4_4_swamp.south.png b/frontend/assets/enemies/enemy.wolf_l4_4_swamp.south.png new file mode 100644 index 0000000..474e950 Binary files /dev/null and b/frontend/assets/enemies/enemy.wolf_l4_4_swamp.south.png differ diff --git a/frontend/assets/enemies/enemy.wolf_l5_5_astral.south.png b/frontend/assets/enemies/enemy.wolf_l5_5_astral.south.png new file mode 100644 index 0000000..65a2a7e Binary files /dev/null and b/frontend/assets/enemies/enemy.wolf_l5_5_astral.south.png differ diff --git a/frontend/assets/enemies/enemy.wolf_l5_5_volcanic.south.png b/frontend/assets/enemies/enemy.wolf_l5_5_volcanic.south.png new file mode 100644 index 0000000..b52cf49 Binary files /dev/null and b/frontend/assets/enemies/enemy.wolf_l5_5_volcanic.south.png differ diff --git a/frontend/assets/prop/prop.bones.v0.png b/frontend/assets/prop/prop.bones.v0.png new file mode 100644 index 0000000..7989330 Binary files /dev/null and b/frontend/assets/prop/prop.bones.v0.png differ diff --git a/frontend/assets/prop/prop.bones.v1.png b/frontend/assets/prop/prop.bones.v1.png new file mode 100644 index 0000000..cde961f Binary files /dev/null and b/frontend/assets/prop/prop.bones.v1.png differ diff --git a/frontend/assets/prop/prop.bush.v0.png b/frontend/assets/prop/prop.bush.v0.png new file mode 100644 index 0000000..818f243 Binary files /dev/null and b/frontend/assets/prop/prop.bush.v0.png differ diff --git a/frontend/assets/prop/prop.bush.v1.png b/frontend/assets/prop/prop.bush.v1.png new file mode 100644 index 0000000..a992afb Binary files /dev/null and b/frontend/assets/prop/prop.bush.v1.png differ diff --git a/frontend/assets/prop/prop.camp_bag.v0.png b/frontend/assets/prop/prop.camp_bag.v0.png new file mode 100644 index 0000000..18b5488 Binary files /dev/null and b/frontend/assets/prop/prop.camp_bag.v0.png differ diff --git a/frontend/assets/prop/prop.camp_fire.v0.png b/frontend/assets/prop/prop.camp_fire.v0.png new file mode 100644 index 0000000..63f54e0 Binary files /dev/null and b/frontend/assets/prop/prop.camp_fire.v0.png differ diff --git a/frontend/assets/prop/prop.camp_tent.v0.png b/frontend/assets/prop/prop.camp_tent.v0.png new file mode 100644 index 0000000..d4f57c2 Binary files /dev/null and b/frontend/assets/prop/prop.camp_tent.v0.png differ diff --git a/frontend/assets/prop/prop.leaves.v0.png b/frontend/assets/prop/prop.leaves.v0.png new file mode 100644 index 0000000..64c1189 Binary files /dev/null and b/frontend/assets/prop/prop.leaves.v0.png differ diff --git a/frontend/assets/prop/prop.leaves.v1.png b/frontend/assets/prop/prop.leaves.v1.png new file mode 100644 index 0000000..9bbba25 Binary files /dev/null and b/frontend/assets/prop/prop.leaves.v1.png differ diff --git a/frontend/assets/prop/prop.mushroom.v0.png b/frontend/assets/prop/prop.mushroom.v0.png new file mode 100644 index 0000000..037c96c Binary files /dev/null and b/frontend/assets/prop/prop.mushroom.v0.png differ diff --git a/frontend/assets/prop/prop.mushroom.v1.png b/frontend/assets/prop/prop.mushroom.v1.png new file mode 100644 index 0000000..bc6919c Binary files /dev/null and b/frontend/assets/prop/prop.mushroom.v1.png differ diff --git a/frontend/assets/prop/prop.rest_camp.wild.png b/frontend/assets/prop/prop.rest_camp.wild.png new file mode 100644 index 0000000..5c1c1b3 Binary files /dev/null and b/frontend/assets/prop/prop.rest_camp.wild.png differ diff --git a/frontend/assets/prop/prop.ruin.v0.png b/frontend/assets/prop/prop.ruin.v0.png new file mode 100644 index 0000000..06d31a4 Binary files /dev/null and b/frontend/assets/prop/prop.ruin.v0.png differ diff --git a/frontend/assets/prop/prop.ruin.v1.png b/frontend/assets/prop/prop.ruin.v1.png new file mode 100644 index 0000000..12b4a1a Binary files /dev/null and b/frontend/assets/prop/prop.ruin.v1.png differ diff --git a/frontend/assets/prop/prop.stump.v0.png b/frontend/assets/prop/prop.stump.v0.png new file mode 100644 index 0000000..a53f560 Binary files /dev/null and b/frontend/assets/prop/prop.stump.v0.png differ diff --git a/frontend/assets/prop/prop.stump.v1.png b/frontend/assets/prop/prop.stump.v1.png new file mode 100644 index 0000000..1d2b928 Binary files /dev/null and b/frontend/assets/prop/prop.stump.v1.png differ diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 0dc4167..16a2a94 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -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", diff --git a/frontend/package.json b/frontend/package.json index fd9b3c6..ca901e3 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -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" } diff --git a/frontend/public/assets/game/manifest.json b/frontend/public/assets/game/manifest.json index beb9f56..dcf6125 100644 --- a/frontend/public/assets/game/manifest.json +++ b/frontend/public/assets/game/manifest.json @@ -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", diff --git a/frontend/scripts/pixellab-enemy-south-batch.mjs b/frontend/scripts/pixellab-enemy-south-batch.mjs new file mode 100644 index 0000000..a338d9d --- /dev/null +++ b/frontend/scripts/pixellab-enemy-south-batch.mjs @@ -0,0 +1,347 @@ +/** + * Batch-generate enemy..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..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..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); +}); diff --git a/frontend/src/game/assets/gameSpriteRegistry.ts b/frontend/src/game/assets/gameSpriteRegistry.ts new file mode 100644 index 0000000..1fb2546 --- /dev/null +++ b/frontend/src/game/assets/gameSpriteRegistry.ts @@ -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(); + private _ready = false; + + private _buildFallbackManifest(keys: string[]): GameTextureManifest { + const textures: Record = {}; + 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 { + 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(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; + } +} diff --git a/frontend/src/game/assets/resolveGameAssetUrl.ts b/frontend/src/game/assets/resolveGameAssetUrl.ts index 177f4f3..58d420e 100644 --- a/frontend/src/game/assets/resolveGameAssetUrl.ts +++ b/frontend/src/game/assets/resolveGameAssetUrl.ts @@ -3,7 +3,7 @@ * Keep in sync with [public/assets/game/manifest.json](../../../../public/assets/game/manifest.json). */ -const raw = import.meta.glob('../../assets/**/*.png', { eager: true, as: 'url' }); +const raw = import.meta.glob('../../../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; } -export async function fetchGameTextureManifest(url = '/assets/game/manifest.json'): Promise { - const res = await fetch(url); +async function fetchManifestJson(resolvedUrl: string): Promise { + const res = await fetch(resolvedUrl); if (!res.ok) throw new Error(`Game manifest fetch failed: ${res.status}`); - return res.json() as Promise; + 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 { + 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; + } } diff --git a/frontend/src/game/assets/spriteMapping.ts b/frontend/src/game/assets/spriteMapping.ts new file mode 100644 index 0000000..335b5cc --- /dev/null +++ b/frontend/src/game/assets/spriteMapping.ts @@ -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 = { + 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 = { + 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 = { + 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 = { + '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, + ]), + ]; +} diff --git a/frontend/src/game/camera.ts b/frontend/src/game/camera.ts index 199e765..e5d97ab 100644 --- a/frontend/src/game/camera.ts +++ b/frontend/src/game/camera.ts @@ -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); } } diff --git a/frontend/src/game/enemyVisuals.ts b/frontend/src/game/enemyVisuals.ts index e295c4e..945d7e2 100644 --- a/frontend/src/game/enemyVisuals.ts +++ b/frontend/src/game/enemyVisuals.ts @@ -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 // --------------------------------------------------------------------------- diff --git a/frontend/src/game/engine.ts b/frontend/src/game/engine.ts index dc45cdc..16f6e73 100644 --- a/frontend/src/game/engine.ts +++ b/frontend/src/game/engine.ts @@ -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 diff --git a/frontend/src/game/renderer.ts b/frontend/src/game/renderer.ts index d7414d2..7de473c 100644 --- a/frontend/src/game/renderer.ts +++ b/frontend/src/game/renderer.ts @@ -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(); + private _objectSpritePool = new Map(); + private _tileSpriteFreeList: Sprite[] = []; + private _objectSpriteFreeList: Sprite[] = []; + private _buildingSpritePool = new Map(); + private _characterSpritePool = new Map(); + private _npcSpritePool = new Map(); + 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, + 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, used: Set): void { + for (const [key, entry] of pool) { + entry.sprite.visible = used.has(key); + } + } + + private _evictSpritesOutsideTileBounds( + pool: Map, + 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,37 +603,85 @@ 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(); + const usedObjectSprites = new Set(); // 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 color = this._terrainColors(terrain, dark); - - gfx.poly([ - iso.x, iso.y - hh, - iso.x + hw, iso.y, - iso.x, iso.y + hh, - iso.x - hw, iso.y, - ]); - gfx.fill({ color, alpha: 1 }); - - gfx.poly([ - iso.x, iso.y - hh, - iso.x + hw, iso.y, - iso.x, iso.y + hh, - iso.x - hw, iso.y, - ]); - gfx.stroke({ - color: this._terrainStrokeColor(terrain), - width: 1, - alpha: 0.25, - }); + 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([ + iso.x, iso.y - hh, + iso.x + hw, iso.y, + iso.x, iso.y + hh, + iso.x - hw, iso.y, + ]); + gfx.fill({ color, alpha: 1 }); + + gfx.poly([ + iso.x, iso.y - hh, + iso.x + hw, iso.y, + iso.x, iso.y + hh, + iso.x - hw, iso.y, + ]); + gfx.stroke({ + color: this._terrainStrokeColor(terrain), + width: 1, + alpha: 0.25, + }); + } } } @@ -512,26 +694,77 @@ 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); - 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); - else if (obj === 'stump') this._drawStump(gfx, iso.x, iso.y, variant); - else if (obj === 'cart') this._drawBrokenCart(gfx, iso.x, iso.y, variant); - else if (obj === 'bones') this._drawBones(gfx, iso.x, iso.y, variant); - else if (obj === 'mushroom') this._drawMushroom(gfx, iso.x, iso.y, variant); - else if (obj === 'ruin') this._drawRuin(gfx, iso.x, iso.y, variant); - else if (obj === 'stall') this._drawMarketStall(gfx, iso.x, iso.y, variant); - else if (obj === 'well') this._drawWell(gfx, iso.x, iso.y, variant); - else if (obj === 'banner') this._drawBanner(gfx, iso.x, iso.y, variant); - else if (obj === 'barrel') this._drawBarrel(gfx, iso.x, iso.y, variant); - else if (obj === 'leaves') this._drawLeafPile(gfx, iso.x, iso.y, variant); + 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); + else if (obj === 'stump') this._drawStump(gfx, iso.x, iso.y, variant); + else if (obj === 'cart') this._drawBrokenCart(gfx, iso.x, iso.y, variant); + else if (obj === 'bones') this._drawBones(gfx, iso.x, iso.y, variant); + else if (obj === 'mushroom') this._drawMushroom(gfx, iso.x, iso.y, variant); + else if (obj === 'ruin') this._drawRuin(gfx, iso.x, iso.y, variant); + else if (obj === 'stall') this._drawMarketStall(gfx, iso.x, iso.y, variant); + else if (obj === 'well') this._drawWell(gfx, iso.x, iso.y, variant); + else if (obj === 'banner') this._drawBanner(gfx, iso.x, iso.y, variant); + else if (obj === 'barrel') this._drawBarrel(gfx, iso.x, iso.y, variant); + else if (obj === 'leaves') this._drawLeafPile(gfx, iso.x, iso.y, variant); + } } } + + 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()); + } } /** @@ -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; + } + } + + hideRestCampSprites(); + gfx.clear(); - // --- Tent (screen-left of hero, reads “behind” in iso) --- - const tx = iso.x - 26; - const ty = iso.y - 4 + bob * 0.4; + // --- 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, buildings: BuildingData[], _townScreenX: number, _townScreenY: number, @@ -936,40 +1308,73 @@ 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'); + } 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); + } else if (bt === 'house.armorer') { + this._drawHouse(gfx, bx, by, w * 1.05, h, rh * 0.85, 0x5a6e8a, 0x2a3548, 1); + } else if (bt === 'house.weapon_smith') { + this._drawHouse(gfx, bx, by, w * 1.05, h, rh * 0.85, 0x8a5a3a, 0x4a3020, 1); + } else if (bt === 'house.jeweler') { + this._drawHouse(gfx, bx, by, w, h * 0.95, rh * 0.9, 0x7a4a9a, 0x3a2050, 2); + } else if (bt === 'house.bounty_hunter') { + this._drawHouse(gfx, bx, by, w, h, rh, 0x906040, 0x4a2818, 0); + this._drawFence(gfx, bx, by, w, 'right'); + } else if (bt === 'house.elder') { + this._drawHouse(gfx, bx, by, w * 0.98, h, rh * 1.05, 0x9a8860, 0x5a4830, 0); + } else if (bt === 'house.healer') { + this._drawHouse(gfx, bx, by, w, h, rh, 0xccccdd, 0x5555aa, 2); + } else if (bt === 'decoration.well') { + this._drawTownWell(gfx, bx, by, scale); + } else if (bt === 'decoration.stall') { + this._drawTownStall(gfx, bx, by, scale * 0.9); + } else if (bt === 'decoration.signpost') { + this._drawSignpost(gfx, bx, by, scale); + } + } 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); + this._drawBuildingIcon(iconGfx, 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); + this._drawBuildingIcon(iconGfx, 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); + this._drawBuildingIcon(iconGfx, 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); + this._drawBuildingIcon(iconGfx, 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); + this._drawBuildingIcon(iconGfx, 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); + this._drawBuildingIcon(iconGfx, 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); + this._drawBuildingIcon(iconGfx, 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') { - this._drawTownStall(gfx, bx, by, scale * 0.9); - } else if (bt === 'decoration.signpost') { - this._drawSignpost(gfx, bx, by, scale); + this._drawBuildingIcon(iconGfx, bx, by - h - rh * 0.5, '+', 0xff6666, scale); } } } @@ -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(); 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(); for (const npc of npcs) { const iso = worldToScreen(npc.worldX, npc.worldY); @@ -1298,154 +1709,210 @@ export class GameRenderer { gfx.ellipse(cx, cy + 8, 10, 3.5); gfx.fill({ color: 0x000000, alpha: 0.22 }); - // NPC body diamond (type-specific color) - let bodyColor: number; - let bodyStroke: number; - let iconText: string; - let iconColor: number; - - switch (npc.type) { - case 'quest_giver': - case 'bounty_hunter': - bodyColor = 0xdaa520; - bodyStroke = 0x8a6510; - iconText = '!'; - iconColor = 0xffd700; - break; - case 'elder': - bodyColor = 0xc4a574; - bodyStroke = 0x7a6040; - iconText = '\u2020'; - iconColor = 0xeeddaa; - break; - case 'merchant': - bodyColor = 0x44aa55; - bodyStroke = 0x2a7a3a; - iconText = '$'; - iconColor = 0x88dd88; - break; - case 'armorer': - bodyColor = 0x5a7a9a; - bodyStroke = 0x304560; - iconText = '\u25C9'; - iconColor = 0xaaccff; - break; - case 'weapon': - bodyColor = 0xaa6633; - bodyStroke = 0x6a3818; - iconText = '\u2694'; - iconColor = 0xffaa66; - break; - case 'jeweler': - bodyColor = 0x8844aa; - bodyStroke = 0x502060; - iconText = '\u2666'; - iconColor = 0xdd88ff; - break; - case 'healer': - bodyColor = 0xdddddd; - bodyStroke = 0x8888aa; - iconText = '+'; - iconColor = 0xff6666; - break; - default: - bodyColor = 0x8888aa; - bodyStroke = 0x555577; - iconText = '?'; - iconColor = 0xaaaacc; - } + 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; + let iconText: string; + let iconColor: number; + + switch (npc.type) { + case 'quest_giver': + case 'bounty_hunter': + bodyColor = 0xdaa520; + bodyStroke = 0x8a6510; + iconText = '!'; + iconColor = 0xffd700; + break; + case 'elder': + bodyColor = 0xc4a574; + bodyStroke = 0x7a6040; + iconText = '\u2020'; + iconColor = 0xeeddaa; + break; + case 'merchant': + bodyColor = 0x44aa55; + bodyStroke = 0x2a7a3a; + iconText = '$'; + iconColor = 0x88dd88; + break; + case 'armorer': + bodyColor = 0x5a7a9a; + bodyStroke = 0x304560; + iconText = '\u25C9'; + iconColor = 0xaaccff; + break; + case 'weapon': + bodyColor = 0xaa6633; + bodyStroke = 0x6a3818; + iconText = '\u2694'; + iconColor = 0xffaa66; + break; + case 'jeweler': + bodyColor = 0x8844aa; + bodyStroke = 0x502060; + iconText = '\u2666'; + iconColor = 0xdd88ff; + break; + case 'healer': + bodyColor = 0xdddddd; + bodyStroke = 0x8888aa; + iconText = '+'; + iconColor = 0xff6666; + break; + default: + bodyColor = 0x8888aa; + bodyStroke = 0x555577; + iconText = '?'; + iconColor = 0xaaaacc; + } - // Diamond body - const ds = 0.85; - gfx.poly([ - cx, cy - 18 * ds, - cx + 10 * ds, cy - 2 * ds, - cx, cy + 8 * ds, - cx - 10 * ds, cy - 2 * ds, - ]); - gfx.fill({ color: bodyColor, alpha: 0.75 }); - gfx.stroke({ color: bodyStroke, width: 1.5, alpha: 0.7 }); + // Diamond body + const ds = 0.85; + gfx.poly([ + cx, cy - 18 * ds, + cx + 10 * ds, cy - 2 * ds, + cx, cy + 8 * ds, + cx - 10 * ds, cy - 2 * ds, + ]); + gfx.fill({ color: bodyColor, alpha: 0.75 }); + gfx.stroke({ color: bodyStroke, width: 1.5, alpha: 0.7 }); - // Head circle - gfx.circle(cx, cy - 16 * ds, 5 * ds); - gfx.fill({ color: bodyColor, alpha: 0.6 }); + // Head circle + gfx.circle(cx, cy - 16 * ds, 5 * ds); + gfx.fill({ color: bodyColor, alpha: 0.6 }); - // Floating icon above head - const iconBob = Math.sin(now * 0.004 + npc.id * 2.3) * 2; - const iconY = cy - 28 * ds + iconBob; + // Floating icon above head + const iconBob = Math.sin(now * 0.004 + npc.id * 2.3) * 2; + const iconY = cy - 28 * ds + iconBob; - // Icon background circle - gfx.circle(cx, iconY, 7); - gfx.fill({ color: 0x000000, alpha: 0.35 }); + // Icon background circle + gfx.circle(cx, iconY, 7); + gfx.fill({ color: 0x000000, alpha: 0.35 }); - // We use Text for the icon character - // (drawn as part of label pool) + // We use Text for the icon character + // (drawn as part of label pool) - // NPC name label below - let nameLabel: Text; - if (labelIdx < this._npcLabels.length) { - nameLabel = this._npcLabels[labelIdx]!; - } else { - if (this._npcLabelPool.length > 0) { - nameLabel = this._npcLabelPool.pop()!; + // NPC name label below + let nameLabel: Text; + if (labelIdx < this._npcLabels.length) { + nameLabel = this._npcLabels[labelIdx]!; } 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); + 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); } - this.entityLayer.addChild(nameLabel); - this._npcLabels.push(nameLabel); - } - - nameLabel.text = npc.name; - nameLabel.x = cx; - nameLabel.y = cy + 14; - nameLabel.visible = true; - nameLabel.zIndex = cy + 101; - labelIdx++; - // Icon label (reusing label pool pattern: +1 entry for the icon) - let iconLabel: Text; - if (labelIdx < this._npcLabels.length) { - iconLabel = this._npcLabels[labelIdx]!; - } else { - if (this._npcLabelPool.length > 0) { - iconLabel = this._npcLabelPool.pop()!; + nameLabel.text = npc.name; + nameLabel.x = cx; + nameLabel.y = cy + 14; + nameLabel.visible = true; + nameLabel.zIndex = cy + 101; + labelIdx++; + + // Icon label (reusing label pool pattern: +1 entry for the icon) + let iconLabel: Text; + if (labelIdx < this._npcLabels.length) { + iconLabel = this._npcLabels[labelIdx]!; } else { - iconLabel = new Text({ - text: '', - style: new TextStyle({ - fontSize: 12, - fontFamily: 'system-ui, sans-serif', - fontWeight: 'bold', - fill: 0xffffff, - align: 'center', - }), - }); - iconLabel.anchor.set(0.5, 0.5); + if (this._npcLabelPool.length > 0) { + iconLabel = this._npcLabelPool.pop()!; + } else { + iconLabel = new Text({ + text: '', + style: new TextStyle({ + fontSize: 12, + fontFamily: 'system-ui, sans-serif', + fontWeight: 'bold', + fill: 0xffffff, + align: 'center', + }), + }); + iconLabel.anchor.set(0.5, 0.5); + } + this.entityLayer.addChild(iconLabel); + this._npcLabels.push(iconLabel); } - this.entityLayer.addChild(iconLabel); - this._npcLabels.push(iconLabel); + + iconLabel.text = iconText; + iconLabel.style.fill = iconColor; + iconLabel.x = cx; + iconLabel.y = iconY; + iconLabel.visible = true; + iconLabel.zIndex = cy + 201; + labelIdx++; + + gfx.zIndex = cy + 100; } - iconLabel.text = iconText; - iconLabel.style.fill = iconColor; - iconLabel.x = cx; - iconLabel.y = iconY; - iconLabel.visible = true; - iconLabel.zIndex = cy + 201; - labelIdx++; + // 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); + } - gfx.zIndex = cy + 100; + 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)); }