import { Application, Container, Graphics, Text, TextStyle } 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 { EnemyType } from './types';
import type { TownData, NPCData, BuildingData } from './types';
import { drawEnemyByType } from './enemyVisuals';

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

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

  // Reusable Graphics objects (avoid GC in hot path)
  private _groundGfx: Graphics | null = null;
  private _heroGfx: Graphics | null = null;
  private _campfireGfx: Graphics | null = null;
  private _enemyGfx: Graphics | null = null;
  private _thoughtGfx: Graphics | null = null;
  private _thoughtText: Text | null = null;
  private _heroNameText: Text | null = null;
  private _heroName = '';

  // Town rendering
  private _townGfx: 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 _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, 1 * s);
    gfx.fill({ color: 0xddd5c8, alpha: 0.9 });
    gfx.circle(x - 5 * s, y - 1 * 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, 1 * s);
    gfx.fill({ color: 0x4a3218, alpha: 0.6 });
    gfx.rect(x - 5 * s, y - 2 * s, 10 * s, 1 * 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;
  }

  /** Called from GameEngine when towns or route waypoints change. */
  setWorldTerrainContext(ctx: WorldTerrainContext | null): void {
    this._worldTerrainContext = ctx;
  }

  constructor() {
    this.app = new Application();
    this.worldContainer = new Container();
    this.groundLayer = new Container();
    this.entityLayer = new Container();
    this.effectLayer = new Container();
    this.uiContainer = 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);

    // 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._groundGfx = new Graphics();
    this.groundLayer.addChild(this._groundGfx);

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

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

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

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

    // Town graphics (drawn between ground and entity layers)
    this._townGfx = new Graphics();
    this.groundLayer.addChild(this._townGfx);

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

  /**
   * Draw visible ground tiles and decorative objects.
   * Terrain is procedurally generated — the world is endless.
   */
  drawGround(camera: Camera, screenWidth: number, screenHeight: number): void {
    const gfx = this._groundGfx;
    if (!gfx) return;
    gfx.clear();

    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 hw = TILE_WIDTH / 2;
    const hh = TILE_HEIGHT / 2;
    const terrainCtx = this._worldTerrainContext;

    // Pass 1: tiles
    for (let wx = startX; wx <= endX; wx++) {
      for (let wy = startY; wy <= endY; wy++) {
        const terrain = proceduralTerrain(wx, wy, terrainCtx);
        const iso = worldToScreen(wx, wy);
        const dark = (wx + wy) % 2 === 0;
        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,
        ]);
        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,
        ]);
        gfx.stroke({
          color: this._terrainStrokeColor(terrain),
          width: 1,
          alpha: 0.25,
        });
      }
    }

    // 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;
    for (let wx = objectStartX; wx <= objectEndX; wx++) {
      for (let wy = objectStartY; wy <= objectEndY; wy++) {
        const terrainHere = proceduralTerrain(wx, wy, terrainCtx);
        const obj = proceduralObject(wx, wy, terrainHere, terrainCtx);
        if (!obj) continue;
        const iso = worldToScreen(wx, wy);
        const variant = tileHash(wx, wy, 999);
        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);
      }
    }
  }

  /**
   * 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): void {
    const gfx = this._heroGfx;
    if (!gfx) return;
    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;

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

    // Cape (behind body)
    gfx.poly([cx - 14, cy - 4, cx - 18, cy + 10, cx + 2, cy + 6, cx + 4, cy - 8]);
    gfx.fill({ color: 0x6a1a2e, alpha: 0.92 });

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

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

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

    // Blade (readability at small zoom)
    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;

    // Hero name label above head (or above thought bubble if visible)
    const nameTxt = this._heroNameText;
    if (nameTxt && this._heroName) {
      nameTxt.text = this._heroName;
      nameTxt.x = iso.x;
      // Default: above hero head; drawThoughtBubble will reposition if active
      nameTxt.y = iso.y - 42;
      nameTxt.visible = true;
      nameTxt.zIndex = cy + 199;
    }
  }

  /** Draw a small campfire near hero while roadside-resting. */
  drawCampfire(wx: number, wy: number, now: number): void {
    const gfx = this._campfireGfx;
    if (!gfx) return;
    gfx.clear();

    const iso = worldToScreen(wx, wy);
    const bob = Math.sin(now * 0.005) * 1.2;
    const cx = iso.x + 18;
    const cy = iso.y + 9 + bob;

    // Ground shadow / ember glow
    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.2 });

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

    // Flame (layered circles for lightweight VFX)
    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 = cy + 96;
  }

  clearCampfire(): void {
    if (this._campfireGfx) this._campfireGfx.clear();
  }

  /**
   * Draw an enemy with type-specific visuals and an HP bar above.
   */
  drawEnemy(wx: number, wy: number, hp: number, maxHp: number, enemyType: EnemyType, now: number): void {
    const gfx = this._enemyGfx;
    if (!gfx) return;
    drawEnemyByType(gfx, wx, wy, hp, maxHp, enemyType, now, worldToScreen);
  }

  /**
   * 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 fadeIn = Math.min(1, elapsed / 300);
    const alpha = fadeIn;
    if (alpha <= 0) {
      txt.visible = false;
      return;
    }

    const iso = worldToScreen(wx, wy);
    const bx = iso.x;
    const by = iso.y - 52; // 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 });

    gfx.zIndex = by + 200; // above hero

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

  /** 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,
    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 w = 60 * scale * (b.footprintW / 2.5);
      const h = 48 * scale * (b.footprintH / 2.0);
      const rh = 32 * scale;

      const bt = b.buildingType;

      if (bt === 'house.quest_giver') {
        this._drawHouse(gfx, bx, by, w, h, rh, 0xb89040, 0x6a3a22, 0);
        this._drawFence(gfx, bx, by, w, 'left');
        this._drawBuildingIcon(gfx, bx, by - h - rh * 0.5, '!', 0xffd700, scale);
      } 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);
        this._drawBuildingIcon(gfx, bx, by - h - rh * 0.3, '$', 0x88dd88, scale);
      } else if (bt === 'house.healer') {
        this._drawHouse(gfx, bx, by, w, h, rh, 0xccccdd, 0x5555aa, 2);
        this._drawBuildingIcon(gfx, bx, by - h - rh * 0.5, '+', 0xff6666, scale);
      } 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);
      }
    }
  }

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

  /** Draw a signpost decoration. */
  private _drawSignpost(gfx: Graphics, cx: number, cy: number, s: number): void {
    gfx.rect(cx - 1 * 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,
  ): 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;
      const dist = spread * (0.2 + r2 * 0.65);
      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;

      this._drawHouse(
        gfx, tx + dx, ty + dy, w, h, rh,
        wallColors[i % wallColors.length]!,
        roofColors[i % roofColors.length]!,
        roofStyle,
      );
      if (i % 4 === 1) {
        this._drawFence(gfx, tx + dx, ty + dy, 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.35;
      this._drawTownStall(
        gfx,
        tx + Math.cos(stallAngle) * stallDist,
        ty + Math.sin(stallAngle) * stallDist * 0.5,
        s * 0.9,
      );
    }
  }

  /**
   * Draw towns visible in the current viewport.
   * Each town renders a ground plane, a large cluster of buildings with detail,
   * market stalls, fences, a name label, and a dashed border.
   */
  drawTowns(towns: TownData[], camera: Camera, screenWidth: number, screenHeight: number): void {
    const gfx = this._townGfx;
    if (!gfx) return;
    gfx.clear();

    // Hide all existing labels first; we'll show visible ones below
    for (const lbl of this._townLabels) {
      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;

    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 ---
      const groundW = borderRadius * 0.85;
      const groundH = groundW * 0.5;
      gfx.ellipse(tx, ty, groundW, groundH);
      gfx.fill({ color: 0x8a7454, alpha: 0.35 });
      // Inner lighter patch
      gfx.ellipse(tx, ty, groundW * 0.6, groundH * 0.6);
      gfx.fill({ color: 0x9a8462, alpha: 0.2 });

      // Glow circle behind town
      gfx.circle(tx, ty, borderRadius * 0.6);
      gfx.fill({ color: 0xdaa520, alpha: 0.04 });

      // --- Buildings: server-driven if available, fallback procedural ---
      const townSeed = typeof town.id === 'number' ? town.id : 0;
      const spread = 100 * s;

      if (town.buildings && town.buildings.length > 0) {
        this._drawServerBuildings(gfx, town.buildings, tx, ty, s);
      } else {
        this._drawProceduralBuildings(gfx, tx, ty, s, spread, town.size, townSeed);
      }

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

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

    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;

      // Idle sway based on NPC id
      const swayY = Math.sin(now * 0.002 + npc.id * 1.7) * 2.5;
      const cx = iso.x;
      const cy = iso.y + swayY;

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

      // NPC body diamond (type-specific color)
      let bodyColor: number;
      let bodyStroke: number;
      let iconText: string;
      let iconColor: number;

      switch (npc.type) {
        case 'quest_giver':
          bodyColor = 0xdaa520;
          bodyStroke = 0x8a6510;
          iconText = '!';
          iconColor = 0xffd700;
          break;
        case 'merchant':
          bodyColor = 0x44aa55;
          bodyStroke = 0x2a7a3a;
          iconText = '$';
          iconColor = 0x88dd88;
          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;
    }
  }

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

  /**
   * Draw nearby heroes as semi-transparent green diamonds with name + level labels.
   * Each hero gets a subtle idle sway animation.
   */
  drawNearbyHeroes(
    heroes: ReadonlyArray<{ name: string; level: number; positionX: number; positionY: number }>,
    now: 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;

    for (const hero of heroes) {
      const iso = worldToScreen(hero.positionX, hero.positionY);
      // Idle sway: use a per-hero offset based on name hash
      const hashOffset = hero.name.length * 1.37;
      const swayY = Math.sin(now * 0.003 + hashOffset) * 2;

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

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

      // Green diamond body (smaller than hero)
      const s = 0.7;
      gfx.poly([
        cx, cy - 16 * s,
        cx + 10 * s, cy,
        cx, cy + 8 * s,
        cx - 10 * s, cy,
      ]);
      gfx.fill({ color: 0x44aa55, alpha: 0.55 });
      gfx.stroke({ color: 0x2d7a3a, width: 1.2, alpha: 0.6 });

      // Small head circle
      gfx.circle(cx, cy - 14 * s, 4 * s);
      gfx.fill({ color: 0x66cc77, alpha: 0.5 });

      // 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 - 22;
      label.visible = true;
      label.zIndex = cy + 101;
      labelIdx++;

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

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

  /** Sort entity layer by y-position for correct isometric depth */
  sortEntities(): void {
    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;
  }
}
