fix tiles overlap

master
Denis Ranneft 1 month ago
parent c1dc5dc770
commit d9d8e72933

@ -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__<name>` 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 AutoHeros **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.<slug>.south` in `manifest.json` and writes `enemies/enemy.<slug>.south.png`.
Implementation: `frontend/scripts/pixellab-enemy-south-batch.mjs` (uses `POST /v2/map-objects`, polls `https://api.pixellab.ai/mcp/map-objects/{id}/download`).

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

@ -440,6 +440,7 @@ export class GameRenderer {
this.groundLayer.sortableChildren = true; this.groundLayer.sortableChildren = true;
this.groundLayer.addChild(this._groundSpriteLayer); this.groundLayer.addChild(this._groundSpriteLayer);
this._groundSpriteLayer.zIndex = 0; this._groundSpriteLayer.zIndex = 0;
this._groundSpriteLayer.sortableChildren = true;
this._groundGfx = new Graphics(); this._groundGfx = new Graphics();
this._groundGfx.zIndex = 1; this._groundGfx.zIndex = 1;
@ -447,9 +448,11 @@ export class GameRenderer {
this.groundLayer.addChild(this._objectSpriteLayer); this.groundLayer.addChild(this._objectSpriteLayer);
this._objectSpriteLayer.zIndex = 2; this._objectSpriteLayer.zIndex = 2;
this._objectSpriteLayer.sortableChildren = true;
this.groundLayer.addChild(this._buildingSpriteLayer); this.groundLayer.addChild(this._buildingSpriteLayer);
this._buildingSpriteLayer.zIndex = 3; this._buildingSpriteLayer.zIndex = 3;
this._buildingSpriteLayer.sortableChildren = true;
this._heroGfx = new Graphics(); this._heroGfx = new Graphics();
this.entityLayer.addChild(this._heroGfx); this.entityLayer.addChild(this._heroGfx);
@ -655,6 +658,7 @@ export class GameRenderer {
); );
entry.sprite.x = iso.x; entry.sprite.x = iso.x;
entry.sprite.y = iso.y + hh; 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 texW = entry.sprite.texture.orig.width || entry.sprite.texture.width || TILE_WIDTH;
const scale = (TILE_WIDTH / texW) * TERRAIN_SEAM_BLEED_SCALE; const scale = (TILE_WIDTH / texW) * TERRAIN_SEAM_BLEED_SCALE;
entry.sprite.scale.set(scale); entry.sprite.scale.set(scale);
@ -720,6 +724,7 @@ export class GameRenderer {
); );
entry.sprite.x = iso.x; entry.sprite.x = iso.x;
entry.sprite.y = iso.y; entry.sprite.y = iso.y;
entry.sprite.zIndex = iso.y;
entry.sprite.visible = true; entry.sprite.visible = true;
} else { } else {
if (obj === 'tree') this._drawTree(gfx, iso.x, iso.y, variant); if (obj === 'tree') this._drawTree(gfx, iso.x, iso.y, variant);

Loading…
Cancel
Save