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; }; 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(); private _objectSpritePool = new Map(); private _tileSpriteFreeList: Sprite[] = []; private _objectSpriteFreeList: Sprite[] = []; private _usedTileSprites = new Set(); private _usedObjectSprites = new Set(); private _townObjectSpritePool = new Map(); private _townObjectSpriteFreeList: Sprite[] = []; private _usedTownObjectSprites = new Set(); private _emptySpriteSet = new Set(); private _buildingSpritePool = new Map(); private _characterSpritePool = new Map(); private _npcSpritePool = new Map(); private _usedBuildingSprites = new Set(); private _usedNpcSprites = new Set(); private _groundDirty = true; private _lastGroundBounds: | { startX: number; endX: number; startY: number; endY: number; } | null = null; private _lastSpritesReady = false; // Reusable Graphics objects (avoid GC in hot path) 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(); private _usedNearbyHeroSprites = new Set(); 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, 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; } private _hideUnusedSprites(pool: Map, used: Set): void { for (const [key, entry] of pool) { entry.sprite.visible = used.has(key); } } private _evictSpritesOutsideTileBounds( pool: Map, 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(); 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 { 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. */ 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( (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; } } if (spritesReady) { this._hideUnusedSprites(this._tileSpritePool, usedTileSprites); this._hideUnusedSprites(this._objectSpritePool, usedObjectSprites); 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); this._hideUnusedSprites(this._objectSpritePool, this._emptySpriteSet); } } /** * 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 = 52; entry.sprite.scale.set(targetH / th); entry.sprite.zIndex = cy + 100; 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, ): 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, 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, 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; 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); } /** * 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; 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); } /** * 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); } /** 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); } /** * 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); } /** * 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); } /** 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; } }