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, TownObjectData } from './types';
import { drawEnemyBySlug, drawEnemyHpBarOnly } from './enemyVisuals';
import { GameSpriteRegistry } from './assets/gameSpriteRegistry';
import {
  buildingTypeToTextureKey,
  heroTextureKey,
  MARKET_STALL_TEXTURE_KEY,
  npcTypeToTextureKey,
  objectToTextureKey,
  PLAZA_FOUNTAIN_TEXTURE_KEY,
  PROCEDURAL_TOWN_HOUSE_FACADE_KEYS,
  restCampTextureKeys,
  resolveEnemySouthTextureKey,
  terrainToTextureKey,
  TOWN_HALL_TEXTURE_KEY,
} from './assets/spriteMapping';

/**
 * Isometric coordinate conversion utilities.
 *
 * We use a standard 2:1 isometric projection.
 * World coordinates are in tile units (float).
 * Screen coordinates are in pixels.
 */

export interface ScreenPoint {
  x: number;
  y: number;
}

export interface WorldPoint {
  x: number;
  y: number;
}

type SpritePoolEntry = {
  sprite: Sprite;
  textureKey: string;
  worldX?: number;
  worldY?: number;
  /** Set when sprite is hidden by pooling; cleared when shown again. */
  hiddenSinceMs?: number;
};

const SPRITE_HIDDEN_EVICT_MS = 3000;

/**
 * Max width×height (local px) for baking Graphics into a texture via {@link Graphics#cacheAsTexture}.
 * Procedural ground fills the whole visible tile grid — caching would allocate a huge RenderTexture.
 */
const MAX_STATIC_GRAPHICS_CACHE_AREA = 2_500_000;

const TERRAIN_SEAM_BLEED_SCALE = 1.002;

/** Convert world (tile) coordinates to screen (pixel) coordinates */
export function worldToScreen(wx: number, wy: number): ScreenPoint {
  return {
    x: (wx - wy) * (TILE_WIDTH / 2),
    y: (wx + wy) * (TILE_HEIGHT / 2),
  };
}

/** Convert screen (pixel) coordinates to world (tile) coordinates */
export function screenToWorld(sx: number, sy: number): WorldPoint {
  return {
    x: (sx / (TILE_WIDTH / 2) + sy / (TILE_HEIGHT / 2)) / 2,
    y: (sy / (TILE_HEIGHT / 2) - sx / (TILE_WIDTH / 2)) / 2,
  };
}

// ---- Procedural terrain generation (shared module) ----
import {
  tileHash,
  proceduralTerrain,
  proceduralObject,
  type WorldTerrainContext,
} from './procedural';

/**
 * Main renderer. Wraps a PixiJS Application and manages the scene graph.
 *
 * Terrain follows server towns + road ring, then active route waypoints; wild tiles use nearest town biome.
 *
 * Scene structure:
 *   app.stage
 *     -> worldContainer (moved by camera)
 *       -> groundLayer  (tiles, terrain)
 *       -> entityLayer  (hero, enemies, sorted by depth)
 *       -> effectLayer  (particles, flashes)
 *     -> uiContainer    (fixed HUD elements rendered in Pixi, if any)
 */
export class GameRenderer {
  app: Application;
  worldContainer: Container;
  groundLayer: Container;
  entityLayer: Container;
  effectLayer: Container;
  uiContainer: Container;

  private _initialized = false;

  /** 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 _townObjectSpriteLayer: Container;
  private _tileSpritePool = new Map<string, SpritePoolEntry>();
  private _objectSpritePool = new Map<string, SpritePoolEntry>();
  private _tileSpriteFreeList: Sprite[] = [];
  private _objectSpriteFreeList: Sprite[] = [];
  private _usedTileSprites = new Set<string>();
  private _usedObjectSprites = new Set<string>();
  private _townObjectSpritePool = new Map<string, SpritePoolEntry>();
  private _townObjectSpriteFreeList: Sprite[] = [];
  private _usedTownObjectSprites = new Set<string>();
  private _emptySpriteSet = new Set<string>();
  private _buildingSpritePool = new Map<string, SpritePoolEntry>();
  private _characterSpritePool = new Map<string, SpritePoolEntry>();
  private _npcSpritePool = new Map<string, SpritePoolEntry>();
  private _usedBuildingSprites = new Set<string>();
  private _usedNpcSprites = new Set<string>();
  private _groundDirty = true;
  private _lastGroundBounds:
    | {
        startX: number;
        endX: number;
        startY: number;
        endY: number;
      }
    | null = null;
  private _lastSpritesReady = false;

  // Reusable Graphics (avoid GC). Animated / full-screen paths are not texture-cached — see drawTowns / drawGround.
  private _groundGfx: Graphics | null = null;
  private _heroGfx: Graphics | null = null;
  /** Second adventurer silhouette during hero_meet (opponent at server stand position). */
  private _meetPartnerGfx: Graphics | null = null;
  private _meetPartnerLabel: Text | null = null;
  /** Tent + campfire while resting in the wild phase (roadside / adventure inline). */
  private _restCampGfx: Graphics | null = null;
  private _enemyGfx: Graphics | null = null;
  private _thoughtGfx: Graphics | null = null;
  private _thoughtText: Text | null = null;
  private _meetBubbleSlots: Array<{
    gfx: Graphics;
    txt: Text;
  }> = [];
  private _heroNameText: Text | null = null;
  private _heroName = '';

  // Town rendering
  private _townGfx: Graphics | null = null;
  private _townIconGfx: Graphics | null = null;
  private _townLabels: Text[] = [];
  private _townLabelPool: Text[] = [];

  // NPC rendering
  private _npcGfx: Graphics | null = null;
  private _npcLabels: Text[] = [];
  private _npcLabelPool: Text[] = [];

  // Nearby hero rendering
  private _nearbyHeroGfx: Graphics | null = null;
  private _nearbyHeroLabels: Text[] = [];
  private _nearbyHeroLabelPool: Text[] = [];
  private _nearbyHeroSpritePool = new Map<string, SpritePoolEntry>();
  private _usedNearbyHeroSprites = new Set<string>();

  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;
    gfx.ellipse(x - 4 * s, y - 7 * s, 7 * s, 4.5 * s);
    gfx.fill({ color: 0x356e2b, alpha: 0.95 });
    gfx.ellipse(x + 3 * s, y - 8 * s, 6.5 * s, 4 * s);
    gfx.fill({ color: 0x2f6526, alpha: 0.95 });
    gfx.ellipse(x, y - 10 * s, 8 * s, 4.5 * s);
    gfx.fill({ color: 0x3f7d32, alpha: 0.95 });
    gfx.ellipse(x, y - 5 * s, 9 * s, 2.5 * s);
    gfx.fill({ color: 0x22481b, alpha: 0.35 });
  }

  private _drawTree(gfx: Graphics, x: number, y: number, variant: number): void {
    const s = (0.95 + variant * 0.35) * 3.5;
    gfx.rect(x - 2 * s, y - 24 * s, 4 * s, 12 * s);
    gfx.fill({ color: 0x5a3d24, alpha: 0.98 });
    gfx.ellipse(x, y - 28 * s, 11 * s, 7.5 * s);
    gfx.fill({ color: 0x2f6c29, alpha: 0.98 });
    gfx.ellipse(x - 6 * s, y - 24 * s, 8 * s, 6 * s);
    gfx.fill({ color: 0x3a7e31, alpha: 0.98 });
    gfx.ellipse(x + 6 * s, y - 24 * s, 8 * s, 6 * s);
    gfx.fill({ color: 0x3a7e31, alpha: 0.98 });
    gfx.ellipse(x, y - 19 * s, 10 * s, 6 * s);
    gfx.fill({ color: 0x2a5f23, alpha: 0.92 });
  }

  private _drawRock(gfx: Graphics, x: number, y: number, variant: number): void {
    const s = (0.8 + variant * 0.4) * 3.5;
    gfx.ellipse(x, y - 4 * s, 7 * s, 4 * s);
    gfx.fill({ color: 0x6b6b6b, alpha: 0.95 });
    gfx.ellipse(x - 2 * s, y - 6 * s, 5 * s, 3.5 * s);
    gfx.fill({ color: 0x7e7e7e, alpha: 0.95 });
    gfx.ellipse(x, y - 2 * s, 8 * s, 2 * s);
    gfx.fill({ color: 0x3a3a3a, alpha: 0.25 });
  }

  private _drawStump(gfx: Graphics, x: number, y: number, variant: number): void {
    const s = (0.85 + variant * 0.2) * 3.2;
    gfx.roundRect(x - 5 * s, y - 8 * s, 10 * s, 9 * s, 2 * s);
    gfx.fill({ color: 0x4a3020, alpha: 0.98 });
    gfx.ellipse(x, y - 10 * s, 6 * s, 3 * s);
    gfx.fill({ color: 0x3d2818, alpha: 0.9 });
  }

  private _drawBrokenCart(gfx: Graphics, x: number, y: number, variant: number): void {
    const s = (0.9 + variant * 0.15) * 3;
    gfx.rect(x - 14 * s, y - 3 * s, 22 * s, 6 * s);
    gfx.fill({ color: 0x5c4030, alpha: 0.95 });
    gfx.rect(x + 8 * s, y - 2 * s, 3 * s, 8 * s);
    gfx.fill({ color: 0x3a3a3a, alpha: 0.9 });
    gfx.circle(x - 10 * s, y + 4 * s, 3 * s);
    gfx.fill({ color: 0x2a2a2a, alpha: 0.85 });
  }

  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, s);
    gfx.fill({ color: 0xddd5c8, alpha: 0.9 });
    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 });
  }

  private _drawMushroom(gfx: Graphics, x: number, y: number, variant: number): void {
    const s = (0.85 + variant * 0.25) * 3;
    gfx.rect(x - 1.5 * s, y - 4 * s, 3 * s, 6 * s);
    gfx.fill({ color: 0xe8e0d5, alpha: 0.95 });
    gfx.ellipse(x, y - 8 * s, 5 * s, 3.5 * s);
    gfx.fill({ color: variant > 0.5 ? 0xc44a4a : 0xd9c04a, alpha: 0.95 });
    gfx.ellipse(x, y - 8 * s, 2 * s, 1.2 * s);
    gfx.fill({ color: 0xffffff, alpha: 0.35 });
  }

  private _drawRuin(gfx: Graphics, x: number, y: number, variant: number): void {
    const s = (0.9 + variant * 0.2) * 3.2;
    gfx.rect(x - 4 * s, y - 14 * s, 4 * s, 14 * s);
    gfx.fill({ color: 0x6a6a72, alpha: 0.95 });
    gfx.rect(x + 2 * s, y - 10 * s, 5 * s, 10 * s);
    gfx.fill({ color: 0x5a5a62, alpha: 0.92 });
    gfx.rect(x - 1 * s, y - 4 * s, 8 * s, 3 * s);
    gfx.fill({ color: 0x4a4a52, alpha: 0.88 });
  }

  private _drawMarketStall(gfx: Graphics, x: number, y: number, variant: number): void {
    const s = (0.95 + variant * 0.1) * 3.5;
    gfx.rect(x - 12 * s, y - 2 * s, 24 * s, 5 * s);
    gfx.fill({ color: 0x6b4a32, alpha: 0.96 });
    gfx.poly([x, y - 16 * s, x + 14 * s, y - 4 * s, x - 14 * s, y - 4 * s]);
    gfx.fill({ color: 0x8b3a3a, alpha: 0.94 });
    gfx.rect(x - 3 * s, y - 8 * s, 6 * s, 5 * s);
    gfx.fill({ color: 0x2a5080, alpha: 0.85 });
  }

  private _drawWell(gfx: Graphics, x: number, y: number, variant: number): void {
    const s = (0.9 + variant * 0.15) * 3.2;
    gfx.ellipse(x, y - 2 * s, 9 * s, 5 * s);
    gfx.fill({ color: 0x5a5a62, alpha: 0.95 });
    gfx.ellipse(x, y - 4 * s, 5 * s, 3 * s);
    gfx.fill({ color: 0x1a4a6e, alpha: 0.85 });
    gfx.rect(x - 2 * s, y - 12 * s, 4 * s, 8 * s);
    gfx.fill({ color: 0x7a7a82, alpha: 0.9 });
  }

  private _drawBanner(gfx: Graphics, x: number, y: number, variant: number): void {
    const s = (0.85 + variant * 0.2) * 3;
    gfx.rect(x - 1.2 * s, y - 18 * s, 2.4 * s, 18 * s);
    gfx.fill({ color: 0x4a3a28, alpha: 0.95 });
    gfx.poly([x + 1.2 * s, y - 18 * s, x + 12 * s, y - 14 * s, x + 1.2 * s, y - 10 * s]);
    gfx.fill({ color: 0x6a3a8e, alpha: 0.92 });
  }

  private _drawBarrel(gfx: Graphics, x: number, y: number, variant: number): void {
    const s = (0.9 + variant * 0.15) * 2.8;
    gfx.ellipse(x, y, 5 * s, 3 * s);
    gfx.fill({ color: 0x5a4228, alpha: 0.9 });
    gfx.rect(x - 5 * s, y - 7 * s, 10 * s, 7 * s);
    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, s);
    gfx.fill({ color: 0x4a3218, alpha: 0.6 });
    gfx.rect(x - 5 * s, y - 2 * s, 10 * s, s);
    gfx.fill({ color: 0x4a3218, alpha: 0.6 });
  }

  private _drawLeafPile(gfx: Graphics, x: number, y: number, variant: number): void {
    const s = (0.85 + variant * 0.25) * 2.5;
    const colors = [0x6a8a2a, 0x8a7a22, 0x5a7a28, 0x9a8a30];
    for (let i = 0; i < 4; i++) {
      const ox = (((variant * 100 + i * 37) | 0) % 7 - 3) * s;
      const oy = (((variant * 100 + i * 53) | 0) % 5 - 2) * s * 0.5;
      gfx.ellipse(x + ox, y + oy, 4 * s, 2.5 * s);
      gfx.fill({ color: colors[i % colors.length]!, alpha: 0.7 });
    }
  }

  private _terrainColors(terrain: string, dark: boolean): number {
    if (terrain === 'plaza') return dark ? 0x5a5a62 : 0x6c6c75;
    if (terrain === 'road') return dark ? 0x7b6545 : 0x8e7550;
    if (terrain === 'dirt') return dark ? 0x6b5338 : 0x7c6242;
    if (terrain === 'stone') return dark ? 0x5c5f67 : 0x6c7078;
    if (terrain === 'forest_floor') return dark ? 0x234a1c : 0x2d5a24;
    if (terrain === 'ruins_floor') return dark ? 0x4a4a48 : 0x5a5a58;
    if (terrain === 'canyon_floor') return dark ? 0x7a6248 : 0x8b7355;
    if (terrain === 'swamp_floor') return dark ? 0x324c36 : 0x3d5c42;
    if (terrain === 'volcanic_floor') return dark ? 0x4a2e28 : 0x5c3830;
    if (terrain === 'astral_floor') return dark ? 0x3a3568 : 0x4a4580;
    return dark ? 0x2d5a1e : 0x3a7a28; // grass
  }

  private _terrainStrokeColor(terrain: string): number {
    if (terrain === 'road') return 0x4e3f2d;
    if (terrain === 'plaza' || terrain === 'stone') return 0x333340;
    if (terrain === 'dirt') return 0x4a3828;
    if (terrain === 'ruins_floor') return 0x2a2a30;
    if (terrain === 'canyon_floor') return 0x5a4835;
    if (terrain === 'swamp_floor') return 0x1a3a28;
    if (terrain === 'volcanic_floor') return 0x4a2020;
    if (terrain === 'astral_floor') return 0x302840;
    return 0x1a4a12;
  }

  private _ensureSprite(
    pool: Map<string, SpritePoolEntry>,
    poolKey: string,
    textureKey: string,
    texture: SpritePoolEntry['sprite']['texture'],
    layer: Container,
    worldX?: number,
    worldY?: number,
    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, worldX, worldY };
      pool.set(poolKey, entry);
      return entry;
    }
    if (entry.textureKey !== textureKey) {
      entry.sprite.texture = texture;
      entry.textureKey = textureKey;
    }
    if (typeof worldX === 'number' && typeof worldY === 'number') {
      entry.worldX = worldX;
      entry.worldY = worldY;
    }
    return entry;
  }

  /**
   * Hide pooled sprites not in `used`. After {@link SPRITE_HIDDEN_EVICT_MS} of being hidden,
   * remove from the pool and recycle or destroy the sprite.
   */
  private _hideUnusedSprites(
    pool: Map<string, SpritePoolEntry>,
    used: Set<string>,
    nowMs: number,
    freeList?: Sprite[],
    maxFreeListSize?: number,
  ): void {
    const toRemove: string[] = [];
    for (const [key, entry] of pool) {
      if (used.has(key)) {
        entry.sprite.visible = true;
        entry.hiddenSinceMs = undefined;
        continue;
      }
      entry.sprite.visible = false;
      if (entry.hiddenSinceMs === undefined) {
        entry.hiddenSinceMs = nowMs;
      } else if (nowMs - entry.hiddenSinceMs >= SPRITE_HIDDEN_EVICT_MS) {
        toRemove.push(key);
      }
    }
    for (const key of toRemove) {
      const entry = pool.get(key);
      if (!entry) continue;
      pool.delete(key);
      const sprite = entry.sprite;
      sprite.visible = false;
      if (
        freeList !== undefined &&
        maxFreeListSize !== undefined &&
        freeList.length < maxFreeListSize
      ) {
        freeList.push(sprite);
      } else {
        sprite.destroy();
      }
    }
  }

  /** Drop GPU cache before mutating vector paths (Pixi 8: prefer cacheAsTexture over legacy cacheAsBitmap). */
  private _releaseGraphicsTextureCache(gfx: Graphics | null): void {
    if (!gfx?.isCachedAsTexture) return;
    gfx.cacheAsTexture(false);
  }

  /**
   * Bake static vector layers to a single quad. Skips huge bounds (see {@link MAX_STATIC_GRAPHICS_CACHE_AREA}).
   * Do not use for per-frame animated Graphics (hero, enemy, NPC, thought bubbles).
   */
  private _cacheStaticGraphicsIfBounded(gfx: Graphics | null): void {
    if (!gfx || gfx.isCachedAsTexture) return;
    const b = gfx.getLocalBounds();
    const area = Math.max(0, b.width) * Math.max(0, b.height);
    if (area < 16 || area > MAX_STATIC_GRAPHICS_CACHE_AREA) return;
    gfx.cacheAsTexture({
      antialias: false,
      resolution: 1,
      scaleMode: 'linear',
    });
  }

  private _evictSpritesOutsideTileBounds(
    pool: Map<string, SpritePoolEntry>,
    minX: number,
    maxX: number,
    minY: number,
    maxY: number,
    freeList: Sprite[],
    maxFreeListSize: number,
  ): void {
    for (const [key, entry] of pool) {
      const wx = entry.worldX ?? Number.NaN;
      const wy = entry.worldY ?? Number.NaN;
      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() {
    this.app = new Application();
    // @ts-ignore
    globalThis.__PIXI_APP__ = this.app;
    this.worldContainer = new Container();
    this.groundLayer = new Container();
    this.entityLayer = new Container();
    this.effectLayer = new Container();
    this.uiContainer = new Container();
    this._groundSpriteLayer = new Container();
    this._objectSpriteLayer = new Container();
    this._buildingSpriteLayer = new Container();
    this._townObjectSpriteLayer = new Container();
  }

  get initialized(): boolean {
    return this._initialized;
  }

  /** Initialize the PixiJS application and attach to canvas container */
  async init(canvasContainer: HTMLElement): Promise<void> {
    if (this._initialized) return;

    const viewport = getViewport();

    await this.app.init({
      width: viewport.width,
      height: viewport.height,
      backgroundColor: 0x1a1a2e,
      resolution: Math.min(window.devicePixelRatio, 2),
      autoDensity: true,
      antialias: false,
      powerPreference: 'high-performance',
    });

    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);
    this.worldContainer.addChild(this.effectLayer);
    this.app.stage.addChild(this.worldContainer);
    this.app.stage.addChild(this.uiContainer);

    // Center the world container and apply zoom
    this.worldContainer.x = viewport.width / 2;
    this.worldContainer.y = viewport.height / 2;
    this.worldContainer.scale.set(MAP_ZOOM);

    // Create reusable graphics objects
    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;
    this.groundLayer.addChild(this._groundGfx);

    this.groundLayer.addChild(this._objectSpriteLayer);
    this._objectSpriteLayer.zIndex = 2;
    this._objectSpriteLayer.sortableChildren = true;

    this.groundLayer.addChild(this._townObjectSpriteLayer);
    this._townObjectSpriteLayer.zIndex = 2;
    this._townObjectSpriteLayer.sortableChildren = true;

    this.groundLayer.addChild(this._buildingSpriteLayer);
    this._buildingSpriteLayer.zIndex = 3;
    this._buildingSpriteLayer.sortableChildren = true;

    this._heroGfx = new Graphics();
    this.entityLayer.addChild(this._heroGfx);

    this._meetPartnerGfx = new Graphics();
    this.entityLayer.addChild(this._meetPartnerGfx);

    this._restCampGfx = new Graphics();
    this.entityLayer.addChild(this._restCampGfx);

    this._enemyGfx = new Graphics();
    this.entityLayer.addChild(this._enemyGfx);

    this._thoughtGfx = new Graphics();
    this.entityLayer.addChild(this._thoughtGfx);

    this._thoughtText = new Text({
      text: '',
      style: new TextStyle({
        fontSize: 11,
        fontFamily: 'system-ui, sans-serif',
        fill: 0x333333,
        wordWrap: true,
        wordWrapWidth: 210,
        align: 'center',
      }),
    });
    this._thoughtText.anchor.set(0.5, 0.5);
    this._thoughtText.visible = false;
    this.entityLayer.addChild(this._thoughtText);

    for (let i = 0; i < 2; i++) {
      const gfx = new Graphics();
      this.entityLayer.addChild(gfx);
      const txt = new Text({
        text: '',
        style: new TextStyle({
          fontSize: 10,
          fontFamily: 'system-ui, sans-serif',
          fill: 0x333333,
          wordWrap: true,
          wordWrapWidth: 200,
          align: 'center',
        }),
      });
      txt.anchor.set(0.5, 0.5);
      txt.visible = false;
      this.entityLayer.addChild(txt);
      this._meetBubbleSlots.push({ gfx, txt });
    }

    this._heroNameText = new Text({
      text: '',
      style: new TextStyle({
        fontSize: 11,
        fontFamily: 'system-ui, sans-serif',
        fill: 0xffffff,
        stroke: { color: 0x000000, width: 3 },
        align: 'center',
      }),
    });
    this._heroNameText.anchor.set(0.5, 0.5);
    this._heroNameText.visible = false;
    this.entityLayer.addChild(this._heroNameText);

    this._meetPartnerLabel = new Text({
      text: '',
      style: new TextStyle({
        fontSize: 11,
        fontFamily: 'system-ui, sans-serif',
        fill: 0xffffff,
        stroke: { color: 0x000000, width: 3 },
        align: 'center',
      }),
    });
    this._meetPartnerLabel.anchor.set(0.5, 0.5);
    this._meetPartnerLabel.visible = false;
    this.entityLayer.addChild(this._meetPartnerLabel);

    // 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);

    // Nearby hero graphics
    this._nearbyHeroGfx = new Graphics();
    this.entityLayer.addChild(this._nearbyHeroGfx);

    this._initialized = true;
    console.info(`[Renderer] Initialized ${viewport.width}x${viewport.height} @${this.app.renderer.resolution}x`);
  }

  /** Handle window/viewport resize */
  resize(): void {
    if (!this._initialized) return;
    const viewport = getViewport();
    this.app.renderer.resize(viewport.width, viewport.height);
    this.worldContainer.x = viewport.width / 2;
    this.worldContainer.y = viewport.height / 2;
    this.worldContainer.scale.set(MAP_ZOOM);
    this._townDrawDirty = true;
  }

  /**
   * Draw visible ground tiles and decorative objects.
   * Terrain is procedurally generated — the world is endless.
   *
   * Vector fallback spans the full visible tile grid; we do not `cacheAsTexture` on `_groundGfx`
   * (bounds would create a very large RenderTexture). Prefer terrain/object sprites when loaded.
   */
  drawGround(camera: Camera, screenWidth: number, screenHeight: number): void {
    const gfx = this._groundGfx;
    if (!gfx) return;

    const cx = camera.finalX;
    const cy = camera.finalY;
    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),
    ];

    const renderPaddingTiles = 4;
    let minWX = Number.POSITIVE_INFINITY;
    let maxWX = Number.NEGATIVE_INFINITY;
    let minWY = Number.POSITIVE_INFINITY;
    let maxWY = Number.NEGATIVE_INFINITY;

    for (const corner of worldCorners) {
      if (corner.x < minWX) minWX = corner.x;
      if (corner.x > maxWX) maxWX = corner.x;
      if (corner.y < minWY) minWY = corner.y;
      if (corner.y > maxWY) maxWY = corner.y;
    }

    minWX -= renderPaddingTiles;
    maxWX += renderPaddingTiles;
    minWY -= renderPaddingTiles;
    maxWY += renderPaddingTiles;

    const startX = Math.floor(minWX);
    const endX = Math.ceil(maxWX);
    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 = this._usedTileSprites;
    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);

    // 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<string | undefined>(
      (objectEndX - objectStartX + 1) * objectGridH,
    );
    const terrainIndex = (wx: number, wy: number): number =>
      (wx - objectStartX) * objectGridH + (wy - objectStartY);
    const terrainAt = (wx: number, wy: number): string => {
      const idx = terrainIndex(wx, wy);
      const cached = terrainGrid[idx];
      if (cached !== undefined) return cached;
      const terrain = proceduralTerrain(wx, wy, terrainCtx);
      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++) {
        if (isoX < tileMinX || isoX > tileMaxX || isoY < tileMinY || isoY > tileMaxY) {
          isoX -= hw;
          isoY += hh;
          continue;
        }
        const terrain = terrainAt(wx, wy);
        const dark = (wx + wy) % 2 === 0;
        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,
            wx,
            wy,
            this._tileSpriteFreeList,
          );
          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);
          entry.sprite.visible = true;
        } else {
          const color = this._terrainColors(terrain, dark);

          gfx.poly([
            isoX, isoY - hh,
            isoX + hw, isoY,
            isoX, isoY + hh,
            isoX - hw, isoY,
          ]);
          gfx.fill({ color, alpha: 1 });

          gfx.poly([
            isoX, isoY - hh,
            isoX + hw, isoY,
            isoX, isoY + hh,
            isoX - hw, isoY,
          ]);
          gfx.stroke({
            color: this._terrainStrokeColor(terrain),
            width: 1,
            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 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++) {
        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) {
          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;
        if (objTextureKey && objTexture) {
          const poolKey = `${wx},${wy}`;
          usedObjectSprites.add(poolKey);
          const entry = this._ensureSprite(
            this._objectSpritePool,
            poolKey,
            objTextureKey,
            objTexture,
            this._objectSpriteLayer,
            wx,
            wy,
            this._objectSpriteFreeList,
          );
          entry.sprite.x = isoX;
          entry.sprite.y = isoY;
          entry.sprite.zIndex = isoY;
          entry.sprite.visible = true;
        } else {
          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;
      }
    }

    const hideSpritesNow = performance.now();
    if (spritesReady) {
      this._hideUnusedSprites(
        this._tileSpritePool,
        usedTileSprites,
        hideSpritesNow,
        this._tileSpriteFreeList,
        2800,
      );
      this._hideUnusedSprites(
        this._objectSpritePool,
        usedObjectSprites,
        hideSpritesNow,
        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,
      );
    } else {
      this._hideUnusedSprites(
        this._tileSpritePool,
        this._emptySpriteSet,
        hideSpritesNow,
        this._tileSpriteFreeList,
        2800,
      );
      this._hideUnusedSprites(
        this._objectSpritePool,
        this._emptySpriteSet,
        hideSpritesNow,
        this._objectSpriteFreeList,
        1800,
      );
    }
  }

  /**
   * Shared adventurer figure for local hero and meet opponent (tint differs for readability).
   */
  private paintHeroSilhouette(
    gfx: Graphics,
    wx: number,
    wy: number,
    phase: 'walk' | 'fight' | 'idle',
    now: number,
    variant: 'self' | 'meet_partner',
  ): { cx: number; cy: number; iso: ScreenPoint } {
    gfx.clear();
    const iso = worldToScreen(wx, wy);
    let yOffset = 0;
    if (phase === 'walk') {
      yOffset = Math.sin(now * 0.006) * 3;
    } else if (phase === 'fight') {
      yOffset = Math.sin(now * 0.012) * 2;
    }
    const cx = iso.x;
    const cy = iso.y + yOffset;

    const cape = variant === 'meet_partner' ? 0x523068 : 0x6a1a2e;
    const tunic = variant === 'meet_partner' ? 0x2e5070 : 0x3a5a8a;
    const tunicStroke = variant === 'meet_partner' ? 0x1a2840 : 0x1a3050;
    const helm = variant === 'meet_partner' ? 0x7a8fa0 : 0x8899aa;
    const helmStroke = variant === 'meet_partner' ? 0x4a5a68 : 0x556070;

    gfx.ellipse(cx, cy + 10, 16, 5);
    gfx.fill({ color: 0x000000, alpha: 0.28 });

    gfx.poly([cx - 14, cy - 4, cx - 18, cy + 10, cx + 2, cy + 6, cx + 4, cy - 8]);
    gfx.fill({ color: cape, alpha: 0.92 });

    gfx.roundRect(cx - 9, cy - 18, 18, 22, 4);
    gfx.fill({ color: tunic, alpha: 0.98 });
    gfx.stroke({ color: tunicStroke, width: 1.2 });

    gfx.rect(cx - 9, cy + 2, 18, 4);
    gfx.fill({ color: 0x3a2818, alpha: 0.95 });

    gfx.roundRect(cx - 7, cy - 30, 14, 13, 5);
    gfx.fill({ color: helm, alpha: 0.98 });
    gfx.stroke({ color: helmStroke, width: 1 });

    gfx.rect(cx + 8, cy - 22, 3, 20);
    gfx.fill({ color: 0xc8d8e8, alpha: 0.95 });
    gfx.rect(cx + 8, cy - 4, 6, 3);
    gfx.fill({ color: 0x4a3018, alpha: 0.95 });

    if (phase === 'fight') {
      const flash = (Math.sin(now * 0.01) + 1) * 0.5;
      if (flash > 0.75) {
        gfx.circle(cx + 14, cy - 10, 5);
        gfx.fill({ color: 0xffffff, alpha: flash * 0.55 });
      }
    }

    gfx.zIndex = cy + 100;
    return { cx, cy, iso };
  }

  /**
   * Draw the hero as a compact adventurer (silhouette + cape + blade) with bob / combat flash.
   */
  drawHero(wx: number, wy: number, phase: 'walk' | 'fight' | 'idle', now: number, modelVariant: number): void {
    const gfx = this._heroGfx;
    if (!gfx) return;
    const iso = worldToScreen(wx, wy);
    const textureKey = this._spritesReady ? heroTextureKey(modelVariant, 'south') : 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 - 92;
      nameTxt.visible = true;
      nameTxt.zIndex = cy + 199;
    }
  }

  /**
   * Meet opponent: same figure as the main hero, idle stance, distinct tint + name label.
   */
  drawMeetPartner(wx: number, wy: number, name: string, level: number, now: number, modelVariant: number): void {
    const gfx = this._meetPartnerGfx;
    const lbl = this._meetPartnerLabel;
    if (!gfx || !lbl) return;
    const iso = worldToScreen(wx, wy);
    const textureKey = this._spritesReady ? heroTextureKey(modelVariant, 'north') : 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 + 10;
    lbl.visible = true;
    lbl.zIndex = cy + 199;
  }

  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;
    }
  }

  /**
   * 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;

    const iso = worldToScreen(wx, wy);
    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 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;
          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); ~2× size vs old fallback ---
    const tx = iso.x - 54;
    const ty = iso.y - 6

    // Ground shadow under tent
    gfx.ellipse(tx, ty + 28, 44, 14);
    gfx.fill({ color: 0x000000, alpha: 0.18 });

    // Tent body (trapezoid wall + triangle roof)
    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 - 28, ty - 16, tx, ty - 44, tx + 28, ty - 16]);
    gfx.fill({ color: 0xc4a574, alpha: 0.96 });
    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 - 10, ty + 4, 20, 20);
    gfx.fill({ color: 0x1a1510, alpha: 0.55 });

    // Guy lines / pegs (tiny)
    gfx.moveTo(tx - 36, ty + 24);
    gfx.lineTo(tx - 52, ty + 32);
    gfx.stroke({ color: 0x4a3a2a, width: 1, alpha: 0.5 });
    gfx.moveTo(tx + 36, ty + 24);
    gfx.lineTo(tx + 52, ty + 32);
    gfx.stroke({ color: 0x4a3a2a, width: 1, alpha: 0.5 });

    // --- Campfire (pushed right; center clear for hero) ---
    const cx = iso.x + 42;
    const cy = iso.y + 8;

    gfx.ellipse(cx, cy + 6, 12, 4);
    gfx.fill({ color: 0x000000, alpha: 0.22 });
    gfx.ellipse(cx, cy + 3, 10, 3.2);
    gfx.fill({ color: 0xff7a1a, alpha: 0.22 });

    gfx.roundRect(cx - 9, cy + 1, 18, 3, 1.5);
    gfx.fill({ color: 0x5a3a24, alpha: 0.95 });
    gfx.roundRect(cx - 8, cy - 1, 16, 3, 1.5);
    gfx.fill({ color: 0x6b4428, alpha: 0.9 });

    const pulse = 0.9 + 0.2 * Math.sin(now * 0.012);
    gfx.circle(cx, cy - 6, 5.2 * pulse);
    gfx.fill({ color: 0xff8a2a, alpha: 0.8 });
    gfx.circle(cx, cy - 7, 3.2 * pulse);
    gfx.fill({ color: 0xffc04d, alpha: 0.9 });
    gfx.circle(cx, cy - 8, 1.6 * pulse);
    gfx.fill({ color: 0xfff3b0, alpha: 0.95 });

    gfx.zIndex = Math.max(ty, cy) + 94;
  }

  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;
    }
  }

  /**
   * Draw an enemy with type-specific visuals and an HP bar above.
   */
  drawEnemy(
    wx: number,
    wy: number,
    hp: number,
    maxHp: number,
    enemySlug: string,
    enemyArchetype: string | undefined,
    now: number,
  ): 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 = this._spritesReady
      ? resolveEnemySouthTextureKey(enemySlug, (k) => this._spriteRegistry.getTexture(k))
      : null;
    const tex = southKey ? this._spriteRegistry.getTexture(southKey) : null;
    if (tex && southKey) {
      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;
      entry.sprite.roundPixels = true;
      const th = Math.max(1, tex.height || tex.width || 48);
      const targetH = 100;
      entry.sprite.scale.set(targetH / th);
      entry.sprite.zIndex = cy + 90;
      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
   * rest period is about to end.
   */
  drawThoughtBubble(wx: number, wy: number, text: string, now: number, startMs: number): void {
    const gfx = this._thoughtGfx;
    const txt = this._thoughtText;
    if (!gfx || !txt) return;
    gfx.clear();

    const elapsed = now - startMs;
    // Fade in over 300ms
    const alpha = Math.min(1, elapsed / 300);
    if (alpha <= 0) {
      txt.visible = false;
      return;
    }

    const iso = worldToScreen(wx, wy);
    const bx = iso.x;
    const by = iso.y - 100; // above hero head

    txt.text = text;
    txt.style.wordWrapWidth = 210;
    const padX = 10;
    const padY = 8;
    const rawW = txt.width;
    const rawH = txt.height;
    const w = Math.min(240, Math.max(100, Math.ceil(rawW) + padX * 2));
    const h = Math.max(30, Math.ceil(rawH) + padY * 2);
    const left = bx - w / 2;
    const top = by - h / 2;

    // Bubble background
    gfx.roundRect(left, top, w, h, 6);
    gfx.fill({ color: 0xffffff, alpha: 0.92 * alpha });
    gfx.stroke({ color: 0xcccccc, width: 1, alpha: 0.6 * alpha });

    // Triangle pointer pointing down
    const triW = 8;
    const triH = 6;
    gfx.poly([
      bx - triW / 2, top + h,
      bx + triW / 2, top + h,
      bx, top + h + triH,
    ]);
    gfx.fill({ color: 0xffffff, alpha: 0.92 * alpha });

    // Keep bubble behind the text so the text stays visible.
    gfx.zIndex = by + 200; // above hero, below text

    // Text position (centered in bubble)
    txt.x = bx;
    txt.y = by;
    txt.alpha = alpha;
    txt.visible = true;
    txt.zIndex = by + 201;

    // Move hero name above the thought bubble
    const nameTxt = this._heroNameText;
    if (nameTxt && this._heroName) {
      nameTxt.y = top - 10;
      nameTxt.x = bx;
      nameTxt.zIndex = by + 202;
    }
  }

  /** Hide the thought bubble when not resting. Called implicitly when drawThoughtBubble is not invoked. */
  clearThoughtBubble(): void {
    if (this._thoughtGfx) this._thoughtGfx.clear();
    if (this._thoughtText) this._thoughtText.visible = false;
  }

  /** Set the hero display name for the above-hero label */
  setHeroName(name: string): void {
    this._heroName = name;
    if (this._heroNameText) {
      this._heroNameText.text = name;
      this._heroNameText.visible = name.length > 0;
    }
  }

  /**
   * Draw a single house with varied detail: windows, door, optional chimney.
   * cx/cy = base center-bottom in screen space.
   * roofStyle: 0 = pointed, 1 = flat, 2 = pointed with chimney
   */
  private _drawHouse(
    gfx: Graphics, cx: number, cy: number,
    w: number, h: number, roofH: number,
    wallColor: number, roofColor: number,
    roofStyle: number = 0,
  ): void {
    // Wall
    gfx.rect(cx - w / 2, cy - h, w, h);
    gfx.fill({ color: wallColor, alpha: 0.95 });
    gfx.stroke({ color: 0x3a2a18, width: 1.2, alpha: 0.5 });

    // Roof
    if (roofStyle === 1) {
      // Flat roof
      gfx.rect(cx - w / 2 - 4, cy - h - roofH * 0.35, w + 8, roofH * 0.35);
      gfx.fill({ color: roofColor, alpha: 0.95 });
      gfx.stroke({ color: 0x4a2a10, width: 1, alpha: 0.4 });
    } else {
      // Pointed roof (triangle)
      gfx.poly([
        cx - w / 2 - 4, cy - h,
        cx + w / 2 + 4, cy - h,
        cx, cy - h - roofH,
      ]);
      gfx.fill({ color: roofColor, alpha: 0.95 });
      gfx.stroke({ color: 0x4a2a10, width: 1, alpha: 0.4 });
    }

    // Chimney (roofStyle 2)
    if (roofStyle === 2) {
      const chimW = w * 0.12;
      const chimH = roofH * 0.6;
      const chimX = cx + w * 0.22;
      gfx.rect(chimX - chimW / 2, cy - h - roofH * 0.7, chimW, chimH);
      gfx.fill({ color: 0x5a4a3a, alpha: 0.95 });
    }

    // Door (dark rectangle at bottom center)
    const doorW = w * 0.18;
    const doorH = h * 0.42;
    gfx.rect(cx - doorW / 2, cy - doorH, doorW, doorH);
    gfx.fill({ color: 0x3a2818, alpha: 0.9 });
    // Door knob
    gfx.circle(cx + doorW * 0.28, cy - doorH * 0.45, w * 0.02);
    gfx.fill({ color: 0xccaa44, alpha: 0.8 });

    // Windows (2-3 yellow rectangles)
    const winS = w * 0.12;
    const winY = cy - h * 0.68;
    // Left window
    gfx.rect(cx - w * 0.32, winY, winS, winS);
    gfx.fill({ color: 0xeedd88, alpha: 0.75 });
    gfx.stroke({ color: 0x5a4a3a, width: 0.8, alpha: 0.5 });
    // Right window
    gfx.rect(cx + w * 0.32 - winS, winY, winS, winS);
    gfx.fill({ color: 0xeedd88, alpha: 0.75 });
    gfx.stroke({ color: 0x5a4a3a, width: 0.8, alpha: 0.5 });
    // Center window (only on wider houses)
    if (w > 40) {
      gfx.rect(cx - winS / 2, winY - winS * 0.3, winS, winS);
      gfx.fill({ color: 0xeedd88, alpha: 0.6 });
      gfx.stroke({ color: 0x5a4a3a, width: 0.8, alpha: 0.4 });
    }
  }

  /** Draw a fence segment beside a house. */
  private _drawFence(gfx: Graphics, cx: number, cy: number, w: number, side: 'left' | 'right'): void {
    const dir = side === 'left' ? -1 : 1;
    const fenceX = cx + dir * (w / 2 + 4);
    for (let i = 0; i < 3; i++) {
      const postX = fenceX + dir * i * 6;
      gfx.rect(postX - 1, cy - 12, 2, 12);
      gfx.fill({ color: 0x6a5a3a, alpha: 0.8 });
    }
    // Rail
    gfx.rect(fenceX - 1, cy - 10, dir * 14, 1.5);
    gfx.fill({ color: 0x6a5a3a, alpha: 0.7 });
    gfx.rect(fenceX - 1, cy - 5, dir * 14, 1.5);
    gfx.fill({ color: 0x6a5a3a, alpha: 0.7 });
  }

  /**
   * Building-layer sprite (anchor bottom-center). Returns true if the texture was placed.
   */
  private _placeBuildingLayerSprite(
    poolKey: string,
    textureKey: string,
    bx: number,
    by: number,
    targetWidth: number,
    usedBuildingSprites: Set<string>,
  ): boolean {
    if (!this._spritesReady) return false;
    const spriteTexture = this._spriteRegistry.getTexture(textureKey);
    if (!spriteTexture) return false;
    usedBuildingSprites.add(poolKey);
    const entry = this._ensureSprite(
      this._buildingSpritePool,
      poolKey,
      textureKey,
      spriteTexture,
      this._buildingSpriteLayer,
    );
    entry.sprite.x = bx;
    entry.sprite.y = by;
    const texW = entry.sprite.texture.width || targetWidth;
    const scaleFactor = texW > 0 ? targetWidth / texW : 1;
    entry.sprite.scale.set(scaleFactor);
    entry.sprite.zIndex = by;
    entry.sprite.visible = true;
    return true;
  }

  /** Draw a market stall structure for town variety. */
  private _drawTownStall(gfx: Graphics, cx: number, cy: number, s: number): void {
    // Counter / table
    gfx.rect(cx - 18 * s, cy - 6 * s, 36 * s, 8 * s);
    gfx.fill({ color: 0x6b4a32, alpha: 0.95 });
    // Cloth awning
    gfx.poly([
      cx - 22 * s, cy - 6 * s,
      cx + 22 * s, cy - 6 * s,
      cx + 20 * s, cy - 20 * s,
      cx - 20 * s, cy - 20 * s,
    ]);
    gfx.fill({ color: 0x8b3a3a, alpha: 0.85 });
    // Supports
    gfx.rect(cx - 18 * s, cy - 20 * s, 2 * s, 14 * s);
    gfx.fill({ color: 0x4a3a28, alpha: 0.9 });
    gfx.rect(cx + 16 * s, cy - 20 * s, 2 * s, 14 * s);
    gfx.fill({ color: 0x4a3a28, alpha: 0.9 });
    // Goods on counter
    gfx.circle(cx - 6 * s, cy - 8 * s, 3 * s);
    gfx.fill({ color: 0xddaa44, alpha: 0.7 });
    gfx.circle(cx + 4 * s, cy - 8 * s, 2.5 * s);
    gfx.fill({ color: 0x44aa88, alpha: 0.7 });
  }

  /**
   * Draw server-defined buildings for a town. Each building type gets a distinct
   * visual style so players can identify NPC houses at a glance.
   */
  private _drawServerBuildings(
    gfx: Graphics,
    iconGfx: Graphics,
    usedBuildingSprites: Set<string>,
    buildings: BuildingData[],
    _townScreenX: number,
    _townScreenY: number,
    scale: number,
  ): void {
    for (let i = 0; i < buildings.length; i++) {
      const b = buildings[i]!;
      const bScreen = worldToScreen(b.worldX, b.worldY);
      const bx = bScreen.x;
      const by = bScreen.y;
      const bt = b.buildingType;
      const isNpcHouse = bt.startsWith('house.');
      const footprintW = Math.max(1, b.footprintW);
      const footprintH = Math.max(1, b.footprintH);
      const baseW = 60 * scale * (footprintW / 2.5);
      const baseH = 48 * scale * (footprintH / 2.0);
      const houseSizeBoost = 1.95;

      const w = isNpcHouse ? Math.max(baseW * houseSizeBoost, 54 * scale) : baseW;
      const h = isNpcHouse ? Math.max(baseH * houseSizeBoost, 42 * scale) : baseH;
      const rh = isNpcHouse ? 32 * scale * 1.65 : 32 * scale;
      const spriteDrawW =
        bt === 'decoration.well'
          ? Math.max(w, 56 * scale)
          : bt === 'decoration.stall'
            ? Math.max(w, 44 * scale)
            : w;
      const spriteKey = buildingTypeToTextureKey(bt);
      const spriteTexture =
        this._spritesReady && spriteKey ? this._spriteRegistry.getTexture(spriteKey) : null;
      const hasUsableSprite = spriteKey !== null && spriteTexture !== null;
      if (spriteKey !== null && spriteTexture !== null) {
        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 || spriteDrawW;
        const scaleFactor = texW > 0 ? spriteDrawW / texW : 1;
        entry.sprite.scale.set(scaleFactor);
        entry.sprite.zIndex = by;
        entry.sprite.visible = true;
      }

      const hasSprite = hasUsableSprite;
      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);
        }
      }

      const twIcon = spriteTexture?.width ?? 0;
      const thIcon = spriteTexture?.height ?? 0;
      const iconYBase =
        hasUsableSprite && twIcon > 0
          ? by - ((thIcon * spriteDrawW) / twIcon) * 0.9
          : by - h - rh * 0.45;

      if (bt === 'house.quest_giver') {
        this._drawBuildingIcon(iconGfx, bx, iconYBase - rh * 0.05, '!', 0xffd700, scale);
      } else if (bt === 'house.merchant') {
        this._drawBuildingIcon(iconGfx, bx, iconYBase + rh * 0.12, '$', 0x88dd88, scale);
      } else if (bt === 'house.armorer') {
        this._drawBuildingIcon(iconGfx, bx, iconYBase, 'A', 0xaaccff, scale);
      } else if (bt === 'house.weapon_smith') {
        this._drawBuildingIcon(iconGfx, bx, iconYBase + rh * 0.04, 'W', 0xffaa66, scale);
      } else if (bt === 'house.jeweler') {
        this._drawBuildingIcon(iconGfx, bx, iconYBase - rh * 0.06, 'J', 0xdd88ff, scale);
      } else if (bt === 'house.bounty_hunter') {
        this._drawBuildingIcon(iconGfx, bx, iconYBase - rh * 0.05, 'B', 0xffcc44, scale);
      } else if (bt === 'house.elder') {
        this._drawBuildingIcon(iconGfx, bx, iconYBase - rh * 0.05, 'E', 0xeeddaa, scale);
      } else if (bt === 'house.healer') {
        this._drawBuildingIcon(iconGfx, bx, iconYBase - rh * 0.05, '+', 0xff6666, scale);
      }
    }
  }

  /** Draw a small icon circle above a building to indicate its purpose. */
  private _drawBuildingIcon(
    gfx: Graphics, cx: number, cy: number, _icon: string, color: number, scale: number,
  ): void {
    const r = 6 * scale;
    gfx.circle(cx, cy, r);
    gfx.fill({ color, alpha: 0.6 });
    gfx.stroke({ color: 0x000000, width: 1.2, alpha: 0.4 });
  }

  /** Draw a town well decoration (server-driven building). */
  private _drawTownWell(gfx: Graphics, cx: number, cy: number, s: number): void {
    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 - 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 });
  }

  /** Small plaza fountain (procedural towns; server towns use decoration.well at center). */
  private _drawTownFountain(gfx: Graphics, cx: number, cy: number, s: number): void {
    gfx.ellipse(cx, cy + 2 * s, 14 * s, 7 * s);
    gfx.fill({ color: 0x4a5a6a, alpha: 0.85 });
    gfx.stroke({ color: 0x3a4550, width: 1.2, alpha: 0.65 });
    gfx.ellipse(cx, cy + 2 * s, 10 * s, 5 * s);
    gfx.fill({ color: 0x5a8aaa, alpha: 0.45 });
    gfx.rect(cx - 3 * s, cy - 14 * s, 6 * s, 16 * s);
    gfx.fill({ color: 0x7a7a88, alpha: 0.9 });
    gfx.rect(cx - 5 * s, cy - 16 * s, 10 * s, 3 * s);
    gfx.fill({ color: 0x6a6a78, alpha: 0.88 });
    gfx.circle(cx, cy - 10 * s, 2.2 * s);
    gfx.fill({ color: 0xaaddff, alpha: 0.35 });
    gfx.arc(cx, cy - 10 * s, 3 * s, -Math.PI * 0.85, -Math.PI * 0.15);
    gfx.stroke({ color: 0x88ccff, width: 1.2, alpha: 0.4 });
  }

  /**
   * Paved town square at the settlement center (under well / fountain and civic building).
   */
  private _drawTownPlaza(
    gfx: Graphics,
    tx: number,
    ty: number,
    groundW: number,
    groundH: number,
  ): void {
    const pw = groundW * 0.42;
    const ph = groundH * 0.42;
    gfx.ellipse(tx, ty, pw, ph);
    gfx.fill({ color: 0x7a7878, alpha: 0.55 });
    gfx.stroke({ color: 0x5a5858, width: 1.2, alpha: 0.45 });
    const step = Math.max(10, pw * 0.14);
    for (let dx = -pw + step * 0.3; dx < pw; dx += step) {
      for (let dy = -ph + step * 0.25; dy < ph; dy += step * 0.85) {
        if ((dx * dx) / (pw * pw) + (dy * dy) / (ph * ph) > 0.82) continue;
        const h = ((Math.floor(dx / step) * 31) ^ (Math.floor(dy / step) * 17)) & 1;
        gfx.rect(tx + dx - step * 0.08, ty + dy - step * 0.08, step * 0.45, step * 0.38);
        gfx.fill({ color: h ? 0x6a686e : 0x757278, alpha: 0.35 });
      }
    }
  }

  /** Single civic building (hall / notice board) facing the plaza — not an NPC home. */
  private _drawCivicBuilding(gfx: Graphics, cx: number, cy: number, s: number): void {
    const w = 52 * s * 3;
    const h = 38 * s * 3;
    const rh = 26 * s * 3;
    gfx.rect(cx - w / 2, cy - h, w, h);
    gfx.fill({ color: 0x8a9098, alpha: 0.95 });
    gfx.stroke({ color: 0x4a5058, width: 1.2, alpha: 0.55 });
    gfx.poly([
      cx - w / 2 - 4 * s, cy - h,
      cx + w / 2 + 4 * s, cy - h,
      cx + w / 2, cy - h - rh,
      cx - w / 2, cy - h - rh,
    ]);
    gfx.fill({ color: 0x4a5560, alpha: 0.92 });
    gfx.rect(cx - 8 * s, cy - h * 0.65, 16 * s, 22 * s);
    gfx.fill({ color: 0x2a3540, alpha: 0.75 });
    gfx.stroke({ color: 0x1a2530, width: 0.8, alpha: 0.5 });
    gfx.rect(cx - w / 2 + 6 * s, cy - h - rh * 0.35, 4 * s, rh * 0.55);
    gfx.fill({ color: 0x6a7580, alpha: 0.85 });
    gfx.rect(cx + w / 2 - 10 * s, cy - h - rh * 0.35, 4 * s, rh * 0.55);
    gfx.fill({ color: 0x6a7580, alpha: 0.85 });
  }

  /** Draw a signpost decoration. */
  private _drawSignpost(gfx: Graphics, cx: number, cy: number, s: number): void {
    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,
      cx + 12 * s, cy - 13 * s,
      cx + 12 * s, cy - 10 * s,
      cx + 2 * s, cy - 9 * s,
    ]);
    gfx.fill({ color: 0x8a7a5a, alpha: 0.85 });
  }

  /**
   * Fallback procedural building placement when server buildings are unavailable.
   */
  private _drawProceduralBuildings(
    gfx: Graphics, tx: number, ty: number, s: number,
    spread: number, size: string, townSeed: number,
    usedBuildingSprites: Set<string>, townId: number,
  ): void {
    const houseCount = size === 'XS' ? 5 : size === 'S' ? 7 : size === 'M' ? 10 : 14;

    const wallColors = [0x9a7e5a, 0x8b7252, 0xa08860, 0x7e6844, 0x907656, 0x9e8862, 0x887050];
    const roofColors = [0x6a3a22, 0x5a3020, 0x7a4028, 0x5e3422, 0x6e3a24, 0x724030, 0x603828];
    const baseW = 60;
    const baseH = 48;
    const baseRH = 32;

    for (let i = 0; i < houseCount; i++) {
      const hash = ((townSeed * 31 + i * 17) ^ (i * 0x45d9f3b)) >>> 0;
      const r1 = (hash & 0xffff) / 0xffff;
      const r2 = ((hash >> 16) & 0xffff) / 0xffff;
      const r3 = ((hash * 7 + i * 13) & 0xff) / 0xff;

      const angle = (i / houseCount) * Math.PI * 2 + r1 * 0.4;
      // Keep town center clear for plaza + fountain/well + civic building
      const dist = spread * (0.36 + r2 * 0.52);
      const dx = Math.cos(angle) * dist;
      const dy = Math.sin(angle) * dist * 0.5;

      const sizeVar = 0.7 + r3 * 0.5;
      const w = baseW * s * sizeVar;
      const h = baseH * s * sizeVar;
      const rh = baseRH * s * sizeVar;
      const roofStyle = i % 5 === 0 ? 1 : i % 3 === 0 ? 2 : 0;

      const hx = tx + dx;
      const hy = ty + dy;
      const facadeKey =
        PROCEDURAL_TOWN_HOUSE_FACADE_KEYS[hash % PROCEDURAL_TOWN_HOUSE_FACADE_KEYS.length]!;
      const poolKey = `town:${townId}:proc_house:${i}`;
      const placedSprite = this._placeBuildingLayerSprite(
        poolKey,
        facadeKey,
        hx,
        hy,
        w,
        usedBuildingSprites,
      );
      if (!placedSprite) {
        this._drawHouse(
          gfx, hx, hy, w, h, rh,
          wallColors[i % wallColors.length]!,
          roofColors[i % roofColors.length]!,
          roofStyle,
        );
      }
      if (i % 4 === 1) {
        this._drawFence(gfx, hx, hy, w, i % 2 === 0 ? 'left' : 'right');
      }
    }

    const stallCount = houseCount >= 10 ? 2 : 1;
    for (let si = 0; si < stallCount; si++) {
      const stallAngle = (si + 0.5) * Math.PI + (townSeed & 0xf) * 0.1;
      const stallDist = spread * 0.42;
      const sx = tx + Math.cos(stallAngle) * stallDist;
      const sy = ty + Math.sin(stallAngle) * stallDist * 0.5;
      const stallW = 44 * s * 0.9;
      const poolKey = `town:${townId}:proc_stall:${si}`;
      if (
        !this._placeBuildingLayerSprite(
          poolKey,
          MARKET_STALL_TEXTURE_KEY,
          sx,
          sy,
          stallW,
          usedBuildingSprites,
        )
      ) {
        this._drawTownStall(gfx, sx, sy, s * 0.9);
      }
    }
  }

  /**
   * Draw towns visible in the current viewport.
   * Each town renders a ground plane, a paved central plaza, a fountain or well at the
   * plaza center (procedural fallback) or from server buildings, one civic hall offset
   * from center, NPC homes and stalls, a name label, and a dashed border.
   */
  drawTowns(towns: TownData[], camera: Camera, screenWidth: number, screenHeight: number): void {
    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;
    this._releaseGraphicsTextureCache(gfx);
    this._releaseGraphicsTextureCache(iconGfx);
    gfx.clear();
    iconGfx.clear();

    // Hide all existing labels first; we'll show visible ones below
    for (const lbl of this._townLabels) {
      lbl.visible = false;
    }

    const halfW = screenWidth / (2 * MAP_ZOOM) + TILE_WIDTH * 10;
    const halfH = screenHeight / (2 * MAP_ZOOM) + TILE_HEIGHT * 10;

    let labelIdx = 0;

    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);

      // Viewport culling: skip towns whose center is far outside the view
      if (
        Math.abs(townScreen.x - cx) > halfW + town.radius * TILE_WIDTH ||
        Math.abs(townScreen.y - cy) > halfH + town.radius * TILE_HEIGHT
      ) {
        continue;
      }

      const tx = townScreen.x;
      const ty = townScreen.y;
      const s = Math.max(0.7, Math.min(1.6, town.radius / 8));

      // --- Much larger border ---
      const borderRadius = town.radius * TILE_WIDTH * 0.7;
      const segments = 48;
      for (let i = 0; i < segments; i++) {
        const a1 = (i / segments) * Math.PI * 2;
        const a2 = ((i + 1) / segments) * Math.PI * 2;
        if (i % 2 === 0) {
          gfx.moveTo(tx + Math.cos(a1) * borderRadius, ty + Math.sin(a1) * borderRadius * 0.5);
          gfx.lineTo(tx + Math.cos(a2) * borderRadius, ty + Math.sin(a2) * borderRadius * 0.5);
          gfx.stroke({ color: 0xdaa520, width: 2, alpha: 0.3 });
        }
      }

      // --- Ground plane: tan/brown dirt ellipse (no extra transparent radius rings) ---
      const groundW = borderRadius * 0.85;
      const groundH = groundW * 0.5;
      gfx.ellipse(tx, ty, groundW, groundH);
      gfx.fill({ color: 0x8a7454, alpha: 0.35 });

      // --- Central plaza (paving); well/fountain + civic sit on or beside it ---
      this._drawTownPlaza(gfx, tx, ty, groundW, groundH);

      const townSeed = typeof town.id === 'number' ? town.id : 0;
      const spread = 100 * s;
      const civicWx = town.centerX + 0.18 * town.radius;
      const civicWy = town.centerY - 0.36 * town.radius;
      const civicScreen = worldToScreen(civicWx, civicWy);

      // --- Buildings: server-driven if available, fallback procedural ---
      if (town.buildings && town.buildings.length > 0) {
        this._drawServerBuildings(gfx, iconGfx, usedBuildingSprites, town.buildings, tx, ty, s);
      } else {
        this._drawProceduralBuildings(
          gfx, tx, ty, s, spread, town.size, townSeed, usedBuildingSprites, town.id,
        );
        const plazaKey = `town:${town.id}:plaza:center`;
        if (
          !this._placeBuildingLayerSprite(
            plazaKey,
            PLAZA_FOUNTAIN_TEXTURE_KEY,
            tx,
            ty,
            56 * s,
            usedBuildingSprites,
          )
        ) {
          if ((townSeed & 1) === 0) {
            this._drawTownFountain(gfx, tx, ty, s);
          } else {
            this._drawTownWell(gfx, tx, ty, s);
          }
        }
      }

      const civicKey = `town:${town.id}:civic`;
      if (
        !this._placeBuildingLayerSprite(
          civicKey,
          TOWN_HALL_TEXTURE_KEY,
          civicScreen.x,
          civicScreen.y,
          52 * s,
          usedBuildingSprites,
        )
      ) {
        this._drawCivicBuilding(gfx, civicScreen.x, civicScreen.y, s);
      }

      // --- Town name label (larger font, positioned higher) ---
      let label: Text;
      if (labelIdx < this._townLabels.length) {
        label = this._townLabels[labelIdx]!;
      } else {
        if (this._townLabelPool.length > 0) {
          label = this._townLabelPool.pop()!;
        } else {
          label = new Text({
            text: '',
            style: new TextStyle({
              fontSize: 18,
              fontFamily: 'system-ui, sans-serif',
              fontWeight: 'bold',
              fill: 0xdaa520,
              stroke: { color: 0x000000, width: 4 },
              align: 'center',
            }),
          });
          label.anchor.set(0.5, 0.5);
        }
        this.groundLayer.addChild(label);
        this._townLabels.push(label);
      }

      label.text = town.name;
      label.x = tx;
      label.y = ty - spread - 30 * s;
      label.visible = true;
      label.zIndex = ty - 500;
      labelIdx++;
    }

    this._hideUnusedSprites(this._buildingSpritePool, usedBuildingSprites, now);
    this._cacheStaticGraphicsIfBounded(gfx);
    this._cacheStaticGraphicsIfBounded(iconGfx);
  }

  /**
   * Draw NPCs within the viewport. Each NPC type has a distinct shape and icon.
   * NPCs have a gentle idle sway animation.
   */
  drawNPCs(
    npcs: NPCData[],
    camera: Camera,
    screenWidth: number,
    screenHeight: number,
    now: number,
  ): void {
    const gfx = this._npcGfx;
    if (!gfx) return;
    // Per-frame idle sway + culling: keep vector path, no cacheAsTexture (would stale or rebuild every frame).
    gfx.clear();

    for (const lbl of this._npcLabels) {
      lbl.visible = false;
    }

    const camX = camera.finalX;
    const camY = camera.finalY;
    const halfW = screenWidth / (2 * MAP_ZOOM) + TILE_WIDTH * 3;
    const halfH = screenHeight / (2 * MAP_ZOOM) + TILE_HEIGHT * 3;

    let labelIdx = 0;
    const usedNpcSprites = this._usedNpcSprites;
    usedNpcSprites.clear();

    for (const npc of npcs) {
      const iso = worldToScreen(npc.worldX, npc.worldY);

      // Viewport culling
      if (
        Math.abs(iso.x - camX) > halfW ||
        Math.abs(iso.y - camY) > halfH
      ) continue;

      const cx = iso.x;
      const cy = iso.y;

      // Shadow
      gfx.ellipse(cx, cy + 8, 10, 3.5);
      gfx.fill({ color: 0x000000, alpha: 0.22 });

      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 });

        // 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;

        // 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)

        // 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()!;
          } 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);
        }

        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()!;
          } 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);
        }

        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;
      }

      // 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);
        }

        nameLabel.text = npc.name;
        nameLabel.x = cx;
        nameLabel.y = cy + 6;
        nameLabel.visible = true;
        nameLabel.zIndex = cy + 101;
        labelIdx++;
      }
    }

    this._hideUnusedSprites(this._npcSpritePool, usedNpcSprites, now);
  }

  /**
   * Draw editor-defined town objects (props) within the viewport.
   */
  drawTownObjects(
    objects: TownObjectData[],
    camera: Camera,
    screenWidth: number,
    screenHeight: number,
  ): void {
    const camX = camera.finalX;
    const camY = camera.finalY;
    const halfW = screenWidth / (2 * MAP_ZOOM) + TILE_WIDTH * 3;
    const halfH = screenHeight / (2 * MAP_ZOOM) + TILE_HEIGHT * 3;

    const usedTownObjects = this._usedTownObjectSprites;
    usedTownObjects.clear();

    for (const obj of objects) {
      const iso = worldToScreen(obj.worldX, obj.worldY);
      if (Math.abs(iso.x - camX) > halfW || Math.abs(iso.y - camY) > halfH) continue;

      const variant = Number.isFinite(obj.variant) ? obj.variant : 0;
      const objTextureKey = this._spritesReady
        ? objectToTextureKey(obj.objectType, variant)
        : null;
      const objTexture = objTextureKey ? this._spriteRegistry.getTexture(objTextureKey) : null;
      if (objTextureKey && objTexture) {
        const poolKey = `town-object:${obj.id}`;
        usedTownObjects.add(poolKey);
        const entry = this._ensureSprite(
          this._townObjectSpritePool,
          poolKey,
          objTextureKey,
          objTexture,
          this._townObjectSpriteLayer,
          obj.worldX,
          obj.worldY,
          this._townObjectSpriteFreeList,
        );
        entry.sprite.x = iso.x;
        entry.sprite.y = iso.y;
        entry.sprite.zIndex = iso.y;
        entry.sprite.visible = true;
      }
    }

    this._hideUnusedSprites(
      this._townObjectSpritePool,
      usedTownObjects,
      performance.now(),
      this._townObjectSpriteFreeList,
      1200,
    );
  }

  /** Clear NPC visuals when there are none to render */
  clearNPCs(): void {
    if (this._npcGfx) this._npcGfx.clear();
    for (const lbl of this._npcLabels) {
      lbl.visible = false;
    }
    this._hideUnusedSprites(this._npcSpritePool, this._emptySpriteSet, performance.now());
  }

  /**
   * Draw nearby heroes using player sprites + name/level labels.
   * Each hero gets a subtle idle sway animation.
   */
  drawNearbyHeroes(
    heroes: ReadonlyArray<{
      id: number;
      name: string;
      level: number;
      modelVariant: number;
      positionX: number;
      positionY: number;
    }>,
  ): void {
    const gfx = this._nearbyHeroGfx;
    if (!gfx) return;
    gfx.clear();

    // Hide all existing labels first
    for (const lbl of this._nearbyHeroLabels) {
      lbl.visible = false;
    }

    let labelIdx = 0;
    const usedNearbySprites = this._usedNearbyHeroSprites;
    usedNearbySprites.clear();

    for (const hero of heroes) {
      const iso = worldToScreen(hero.positionX, hero.positionY);

      const cx = iso.x;
      const cy = iso.y;

      // Shadow
      gfx.ellipse(cx, cy + 8, 10, 3);
      gfx.fill({ color: 0x000000, alpha: 0.2 });

      const textureKey = this._spritesReady ? heroTextureKey(hero.modelVariant ?? 0, 'south') : null;
      const texture = textureKey ? this._spriteRegistry.getTexture(textureKey) : null;
      if (textureKey && texture) {
        const poolKey = `nearby_hero:${hero.id}`;
        usedNearbySprites.add(poolKey);
        const entry = this._ensureSprite(
          this._nearbyHeroSpritePool,
          poolKey,
          textureKey,
          texture,
          this.entityLayer,
        );
        entry.sprite.x = cx;
        entry.sprite.y = cy;
        entry.sprite.scale.set(0.85);
        entry.sprite.zIndex = cy + 100;
        entry.sprite.visible = true;
      }

      // Label: "Name Lv.X"
      let label: Text;
      if (labelIdx < this._nearbyHeroLabels.length) {
        label = this._nearbyHeroLabels[labelIdx]!;
      } else {
        if (this._nearbyHeroLabelPool.length > 0) {
          label = this._nearbyHeroLabelPool.pop()!;
        } else {
          label = new Text({
            text: '',
            style: new TextStyle({
              fontSize: 9,
              fontFamily: 'system-ui, sans-serif',
              fill: 0x88ddaa,
              stroke: { color: 0x000000, width: 2 },
              align: 'center',
            }),
          });
          label.anchor.set(0.5, 0.5);
        }
        this.entityLayer.addChild(label);
        this._nearbyHeroLabels.push(label);
      }

      label.text = `${hero.name} Lv.${hero.level}`;
      label.x = cx;
      label.y = cy - 90;
      label.visible = true;
      label.zIndex = cy + 101;
      labelIdx++;

      // Set gfx z-index for depth sorting
      gfx.zIndex = cy + 100;
    }

    this._hideUnusedSprites(this._nearbyHeroSpritePool, usedNearbySprites, performance.now());
  }

  /**
   * Up to two speech bubbles for hero meet. Local player's typed line: tinted fill; all lines use dark text.
   */
  drawHeroMeetBubbles(
    items: ReadonlyArray<{
      wx: number;
      wy: number;
      text: string;
      startMs: number;
      ownPlayerMessage: boolean;
    }>,
    now: number,
  ): void {
    for (let i = 0; i < this._meetBubbleSlots.length; i++) {
      const slot = this._meetBubbleSlots[i]!;
      const item = items[i];
      const gfx = slot.gfx;
      const txt = slot.txt;
      if (!item || !item.text.trim()) {
        gfx.clear();
        txt.visible = false;
        continue;
      }
      const elapsed = now - item.startMs;
      const alpha = Math.min(1, elapsed / 200);
      if (alpha <= 0) {
        txt.visible = false;
        gfx.clear();
        continue;
      }
      const iso = worldToScreen(item.wx, item.wy);
      const bx = iso.x;
      const by = iso.y - 88; // above hero head
      txt.text = item.text;
      txt.style.fill = 0x1a1a1a;
      txt.style.stroke = { color: 0x000000, width: 0 };
      const padX = 8;
      const padY = 6;
      const w = Math.min(220, Math.max(72, Math.ceil(txt.width) + padX * 2));
      const h = Math.max(26, Math.ceil(txt.height) + padY * 2);
      const left = bx - w / 2;
      const top = by - h / 2;
      const fillRgb = item.ownPlayerMessage ? 0xcffafe : 0xffffff;
      const strokeRgb = item.ownPlayerMessage ? 0x67e8f9 : 0xcccccc;
      gfx.clear();
      gfx.roundRect(left, top, w, h, 6);
      gfx.fill({ color: fillRgb, alpha: 0.94 * alpha });
      gfx.stroke({ color: strokeRgb, width: 1, alpha: 0.8 * alpha });
      const triW = 7;
      const triH = 5;
      gfx.poly([bx - triW / 2, top + h, bx + triW / 2, top + h, bx, top + h + triH]);
      gfx.fill({ color: fillRgb, alpha: 0.94 * alpha });
      txt.x = bx;
      txt.y = by;
      txt.alpha = alpha;
      txt.visible = true;
      txt.zIndex = by + 250;
      gfx.zIndex = by + 249;
    }
  }

  clearHeroMeetBubbles(): void {
    this.drawHeroMeetBubbles([], performance.now());
  }

  /** Clear nearby hero visuals when there are none to render */
  clearNearbyHeroes(): void {
    if (this._nearbyHeroGfx) this._nearbyHeroGfx.clear();
    for (const lbl of this._nearbyHeroLabels) {
      lbl.visible = false;
    }
    this._hideUnusedSprites(this._nearbyHeroSpritePool, this._emptySpriteSet, performance.now());
  }

  /** 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));
  }

  /** Clean up the renderer */
  destroy(): void {
    if (!this._initialized) return;
    this.app.destroy(true, { children: true, texture: true });
    this._initialized = false;
  }
}
