diff --git a/frontend/src/game/renderer.ts b/frontend/src/game/renderer.ts index f351de0..f0f7a5b 100644 --- a/frontend/src/game/renderer.ts +++ b/frontend/src/game/renderer.ts @@ -56,6 +56,13 @@ const MAX_STATIC_GRAPHICS_CACHE_AREA = 2_500_000; const TERRAIN_SEAM_BLEED_SCALE = 1.002; +/** + * Snap camera center (px, same space as worldToScreen) when computing visible tile indices. + * Without this, sub-pixel camera follow changes floor(minWX)/ceil(maxWX) often and forces a full + * tile/object pass + pool scans every frame while walking. + */ +const GROUND_CAMERA_BOUNDS_QUANTIZE_PX = TILE_HEIGHT; + /** Convert world (tile) coordinates to screen (pixel) coordinates */ export function worldToScreen(wx: number, wy: number): ScreenPoint { return { @@ -136,6 +143,8 @@ export class GameRenderer { endY: number; } | null = null; + /** Incremented on each full ground rebuild; used to throttle eviction passes. */ + private _groundRebuildCounter = 0; // Reusable Graphics (avoid GC). UI/combat overlays; world tiles are sprite-only. private _heroGfx: Graphics | null = null; @@ -173,7 +182,8 @@ export class GameRenderer { private _usedNearbyHeroSprites = new Set(); private _lastEntitySortMs = 0; - private _entitySortIntervalMs = 120; + /** Depth sort is O(n log n) on entityLayer; ~8 Hz is enough for walking fights. */ + private _entitySortIntervalMs = 200; private _townDrawDirty = true; private _lastTownDrawMs = 0; private _townDrawIntervalMs = 120; @@ -502,17 +512,20 @@ export class GameRenderer { drawGround(camera: Camera, screenWidth: number, screenHeight: number): void { const cx = camera.finalX; const cy = camera.finalY; + const q = GROUND_CAMERA_BOUNDS_QUANTIZE_PX; + const qcx = Math.round(cx / q) * q; + const qcy = Math.round(cy / q) * q; const halfW = screenWidth / (2 * MAP_ZOOM) + TILE_WIDTH * 2; const halfH = screenHeight / (2 * MAP_ZOOM) + TILE_HEIGHT * 2; const worldCorners = [ - screenToWorld(cx - halfW, cy - halfH), - screenToWorld(cx + halfW, cy - halfH), - screenToWorld(cx - halfW, cy + halfH), - screenToWorld(cx + halfW, cy + halfH), + screenToWorld(qcx - halfW, qcy - halfH), + screenToWorld(qcx + halfW, qcy - halfH), + screenToWorld(qcx - halfW, qcy + halfH), + screenToWorld(qcx + halfW, qcy + halfH), ]; - const renderPaddingTiles = 4; + const renderPaddingTiles = 5; let minWX = Number.POSITIVE_INFINITY; let maxWX = Number.NEGATIVE_INFINITY; let minWY = Number.POSITIVE_INFINITY; @@ -548,6 +561,8 @@ export class GameRenderer { this._groundDirty = false; this._lastGroundBounds = { startX, endX, startY, endY }; + this._groundRebuildCounter += 1; + const runTileEviction = (this._groundRebuildCounter & 1) === 0; const hw = TILE_WIDTH / 2; const hh = TILE_HEIGHT / 2; @@ -556,10 +571,10 @@ export class GameRenderer { const usedObjectSprites = this._usedObjectSprites; usedTileSprites.clear(); usedObjectSprites.clear(); - const tileMinX = cx - (halfW + hw); - const tileMaxX = cx + (halfW + hw); - const tileMinY = cy - (halfH + hh); - const tileMaxY = cy + (halfH + hh); + const tileMinX = qcx - (halfW + hw); + const tileMaxX = qcx + (halfW + hw); + const tileMinY = qcy - (halfH + hh); + const tileMaxY = qcy + (halfH + hh); // Pass 2 needs slightly expanded bounds for oversized props. const objectPaddingTiles = 4; @@ -623,10 +638,10 @@ export class GameRenderer { // Pass 2: objects (drawn after tiles so they layer on top) // Slightly expanded object bounds prevent enlarged props from edge clipping. - 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); + const objectMinX = qcx - (halfW + TILE_WIDTH * 1.5); + const objectMaxX = qcx + (halfW + TILE_WIDTH * 1.5); + const objectMinY = qcy - (halfH + TILE_HEIGHT * 2); + const objectMaxY = qcy + (halfH + TILE_HEIGHT * 2); for (let wx = objectStartX; wx <= objectEndX; wx++) { let isoX = (wx - objectStartY) * hw; let isoY = (wx + objectStartY) * hh; @@ -684,25 +699,27 @@ export class GameRenderer { this._objectSpriteFreeList, 1800, ); - 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, - ); + if (runTileEviction) { + 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, + ); + } } /** @@ -717,7 +734,6 @@ export class GameRenderer { let cy = iso.y; if (texture) { - gfx.clear(); const entry = this._ensureSprite( this._characterSpritePool, 'hero', @@ -766,7 +782,6 @@ export class GameRenderer { return; } - gfx.clear(); const entry = this._ensureSprite( this._characterSpritePool, 'meet_partner', @@ -824,7 +839,6 @@ export class GameRenderer { return; } - gfx.clear(); const place = ( poolKey: string, textureKey: string,