diff --git a/docs/pixellab-mcp-schema.md b/docs/pixellab-mcp-schema.md deleted file mode 100644 index f47baf7..0000000 --- a/docs/pixellab-mcp-schema.md +++ /dev/null @@ -1,69 +0,0 @@ -# PixelLab MCP — verified tool surface (AutoHero) - -Reference date: aligned with [https://api.pixellab.ai/mcp/docs](https://api.pixellab.ai/mcp/docs) (auto-generated from FastMCP). **Re-check the live docs** if tools fail or names differ in your Cursor client (tools may appear as `mcp__pixellab__` or similar). - -## Cross-cutting behavior - -- Creation tools **return immediately** with IDs; generation runs in the background (order of minutes). -- Poll with matching **`get_*`** tools until status is **completed** (or handle `processing` / `failed` per response). -- **Do not** treat remote URLs as durable storage for the repo: **download or decode base64 and write PNGs under** `frontend/assets/` (`tiles/`, `prop/`, `building/`, `enemies/`, `characters/`). Register each texture in `frontend/public/assets/game/manifest.json` (`file` paths are relative to `frontend/assets/`). - -## Character and animation - -| Tool | Role | Key parameters | -|------|------|----------------| -| `create_character` | Queue 4/8-direction character | `description`, `n_directions` (4\|8), `size` (default 48), `body_type` (`humanoid`\|`quadruped`), `template` (quadruped: bear, cat, dog, horse, lion), `proportions` (JSON preset), `view` | -| `animate_character` | Queue animation on existing character | `character_id`, `template_animation_id`, optional `action_description`, `confirm_cost` | -| `get_character` | Status, rotations, animations, download | `character_id` | -| `list_characters` | Paginated list | `limit`, `offset`, `tags` | -| `delete_character` | Remove character | `character_id`, `confirm` | - -Per docs: characters are **stored on PixelLab** for reuse; still **export PNGs/ZIP into the repo** for builds and versioning. - -## Isometric tiles - -| Tool | Role | Key parameters | -|------|------|----------------| -| `create_isometric_tile` | Single isometric tile | `description`, `size` (default **32**, docs recommend ≥32), `tile_shape` (`thin`\|`thick`\|`block`), `outline`, `shading`, `detail`, `seed` | -| `get_isometric_tile` | Status + **base64 PNG** / download URL | `tile_id` | -| `list_isometric_tiles` | Paginated list | `limit`, `offset` | -| `delete_isometric_tile` | Delete | `tile_id` | - -## Map objects (transparent props) - -| Tool | Role | Key parameters | -|------|------|----------------| -| `create_map_object` | Prop with alpha | `description`, `width`, `height`, `view`, `outline`, `shading`, `detail`, `background_image`, `inpainting` | -| `get_map_object` | Status and asset data | `object_id` | - -Marketing copy elsewhere mentioned **time-limited hosting** for some assets; **treat all MCP outputs as ephemeral until checked into the project.** - -## Tiles Pro (optional) - -| Tool | Role | Key parameters | -|------|------|----------------| -| `create_tiles_pro` | Batch / pro tiles | `description`, `tile_type` (default `isometric`), `tile_size`, `n_tiles`, `tile_view`, `seed`, `style_images`, … | -| `get_tiles_pro` | Status and data | `tile_id` | -| `list_tiles_pro` | List | `limit`, `offset` | -| `delete_tiles_pro` | Delete | `tile_id` | - -## Top-down and sidescroller Wang tilesets - -- `create_topdown_tileset` / `get_topdown_tileset` / `list_topdown_tilesets` / `delete_topdown_tileset` — corner Wang sets, `tile_size` default 16×16, chain via `lower_base_tile_id`. -- `create_sidescroller_tileset` / `get_sidescroller_tileset` / `list_sidescroller_tilesets` / `delete_sidescroller_tileset` — platformer side-view sets. - -Less central to AutoHero’s **diamond isometric** ground plane but valid for experiments or UI. - -## 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/scripts/pixellab-enemy-south-batch.mjs b/frontend/scripts/pixellab-enemy-south-batch.mjs deleted file mode 100644 index a338d9d..0000000 --- a/frontend/scripts/pixellab-enemy-south-batch.mjs +++ /dev/null @@ -1,347 +0,0 @@ -/** - * 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/renderer.ts b/frontend/src/game/renderer.ts index 7de473c..6a35f22 100644 --- a/frontend/src/game/renderer.ts +++ b/frontend/src/game/renderer.ts @@ -440,6 +440,7 @@ export class GameRenderer { this.groundLayer.sortableChildren = true; this.groundLayer.addChild(this._groundSpriteLayer); this._groundSpriteLayer.zIndex = 0; + this._groundSpriteLayer.sortableChildren = true; this._groundGfx = new Graphics(); this._groundGfx.zIndex = 1; @@ -447,9 +448,11 @@ export class GameRenderer { this.groundLayer.addChild(this._objectSpriteLayer); this._objectSpriteLayer.zIndex = 2; + this._objectSpriteLayer.sortableChildren = true; this.groundLayer.addChild(this._buildingSpriteLayer); this._buildingSpriteLayer.zIndex = 3; + this._buildingSpriteLayer.sortableChildren = true; this._heroGfx = new Graphics(); this.entityLayer.addChild(this._heroGfx); @@ -655,6 +658,7 @@ export class GameRenderer { ); entry.sprite.x = iso.x; entry.sprite.y = iso.y + hh; + entry.sprite.zIndex = 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); @@ -720,6 +724,7 @@ export class GameRenderer { ); entry.sprite.x = iso.x; entry.sprite.y = iso.y; + entry.sprite.zIndex = iso.y; entry.sprite.visible = true; } else { if (obj === 'tree') this._drawTree(gfx, iso.x, iso.y, variant);