diff --git a/frontend/public/assets/game/manifest.json b/frontend/public/assets/game/manifest.json index 1fe5246..615727f 100644 --- a/frontend/public/assets/game/manifest.json +++ b/frontend/public/assets/game/manifest.json @@ -1,5 +1,5 @@ { - "version": 13, + "version": 20, "assetsRoot": "frontend/assets", "note": "file paths relative to frontend/assets. Rest camp: prop.camp_tent/fire/bag.v0 (wild rest). Other props + heroes + NPC.", "textures": { @@ -659,72 +659,86 @@ "enemy.boar_l5_5_canyon.south": { "file": "enemies/enemy.boar_l5_5_canyon.south.png", "kind": "map_object", - "rotation": "south" + "rotation": "south", + "pixellabObjectId": "e0a21c3e-f78a-4e13-b09b-14d9a4dea9b8" }, "enemy.boar_l5_5_swamp.south": { "file": "enemies/enemy.boar_l5_5_swamp.south.png", "kind": "map_object", - "rotation": "south" + "rotation": "south", + "pixellabObjectId": "0f3bdb7e-a9ce-48eb-b177-b0f753a527ed" }, "enemy.boar_l6_6_volcanic.south": { "file": "enemies/enemy.boar_l6_6_volcanic.south.png", "kind": "map_object", - "rotation": "south" + "rotation": "south", + "pixellabObjectId": "344f7710-9aa0-4ba7-ae6c-4f4b1a66f053" }, "enemy.boar_l6_6_astral.south": { "file": "enemies/enemy.boar_l6_6_astral.south.png", "kind": "map_object", - "rotation": "south" + "rotation": "south", + "pixellabObjectId": "ab9e6ea1-b63d-4c93-9853-8814bae2d2b3" }, "enemy.zombie_l3_4_meadow.south": { "file": "enemies/enemy.zombie_l3_4_meadow.south.png", "kind": "map_object", - "rotation": "south" + "rotation": "south", + "pixellabObjectId": "5e55c635-8947-407d-a86c-b508dbcc72b6" }, "enemy.zombie_l3_4_forest.south": { "file": "enemies/enemy.zombie_l3_4_forest.south.png", "kind": "map_object", - "rotation": "south" + "rotation": "south", + "pixellabObjectId": "e33f6775-7399-4087-8baa-520cb17101f9" }, "enemy.zombie_l5_5_forest.south": { "file": "enemies/enemy.zombie_l5_5_forest.south.png", "kind": "map_object", - "rotation": "south" + "rotation": "south", + "pixellabObjectId": "79169a55-37f0-46b3-b988-d358498163a6" }, "enemy.zombie_l5_5_ruins.south": { "file": "enemies/enemy.zombie_l5_5_ruins.south.png", "kind": "map_object", - "rotation": "south" + "rotation": "south", + "pixellabObjectId": "ca489425-7ff8-41f1-aebc-4dce32e74fd5" }, "enemy.zombie_l6_6_ruins.south": { "file": "enemies/enemy.zombie_l6_6_ruins.south.png", "kind": "map_object", - "rotation": "south" + "rotation": "south", + "pixellabObjectId": "96b5dacc-f089-43d3-92f2-45f826c64cad" }, "enemy.zombie_l6_6_canyon.south": { "file": "enemies/enemy.zombie_l6_6_canyon.south.png", "kind": "map_object", - "rotation": "south" + "rotation": "south", + "pixellabObjectId": "9890b8a8-fed4-4fc1-b79f-7c9d2ebb0c60" }, "enemy.zombie_l7_7_canyon.south": { "file": "enemies/enemy.zombie_l7_7_canyon.south.png", "kind": "map_object", - "rotation": "south" + "rotation": "south", + "pixellabObjectId": "fbc52c9e-f2ba-4737-9eda-358a4cc19531" }, "enemy.zombie_l7_7_swamp.south": { "file": "enemies/enemy.zombie_l7_7_swamp.south.png", "kind": "map_object", - "rotation": "south" + "rotation": "south", + "pixellabObjectId": "2cb494a5-46ed-4390-b8d6-0c71ba4c02b0" }, "enemy.zombie_l8_8_volcanic.south": { "file": "enemies/enemy.zombie_l8_8_volcanic.south.png", "kind": "map_object", - "rotation": "south" + "rotation": "south", + "pixellabObjectId": "2981d74e-06e5-45c4-8401-88bd121b0543" }, "enemy.zombie_l8_8_astral.south": { "file": "enemies/enemy.zombie_l8_8_astral.south.png", "kind": "map_object", - "rotation": "south" + "rotation": "south", + "pixellabObjectId": "a1700116-d83f-4c04-9fe6-79d7e0f8d2c4" }, "enemy.spider_l4_5_meadow.south": { "file": "enemies/enemy.spider_l4_5_meadow.south.png", diff --git a/frontend/src/game/renderer.ts b/frontend/src/game/renderer.ts index 6a025d2..8c61422 100644 --- a/frontend/src/game/renderer.ts +++ b/frontend/src/game/renderer.ts @@ -106,10 +106,11 @@ export class GameRenderer { private _usedTileSprites = new Set(); private _usedObjectSprites = new Set(); private _emptySpriteSet = new Set(); - private _groundTerrainCache = new Map(); private _buildingSpritePool = new Map(); private _characterSpritePool = new Map(); private _npcSpritePool = new Map(); + private _usedBuildingSprites = new Set(); + private _usedNpcSprites = new Set(); private _groundDirty = true; private _lastGroundBounds: | { @@ -157,6 +158,13 @@ export class GameRenderer { private _lastEntitySortMs = 0; private _entitySortIntervalMs = 120; + private _townDrawDirty = true; + private _lastTownDrawMs = 0; + private _townDrawIntervalMs = 120; + private _lastTownCameraX = Number.NaN; + private _lastTownCameraY = Number.NaN; + private _lastTownsRef: TownData[] | null = null; + private _lastTownCount = -1; private _drawBush(gfx: Graphics, x: number, y: number, variant: number): void { const s = (0.9 + variant * 0.25) * 3.5; @@ -571,6 +579,7 @@ export class GameRenderer { this.worldContainer.x = viewport.width / 2; this.worldContainer.y = viewport.height / 2; this.worldContainer.scale.set(MAP_ZOOM); + this._townDrawDirty = true; } /** @@ -641,25 +650,40 @@ export class GameRenderer { const usedObjectSprites = this._usedObjectSprites; usedTileSprites.clear(); usedObjectSprites.clear(); - const terrainCache = this._groundTerrainCache; - terrainCache.clear(); + const tileMinX = cx - (halfW + hw); + const tileMaxX = cx + (halfW + hw); + const tileMinY = cy - (halfH + hh); + const tileMaxY = cy + (halfH + hh); + + // Pass 2 needs slightly expanded bounds for oversized props. + const objectPaddingTiles = 4; + const objectStartX = startX - objectPaddingTiles; + const objectEndX = endX + objectPaddingTiles; + const objectStartY = startY - objectPaddingTiles; + const objectEndY = endY + objectPaddingTiles; + const objectGridH = objectEndY - objectStartY + 1; + const terrainGrid = new Array( + (objectEndX - objectStartX + 1) * objectGridH, + ); + const terrainIndex = (wx: number, wy: number): number => + (wx - objectStartX) * objectGridH + (wy - objectStartY); const terrainAt = (wx: number, wy: number): string => { - const key = `${wx},${wy}`; - const cached = terrainCache.get(key); - if (cached) return cached; + const idx = terrainIndex(wx, wy); + const cached = terrainGrid[idx]; + if (cached !== undefined) return cached; const terrain = proceduralTerrain(wx, wy, terrainCtx); - terrainCache.set(key, terrain); + terrainGrid[idx] = terrain; return terrain; }; // Pass 1: tiles for (let wx = startX; wx <= endX; wx++) { + let isoX = (wx - startY) * hw; + let isoY = (wx + startY) * hh; for (let wy = startY; wy <= endY; wy++) { - const iso = worldToScreen(wx, wy); - if ( - Math.abs(iso.x - cx) > halfW + hw || - Math.abs(iso.y - cy) > halfH + hh - ) { + if (isoX < tileMinX || isoX > tileMaxX || isoY < tileMinY || isoY > tileMaxY) { + isoX -= hw; + isoY += hh; continue; } const terrain = terrainAt(wx, wy); @@ -680,9 +704,9 @@ export class GameRenderer { wy, this._tileSpriteFreeList, ); - entry.sprite.x = iso.x; - entry.sprite.y = iso.y + hh; - entry.sprite.zIndex = iso.y + hh; + entry.sprite.x = isoX; + entry.sprite.y = isoY + hh; + entry.sprite.zIndex = isoY + 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); @@ -691,18 +715,18 @@ export class GameRenderer { 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, + isoX, isoY - hh, + isoX + hw, isoY, + isoX, isoY + hh, + isoX - hw, isoY, ]); 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, + isoX, isoY - hh, + isoX + hw, isoY, + isoX, isoY + hh, + isoX - hw, isoY, ]); gfx.stroke({ color: this._terrainStrokeColor(terrain), @@ -710,28 +734,33 @@ export class GameRenderer { alpha: 0.25, }); } + isoX -= hw; + isoY += hh; } } // Pass 2: objects (drawn after tiles so they layer on top) // Slightly expanded object bounds prevent enlarged props from edge clipping. - const objectPaddingTiles = 4; - const objectStartX = startX - objectPaddingTiles; - const objectEndX = endX + objectPaddingTiles; - const objectStartY = startY - objectPaddingTiles; - const objectEndY = endY + objectPaddingTiles; + const objectMinX = cx - (halfW + TILE_WIDTH * 1.5); + const objectMaxX = cx + (halfW + TILE_WIDTH * 1.5); + const objectMinY = cy - (halfH + TILE_HEIGHT * 2); + const objectMaxY = cy + (halfH + TILE_HEIGHT * 2); for (let wx = objectStartX; wx <= objectEndX; wx++) { + let isoX = (wx - objectStartY) * hw; + let isoY = (wx + objectStartY) * hh; 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 - ) { + if (isoX < objectMinX || isoX > objectMaxX || isoY < objectMinY || isoY > objectMaxY) { + isoX -= hw; + isoY += hh; continue; } const terrainHere = terrainAt(wx, wy); const obj = proceduralObject(wx, wy, terrainHere, terrainCtx); - if (!obj) continue; + if (!obj) { + isoX -= hw; + isoY += hh; + continue; + } const variant = tileHash(wx, wy, 999); const objTextureKey = spritesReady ? objectToTextureKey(obj, variant) : null; const objTexture = objTextureKey ? this._spriteRegistry.getTexture(objTextureKey) : null; @@ -748,25 +777,27 @@ export class GameRenderer { wy, this._objectSpriteFreeList, ); - entry.sprite.x = iso.x; - entry.sprite.y = iso.y; - entry.sprite.zIndex = iso.y; + entry.sprite.x = isoX; + entry.sprite.y = isoY; + entry.sprite.zIndex = isoY; 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 (obj === 'tree') this._drawTree(gfx, isoX, isoY, variant); + else if (obj === 'bush') this._drawBush(gfx, isoX, isoY, variant); + else if (obj === 'rock') this._drawRock(gfx, isoX, isoY, variant); + else if (obj === 'stump') this._drawStump(gfx, isoX, isoY, variant); + else if (obj === 'cart') this._drawBrokenCart(gfx, isoX, isoY, variant); + else if (obj === 'bones') this._drawBones(gfx, isoX, isoY, variant); + else if (obj === 'mushroom') this._drawMushroom(gfx, isoX, isoY, variant); + else if (obj === 'ruin') this._drawRuin(gfx, isoX, isoY, variant); + else if (obj === 'stall') this._drawMarketStall(gfx, isoX, isoY, variant); + else if (obj === 'well') this._drawWell(gfx, isoX, isoY, variant); + else if (obj === 'banner') this._drawBanner(gfx, isoX, isoY, variant); + else if (obj === 'barrel') this._drawBarrel(gfx, isoX, isoY, variant); + else if (obj === 'leaves') this._drawLeafPile(gfx, isoX, isoY, variant); } + isoX -= hw; + isoY += hh; } } @@ -1578,6 +1609,28 @@ export class GameRenderer { const gfx = this._townGfx; const iconGfx = this._townIconGfx; if (!gfx || !iconGfx) return; + const cx = camera.finalX; + const cy = camera.finalY; + if (this._lastTownsRef !== towns || this._lastTownCount !== towns.length) { + this._townDrawDirty = true; + this._lastTownsRef = towns; + this._lastTownCount = towns.length; + } + const now = performance.now(); + const dx = Math.abs(cx - this._lastTownCameraX); + const dy = Math.abs(cy - this._lastTownCameraY); + if ( + !this._townDrawDirty && + now - this._lastTownDrawMs < this._townDrawIntervalMs && + dx < TILE_WIDTH && + dy < TILE_HEIGHT + ) { + return; + } + this._townDrawDirty = false; + this._lastTownDrawMs = now; + this._lastTownCameraX = cx; + this._lastTownCameraY = cy; gfx.clear(); iconGfx.clear(); @@ -1586,14 +1639,13 @@ export class GameRenderer { lbl.visible = false; } - const cx = camera.finalX; - const cy = camera.finalY; const halfW = screenWidth / (2 * MAP_ZOOM) + TILE_WIDTH * 10; const halfH = screenHeight / (2 * MAP_ZOOM) + TILE_HEIGHT * 10; let labelIdx = 0; - const usedBuildingSprites = new Set(); + const usedBuildingSprites = this._usedBuildingSprites; + usedBuildingSprites.clear(); for (const town of towns) { // Convert town world position to screen space const townScreen = worldToScreen(town.centerX, town.centerY); @@ -1720,7 +1772,8 @@ export class GameRenderer { const halfH = screenHeight / (2 * MAP_ZOOM) + TILE_HEIGHT * 3; let labelIdx = 0; - const usedNpcSprites = new Set(); + const usedNpcSprites = this._usedNpcSprites; + usedNpcSprites.clear(); for (const npc of npcs) { const iso = worldToScreen(npc.worldX, npc.worldY);